diff --git a/src/KArtSell.Host/Features/ShadowRun/Handler.cs b/src/KArtSell.Host/Features/ShadowRun/Handler.cs index 02ff692d..905964d3 100644 --- a/src/KArtSell.Host/Features/ShadowRun/Handler.cs +++ b/src/KArtSell.Host/Features/ShadowRun/Handler.cs @@ -7,11 +7,13 @@ using Microsoft.Extensions.Logging; namespace KArtSell.Host.Features.ShadowRun; /// -/// Handles shadow run initiation: validates, creates job, enqueues to Hangfire. -/// Transaction boundary: Single DB write (shadow_run record) + Hangfire enqueue. -/// +/// Handles shadow run initiation: pre-insert shadow_run with "Queued" status, enqueue Hangfire job. +/// Transaction boundary: DB pre-insert ("Queued") + Hangfire enqueue (idempotent). +/// Polling endpoint works immediately after response (202 Accepted). +/// public sealed class InitiateShadowRunHandler( IBackgroundJobClient backgroundJobClient, + ShadowRunQueries queries, IClock clock, ILogger logger) { @@ -50,6 +52,16 @@ public sealed class InitiateShadowRunHandler( LogInitiated(logger, runId, request.ModelId, request.WindowStart, request.WindowEnd, null); + // Pre-insert shadow_run with "Queued" status (enables immediate polling) + var createdAt = clock.UtcNow; + await queries.InsertShadowRunQueuedAsync( + runId, + request.ModelId, + request.WindowStart, + request.WindowEnd, + createdAt, + CancellationToken.None); + // Enqueue Hangfire job (durable; survives app restart) var jobId = backgroundJobClient.Enqueue( job => job.ExecuteAsync(command, CancellationToken.None)); @@ -62,7 +74,7 @@ public sealed class InitiateShadowRunHandler( Status: "Queued", JobId: jobId, EstimatedSeconds: 3600, // 1 hour estimate - CreatedAt: clock.UtcNow); + CreatedAt: createdAt); } private static MarketPhaseFilter ParsePhaseFilter(string phase) => diff --git a/src/KArtSell.Modules.ModelOperations/ShadowRun/Sql.cs b/src/KArtSell.Modules.ModelOperations/ShadowRun/Sql.cs index fa92f0f1..fa07c3d3 100644 --- a/src/KArtSell.Modules.ModelOperations/ShadowRun/Sql.cs +++ b/src/KArtSell.Modules.ModelOperations/ShadowRun/Sql.cs @@ -50,6 +50,40 @@ public sealed class ShadowRunQueries(IDbConnectionFactory connectionFactory) cancellationToken: cancellationToken)); } + /// + /// Pre-insert shadow run with "Queued" status (no metrics yet). + /// Called by InitiateShadowRunHandler to enable immediate polling. + /// + public async Task InsertShadowRunQueuedAsync( + Guid runId, + Guid modelId, + DateOnly windowStart, + DateOnly windowEnd, + DateTimeOffset createdAt, + CancellationToken cancellationToken) + { + const string sql = """ + insert into model_operations.shadow_run + (run_id, model_id, window_start, window_end, status, created_at) + values (@RunId, @ModelId, @WindowStart, @WindowEnd, @Status, @CreatedAt) + """; + + await using var connection = await connectionFactory.OpenAsync(cancellationToken); + await connection.ExecuteAsync( + new CommandDefinition( + sql, + new + { + RunId = runId, + ModelId = modelId, + WindowStart = windowStart, + WindowEnd = windowEnd, + Status = "Queued", + CreatedAt = createdAt + }, + cancellationToken: cancellationToken)); + } + /// /// Retrieve latest shadow run for model (PIT: published_at <= cutoff). ///