refactor(dotnet): add scheduler audit trail
This commit is contained in:
@@ -87,6 +87,26 @@ public class SchedulerServiceTests
|
|||||||
Assert.Contains(defs, d => d.JobId == "monthly-optimization" && d.IsRecurring);
|
Assert.Contains(defs, d => d.JobId == "monthly-optimization" && d.IsRecurring);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FetchPriceAsync_WritesAuditTrail()
|
||||||
|
{
|
||||||
|
var root = FindRepoRoot();
|
||||||
|
var auditDir = Path.Combine(root, "Temp", "scheduler_audit");
|
||||||
|
if (Directory.Exists(auditDir))
|
||||||
|
{
|
||||||
|
Directory.Delete(auditDir, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
var service = CreateService();
|
||||||
|
await service.FetchPriceAsync("005930");
|
||||||
|
|
||||||
|
var auditPath = Path.Combine(auditDir, "fetch-price.jsonl");
|
||||||
|
Assert.True(File.Exists(auditPath));
|
||||||
|
var lines = File.ReadAllLines(auditPath);
|
||||||
|
Assert.NotEmpty(lines);
|
||||||
|
Assert.Contains("\"State\":\"SUCCEEDED\"", lines[0]);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void LoadTickersFromJson_WhenFileMissing_FallsBackToDefaultUniverse()
|
public void LoadTickersFromJson_WhenFileMissing_FallsBackToDefaultUniverse()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
namespace QuantEngine.Web.Services;
|
namespace QuantEngine.Web.Services;
|
||||||
|
|
||||||
|
using System;
|
||||||
|
|
||||||
public sealed record SchedulerJobDefinition(
|
public sealed record SchedulerJobDefinition(
|
||||||
string JobId,
|
string JobId,
|
||||||
string Cron,
|
string Cron,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Hangfire.States;
|
|||||||
using Hangfire.Dashboard;
|
using Hangfire.Dashboard;
|
||||||
using Hangfire.PostgreSql;
|
using Hangfire.PostgreSql;
|
||||||
using Hangfire.MemoryStorage;
|
using Hangfire.MemoryStorage;
|
||||||
|
using System.Text.Json;
|
||||||
using System.Linq.Expressions;
|
using System.Linq.Expressions;
|
||||||
using QuantEngine.Application.Services;
|
using QuantEngine.Application.Services;
|
||||||
using QuantEngine.Application.Interfaces;
|
using QuantEngine.Application.Interfaces;
|
||||||
@@ -22,6 +23,7 @@ public class SchedulerService
|
|||||||
private readonly IServiceScopeFactory _scopeFactory;
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
private readonly IConfiguration _configuration;
|
private readonly IConfiguration _configuration;
|
||||||
private readonly GatherTradingDataParser _parser;
|
private readonly GatherTradingDataParser _parser;
|
||||||
|
private readonly string _auditRoot;
|
||||||
|
|
||||||
public SchedulerService(
|
public SchedulerService(
|
||||||
ILogger<SchedulerService> logger,
|
ILogger<SchedulerService> logger,
|
||||||
@@ -37,6 +39,55 @@ public class SchedulerService
|
|||||||
_scopeFactory = scopeFactory;
|
_scopeFactory = scopeFactory;
|
||||||
_configuration = configuration;
|
_configuration = configuration;
|
||||||
_parser = parser;
|
_parser = parser;
|
||||||
|
_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()
|
private List<string> LoadTickersFromJson()
|
||||||
@@ -139,6 +190,7 @@ public class SchedulerService
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Starting daily data collection job at {Time}", DateTime.Now);
|
_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();
|
var tickers = LoadTickersFromJson();
|
||||||
|
|
||||||
@@ -153,7 +205,10 @@ public class SchedulerService
|
|||||||
var accountMode = _configuration["Kis:AccountMode"] ?? "mock";
|
var accountMode = _configuration["Kis:AccountMode"] ?? "mock";
|
||||||
|
|
||||||
// Execute collection
|
// Execute collection
|
||||||
var result = await orchestrator.RunCollectionAsync(runId, accountMode, tickers);
|
var result = await ExecuteWithAuditAsync(
|
||||||
|
"daily-collection",
|
||||||
|
() => orchestrator.RunCollectionAsync(runId, accountMode, tickers),
|
||||||
|
resourceKey: "collection");
|
||||||
|
|
||||||
// Log completion
|
// Log completion
|
||||||
_logger.LogInformation("Collection run {RunId} completed: {Snapshots} snapshots, {Errors} errors",
|
_logger.LogInformation("Collection run {RunId} completed: {Snapshots} snapshots, {Errors} errors",
|
||||||
@@ -173,6 +228,7 @@ public class SchedulerService
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Starting hourly price update at {Time}", DateTime.Now);
|
_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();
|
var tickers = LoadTickersFromJson();
|
||||||
|
|
||||||
@@ -186,6 +242,7 @@ public class SchedulerService
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to enqueue price update for {Ticker}", ticker);
|
_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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,10 +265,12 @@ public class SchedulerService
|
|||||||
// TODO: Implement actual price fetching
|
// TODO: Implement actual price fetching
|
||||||
await Task.Delay(50);
|
await Task.Delay(50);
|
||||||
_logger.LogInformation("Price fetched successfully for {Ticker}", ticker);
|
_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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Error fetching price for {Ticker}", ticker);
|
_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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,15 +282,18 @@ public class SchedulerService
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Starting weekly report generation at {Time}", DateTime.Now);
|
_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
|
// TODO: Implement report generation logic
|
||||||
await Task.Delay(500);
|
await Task.Delay(500);
|
||||||
|
|
||||||
_logger.LogInformation("Weekly report generated successfully");
|
_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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Error generating weekly report");
|
_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"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,15 +305,18 @@ public class SchedulerService
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Starting monthly optimization at {Time}", DateTime.Now);
|
_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
|
// TODO: Implement optimization logic
|
||||||
await Task.Delay(1000);
|
await Task.Delay(1000);
|
||||||
|
|
||||||
_logger.LogInformation("Monthly optimization completed");
|
_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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Error during monthly optimization");
|
_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"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user