using StellaOps.Graph.Api.Contracts; using System.Runtime.CompilerServices; using System.Text.Json; using System.Text.Json.Serialization; namespace StellaOps.Graph.Api.Services; public sealed class InMemoryGraphDiffService : IGraphDiffService { private readonly InMemoryGraphRepository _repository; private readonly IGraphMetrics _metrics; private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web) { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; public InMemoryGraphDiffService(InMemoryGraphRepository repository, IGraphMetrics metrics) { _repository = repository; _metrics = metrics; } public async IAsyncEnumerable DiffAsync(string tenant, GraphDiffRequest request, [EnumeratorCancellation] CancellationToken ct = default) { var budget = (request.Budget?.ApplyDefaults()) ?? GraphQueryBudget.Default.ApplyDefaults(); var tileBudgetLimit = Math.Clamp(budget.Tiles ?? 6000, 1, 6000); var nodeBudgetRemaining = budget.Nodes ?? 5000; var edgeBudgetRemaining = budget.Edges ?? 10000; var budgetRemaining = tileBudgetLimit; var seq = 0; var stopwatch = System.Diagnostics.Stopwatch.StartNew(); var snapA = _repository.GetSnapshot(tenant, request.SnapshotA); var snapB = _repository.GetSnapshot(tenant, request.SnapshotB); if (snapA is null || snapB is null) { var error = new ErrorResponse { Error = "GRAPH_SNAPSHOT_NOT_FOUND", Message = "One or both snapshots are missing.", Details = new { request.SnapshotA, request.SnapshotB } }; yield return JsonSerializer.Serialize(new TileEnvelope("error", seq++, error, Cost(tileBudgetLimit, budgetRemaining)), Options); stopwatch.Stop(); _metrics.QueryLatencySeconds.Record(stopwatch.Elapsed.TotalSeconds, new KeyValuePair("route", "/graph/diff")); yield break; } var nodesA = snapA.Value.Nodes.ToDictionary(n => n.Id, StringComparer.Ordinal); var nodesB = snapB.Value.Nodes.ToDictionary(n => n.Id, StringComparer.Ordinal); var edgesA = snapA.Value.Edges.ToDictionary(e => e.Id, StringComparer.Ordinal); var edgesB = snapB.Value.Edges.ToDictionary(e => e.Id, StringComparer.Ordinal); foreach (var added in nodesB.Values.Where(n => !nodesA.ContainsKey(n.Id)).OrderBy(n => n.Id, StringComparer.Ordinal)) { if (!Spend(ref budgetRemaining, ref nodeBudgetRemaining, tileBudgetLimit, seq, out var tile)) { _metrics.BudgetDenied.Add(1, new KeyValuePair("reason", "nodes")); yield return tile!; yield break; } yield return JsonSerializer.Serialize(new TileEnvelope("node_added", seq++, added, Cost(tileBudgetLimit, budgetRemaining)), Options); } foreach (var removed in nodesA.Values.Where(n => !nodesB.ContainsKey(n.Id)).OrderBy(n => n.Id, StringComparer.Ordinal)) { if (!Spend(ref budgetRemaining, ref nodeBudgetRemaining, tileBudgetLimit, seq, out var tile)) { _metrics.BudgetDenied.Add(1, new KeyValuePair("reason", "nodes")); yield return tile!; yield break; } yield return JsonSerializer.Serialize(new TileEnvelope("node_removed", seq++, removed, Cost(tileBudgetLimit, budgetRemaining)), Options); } foreach (var common in nodesA.Keys.Intersect(nodesB.Keys, StringComparer.Ordinal).OrderBy(k => k, StringComparer.Ordinal)) { var a = nodesA[common]; var b = nodesB[common]; if (!AttributesEqual(a.Attributes, b.Attributes)) { if (!Spend(ref budgetRemaining, ref nodeBudgetRemaining, tileBudgetLimit, seq, out var tile)) { _metrics.BudgetDenied.Add(1, new KeyValuePair("reason", "nodes")); yield return tile!; yield break; } var diff = new DiffTile { EntityType = "node", ChangeType = "changed", Id = common, Before = a, After = b }; yield return JsonSerializer.Serialize(new TileEnvelope("node_changed", seq++, diff, Cost(tileBudgetLimit, budgetRemaining)), Options); } } if (request.IncludeEdges) { foreach (var added in edgesB.Values.Where(e => !edgesA.ContainsKey(e.Id)).OrderBy(e => e.Id, StringComparer.Ordinal)) { if (!Spend(ref budgetRemaining, ref edgeBudgetRemaining, tileBudgetLimit, seq, out var tile)) { _metrics.BudgetDenied.Add(1, new KeyValuePair("reason", "edges")); yield return tile!; yield break; } yield return JsonSerializer.Serialize(new TileEnvelope("edge_added", seq++, added, Cost(tileBudgetLimit, budgetRemaining)), Options); } foreach (var removed in edgesA.Values.Where(e => !edgesB.ContainsKey(e.Id)).OrderBy(e => e.Id, StringComparer.Ordinal)) { if (!Spend(ref budgetRemaining, ref edgeBudgetRemaining, tileBudgetLimit, seq, out var tile)) { _metrics.BudgetDenied.Add(1, new KeyValuePair("reason", "edges")); yield return tile!; yield break; } yield return JsonSerializer.Serialize(new TileEnvelope("edge_removed", seq++, removed, Cost(tileBudgetLimit, budgetRemaining)), Options); } foreach (var common in edgesA.Keys.Intersect(edgesB.Keys, StringComparer.Ordinal).OrderBy(k => k, StringComparer.Ordinal)) { var a = edgesA[common]; var b = edgesB[common]; if (!AttributesEqual(a.Attributes, b.Attributes)) { if (!Spend(ref budgetRemaining, ref edgeBudgetRemaining, tileBudgetLimit, seq, out var tile)) { _metrics.BudgetDenied.Add(1, new KeyValuePair("reason", "edges")); yield return tile!; yield break; } var diff = new DiffTile { EntityType = "edge", ChangeType = "changed", Id = common, Before = a, After = b }; yield return JsonSerializer.Serialize(new TileEnvelope("edge_changed", seq++, diff, Cost(tileBudgetLimit, budgetRemaining)), Options); } } } if (request.IncludeStats && budgetRemaining > 0) { var stats = new DiffStatsTile { NodesAdded = nodesB.Count(n => !nodesA.ContainsKey(n.Key)), NodesRemoved = nodesA.Count(n => !nodesB.ContainsKey(n.Key)), NodesChanged = nodesA.Keys.Intersect(nodesB.Keys, StringComparer.Ordinal).Count(id => !AttributesEqual(nodesA[id].Attributes, nodesB[id].Attributes)), EdgesAdded = request.IncludeEdges ? edgesB.Count(e => !edgesA.ContainsKey(e.Key)) : 0, EdgesRemoved = request.IncludeEdges ? edgesA.Count(e => !edgesB.ContainsKey(e.Key)) : 0, EdgesChanged = request.IncludeEdges ? edgesA.Keys.Intersect(edgesB.Keys, StringComparer.Ordinal).Count(id => !AttributesEqual(edgesA[id].Attributes, edgesB[id].Attributes)) : 0 }; yield return JsonSerializer.Serialize(new TileEnvelope("stats", seq++, stats, Cost(tileBudgetLimit, budgetRemaining)), Options); } stopwatch.Stop(); _metrics.QueryLatencySeconds.Record(stopwatch.Elapsed.TotalSeconds, new KeyValuePair("route", "/graph/diff")); await Task.CompletedTask; } private static bool Spend(ref int budgetRemaining, ref int entityBudget, int limit, int seq, out string? tile) { if (budgetRemaining <= 0 || entityBudget <= 0) { tile = JsonSerializer.Serialize(new TileEnvelope("error", seq, new ErrorResponse { Error = "GRAPH_BUDGET_EXCEEDED", Message = "Diff exceeded budget." }, Cost(limit, budgetRemaining)), Options); return false; } budgetRemaining--; entityBudget--; tile = null; return true; } private static bool AttributesEqual(IDictionary a, IDictionary b) { if (a.Count != b.Count) return false; foreach (var kvp in a) { if (!b.TryGetValue(kvp.Key, out var other)) return false; if (!(kvp.Value?.ToString() ?? string.Empty).Equals(other?.ToString() ?? string.Empty, StringComparison.Ordinal)) { return false; } } return true; } private static CostBudget Cost(int limit, int remaining) => new(limit, remaining - 1, limit - (remaining - 1)); }