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
};
}