using Hangfire; using Hangfire.States; using Hangfire.Dashboard; using Hangfire.PostgreSql; using Hangfire.MemoryStorage; using System.Text.Json; using System.Linq.Expressions; using QuantEngine.Application.Services; using QuantEngine.Application.Interfaces; using QuantEngine.Infrastructure.Data; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Options; namespace QuantEngine.Web.Services; /// /// Scheduler Service for managing background jobs with Hangfire /// public class SchedulerService { private readonly ILogger _logger; private readonly IBackgroundJobClient _jobClient; private readonly IRecurringJobManager _recurringJobManager; private readonly IServiceScopeFactory _scopeFactory; private readonly IConfiguration _configuration; private readonly GatherTradingDataParser _parser; private readonly SchedulerServiceOptions _options; private readonly string _auditRoot; public SchedulerService( ILogger logger, IBackgroundJobClient jobClient, IRecurringJobManager recurringJobManager, IServiceScopeFactory scopeFactory, IConfiguration configuration, GatherTradingDataParser parser, IOptions options) { _logger = logger; _jobClient = jobClient; _recurringJobManager = recurringJobManager; _scopeFactory = scopeFactory; _configuration = configuration; _parser = parser; _options = options.Value ?? new SchedulerServiceOptions(); _auditRoot = FindRepoTempRoot(); } private static string FindRepoTempRoot() { var current = new DirectoryInfo(AppContext.BaseDirectory); while (current != null) { if (Directory.Exists(Path.Combine(current.FullName, ".git"))) { return Path.Combine(current.FullName, "Temp", "scheduler_audit"); } current = current.Parent; } return Path.Combine(Directory.GetCurrentDirectory(), "Temp", "scheduler_audit"); } private void AppendAudit(SchedulerJobExecutionAudit audit) { Directory.CreateDirectory(_auditRoot); var path = Path.Combine(_auditRoot, $"{audit.JobId}.jsonl"); File.AppendAllText(path, JsonSerializer.Serialize(audit, new JsonSerializerOptions { WriteIndented = false }) + Environment.NewLine); } private async Task ExecuteWithAuditAsync(string jobId, Func> action, Func? fallback = null, string? resourceKey = null) { var startedAt = DateTimeOffset.UtcNow; AppendAudit(new SchedulerJobExecutionAudit(jobId, $"{jobId}-{startedAt:yyyyMMddHHmmssfff}", SchedulerStates.Running, null, startedAt, null, resourceKey)); try { var result = await action(); AppendAudit(new SchedulerJobExecutionAudit(jobId, $"{jobId}-{startedAt:yyyyMMddHHmmssfff}", SchedulerStates.Succeeded, null, startedAt, DateTimeOffset.UtcNow, resourceKey)); return result; } catch (Exception ex) { AppendAudit(new SchedulerJobExecutionAudit(jobId, $"{jobId}-{startedAt:yyyyMMddHHmmssfff}", SchedulerStates.Failed, ex.Message, startedAt, DateTimeOffset.UtcNow, resourceKey)); if (fallback is not null) { return fallback(); } throw; } } private async Task ExecuteWithAuditAsync(string jobId, Func action, string? resourceKey = null) { await ExecuteWithAuditAsync(jobId, async () => { await action(); return true; }, resourceKey: resourceKey); } private List LoadTickersFromJson() { try { var jsonPath = FindGatherTradingDataJson(); if (string.IsNullOrEmpty(jsonPath)) { _logger.LogWarning("GatherTradingData.json not found, falling back to default universe"); return new List { "005930" }; } var data = _parser.ParseGatherTradingData(jsonPath); var tickers = new HashSet(); foreach (var row in data) { if (row.TryGetValue("Ticker", out var tickerObj) && tickerObj is string tickerRaw && !string.IsNullOrEmpty(tickerRaw)) { var ticker = tickerRaw.Trim('"'); if (!string.IsNullOrEmpty(ticker)) { tickers.Add(ticker); } } } var result = tickers.ToList(); _logger.LogInformation("Loaded {Count} tickers from GatherTradingData.json", result.Count); return result; } catch (Exception ex) { _logger.LogWarning(ex, "Error loading tickers from GatherTradingData.json, falling back to default universe"); return new List { "005930" }; } } public IReadOnlyList GetRecurringJobDefinitions() => _options.JobDefinitions.Count > 0 ? _options.JobDefinitions : new SchedulerServiceOptions().JobDefinitions; private static string? FindGatherTradingDataJson() { var baseDir = AppContext.BaseDirectory; var current = new DirectoryInfo(baseDir); while (current != null) { var gatherPath = Path.Combine(current.FullName, "GatherTradingData.json"); if (Directory.Exists(Path.Combine(current.FullName, ".git")) || File.Exists(gatherPath)) { return File.Exists(gatherPath) ? gatherPath : null; } current = current.Parent; } return null; } /// /// Initialize scheduled jobs /// public void InitializeSchedules() { try { _logger.LogInformation("Initializing Hangfire schedules..."); foreach (var job in GetRecurringJobDefinitions()) { _recurringJobManager.AddOrUpdate( job.JobId, ResolveRecurringJob(job.JobId), job.Cron, new RecurringJobOptions { TimeZone = TimeZoneInfo.Local } ); _logger.LogInformation("Registered recurring job {JobId}: {Description}", job.JobId, job.Description); } _logger.LogInformation("Hangfire schedules initialized successfully"); } catch (Exception ex) { _logger.LogError(ex, "Error initializing Hangfire schedules"); } } /// /// Run daily data collection /// public async Task RunDailyCollectionAsync() { try { _logger.LogInformation("Starting daily data collection job at {Time}", DateTime.Now); AppendAudit(new SchedulerJobExecutionAudit("daily-collection", $"daily-{DateTime.UtcNow:yyyyMMddHHmmssfff}", SchedulerStates.Pending, null, DateTimeOffset.UtcNow, null, "collection")); var tickers = LoadTickersFromJson(); // Create scope for scoped services using var scope = _scopeFactory.CreateScope(); var orchestrator = scope.ServiceProvider.GetRequiredService(); // Build runId with timestamp var runId = $"daily-{DateTime.Now:yyyyMMdd-HHmmss}"; // Read account mode from configuration (default to "mock") var accountMode = _configuration["Kis:AccountMode"] ?? "mock"; // Execute collection var result = await ExecuteWithAuditAsync( "daily-collection", () => orchestrator.RunCollectionAsync(runId, accountMode, tickers), resourceKey: "collection"); // Log completion _logger.LogInformation("Collection run {RunId} completed: {Snapshots} snapshots, {Errors} errors", runId, result.SuccessCount, result.ErrorCount); } catch (Exception ex) { _logger.LogError(ex, "Error during daily collection"); } } /// /// Update prices hourly /// public async Task UpdatePricesAsync() { try { _logger.LogInformation("Starting hourly price update at {Time}", DateTime.Now); AppendAudit(new SchedulerJobExecutionAudit("hourly-price-update", $"hourly-{DateTime.UtcNow:yyyyMMddHHmmssfff}", SchedulerStates.Pending, null, DateTimeOffset.UtcNow, null, "price-update")); var tickers = LoadTickersFromJson(); foreach (var ticker in tickers) { try { // Enqueue price update as background job _jobClient.Enqueue(() => FetchPriceAsync(ticker)); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to enqueue price update for {Ticker}", ticker); AppendAudit(new SchedulerJobExecutionAudit("hourly-price-update", $"hourly-{DateTime.UtcNow:yyyyMMddHHmmssfff}", SchedulerStates.Retrying, ex.Message, DateTimeOffset.UtcNow, null, ticker)); } } _logger.LogInformation("Hourly price update completed"); } catch (Exception ex) { _logger.LogError(ex, "Error during price update"); } } /// /// Fetch price for specific ticker /// public async Task FetchPriceAsync(string ticker) { try { _logger.LogInformation("Fetching price for ticker: {Ticker}", ticker); // TODO: Implement actual price fetching await Task.Delay(50); _logger.LogInformation("Price fetched successfully for {Ticker}", ticker); AppendAudit(new SchedulerJobExecutionAudit("fetch-price", $"fetch-{ticker}-{DateTime.UtcNow:yyyyMMddHHmmssfff}", SchedulerStates.Succeeded, null, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, ticker)); } catch (Exception ex) { _logger.LogError(ex, "Error fetching price for {Ticker}", ticker); AppendAudit(new SchedulerJobExecutionAudit("fetch-price", $"fetch-{ticker}-{DateTime.UtcNow:yyyyMMddHHmmssfff}", SchedulerStates.Failed, ex.Message, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, ticker)); } } /// /// Generate weekly report /// public async Task GenerateWeeklyReportAsync() { try { _logger.LogInformation("Starting weekly report generation at {Time}", DateTime.Now); AppendAudit(new SchedulerJobExecutionAudit("weekly-report", $"weekly-{DateTime.UtcNow:yyyyMMddHHmmssfff}", SchedulerStates.Pending, null, DateTimeOffset.UtcNow, null, "report")); // TODO: Implement report generation logic await Task.Delay(500); _logger.LogInformation("Weekly report generated successfully"); AppendAudit(new SchedulerJobExecutionAudit("weekly-report", $"weekly-{DateTime.UtcNow:yyyyMMddHHmmssfff}", SchedulerStates.Succeeded, null, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, "report")); } catch (Exception ex) { _logger.LogError(ex, "Error generating weekly report"); AppendAudit(new SchedulerJobExecutionAudit("weekly-report", $"weekly-{DateTime.UtcNow:yyyyMMddHHmmssfff}", SchedulerStates.Failed, ex.Message, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, "report")); } } /// /// Run monthly optimization /// public async Task RunMonthlyOptimizationAsync() { try { _logger.LogInformation("Starting monthly optimization at {Time}", DateTime.Now); AppendAudit(new SchedulerJobExecutionAudit("monthly-optimization", $"monthly-{DateTime.UtcNow:yyyyMMddHHmmssfff}", SchedulerStates.Pending, null, DateTimeOffset.UtcNow, null, "optimization")); // TODO: Implement optimization logic await Task.Delay(1000); _logger.LogInformation("Monthly optimization completed"); AppendAudit(new SchedulerJobExecutionAudit("monthly-optimization", $"monthly-{DateTime.UtcNow:yyyyMMddHHmmssfff}", SchedulerStates.Succeeded, null, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, "optimization")); } catch (Exception ex) { _logger.LogError(ex, "Error during monthly optimization"); AppendAudit(new SchedulerJobExecutionAudit("monthly-optimization", $"monthly-{DateTime.UtcNow:yyyyMMddHHmmssfff}", SchedulerStates.Failed, ex.Message, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, "optimization")); } } /// /// Enqueue one-time job /// public string EnqueueJob(string jobName, Expression> job) { var jobId = _jobClient.Enqueue(job); _logger.LogInformation("Enqueued job {JobName} with ID {JobId}", jobName, jobId); return jobId; } /// /// Get job status /// public string? GetJobStatus(string jobId) { return JobStorage.Current.GetConnection().GetJobData(jobId)?.State; } private Expression> ResolveRecurringJob(string jobId) => jobId switch { "daily-collection" => () => RunDailyCollectionAsync(), "hourly-price-update" => () => UpdatePricesAsync(), "weekly-report" => () => GenerateWeeklyReportAsync(), "monthly-optimization" => () => RunMonthlyOptimizationAsync(), _ => throw new InvalidOperationException($"Unknown recurring job id: {jobId}") }; /// /// Cancel scheduled job /// public void CancelScheduledJob(string jobName) { _recurringJobManager.RemoveIfExists(jobName); _logger.LogInformation("Cancelled scheduled job: {JobName}", jobName); } } /// /// Extension methods for Hangfire registration /// public static class HangfireServiceExtensions { /// /// Register Hangfire with SQL Server storage /// public static IServiceCollection AddHangfireServices( this IServiceCollection services, string connectionString) { // Add Hangfire services services.AddHangfire(configuration => { configuration .SetDataCompatibilityLevel(CompatibilityLevel.Version_180) .UseSimpleAssemblyNameTypeSerializer() .UseRecommendedSerializerSettings(); try { using (var conn = new Npgsql.NpgsqlConnection(connectionString)) { conn.Open(); } configuration.UsePostgreSqlStorage(options => options.UseNpgsqlConnection(connectionString), new PostgreSqlStorageOptions { QueuePollInterval = TimeSpan.FromSeconds(15), PrepareSchemaIfNecessary = true }); Console.WriteLine("[Hangfire] Configured PostgreSQL storage successfully."); } catch (Exception ex) { Console.WriteLine($"[Hangfire] PostgreSQL connection failed ({ex.Message}). Falling back to MemoryStorage."); configuration.UseMemoryStorage(); } }); // Add Hangfire server services.AddHangfireServer(options => { options.WorkerCount = Environment.ProcessorCount * 2; options.Queues = new[] { "default" }; }); // Register scheduler service services.AddScoped(); return services; } /// /// Use Hangfire dashboard and initialize schedules /// public static IApplicationBuilder UseHangfireSetup( this IApplicationBuilder app, IServiceProvider serviceProvider) { // Use Hangfire Dashboard app.UseHangfireDashboard("/hangfire", new DashboardOptions { Authorization = new[] { new HangfireAuthorizationFilter() } }); // Initialize schedules. SchedulerService is registered as Scoped // (AddScoped above), so it cannot be resolved directly from the // root provider passed in here (app.Services) -- doing so silently // failed every startup with "Cannot resolve scoped service // 'SchedulerService' from root provider", meaning the recurring // jobs were never being freshly registered/updated on boot (they // only appeared to work because Hangfire persists them in // PostgreSQL from whichever startup last managed to run this). using var scope = serviceProvider.CreateScope(); var schedulerService = scope.ServiceProvider.GetRequiredService(); schedulerService.InitializeSchedules(); return app; } } /// /// Simple authorization filter for Hangfire Dashboard /// public class HangfireAuthorizationFilter : IDashboardAuthorizationFilter { public bool Authorize(DashboardContext context) { var httpContext = context.GetHttpContext(); return httpContext.User.Identity?.IsAuthenticated == true; } }