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; /// /// Synchronizes canonical callgraph documents to relational tables. /// public sealed class CallGraphSyncService : ICallGraphSyncService { private readonly ICallGraphProjectionRepository _projectionRepository; private readonly ILogger _logger; private readonly TimeProvider _timeProvider; public CallGraphSyncService( ICallGraphProjectionRepository projectionRepository, TimeProvider timeProvider, ILogger logger) { _projectionRepository = projectionRepository ?? throw new ArgumentNullException(nameof(projectionRepository)); _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// public async Task 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; } } /// 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? 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(); } }