From ee4ae5583d1873ed64d27cdfb7f29843cecd3148 Mon Sep 17 00:00:00 2001 From: kjh2064 Date: Mon, 13 Jul 2026 00:11:52 +0900 Subject: [PATCH] refactor(dotnet): add scheduler audit trail --- .../SchedulerServiceTests.cs | 20 ++++++ .../Services/SchedulerModels.cs | 2 + .../Services/SchedulerService.cs | 67 ++++++++++++++++++- 3 files changed, 88 insertions(+), 1 deletion(-) diff --git a/src/dotnet/QuantEngine.Core.Tests/SchedulerServiceTests.cs b/src/dotnet/QuantEngine.Core.Tests/SchedulerServiceTests.cs index 979bfa28..9477e763 100644 --- a/src/dotnet/QuantEngine.Core.Tests/SchedulerServiceTests.cs +++ b/src/dotnet/QuantEngine.Core.Tests/SchedulerServiceTests.cs @@ -87,6 +87,26 @@ public class SchedulerServiceTests 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] public void LoadTickersFromJson_WhenFileMissing_FallsBackToDefaultUniverse() { diff --git a/src/dotnet/QuantEngine.Web/Services/SchedulerModels.cs b/src/dotnet/QuantEngine.Web/Services/SchedulerModels.cs index b85371e7..c7ac2004 100644 --- a/src/dotnet/QuantEngine.Web/Services/SchedulerModels.cs +++ b/src/dotnet/QuantEngine.Web/Services/SchedulerModels.cs @@ -1,5 +1,7 @@ namespace QuantEngine.Web.Services; +using System; + public sealed record SchedulerJobDefinition( string JobId, string Cron, diff --git a/src/dotnet/QuantEngine.Web/Services/SchedulerService.cs b/src/dotnet/QuantEngine.Web/Services/SchedulerService.cs index 4a03047c..0d2ab612 100644 --- a/src/dotnet/QuantEngine.Web/Services/SchedulerService.cs +++ b/src/dotnet/QuantEngine.Web/Services/SchedulerService.cs @@ -3,6 +3,7 @@ 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; @@ -22,6 +23,7 @@ public class SchedulerService private readonly IServiceScopeFactory _scopeFactory; private readonly IConfiguration _configuration; private readonly GatherTradingDataParser _parser; + private readonly string _auditRoot; public SchedulerService( ILogger logger, @@ -37,6 +39,55 @@ public class SchedulerService _scopeFactory = scopeFactory; _configuration = configuration; _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 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() @@ -139,6 +190,7 @@ public class SchedulerService 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(); @@ -153,7 +205,10 @@ public class SchedulerService var accountMode = _configuration["Kis:AccountMode"] ?? "mock"; // 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 _logger.LogInformation("Collection run {RunId} completed: {Snapshots} snapshots, {Errors} errors", @@ -173,6 +228,7 @@ public class SchedulerService 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(); @@ -186,6 +242,7 @@ public class SchedulerService 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)); } } @@ -208,10 +265,12 @@ public class SchedulerService // 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)); } } @@ -223,15 +282,18 @@ public class SchedulerService 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")); } } @@ -243,15 +305,18 @@ public class SchedulerService 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")); } }