Add unit tests for SBOM ingestion and transformation
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

- Implement `SbomIngestServiceCollectionExtensionsTests` to verify the SBOM ingestion pipeline exports snapshots correctly.
- Create `SbomIngestTransformerTests` to ensure the transformation produces expected nodes and edges, including deduplication of license nodes and normalization of timestamps.
- Add `SbomSnapshotExporterTests` to test the export functionality for manifest, adjacency, nodes, and edges.
- Introduce `VexOverlayTransformerTests` to validate the transformation of VEX nodes and edges.
- Set up project file for the test project with necessary dependencies and configurations.
- Include JSON fixture files for testing purposes.
This commit is contained in:
master
2025-11-04 07:49:39 +02:00
parent f72c5c513a
commit 2eb6852d34
491 changed files with 39445 additions and 3917 deletions

View File

@@ -0,0 +1,60 @@
using System;
using System.Collections.Immutable;
using System.Linq;
using StellaOps.Scheduler.Queue;
namespace StellaOps.Scheduler.WebService.Runs;
internal interface IQueueLagSummaryProvider
{
QueueLagSummaryResponse Capture();
}
internal sealed class QueueLagSummaryProvider : IQueueLagSummaryProvider
{
private readonly TimeProvider _timeProvider;
public QueueLagSummaryProvider(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
public QueueLagSummaryResponse Capture()
{
var samples = SchedulerQueueMetrics.CaptureDepthSamples();
if (samples.Count == 0)
{
return new QueueLagSummaryResponse(
_timeProvider.GetUtcNow(),
0,
0,
ImmutableArray<QueueLagEntry>.Empty);
}
var ordered = samples
.OrderBy(static sample => sample.Transport, StringComparer.Ordinal)
.ThenBy(static sample => sample.Queue, StringComparer.Ordinal)
.ToArray();
var builder = ImmutableArray.CreateBuilder<QueueLagEntry>(ordered.Length);
long totalDepth = 0;
long maxDepth = 0;
foreach (var sample in ordered)
{
totalDepth += sample.Depth;
if (sample.Depth > maxDepth)
{
maxDepth = sample.Depth;
}
builder.Add(new QueueLagEntry(sample.Transport, sample.Queue, sample.Depth));
}
return new QueueLagSummaryResponse(
_timeProvider.GetUtcNow(),
totalDepth,
maxDepth,
builder.ToImmutable());
}
}

View File

@@ -10,8 +10,9 @@ internal sealed record RunCreateRequest(
[property: JsonPropertyName("reason")] RunReason? Reason = null,
[property: JsonPropertyName("correlationId")] string? CorrelationId = null);
internal sealed record RunCollectionResponse(
[property: JsonPropertyName("runs")] IReadOnlyList<Run> Runs);
internal sealed record RunCollectionResponse(
[property: JsonPropertyName("runs")] IReadOnlyList<Run> Runs,
[property: JsonPropertyName("nextCursor")] string? NextCursor = null);
internal sealed record RunResponse(
[property: JsonPropertyName("run")] Run Run);
@@ -31,10 +32,24 @@ internal sealed record ImpactPreviewResponse(
[property: JsonPropertyName("snapshotId")] string? SnapshotId,
[property: JsonPropertyName("sample")] ImmutableArray<ImpactPreviewSample> Sample);
internal sealed record ImpactPreviewSample(
[property: JsonPropertyName("imageDigest")] string ImageDigest,
[property: JsonPropertyName("registry")] string Registry,
[property: JsonPropertyName("repository")] string Repository,
[property: JsonPropertyName("namespaces")] ImmutableArray<string> Namespaces,
[property: JsonPropertyName("tags")] ImmutableArray<string> Tags,
[property: JsonPropertyName("usedByEntrypoint")] bool UsedByEntrypoint);
internal sealed record ImpactPreviewSample(
[property: JsonPropertyName("imageDigest")] string ImageDigest,
[property: JsonPropertyName("registry")] string Registry,
[property: JsonPropertyName("repository")] string Repository,
[property: JsonPropertyName("namespaces")] ImmutableArray<string> Namespaces,
[property: JsonPropertyName("tags")] ImmutableArray<string> Tags,
[property: JsonPropertyName("usedByEntrypoint")] bool UsedByEntrypoint);
internal sealed record RunDeltaCollectionResponse(
[property: JsonPropertyName("deltas")] ImmutableArray<DeltaSummary> Deltas);
internal sealed record QueueLagSummaryResponse(
[property: JsonPropertyName("capturedAt")] DateTimeOffset CapturedAt,
[property: JsonPropertyName("totalDepth")] long TotalDepth,
[property: JsonPropertyName("maxDepth")] long MaxDepth,
[property: JsonPropertyName("queues")] ImmutableArray<QueueLagEntry> Queues);
internal sealed record QueueLagEntry(
[property: JsonPropertyName("transport")] string Transport,
[property: JsonPropertyName("queue")] string Queue,
[property: JsonPropertyName("depth")] long Depth);

View File

@@ -3,7 +3,8 @@ using System.Collections.Generic;
using System.Collections.Immutable;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using Microsoft.AspNetCore.Http;
using System.Threading;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Primitives;
@@ -15,31 +16,57 @@ using StellaOps.Scheduler.WebService.Auth;
namespace StellaOps.Scheduler.WebService.Runs;
internal static class RunEndpoints
{
private const string ReadScope = "scheduler.runs.read";
private const string WriteScope = "scheduler.runs.write";
private const string PreviewScope = "scheduler.runs.preview";
internal static class RunEndpoints
{
private const string ReadScope = "scheduler.runs.read";
private const string WriteScope = "scheduler.runs.write";
private const string PreviewScope = "scheduler.runs.preview";
private const string ManageScope = "scheduler.runs.manage";
private const int DefaultRunListLimit = 50;
public static IEndpointRouteBuilder MapRunEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/v1/scheduler/runs");
group.MapGet("/", ListRunsAsync);
group.MapGet("/queue/lag", GetQueueLagAsync);
group.MapGet("/{runId}/deltas", GetRunDeltasAsync);
group.MapGet("/{runId}/stream", StreamRunAsync);
group.MapGet("/{runId}", GetRunAsync);
group.MapPost("/", CreateRunAsync);
group.MapPost("/{runId}/cancel", CancelRunAsync);
group.MapPost("/{runId}/retry", RetryRunAsync);
group.MapPost("/preview", PreviewImpactAsync);
return routes;
}
public static IEndpointRouteBuilder MapRunEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/v1/scheduler/runs");
group.MapGet("/", ListRunsAsync);
group.MapGet("/{runId}", GetRunAsync);
group.MapPost("/", CreateRunAsync);
group.MapPost("/{runId}/cancel", CancelRunAsync);
group.MapPost("/preview", PreviewImpactAsync);
return routes;
}
private static async Task<IResult> ListRunsAsync(
HttpContext httpContext,
[FromServices] ITenantContextAccessor tenantAccessor,
[FromServices] IScopeAuthorizer scopeAuthorizer,
[FromServices] IRunRepository repository,
CancellationToken cancellationToken)
private static IResult GetQueueLagAsync(
HttpContext httpContext,
[FromServices] ITenantContextAccessor tenantAccessor,
[FromServices] IScopeAuthorizer scopeAuthorizer,
[FromServices] IQueueLagSummaryProvider queueLagProvider)
{
try
{
scopeAuthorizer.EnsureScope(httpContext, ReadScope);
tenantAccessor.GetTenant(httpContext);
var summary = queueLagProvider.Capture();
return Results.Ok(summary);
}
catch (Exception ex) when (ex is ArgumentException or ValidationException)
{
return Results.BadRequest(new { error = ex.Message });
}
}
private static async Task<IResult> ListRunsAsync(
HttpContext httpContext,
[FromServices] ITenantContextAccessor tenantAccessor,
[FromServices] IScopeAuthorizer scopeAuthorizer,
[FromServices] IRunRepository repository,
CancellationToken cancellationToken)
{
try
{
@@ -50,24 +77,35 @@ internal static class RunEndpoints
? scheduleValues.ToString().Trim()
: null;
var states = ParseRunStates(httpContext.Request.Query.TryGetValue("state", out var stateValues) ? stateValues : StringValues.Empty);
var createdAfter = SchedulerEndpointHelpers.TryParseDateTimeOffset(httpContext.Request.Query.TryGetValue("createdAfter", out var createdAfterValues) ? createdAfterValues.ToString() : null);
var limit = SchedulerEndpointHelpers.TryParsePositiveInt(httpContext.Request.Query.TryGetValue("limit", out var limitValues) ? limitValues.ToString() : null);
var sortAscending = httpContext.Request.Query.TryGetValue("sort", out var sortValues) &&
sortValues.Any(value => string.Equals(value, "asc", StringComparison.OrdinalIgnoreCase));
var options = new RunQueryOptions
{
ScheduleId = string.IsNullOrWhiteSpace(scheduleId) ? null : scheduleId,
States = states,
CreatedAfter = createdAfter,
Limit = limit,
SortAscending = sortAscending,
};
var runs = await repository.ListAsync(tenant.TenantId, options, cancellationToken: cancellationToken).ConfigureAwait(false);
return Results.Ok(new RunCollectionResponse(runs));
var states = ParseRunStates(httpContext.Request.Query.TryGetValue("state", out var stateValues) ? stateValues : StringValues.Empty);
var createdAfter = SchedulerEndpointHelpers.TryParseDateTimeOffset(httpContext.Request.Query.TryGetValue("createdAfter", out var createdAfterValues) ? createdAfterValues.ToString() : null);
var limit = SchedulerEndpointHelpers.TryParsePositiveInt(httpContext.Request.Query.TryGetValue("limit", out var limitValues) ? limitValues.ToString() : null);
var cursor = SchedulerEndpointHelpers.TryParseRunCursor(httpContext.Request.Query.TryGetValue("cursor", out var cursorValues) ? cursorValues.ToString() : null);
var sortAscending = httpContext.Request.Query.TryGetValue("sort", out var sortValues) &&
sortValues.Any(value => string.Equals(value, "asc", StringComparison.OrdinalIgnoreCase));
var appliedLimit = limit ?? DefaultRunListLimit;
var options = new RunQueryOptions
{
ScheduleId = string.IsNullOrWhiteSpace(scheduleId) ? null : scheduleId,
States = states,
CreatedAfter = createdAfter,
Cursor = cursor,
Limit = appliedLimit,
SortAscending = sortAscending,
};
var runs = await repository.ListAsync(tenant.TenantId, options, cancellationToken: cancellationToken).ConfigureAwait(false);
string? nextCursor = null;
if (runs.Count == appliedLimit && runs.Count > 0)
{
var last = runs[^1];
nextCursor = SchedulerEndpointHelpers.CreateRunCursor(last);
}
return Results.Ok(new RunCollectionResponse(runs, nextCursor));
}
catch (Exception ex) when (ex is ArgumentException or ValidationException)
{
@@ -75,32 +113,59 @@ internal static class RunEndpoints
}
}
private static async Task<IResult> GetRunAsync(
HttpContext httpContext,
string runId,
[FromServices] ITenantContextAccessor tenantAccessor,
[FromServices] IScopeAuthorizer scopeAuthorizer,
[FromServices] IRunRepository repository,
CancellationToken cancellationToken)
{
try
{
scopeAuthorizer.EnsureScope(httpContext, ReadScope);
var tenant = tenantAccessor.GetTenant(httpContext);
var run = await repository.GetAsync(tenant.TenantId, runId, cancellationToken: cancellationToken).ConfigureAwait(false);
if (run is null)
{
return Results.NotFound();
}
return Results.Ok(new RunResponse(run));
}
catch (Exception ex) when (ex is ArgumentException or ValidationException)
{
return Results.BadRequest(new { error = ex.Message });
}
}
private static async Task<IResult> GetRunAsync(
HttpContext httpContext,
string runId,
[FromServices] ITenantContextAccessor tenantAccessor,
[FromServices] IScopeAuthorizer scopeAuthorizer,
[FromServices] IRunRepository repository,
CancellationToken cancellationToken)
{
try
{
scopeAuthorizer.EnsureScope(httpContext, ReadScope);
var tenant = tenantAccessor.GetTenant(httpContext);
var run = await repository.GetAsync(tenant.TenantId, runId, cancellationToken: cancellationToken).ConfigureAwait(false);
if (run is null)
{
return Results.NotFound();
}
return Results.Ok(new RunResponse(run));
}
catch (Exception ex) when (ex is ArgumentException or ValidationException)
{
return Results.BadRequest(new { error = ex.Message });
}
}
private static async Task<IResult> GetRunDeltasAsync(
HttpContext httpContext,
string runId,
[FromServices] ITenantContextAccessor tenantAccessor,
[FromServices] IScopeAuthorizer scopeAuthorizer,
[FromServices] IRunRepository repository,
CancellationToken cancellationToken)
{
try
{
scopeAuthorizer.EnsureScope(httpContext, ReadScope);
var tenant = tenantAccessor.GetTenant(httpContext);
var run = await repository.GetAsync(tenant.TenantId, runId, cancellationToken: cancellationToken).ConfigureAwait(false);
if (run is null)
{
return Results.NotFound();
}
return Results.Ok(new RunDeltaCollectionResponse(run.Deltas));
}
catch (Exception ex) when (ex is ArgumentException or ValidationException)
{
return Results.BadRequest(new { error = ex.Message });
}
}
private static async Task<IResult> CreateRunAsync(
HttpContext httpContext,
@@ -116,7 +181,7 @@ internal static class RunEndpoints
{
try
{
scopeAuthorizer.EnsureScope(httpContext, WriteScope);
scopeAuthorizer.EnsureScope(httpContext, ManageScope);
var tenant = tenantAccessor.GetTenant(httpContext);
if (string.IsNullOrWhiteSpace(request.ScheduleId))
@@ -184,11 +249,11 @@ internal static class RunEndpoints
}
}
private static async Task<IResult> CancelRunAsync(
HttpContext httpContext,
string runId,
[FromServices] ITenantContextAccessor tenantAccessor,
[FromServices] IScopeAuthorizer scopeAuthorizer,
private static async Task<IResult> CancelRunAsync(
HttpContext httpContext,
string runId,
[FromServices] ITenantContextAccessor tenantAccessor,
[FromServices] IScopeAuthorizer scopeAuthorizer,
[FromServices] IRunRepository repository,
[FromServices] IRunSummaryService runSummaryService,
[FromServices] ISchedulerAuditService auditService,
@@ -243,9 +308,145 @@ internal static class RunEndpoints
}
catch (Exception ex) when (ex is ArgumentException or ValidationException)
{
return Results.BadRequest(new { error = ex.Message });
}
}
return Results.BadRequest(new { error = ex.Message });
}
}
private static async Task<IResult> RetryRunAsync(
HttpContext httpContext,
string runId,
[FromServices] ITenantContextAccessor tenantAccessor,
[FromServices] IScopeAuthorizer scopeAuthorizer,
[FromServices] IScheduleRepository scheduleRepository,
[FromServices] IRunRepository runRepository,
[FromServices] IRunSummaryService runSummaryService,
[FromServices] ISchedulerAuditService auditService,
[FromServices] TimeProvider timeProvider,
CancellationToken cancellationToken)
{
try
{
scopeAuthorizer.EnsureScope(httpContext, ManageScope);
var tenant = tenantAccessor.GetTenant(httpContext);
var existing = await runRepository.GetAsync(tenant.TenantId, runId, cancellationToken: cancellationToken).ConfigureAwait(false);
if (existing is null)
{
return Results.NotFound();
}
if (string.IsNullOrWhiteSpace(existing.ScheduleId))
{
return Results.BadRequest(new { error = "Run cannot be retried because it is not associated with a schedule." });
}
if (!RunStateMachine.IsTerminal(existing.State))
{
return Results.Conflict(new { error = "Run is not in a terminal state and cannot be retried." });
}
var schedule = await scheduleRepository.GetAsync(tenant.TenantId, existing.ScheduleId!, cancellationToken: cancellationToken).ConfigureAwait(false);
if (schedule is null)
{
return Results.BadRequest(new { error = "Associated schedule no longer exists." });
}
var now = timeProvider.GetUtcNow();
var newRunId = SchedulerEndpointHelpers.GenerateIdentifier("run");
var baselineReason = existing.Reason ?? RunReason.Empty;
var manualReason = string.IsNullOrWhiteSpace(baselineReason.ManualReason)
? $"retry-of:{existing.Id}"
: $"{baselineReason.ManualReason};retry-of:{existing.Id}";
var newReason = new RunReason(
manualReason,
baselineReason.ConselierExportId,
baselineReason.ExcitorExportId,
baselineReason.Cursor)
{
ImpactWindowFrom = baselineReason.ImpactWindowFrom,
ImpactWindowTo = baselineReason.ImpactWindowTo
};
var retryRun = new Run(
newRunId,
tenant.TenantId,
RunTrigger.Manual,
RunState.Planning,
RunStats.Empty,
now,
newReason,
existing.ScheduleId,
retryOf: existing.Id);
await runRepository.InsertAsync(retryRun, cancellationToken: cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(retryRun.ScheduleId))
{
await runSummaryService.ProjectAsync(retryRun, cancellationToken).ConfigureAwait(false);
}
await auditService.WriteAsync(
new SchedulerAuditEvent(
tenant.TenantId,
"scheduler.run",
"retry",
SchedulerEndpointHelpers.ResolveAuditActor(httpContext),
RunId: retryRun.Id,
ScheduleId: retryRun.ScheduleId,
Metadata: BuildMetadata(
("state", retryRun.State.ToString().ToLowerInvariant()),
("retryOf", existing.Id),
("trigger", retryRun.Trigger.ToString().ToLowerInvariant()))),
cancellationToken).ConfigureAwait(false);
return Results.Created($"/api/v1/scheduler/runs/{retryRun.Id}", new RunResponse(retryRun));
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
catch (Exception ex) when (ex is ArgumentException or ValidationException)
{
return Results.BadRequest(new { error = ex.Message });
}
}
private static async Task StreamRunAsync(
HttpContext httpContext,
string runId,
[FromServices] ITenantContextAccessor tenantAccessor,
[FromServices] IScopeAuthorizer scopeAuthorizer,
[FromServices] IRunRepository runRepository,
[FromServices] IRunStreamCoordinator runStreamCoordinator,
CancellationToken cancellationToken)
{
try
{
scopeAuthorizer.EnsureScope(httpContext, ReadScope);
var tenant = tenantAccessor.GetTenant(httpContext);
var run = await runRepository.GetAsync(tenant.TenantId, runId, cancellationToken: cancellationToken).ConfigureAwait(false);
if (run is null)
{
await Results.NotFound().ExecuteAsync(httpContext);
return;
}
await runStreamCoordinator.StreamAsync(httpContext, tenant.TenantId, run, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Client disconnected; nothing to do.
}
catch (Exception ex) when (ex is ArgumentException or ValidationException)
{
if (!httpContext.Response.HasStarted)
{
await Results.BadRequest(new { error = ex.Message }).ExecuteAsync(httpContext);
}
}
}
private static async Task<IResult> PreviewImpactAsync(
HttpContext httpContext,

View File

@@ -0,0 +1,225 @@
using System;
using System.Collections.Immutable;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Storage.Mongo.Repositories;
namespace StellaOps.Scheduler.WebService.Runs;
internal interface IRunStreamCoordinator
{
Task StreamAsync(HttpContext context, string tenantId, Run initialRun, CancellationToken cancellationToken);
}
internal sealed class RunStreamCoordinator : IRunStreamCoordinator
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
private readonly IRunRepository _runRepository;
private readonly IQueueLagSummaryProvider _queueLagProvider;
private readonly TimeProvider _timeProvider;
private readonly ILogger<RunStreamCoordinator> _logger;
private readonly RunStreamOptions _options;
public RunStreamCoordinator(
IRunRepository runRepository,
IQueueLagSummaryProvider queueLagProvider,
IOptions<RunStreamOptions> options,
TimeProvider? timeProvider,
ILogger<RunStreamCoordinator> logger)
{
_runRepository = runRepository ?? throw new ArgumentNullException(nameof(runRepository));
_queueLagProvider = queueLagProvider ?? throw new ArgumentNullException(nameof(queueLagProvider));
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_options = (options ?? throw new ArgumentNullException(nameof(options))).Value.Validate();
}
public async Task StreamAsync(HttpContext context, string tenantId, Run initialRun, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(initialRun);
var response = context.Response;
ConfigureSseHeaders(response);
await SseWriter.WriteRetryAsync(response, _options.ReconnectDelay, cancellationToken).ConfigureAwait(false);
var lastRun = initialRun;
await SseWriter.WriteEventAsync(response, "initial", RunSnapshotPayload.From(lastRun), SerializerOptions, cancellationToken).ConfigureAwait(false);
await SseWriter.WriteEventAsync(response, "queueLag", _queueLagProvider.Capture(), SerializerOptions, cancellationToken).ConfigureAwait(false);
await SseWriter.WriteEventAsync(response, "heartbeat", HeartbeatPayload.Create(_timeProvider.GetUtcNow()), SerializerOptions, cancellationToken).ConfigureAwait(false);
if (RunStateMachine.IsTerminal(lastRun.State))
{
await SseWriter.WriteEventAsync(response, "completed", RunSnapshotPayload.From(lastRun), SerializerOptions, cancellationToken).ConfigureAwait(false);
return;
}
using var pollTimer = new PeriodicTimer(_options.PollInterval);
using var queueTimer = new PeriodicTimer(_options.QueueLagInterval);
using var heartbeatTimer = new PeriodicTimer(_options.HeartbeatInterval);
try
{
while (!cancellationToken.IsCancellationRequested)
{
var pollTask = pollTimer.WaitForNextTickAsync(cancellationToken).AsTask();
var queueTask = queueTimer.WaitForNextTickAsync(cancellationToken).AsTask();
var heartbeatTask = heartbeatTimer.WaitForNextTickAsync(cancellationToken).AsTask();
var completed = await Task.WhenAny(pollTask, queueTask, heartbeatTask).ConfigureAwait(false);
if (completed == pollTask && await pollTask.ConfigureAwait(false))
{
var current = await _runRepository.GetAsync(tenantId, lastRun.Id, cancellationToken: cancellationToken).ConfigureAwait(false);
if (current is null)
{
_logger.LogWarning("Run {RunId} disappeared while streaming; signalling notFound event.", lastRun.Id);
await SseWriter.WriteEventAsync(response, "notFound", new RunNotFoundPayload(lastRun.Id), SerializerOptions, cancellationToken).ConfigureAwait(false);
break;
}
await EmitRunDifferencesAsync(response, lastRun, current, cancellationToken).ConfigureAwait(false);
lastRun = current;
if (RunStateMachine.IsTerminal(lastRun.State))
{
await SseWriter.WriteEventAsync(response, "completed", RunSnapshotPayload.From(lastRun), SerializerOptions, cancellationToken).ConfigureAwait(false);
break;
}
}
else if (completed == queueTask && await queueTask.ConfigureAwait(false))
{
await SseWriter.WriteEventAsync(response, "queueLag", _queueLagProvider.Capture(), SerializerOptions, cancellationToken).ConfigureAwait(false);
}
else if (completed == heartbeatTask && await heartbeatTask.ConfigureAwait(false))
{
await SseWriter.WriteEventAsync(response, "heartbeat", HeartbeatPayload.Create(_timeProvider.GetUtcNow()), SerializerOptions, cancellationToken).ConfigureAwait(false);
}
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
_logger.LogDebug("Run stream cancelled for run {RunId}.", lastRun.Id);
}
}
private static void ConfigureSseHeaders(HttpResponse response)
{
response.StatusCode = StatusCodes.Status200OK;
response.Headers.CacheControl = "no-store";
response.Headers["X-Accel-Buffering"] = "no";
response.Headers["Connection"] = "keep-alive";
response.ContentType = "text/event-stream";
}
private async Task EmitRunDifferencesAsync(HttpResponse response, Run previous, Run current, CancellationToken cancellationToken)
{
var stateChanged = current.State != previous.State || current.StartedAt != previous.StartedAt || current.FinishedAt != previous.FinishedAt || !string.Equals(current.Error, previous.Error, StringComparison.Ordinal);
if (stateChanged)
{
await SseWriter.WriteEventAsync(response, "stateChanged", RunStateChangedPayload.From(current), SerializerOptions, cancellationToken).ConfigureAwait(false);
}
if (!ReferenceEquals(current.Stats, previous.Stats) && current.Stats != previous.Stats)
{
await SseWriter.WriteEventAsync(response, "segmentProgress", RunStatsPayload.From(current), SerializerOptions, cancellationToken).ConfigureAwait(false);
}
if (!current.Deltas.SequenceEqual(previous.Deltas))
{
await SseWriter.WriteEventAsync(response, "deltaSummary", new RunDeltaPayload(current.Id, current.Deltas), SerializerOptions, cancellationToken).ConfigureAwait(false);
}
}
private sealed record RunSnapshotPayload(
[property: JsonPropertyName("run")] Run Run)
{
public static RunSnapshotPayload From(Run run)
=> new(run);
}
private sealed record RunStateChangedPayload(
[property: JsonPropertyName("runId")] string RunId,
[property: JsonPropertyName("state")] string State,
[property: JsonPropertyName("startedAt")] DateTimeOffset? StartedAt,
[property: JsonPropertyName("finishedAt")] DateTimeOffset? FinishedAt,
[property: JsonPropertyName("error")] string? Error)
{
public static RunStateChangedPayload From(Run run)
=> new(
run.Id,
run.State.ToString().ToLowerInvariant(),
run.StartedAt,
run.FinishedAt,
run.Error);
}
private sealed record RunStatsPayload(
[property: JsonPropertyName("runId")] string RunId,
[property: JsonPropertyName("stats")] RunStats Stats)
{
public static RunStatsPayload From(Run run)
=> new(run.Id, run.Stats);
}
private sealed record RunDeltaPayload(
[property: JsonPropertyName("runId")] string RunId,
[property: JsonPropertyName("deltas")] ImmutableArray<DeltaSummary> Deltas);
private sealed record HeartbeatPayload(
[property: JsonPropertyName("ts")] DateTimeOffset Timestamp)
{
public static HeartbeatPayload Create(DateTimeOffset timestamp)
=> new(timestamp);
}
private sealed record RunNotFoundPayload(
[property: JsonPropertyName("runId")] string RunId);
}
internal sealed class RunStreamOptions
{
private static readonly TimeSpan MinimumInterval = TimeSpan.FromMilliseconds(100);
private static readonly TimeSpan MinimumReconnectDelay = TimeSpan.FromMilliseconds(500);
public TimeSpan PollInterval { get; set; } = TimeSpan.FromSeconds(2);
public TimeSpan QueueLagInterval { get; set; } = TimeSpan.FromSeconds(10);
public TimeSpan HeartbeatInterval { get; set; } = TimeSpan.FromSeconds(5);
public TimeSpan ReconnectDelay { get; set; } = TimeSpan.FromSeconds(5);
public RunStreamOptions Validate()
{
if (PollInterval < MinimumInterval)
{
throw new ArgumentOutOfRangeException(nameof(PollInterval), PollInterval, "Poll interval must be at least 100ms.");
}
if (QueueLagInterval < MinimumInterval)
{
throw new ArgumentOutOfRangeException(nameof(QueueLagInterval), QueueLagInterval, "Queue lag interval must be at least 100ms.");
}
if (HeartbeatInterval < MinimumInterval)
{
throw new ArgumentOutOfRangeException(nameof(HeartbeatInterval), HeartbeatInterval, "Heartbeat interval must be at least 100ms.");
}
if (ReconnectDelay < MinimumReconnectDelay)
{
throw new ArgumentOutOfRangeException(nameof(ReconnectDelay), ReconnectDelay, "Reconnect delay must be at least 500ms.");
}
return this;
}
}

View File

@@ -0,0 +1,45 @@
using System;
using System.IO;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
namespace StellaOps.Scheduler.WebService.Runs;
internal static class SseWriter
{
public static async Task WriteRetryAsync(HttpResponse response, TimeSpan reconnectDelay, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(response);
var milliseconds = (int)Math.Clamp(reconnectDelay.TotalMilliseconds, 1, int.MaxValue);
await response.WriteAsync($"retry: {milliseconds}\r\n\r\n", cancellationToken).ConfigureAwait(false);
await response.Body.FlushAsync(cancellationToken).ConfigureAwait(false);
}
public static async Task WriteEventAsync(HttpResponse response, string eventName, object payload, JsonSerializerOptions serializerOptions, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(response);
ArgumentNullException.ThrowIfNull(payload);
ArgumentNullException.ThrowIfNull(serializerOptions);
if (string.IsNullOrWhiteSpace(eventName))
{
throw new ArgumentException("Event name must be provided.", nameof(eventName));
}
await response.WriteAsync($"event: {eventName}\r\n", cancellationToken).ConfigureAwait(false);
var json = JsonSerializer.Serialize(payload, serializerOptions);
using var reader = new StringReader(json);
string? line;
while ((line = reader.ReadLine()) is not null)
{
await response.WriteAsync($"data: {line}\r\n", cancellationToken).ConfigureAwait(false);
}
await response.WriteAsync("\r\n", cancellationToken).ConfigureAwait(false);
await response.Body.FlushAsync(cancellationToken).ConfigureAwait(false);
}
}