Merge branch 'main' of https://git.stella-ops.org/stella-ops.org/git.stella-ops.org
This commit is contained in:
169
src/Signals/StellaOps.Signals/Services/CallGraphSyncService.cs
Normal file
169
src/Signals/StellaOps.Signals/Services/CallGraphSyncService.cs
Normal file
@@ -0,0 +1,169 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Signals.Models;
|
||||
using StellaOps.Signals.Persistence;
|
||||
|
||||
namespace StellaOps.Signals.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Synchronizes canonical callgraph documents to relational tables.
|
||||
/// </summary>
|
||||
public sealed class CallGraphSyncService : ICallGraphSyncService
|
||||
{
|
||||
private readonly ICallGraphProjectionRepository _projectionRepository;
|
||||
private readonly ILogger<CallGraphSyncService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public CallGraphSyncService(
|
||||
ICallGraphProjectionRepository projectionRepository,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<CallGraphSyncService> logger)
|
||||
{
|
||||
_projectionRepository = projectionRepository ?? throw new ArgumentNullException(nameof(projectionRepository));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<CallGraphSyncResult> SyncAsync(
|
||||
Guid scanId,
|
||||
string artifactDigest,
|
||||
CallgraphDocument document,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(artifactDigest);
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Starting callgraph projection for scan {ScanId}, artifact {ArtifactDigest}, nodes={NodeCount}, edges={EdgeCount}",
|
||||
scanId, artifactDigest, document.Nodes.Count, document.Edges.Count);
|
||||
|
||||
try
|
||||
{
|
||||
var sbomDigest = TryGetMetadataValue(document.Metadata, "sbomDigest");
|
||||
if (string.IsNullOrWhiteSpace(sbomDigest))
|
||||
{
|
||||
sbomDigest = string.IsNullOrWhiteSpace(document.GraphHash) ? null : document.GraphHash;
|
||||
}
|
||||
|
||||
var repoUri = TryGetMetadataValue(document.Metadata, "repoUri");
|
||||
var commitSha = TryGetMetadataValue(document.Metadata, "commitSha")?.ToLowerInvariant();
|
||||
|
||||
var sortedNodes = document.Nodes
|
||||
.OrderBy(n => n.Id, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var sortedEdges = document.Edges
|
||||
.OrderBy(e => e.SourceId, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.TargetId, StringComparer.Ordinal)
|
||||
.ThenBy(e => (int)e.Kind)
|
||||
.ThenBy(e => (int)e.Reason)
|
||||
.ThenBy(e => e.Offset ?? -1)
|
||||
.ThenBy(e => e.Type, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var sortedEntrypoints = document.Entrypoints
|
||||
.OrderBy(e => (int)e.Phase)
|
||||
.ThenBy(e => e.Order)
|
||||
.ThenBy(e => e.NodeId, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.Kind)
|
||||
.ThenBy(e => e.Framework)
|
||||
.ThenBy(e => e.Route, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.HttpMethod, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.Source, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
// Step 1: Upsert scan record
|
||||
await _projectionRepository.UpsertScanAsync(
|
||||
scanId,
|
||||
artifactDigest,
|
||||
sbomDigest: sbomDigest,
|
||||
repoUri: repoUri,
|
||||
commitSha: commitSha,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Step 2: Project nodes in stable order
|
||||
var nodesProjected = await _projectionRepository.UpsertNodesAsync(
|
||||
scanId,
|
||||
sortedNodes,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Step 3: Project edges in stable order
|
||||
var edgesProjected = await _projectionRepository.UpsertEdgesAsync(
|
||||
scanId,
|
||||
sortedEdges,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Step 4: Project entrypoints in stable order
|
||||
var entrypointsProjected = 0;
|
||||
if (sortedEntrypoints is { Count: > 0 })
|
||||
{
|
||||
entrypointsProjected = await _projectionRepository.UpsertEntrypointsAsync(
|
||||
scanId,
|
||||
sortedEntrypoints,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Step 5: Mark scan as completed
|
||||
await _projectionRepository.CompleteScanAsync(scanId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Completed callgraph projection for scan {ScanId}: nodes={NodesProjected}, edges={EdgesProjected}, entrypoints={EntrypointsProjected}, duration={DurationMs}ms",
|
||||
scanId, nodesProjected, edgesProjected, entrypointsProjected, stopwatch.ElapsedMilliseconds);
|
||||
|
||||
return new CallGraphSyncResult(
|
||||
ScanId: scanId,
|
||||
NodesProjected: nodesProjected,
|
||||
EdgesProjected: edgesProjected,
|
||||
EntrypointsProjected: entrypointsProjected,
|
||||
WasUpdated: nodesProjected > 0 || edgesProjected > 0,
|
||||
DurationMs: stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Failed callgraph projection for scan {ScanId} after {DurationMs}ms: {ErrorMessage}",
|
||||
scanId, stopwatch.ElapsedMilliseconds, ex.Message);
|
||||
|
||||
await _projectionRepository.FailScanAsync(scanId, ex.Message, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteByScanAsync(Guid scanId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogInformation("Deleting callgraph projection for scan {ScanId}", scanId);
|
||||
|
||||
await _projectionRepository.DeleteScanAsync(scanId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Deleted callgraph projection for scan {ScanId}", scanId);
|
||||
}
|
||||
|
||||
private static string? TryGetMetadataValue(IReadOnlyDictionary<string, string?>? metadata, string key)
|
||||
{
|
||||
if (metadata is null || string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!metadata.TryGetValue(key, out var value) || string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user