using Hangfire; using Hangfire.States; using Hangfire.Dashboard; using Hangfire.PostgreSql; using Hangfire.MemoryStorage; using System.Linq.Expressions; using QuantEngine.Application.Services; using QuantEngine.Application.Interfaces; using QuantEngine.Infrastructure.Data; using Microsoft.Extensions.Configuration; 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; public SchedulerService( ILogger logger, IBackgroundJobClient jobClient, IRecurringJobManager recurringJobManager, IServiceScopeFactory scopeFactory, IConfiguration configuration) { _logger = logger; _jobClient = jobClient; _recurringJobManager = recurringJobManager; _scopeFactory = scopeFactory; _configuration = configuration; } /// /// Initialize scheduled jobs /// public void InitializeSchedules() { try { _logger.LogInformation("Initializing Hangfire schedules..."); // Daily data collection at 9:00 AM _recurringJobManager.AddOrUpdate( "daily-collection", () => RunDailyCollectionAsync(), "0 9 * * *", // Every day at 9:00 AM new RecurringJobOptions { TimeZone = TimeZoneInfo.Local } ); // Hourly price update (during market hours 9 AM - 4 PM, every 2 hours) _recurringJobManager.AddOrUpdate( "hourly-price-update", () => UpdatePricesAsync(), "0 9,11,13,15 * * 1-5", // 9:00, 11:00, 13:00, 15:00 on Mon-Fri new RecurringJobOptions { TimeZone = TimeZoneInfo.Local } ); // Weekly report generation (Friday at 5:00 PM) _recurringJobManager.AddOrUpdate( "weekly-report", () => GenerateWeeklyReportAsync(), "0 17 * * 5", // Every Friday at 5:00 PM new RecurringJobOptions { TimeZone = TimeZoneInfo.Local } ); // Monthly optimization (First day of month at 2:00 AM) _recurringJobManager.AddOrUpdate( "monthly-optimization", () => RunMonthlyOptimizationAsync(), "0 2 1 * *", // First day of month at 2:00 AM new RecurringJobOptions { TimeZone = TimeZoneInfo.Local } ); _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); // List of tickers to collect var tickers = new[] { "005930", "000660", "051910", "005380", "010140", "005490" }; // 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 orchestrator.RunCollectionAsync(runId, accountMode, tickers.ToList()); // 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); var tickers = new[] { "005930", "000660", "051910" }; 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); } } _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); } catch (Exception ex) { _logger.LogError(ex, "Error fetching price for {Ticker}", ticker); } } /// /// Generate weekly report /// public async Task GenerateWeeklyReportAsync() { try { _logger.LogInformation("Starting weekly report generation at {Time}", DateTime.Now); // TODO: Implement report generation logic await Task.Delay(500); _logger.LogInformation("Weekly report generated successfully"); } catch (Exception ex) { _logger.LogError(ex, "Error generating weekly report"); } } /// /// Run monthly optimization /// public async Task RunMonthlyOptimizationAsync() { try { _logger.LogInformation("Starting monthly optimization at {Time}", DateTime.Now); // TODO: Implement optimization logic await Task.Delay(1000); _logger.LogInformation("Monthly optimization completed"); } catch (Exception ex) { _logger.LogError(ex, "Error during monthly 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; } /// /// 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; } }