using Hangfire; using KArtSell.BuildingBlocks.Time; using KArtSell.Host.Jobs; using KArtSell.Modules.ModelOperations.ShadowRun; using Microsoft.Extensions.Logging; namespace KArtSell.Host.Features.ShadowRun; /// /// 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) { private static readonly Action LogInitiated = LoggerMessage.Define( LogLevel.Information, new EventId(1, nameof(LogInitiated)), "Shadow run {RunId} initiated for model {ModelId} ({Start:yyyy-MM-dd} to {End:yyyy-MM-dd})"); private static readonly Action LogJobEnqueued = LoggerMessage.Define( LogLevel.Information, new EventId(2, nameof(LogJobEnqueued)), "Hangfire job enqueued for shadow run {RunId}"); public async Task HandleAsync( InitiateShadowRunRequest request, Guid correlationId, CancellationToken cancellationToken) { // Validate: Model exists (stub for now; would query DB in production) if (request.ModelId == Guid.Empty) throw new InvalidOperationException("ModelId cannot be empty"); // Create shadow run command with idempotency key var idempotencyKey = Guid.NewGuid(); var runId = Guid.NewGuid(); var command = new ShadowRunCommand( ModelId: request.ModelId, CorrelationId: correlationId, IdempotencyKey: idempotencyKey, WindowStartDate: request.WindowStart, WindowEndDate: request.WindowEnd, PhaseFilter: ParsePhaseFilter(request.PhaseFilter)); 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 to q-evaluation queue (Host listens to this queue) var jobId = backgroundJobClient.Enqueue( "q-evaluation", job => job.ExecuteAsync(command, CancellationToken.None)); LogJobEnqueued(logger, runId, null); // Return response immediately (202 Accepted) return new InitiateShadowRunResponse( RunId: runId, Status: "Queued", JobId: jobId, EstimatedSeconds: 3600, // 1 hour estimate CreatedAt: createdAt); } private static MarketPhaseFilter ParsePhaseFilter(string phase) => phase switch { "BullMarket" => MarketPhaseFilter.BullMarket, "BearMarket" => MarketPhaseFilter.BearMarket, "Sideways" => MarketPhaseFilter.Sideways, "HighVolatility" => MarketPhaseFilter.HighVolatility, _ => MarketPhaseFilter.All }; }