453 lines
17 KiB
C#
453 lines
17 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Scheduler Service for managing background jobs with Hangfire
|
|
/// </summary>
|
|
public class SchedulerService
|
|
{
|
|
private readonly ILogger<SchedulerService> _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<SchedulerService> logger,
|
|
IBackgroundJobClient jobClient,
|
|
IRecurringJobManager recurringJobManager,
|
|
IServiceScopeFactory scopeFactory,
|
|
IConfiguration configuration,
|
|
GatherTradingDataParser parser,
|
|
IOptions<SchedulerServiceOptions> 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<T> ExecuteWithAuditAsync<T>(string jobId, Func<Task<T>> action, Func<T>? 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<Task> action, string? resourceKey = null)
|
|
{
|
|
await ExecuteWithAuditAsync(jobId, async () => { await action(); return true; }, resourceKey: resourceKey);
|
|
}
|
|
|
|
private List<string> LoadTickersFromJson()
|
|
{
|
|
try
|
|
{
|
|
var jsonPath = FindGatherTradingDataJson();
|
|
if (string.IsNullOrEmpty(jsonPath))
|
|
{
|
|
_logger.LogWarning("GatherTradingData.json not found, falling back to default universe");
|
|
return new List<string> { "005930" };
|
|
}
|
|
|
|
var data = _parser.ParseGatherTradingData(jsonPath);
|
|
var tickers = new HashSet<string>();
|
|
|
|
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<string> { "005930" };
|
|
}
|
|
}
|
|
|
|
public IReadOnlyList<SchedulerJobDefinition> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Initialize scheduled jobs
|
|
/// </summary>
|
|
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");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Run daily data collection
|
|
/// </summary>
|
|
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<ICollectionOrchestrator>();
|
|
|
|
// 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");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Update prices hourly
|
|
/// </summary>
|
|
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");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fetch price for specific ticker
|
|
/// </summary>
|
|
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));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Generate weekly report
|
|
/// </summary>
|
|
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"));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Run monthly optimization
|
|
/// </summary>
|
|
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"));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Enqueue one-time job
|
|
/// </summary>
|
|
public string EnqueueJob(string jobName, Expression<Func<Task>> job)
|
|
{
|
|
var jobId = _jobClient.Enqueue(job);
|
|
_logger.LogInformation("Enqueued job {JobName} with ID {JobId}", jobName, jobId);
|
|
return jobId;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get job status
|
|
/// </summary>
|
|
public string? GetJobStatus(string jobId)
|
|
{
|
|
return JobStorage.Current.GetConnection().GetJobData(jobId)?.State;
|
|
}
|
|
|
|
private Expression<Func<Task>> 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}")
|
|
};
|
|
|
|
/// <summary>
|
|
/// Cancel scheduled job
|
|
/// </summary>
|
|
public void CancelScheduledJob(string jobName)
|
|
{
|
|
_recurringJobManager.RemoveIfExists(jobName);
|
|
_logger.LogInformation("Cancelled scheduled job: {JobName}", jobName);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Extension methods for Hangfire registration
|
|
/// </summary>
|
|
public static class HangfireServiceExtensions
|
|
{
|
|
/// <summary>
|
|
/// Register Hangfire with SQL Server storage
|
|
/// </summary>
|
|
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<SchedulerService>();
|
|
|
|
return services;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Use Hangfire dashboard and initialize schedules
|
|
/// </summary>
|
|
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>();
|
|
schedulerService.InitializeSchedules();
|
|
|
|
return app;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Simple authorization filter for Hangfire Dashboard
|
|
/// </summary>
|
|
public class HangfireAuthorizationFilter : IDashboardAuthorizationFilter
|
|
{
|
|
public bool Authorize(DashboardContext context)
|
|
{
|
|
var httpContext = context.GetHttpContext();
|
|
return httpContext.User.Identity?.IsAuthenticated == true;
|
|
}
|
|
}
|