diff --git a/src/KArtSell.Host/Jobs/HistoricalBatchShadowRunJob.cs b/src/KArtSell.Host/Jobs/HistoricalBatchShadowRunJob.cs new file mode 100644 index 00000000..8c13a1bc --- /dev/null +++ b/src/KArtSell.Host/Jobs/HistoricalBatchShadowRunJob.cs @@ -0,0 +1,87 @@ +using Hangfire; +using KArtSell.BuildingBlocks.Time; +using KArtSell.Modules.ModelOperations.ShadowRun; +using Microsoft.Extensions.Logging; + +namespace KArtSell.Host.Jobs; + +/// +/// Historical batch shadow run: processes full 1-year past data in single job. +/// +/// Usage: Manually trigger or schedule daily at off-peak (e.g., 21:00 KST). +/// Invokes ShadowRunJob with 1-year date range to complete Phase 1 validation. +/// +/// Idempotency: Each run generates unique RunId + IdempotencyKey; safe to retry. +/// +public sealed class HistoricalBatchShadowRunJob( + IBackgroundJobClient backgroundJobClient, + ShadowRunQueries queries, + IClock clock, + ILogger logger) +{ + private static readonly Action LogHistoricalBatchStarted = + LoggerMessage.Define( + LogLevel.Information, + new EventId(1, nameof(LogHistoricalBatchStarted)), + "Historical batch shadow run initiated: {Start:yyyy-MM-dd} to {End:yyyy-MM-dd}"); + + private static readonly Action LogBatchJobEnqueued = + LoggerMessage.Define( + LogLevel.Information, + new EventId(2, nameof(LogBatchJobEnqueued)), + "Historical batch job {RunId} enqueued; Hangfire job: {JobId}"); + + /// + /// Execute historical batch: load past 1 year of data and validate in single job. + /// + [Queue("q-research")] + [DisableConcurrentExecution(timeoutInSeconds: 300)] // 5 min to queue; job itself has 30 min + public async Task ExecuteAsync(Guid? modelId = null, CancellationToken cancellationToken = default) + { + // Use provided modelId or default to all-models sentinel + var targetModelId = modelId ?? Guid.Empty; + + // Calculate 1-year window: 252 trading days ≈ 365 calendar days + var endDate = DateOnly.FromDateTime(clock.UtcNow.Date); + var startDate = endDate.AddDays(-365); // 1 year back + + LogHistoricalBatchStarted(logger, startDate, endDate, null); + + try + { + // Create unique run + var runId = Guid.NewGuid(); + var idempotencyKey = Guid.NewGuid(); + var correlationId = Guid.NewGuid(); + + // Pre-insert shadow_run with "Queued" status + var createdAt = clock.UtcNow; + await queries.InsertShadowRunQueuedAsync( + runId, + targetModelId, + startDate, + endDate, + createdAt, + CancellationToken.None); + + // Enqueue ShadowRunJob with 1-year range + var command = new ShadowRunCommand( + ModelId: targetModelId, + CorrelationId: correlationId, + IdempotencyKey: idempotencyKey, + WindowStartDate: startDate, + WindowEndDate: endDate, + PhaseFilter: MarketPhaseFilter.All); + + var jobId = backgroundJobClient.Enqueue( + job => job.ExecuteAsync(command, CancellationToken.None)); + + LogBatchJobEnqueued(logger, runId, jobId, null); + } + catch (Exception ex) + { + logger.LogError(ex, "Historical batch shadow run failed to enqueue"); + throw; + } + } +} diff --git a/src/KArtSell.Host/Jobs/ShadowRunJob.cs b/src/KArtSell.Host/Jobs/ShadowRunJob.cs index c42adb80..1665f0a7 100644 --- a/src/KArtSell.Host/Jobs/ShadowRunJob.cs +++ b/src/KArtSell.Host/Jobs/ShadowRunJob.cs @@ -76,7 +76,7 @@ public sealed class ShadowRunJob( "Shadow run {RunId} phase 4 (phase segmentation) complete"); [Queue("q-research")] - [DisableConcurrentExecution(timeoutInSeconds: 3600)] // Max 60 minutes + [DisableConcurrentExecution(timeoutInSeconds: 1800)] // 30 min for bulk historical (252+ days) [AutomaticRetry(Attempts = MaxAttempts, OnAttemptsExceeded = AttemptsExceededAction.Fail)] public async Task ExecuteAsync(ShadowRunCommand command, CancellationToken cancellationToken = default) { diff --git a/src/KArtSell.Host/Program.cs b/src/KArtSell.Host/Program.cs index 7c2f4b8c..e1a1a190 100644 --- a/src/KArtSell.Host/Program.cs +++ b/src/KArtSell.Host/Program.cs @@ -395,6 +395,7 @@ else // Register other Hangfire jobs var kstTimeZone = TimeZoneInfo.FindSystemTimeZoneById("Asia/Seoul"); +try { RecurringJob.AddOrUpdate("historical-batch-shadow-run", job => job.ExecuteAsync(null, CancellationToken.None), "0 21 * * *", new RecurringJobOptions { TimeZone = kstTimeZone }); logger.LogInformation("✅ historical-batch-shadow-run registered (daily 21:00 KST)"); } catch (Exception ex) { logger.LogWarning(ex, "⚠️ historical-batch-shadow-run error"); } try { RecurringJob.AddOrUpdate("opendart-daily-batch", job => job.ExecuteAsync(CancellationToken.None), "0 9 * * *", new RecurringJobOptions { TimeZone = kstTimeZone }); logger.LogInformation("✅ opendart-daily-batch registered"); } catch (Exception ex) { logger.LogWarning(ex, "⚠️ opendart-daily-batch error"); } try { RecurringJob.AddOrUpdate("daily-recommendation", job => job.ExecuteAsync(CancellationToken.None), "0 9 * * *", new RecurringJobOptions { TimeZone = kstTimeZone }); logger.LogInformation("✅ daily-recommendation registered"); } catch (Exception ex) { logger.LogWarning(ex, "⚠️ daily-recommendation error"); } try { RecurringJob.AddOrUpdate("weekly-recommendation", job => job.ExecuteAsync(CancellationToken.None), "0 9 * * 6", new RecurringJobOptions { TimeZone = kstTimeZone }); logger.LogInformation("✅ weekly-recommendation registered"); } catch (Exception ex) { logger.LogWarning(ex, "⚠️ weekly-recommendation error"); }