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).
///