// // Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later. // using Microsoft.Extensions.Logging; using StellaOps.AirGap.Sync.Models; namespace StellaOps.AirGap.Sync.Services; /// /// Interface for HLC-based merge operations. /// public interface IHlcMergeService { /// /// Merges job logs from multiple offline nodes into a unified, HLC-ordered stream. /// /// The node logs to merge. /// Cancellation token. /// The merge result. Task MergeAsync( IReadOnlyList nodeLogs, CancellationToken cancellationToken = default); } /// /// Service for merging job logs from multiple offline nodes using HLC total ordering. /// public sealed class HlcMergeService : IHlcMergeService { private readonly IConflictResolver _conflictResolver; private readonly ILogger _logger; /// /// Initializes a new instance of the class. /// public HlcMergeService( IConflictResolver conflictResolver, ILogger logger) { _conflictResolver = conflictResolver ?? throw new ArgumentNullException(nameof(conflictResolver)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// public Task MergeAsync( IReadOnlyList nodeLogs, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(nodeLogs); cancellationToken.ThrowIfCancellationRequested(); if (nodeLogs.Count == 0) { return Task.FromResult(new MergeResult { MergedEntries = Array.Empty(), Duplicates = Array.Empty(), SourceNodes = Array.Empty() }); } _logger.LogInformation( "Starting merge of {NodeCount} node logs with {TotalEntries} total entries", nodeLogs.Count, nodeLogs.Sum(l => l.Entries.Count)); // 1. Collect all entries from all nodes var allEntries = nodeLogs .SelectMany(log => log.Entries.Select(e => (log.NodeId, Entry: e))) .ToList(); // 2. Sort by HLC total order: (PhysicalTime, LogicalCounter, NodeId, JobId) var sorted = allEntries .OrderBy(x => x.Entry.THlc.PhysicalTime) .ThenBy(x => x.Entry.THlc.LogicalCounter) .ThenBy(x => x.Entry.THlc.NodeId, StringComparer.Ordinal) .ThenBy(x => x.Entry.JobId) .ToList(); // 3. Group by JobId to detect duplicates var groupedByJobId = sorted.GroupBy(x => x.Entry.JobId).ToList(); var deduplicated = new List(); var duplicates = new List(); foreach (var group in groupedByJobId) { var entries = group.ToList(); if (entries.Count == 1) { // No conflict - add directly var (nodeId, entry) = entries[0]; deduplicated.Add(CreateMergedEntry(nodeId, entry)); } else { // Multiple entries with same JobId - resolve conflict var resolution = _conflictResolver.Resolve(group.Key, entries); if (resolution.Resolution == ResolutionStrategy.Error) { _logger.LogError( "Conflict resolution failed for JobId {JobId}: {Error}", group.Key, resolution.Error); throw new InvalidOperationException(resolution.Error); } // Add the selected entry if (resolution.SelectedEntry is not null) { var sourceEntry = entries.First(e => e.Entry == resolution.SelectedEntry); deduplicated.Add(CreateMergedEntry(sourceEntry.NodeId, resolution.SelectedEntry)); } // Record duplicates foreach (var dropped in resolution.DroppedEntries ?? Array.Empty()) { var sourceEntry = entries.First(e => e.Entry == dropped); duplicates.Add(new DuplicateEntry(dropped.JobId, sourceEntry.NodeId, dropped.THlc)); } } } // 4. Sort deduplicated entries by HLC order deduplicated = deduplicated .OrderBy(x => x.THlc.PhysicalTime) .ThenBy(x => x.THlc.LogicalCounter) .ThenBy(x => x.THlc.NodeId, StringComparer.Ordinal) .ThenBy(x => x.JobId) .ToList(); // 5. Recompute unified chain byte[]? prevLink = null; foreach (var entry in deduplicated) { entry.MergedLink = OfflineHlcManager.ComputeLink( prevLink, entry.JobId, entry.THlc, entry.PayloadHash); prevLink = entry.MergedLink; } _logger.LogInformation( "Merge complete: {MergedCount} entries, {DuplicateCount} duplicates dropped", deduplicated.Count, duplicates.Count); return Task.FromResult(new MergeResult { MergedEntries = deduplicated, Duplicates = duplicates, MergedChainHead = prevLink, SourceNodes = nodeLogs.Select(l => l.NodeId).ToList() }); } private static MergedJobEntry CreateMergedEntry(string nodeId, OfflineJobLogEntry entry) => new() { SourceNodeId = nodeId, THlc = entry.THlc, JobId = entry.JobId, PartitionKey = entry.PartitionKey, Payload = entry.Payload, PayloadHash = entry.PayloadHash, OriginalLink = entry.Link }; }