up
This commit is contained in:
@@ -60,7 +60,9 @@ app.MapPost("/graph/search", async (HttpContext context, GraphSearchRequest requ
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
await foreach (var line in service.SearchAsync(tenant!, request, ct))
|
||||
var tenantId = tenant!;
|
||||
|
||||
await foreach (var line in service.SearchAsync(tenantId, request, ct))
|
||||
{
|
||||
await context.Response.WriteAsync(line, ct);
|
||||
await context.Response.WriteAsync("\n", ct);
|
||||
@@ -113,7 +115,9 @@ app.MapPost("/graph/query", async (HttpContext context, GraphQueryRequest reques
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
await foreach (var line in service.QueryAsync(tenant!, request, ct))
|
||||
var tenantId = tenant!;
|
||||
|
||||
await foreach (var line in service.QueryAsync(tenantId, request, ct))
|
||||
{
|
||||
await context.Response.WriteAsync(line, ct);
|
||||
await context.Response.WriteAsync("\n", ct);
|
||||
@@ -166,7 +170,9 @@ app.MapPost("/graph/paths", async (HttpContext context, GraphPathRequest request
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
await foreach (var line in service.FindPathsAsync(tenant!, request, ct))
|
||||
var tenantId = tenant!;
|
||||
|
||||
await foreach (var line in service.FindPathsAsync(tenantId, request, ct))
|
||||
{
|
||||
await context.Response.WriteAsync(line, ct);
|
||||
await context.Response.WriteAsync("\n", ct);
|
||||
@@ -219,7 +225,9 @@ app.MapPost("/graph/diff", async (HttpContext context, GraphDiffRequest request,
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
await foreach (var line in service.DiffAsync(tenant!, request, ct))
|
||||
var tenantId = tenant!;
|
||||
|
||||
await foreach (var line in service.DiffAsync(tenantId, request, ct))
|
||||
{
|
||||
await context.Response.WriteAsync(line, ct);
|
||||
await context.Response.WriteAsync("\n", ct);
|
||||
@@ -272,7 +280,8 @@ app.MapPost("/graph/export", async (HttpContext context, GraphExportRequest requ
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
var job = await service.StartExportAsync(tenant!, request, ct);
|
||||
var tenantId = tenant!;
|
||||
var job = await service.StartExportAsync(tenantId, request, ct);
|
||||
var manifest = new
|
||||
{
|
||||
jobId = job.JobId,
|
||||
|
||||
@@ -8,14 +8,16 @@ 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)
|
||||
public InMemoryGraphDiffService(InMemoryGraphRepository repository, IGraphMetrics metrics)
|
||||
{
|
||||
_repository = repository;
|
||||
_metrics = metrics;
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<string> DiffAsync(string tenant, GraphDiffRequest request, [EnumeratorCancellation] CancellationToken ct = default)
|
||||
@@ -26,6 +28,7 @@ public sealed class InMemoryGraphDiffService : IGraphDiffService
|
||||
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);
|
||||
@@ -39,6 +42,8 @@ public sealed class InMemoryGraphDiffService : IGraphDiffService
|
||||
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<string, object?>("route", "/graph/diff"));
|
||||
yield break;
|
||||
}
|
||||
|
||||
@@ -49,15 +54,15 @@ public sealed class InMemoryGraphDiffService : IGraphDiffService
|
||||
|
||||
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)) { yield return tile!; yield break; }
|
||||
yield return JsonSerializer.Serialize(new TileEnvelope("node_added", seq++, added, Cost(tileBudgetLimit, budgetRemaining)), Options);
|
||||
}
|
||||
if (!Spend(ref budgetRemaining, ref nodeBudgetRemaining, tileBudgetLimit, seq, out var tile)) { _metrics.BudgetDenied.Add(1, new KeyValuePair<string, object?>("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)) { yield return tile!; yield break; }
|
||||
yield return JsonSerializer.Serialize(new TileEnvelope("node_removed", seq++, removed, Cost(tileBudgetLimit, budgetRemaining)), Options);
|
||||
}
|
||||
if (!Spend(ref budgetRemaining, ref nodeBudgetRemaining, tileBudgetLimit, seq, out var tile)) { _metrics.BudgetDenied.Add(1, new KeyValuePair<string, object?>("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))
|
||||
{
|
||||
@@ -65,7 +70,7 @@ public sealed class InMemoryGraphDiffService : IGraphDiffService
|
||||
var b = nodesB[common];
|
||||
if (!AttributesEqual(a.Attributes, b.Attributes))
|
||||
{
|
||||
if (!Spend(ref budgetRemaining, ref nodeBudgetRemaining, tileBudgetLimit, seq, out var tile)) { yield return tile!; yield break; }
|
||||
if (!Spend(ref budgetRemaining, ref nodeBudgetRemaining, tileBudgetLimit, seq, out var tile)) { _metrics.BudgetDenied.Add(1, new KeyValuePair<string, object?>("reason", "nodes")); yield return tile!; yield break; }
|
||||
var diff = new DiffTile
|
||||
{
|
||||
EntityType = "node",
|
||||
@@ -82,13 +87,13 @@ public sealed class InMemoryGraphDiffService : IGraphDiffService
|
||||
{
|
||||
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)) { yield return tile!; yield break; }
|
||||
if (!Spend(ref budgetRemaining, ref edgeBudgetRemaining, tileBudgetLimit, seq, out var tile)) { _metrics.BudgetDenied.Add(1, new KeyValuePair<string, object?>("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)) { yield return tile!; yield break; }
|
||||
if (!Spend(ref budgetRemaining, ref edgeBudgetRemaining, tileBudgetLimit, seq, out var tile)) { _metrics.BudgetDenied.Add(1, new KeyValuePair<string, object?>("reason", "edges")); yield return tile!; yield break; }
|
||||
yield return JsonSerializer.Serialize(new TileEnvelope("edge_removed", seq++, removed, Cost(tileBudgetLimit, budgetRemaining)), Options);
|
||||
}
|
||||
|
||||
@@ -98,12 +103,12 @@ public sealed class InMemoryGraphDiffService : IGraphDiffService
|
||||
var b = edgesB[common];
|
||||
if (!AttributesEqual(a.Attributes, b.Attributes))
|
||||
{
|
||||
if (!Spend(ref budgetRemaining, ref edgeBudgetRemaining, tileBudgetLimit, seq, out var tile)) { yield return tile!; yield break; }
|
||||
var diff = new DiffTile
|
||||
{
|
||||
EntityType = "edge",
|
||||
ChangeType = "changed",
|
||||
Id = common,
|
||||
if (!Spend(ref budgetRemaining, ref edgeBudgetRemaining, tileBudgetLimit, seq, out var tile)) { _metrics.BudgetDenied.Add(1, new KeyValuePair<string, object?>("reason", "edges")); yield return tile!; yield break; }
|
||||
var diff = new DiffTile
|
||||
{
|
||||
EntityType = "edge",
|
||||
ChangeType = "changed",
|
||||
Id = common,
|
||||
Before = a,
|
||||
After = b
|
||||
};
|
||||
@@ -126,6 +131,9 @@ public sealed class InMemoryGraphDiffService : IGraphDiffService
|
||||
yield return JsonSerializer.Serialize(new TileEnvelope("stats", seq++, stats, Cost(tileBudgetLimit, budgetRemaining)), Options);
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
_metrics.QueryLatencySeconds.Record(stopwatch.Elapsed.TotalSeconds, new KeyValuePair<string, object?>("route", "/graph/diff"));
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,15 +9,17 @@ public sealed class InMemoryGraphPathService : IGraphPathService
|
||||
{
|
||||
private readonly InMemoryGraphRepository _repository;
|
||||
private readonly IOverlayService _overlayService;
|
||||
private readonly IGraphMetrics _metrics;
|
||||
private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
public InMemoryGraphPathService(InMemoryGraphRepository repository, IOverlayService overlayService)
|
||||
public InMemoryGraphPathService(InMemoryGraphRepository repository, IOverlayService overlayService, IGraphMetrics metrics)
|
||||
{
|
||||
_repository = repository;
|
||||
_overlayService = overlayService;
|
||||
_metrics = metrics;
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<string> FindPathsAsync(string tenant, GraphPathRequest request, [EnumeratorCancellation] CancellationToken ct = default)
|
||||
@@ -29,6 +31,7 @@ public sealed class InMemoryGraphPathService : IGraphPathService
|
||||
var edgeBudgetRemaining = budget.Edges ?? 10000;
|
||||
var budgetRemaining = tileBudgetLimit;
|
||||
var seq = 0;
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
var result = FindShortestPath(tenant, request, maxDepth);
|
||||
|
||||
@@ -42,6 +45,8 @@ public sealed class InMemoryGraphPathService : IGraphPathService
|
||||
};
|
||||
|
||||
yield return JsonSerializer.Serialize(new TileEnvelope("error", seq++, error, Cost(tileBudgetLimit, budgetRemaining)), Options);
|
||||
stopwatch.Stop();
|
||||
_metrics.QueryLatencySeconds.Record(stopwatch.Elapsed.TotalSeconds, new KeyValuePair<string, object?>("route", "/graph/paths"));
|
||||
yield break;
|
||||
}
|
||||
|
||||
@@ -58,6 +63,7 @@ public sealed class InMemoryGraphPathService : IGraphPathService
|
||||
{
|
||||
if (budgetRemaining <= 0 || nodeBudgetRemaining <= 0)
|
||||
{
|
||||
_metrics.BudgetDenied.Add(1, new KeyValuePair<string, object?>("reason", "tiles"));
|
||||
yield return BudgetExceeded(tileBudgetLimit, budgetRemaining, seq++);
|
||||
yield break;
|
||||
}
|
||||
@@ -75,6 +81,7 @@ public sealed class InMemoryGraphPathService : IGraphPathService
|
||||
{
|
||||
if (budgetRemaining <= 0 || edgeBudgetRemaining <= 0)
|
||||
{
|
||||
_metrics.BudgetDenied.Add(1, new KeyValuePair<string, object?>("reason", "tiles"));
|
||||
yield return BudgetExceeded(tileBudgetLimit, budgetRemaining, seq++);
|
||||
yield break;
|
||||
}
|
||||
@@ -93,6 +100,9 @@ public sealed class InMemoryGraphPathService : IGraphPathService
|
||||
yield return JsonSerializer.Serialize(new TileEnvelope("stats", seq++, stats, Cost(tileBudgetLimit, budgetRemaining)), Options);
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
_metrics.QueryLatencySeconds.Record(stopwatch.Elapsed.TotalSeconds, new KeyValuePair<string, object?>("route", "/graph/paths"));
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
|
||||
@@ -49,21 +49,17 @@ public sealed class InMemoryGraphQueryService : IGraphQueryService
|
||||
var cursorOffset = CursorCodec.Decode(request.Cursor);
|
||||
var (nodes, edges) = _repository.QueryGraph(tenant, request);
|
||||
|
||||
if (nodes.Count > nodeBudgetLimit)
|
||||
{
|
||||
_metrics.BudgetDenied.Add(1, new KeyValuePair<string, object?>("reason", "nodes"));
|
||||
yield return BuildBudgetError(cacheKey, "nodes", nodeBudgetLimit, nodes.Count, edges.Count, budget);
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (request.IncludeEdges && edges.Count > edgeBudgetLimit)
|
||||
{
|
||||
_metrics.BudgetDenied.Add(1, new KeyValuePair<string, object?>("reason", "edges"));
|
||||
var error = new ErrorResponse
|
||||
{
|
||||
Error = "GRAPH_BUDGET_EXCEEDED",
|
||||
Message = $"Query exceeded edge budget (edges>{edgeBudgetLimit}).",
|
||||
Details = new { nodes = nodes.Count, edges = edges.Count, budget }
|
||||
};
|
||||
var errorLine = JsonSerializer.Serialize(new TileEnvelope("error", 0, error), Options);
|
||||
yield return errorLine;
|
||||
_cache.Set(cacheKey, new[] { errorLine }, new MemoryCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(2)
|
||||
});
|
||||
yield return BuildBudgetError(cacheKey, "edges", edgeBudgetLimit, nodes.Count, edges.Count, budget);
|
||||
yield break;
|
||||
}
|
||||
|
||||
@@ -206,4 +202,20 @@ public sealed class InMemoryGraphQueryService : IGraphQueryService
|
||||
|
||||
private static CostBudget Cost(int limit, int remainingBudget) =>
|
||||
new(limit, remainingBudget - 1, limit - (remainingBudget - 1));
|
||||
|
||||
private string BuildBudgetError(string cacheKey, string exceeded, int budgetLimit, int nodesCount, int edgesCount, GraphQueryBudget budget)
|
||||
{
|
||||
var error = new ErrorResponse
|
||||
{
|
||||
Error = "GRAPH_BUDGET_EXCEEDED",
|
||||
Message = $"Query exceeded {exceeded} budget ({exceeded}>{budgetLimit}).",
|
||||
Details = new { nodes = nodesCount, edges = edgesCount, budget }
|
||||
};
|
||||
var errorLine = JsonSerializer.Serialize(new TileEnvelope("error", 0, error), Options);
|
||||
_cache.Set(cacheKey, new[] { errorLine }, new MemoryCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(2)
|
||||
});
|
||||
return errorLine;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ namespace StellaOps.Graph.Api.Services;
|
||||
}
|
||||
|
||||
// Always return a fresh copy so we can inject a single explain trace without polluting cache.
|
||||
var overlays = new Dictionary<string, OverlayPayload>(cachedBase, StringComparer.Ordinal);
|
||||
var overlays = new Dictionary<string, OverlayPayload>(cachedBase!, StringComparer.Ordinal);
|
||||
|
||||
if (sampleExplain && !explainEmitted)
|
||||
{
|
||||
|
||||
@@ -11,7 +11,8 @@ public class DiffServiceTests
|
||||
public async Task DiffAsync_EmitsAddedRemovedChangedAndStats()
|
||||
{
|
||||
var repo = new InMemoryGraphRepository();
|
||||
var service = new InMemoryGraphDiffService(repo);
|
||||
var metrics = new GraphMetrics();
|
||||
var service = new InMemoryGraphDiffService(repo, metrics);
|
||||
|
||||
var request = new GraphDiffRequest
|
||||
{
|
||||
@@ -37,7 +38,8 @@ public class DiffServiceTests
|
||||
public async Task DiffAsync_WhenSnapshotMissing_ReturnsError()
|
||||
{
|
||||
var repo = new InMemoryGraphRepository();
|
||||
var service = new InMemoryGraphDiffService(repo);
|
||||
var metrics = new GraphMetrics();
|
||||
var service = new InMemoryGraphDiffService(repo, metrics);
|
||||
|
||||
var request = new GraphDiffRequest
|
||||
{
|
||||
|
||||
@@ -13,8 +13,9 @@ public class PathServiceTests
|
||||
{
|
||||
var repo = new InMemoryGraphRepository();
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var overlays = new InMemoryOverlayService(cache);
|
||||
var service = new InMemoryGraphPathService(repo, overlays);
|
||||
var metrics = new GraphMetrics();
|
||||
var overlays = new InMemoryOverlayService(cache, metrics);
|
||||
var service = new InMemoryGraphPathService(repo, overlays, metrics);
|
||||
|
||||
var request = new GraphPathRequest
|
||||
{
|
||||
@@ -39,8 +40,9 @@ public class PathServiceTests
|
||||
{
|
||||
var repo = new InMemoryGraphRepository();
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var overlays = new InMemoryOverlayService(cache);
|
||||
var service = new InMemoryGraphPathService(repo, overlays);
|
||||
var metrics = new GraphMetrics();
|
||||
var overlays = new InMemoryOverlayService(cache, metrics);
|
||||
var service = new InMemoryGraphPathService(repo, overlays, metrics);
|
||||
|
||||
var request = new GraphPathRequest
|
||||
{
|
||||
|
||||
@@ -107,8 +107,9 @@ namespace StellaOps.Graph.Api.Tests;
|
||||
|
||||
private static InMemoryGraphQueryService CreateService(InMemoryGraphRepository? repository = null)
|
||||
{
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var overlays = new InMemoryOverlayService(cache);
|
||||
return new InMemoryGraphQueryService(repository ?? new InMemoryGraphRepository(), cache, overlays);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var metrics = new GraphMetrics();
|
||||
var overlays = new InMemoryOverlayService(cache, metrics);
|
||||
return new InMemoryGraphQueryService(repository ?? new InMemoryGraphRepository(), cache, overlays, metrics);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,8 +121,9 @@ public class SearchServiceTests
|
||||
}, Array.Empty<EdgeTile>());
|
||||
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var overlays = new InMemoryOverlayService(cache);
|
||||
var service = new InMemoryGraphQueryService(repo, cache, overlays);
|
||||
var metrics = new GraphMetrics();
|
||||
var overlays = new InMemoryOverlayService(cache, metrics);
|
||||
var service = new InMemoryGraphQueryService(repo, cache, overlays, metrics);
|
||||
var request = new GraphQueryRequest
|
||||
{
|
||||
Kinds = new[] { "component" },
|
||||
@@ -154,8 +155,9 @@ public class SearchServiceTests
|
||||
});
|
||||
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var overlays = new InMemoryOverlayService(cache);
|
||||
var service = new InMemoryGraphQueryService(repo, cache, overlays);
|
||||
var metrics = new GraphMetrics();
|
||||
var overlays = new InMemoryOverlayService(cache, metrics);
|
||||
var service = new InMemoryGraphQueryService(repo, cache, overlays, metrics);
|
||||
var request = new GraphQueryRequest
|
||||
{
|
||||
Kinds = new[] { "component" },
|
||||
|
||||
Reference in New Issue
Block a user