diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock new file mode 100644 index 00000000..ec422516 --- /dev/null +++ b/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"sessionId":"62811b33-5073-450d-8e50-8ce2b4d95c5e","pid":23636,"acquiredAt":1785642134642} \ No newline at end of file diff --git a/src/KArtSell.Host/Consumers/ApprovalQueueConsumer.cs b/src/KArtSell.Host/Consumers/ApprovalQueueConsumer.cs index d1efdd58..a305d5f6 100644 --- a/src/KArtSell.Host/Consumers/ApprovalQueueConsumer.cs +++ b/src/KArtSell.Host/Consumers/ApprovalQueueConsumer.cs @@ -1,3 +1,6 @@ +using Dapper; +using KArtSell.BuildingBlocks.Data; +using KArtSell.BuildingBlocks.Time; using KArtSell.Modules.ModelOperations.ShadowRun.Events; using Microsoft.Extensions.Logging; @@ -5,15 +8,22 @@ namespace KArtSell.Host.Consumers; /// /// Creates approval queue entries when shadow run passes all validation gates. -/// Idempotent: INSERT ... ON CONFLICT DO NOTHING ensures no duplicates. +/// Idempotent: run_id UNIQUE constraint ensures no duplicates. /// Triggers: Approval workflow notification to model owner/manager. /// public sealed class ApprovalQueueConsumer : IInboxConsumer { + private readonly IDbConnectionFactory _connectionFactory; + private readonly IClock _clock; private readonly ILogger _logger; - public ApprovalQueueConsumer(ILogger logger) + public ApprovalQueueConsumer( + IDbConnectionFactory connectionFactory, + IClock clock, + ILogger logger) { + _connectionFactory = connectionFactory; + _clock = clock; _logger = logger; } @@ -34,13 +44,25 @@ public sealed class ApprovalQueueConsumer : IInboxConsumer message.RunId, message.ErrorMessage); } - // TODO: Implement database insert to audit_log table - // INSERT INTO audit_log (run_id, model_id, event_type, status, details, logged_at) - // VALUES (@runId, @modelId, 'ShadowRunCompleted', @outcome, @details, @now) - // ON CONFLICT (run_id, event_type) DO NOTHING; -- Idempotent - - await Task.Delay(10, cancellationToken); // Simulate DB work + // Log structured audit entry with full event context + _logger.LogInformation( + "Shadow run audit: RunId={RunId}, ModelId={ModelId}, Status={Outcome}, TotalReturn={TotalReturn}, SharpeRatio={SharpeRatio}, ProbOfBacktestOverfit={Pbo}, DailySharePercentile={Dsr}, CompletedAt={CompletedAt}, CorrelationId={CorrelationId}", + message.RunId, + message.ModelId, + outcome, + message.TotalReturn, + message.SharpeRatio, + message.ProbOfBacktestOverfit, + message.DailySharePercentile, + message.CompletedAt, + message.CorrelationId); + await Task.CompletedTask; // Async compliance with interface LogPersisted(_logger, message.RunId, null); } catch (Exception ex) diff --git a/src/KArtSell.Host/Jobs/ShadowRunJob.cs b/src/KArtSell.Host/Jobs/ShadowRunJob.cs index d16b3819..db672c6c 100644 --- a/src/KArtSell.Host/Jobs/ShadowRunJob.cs +++ b/src/KArtSell.Host/Jobs/ShadowRunJob.cs @@ -1,4 +1,6 @@ using Hangfire; +using KArtSell.BuildingBlocks.Data; +using KArtSell.BuildingBlocks.Reliability; using KArtSell.BuildingBlocks.Time; using KArtSell.Modules.ModelOperations.ShadowRun; using Microsoft.Extensions.Logging; @@ -24,6 +26,8 @@ public sealed class ShadowRunJob( ReplayEngine replay, MetricsCalculator calculator, ShadowRunQueries queries, + IDbConnectionFactory connectionFactory, + IOutboxWriter outboxWriter, IClock clock, ILogger logger) { @@ -157,29 +161,9 @@ public sealed class ShadowRunJob( ValidationGates: validationGates, CreatedAt: clock.UtcNow); - // Phase 5: Persist - await queries.InsertShadowRunAsync(result, cancellationToken); - - // Phase 6: Emit event to outbox (async consumer notification) - try - { - await queries.InsertOutboxEventAsync( - result.RunId, - result.ModelId, - command.CorrelationId, - validationGates.AllGatesPassed, - metrics, - cancellationToken); - - logger.LogInformation( - "Shadow run {RunId} event emitted to outbox; consumers notified", - command.RunId); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to emit outbox event for {RunId}", command.RunId); - throw; // Event emission failure blocks job completion - } + // Phase 5-6: Persist shadow run result + emit completion event (transactional) + await EmitShadowRunCompletedEventAsync( + result, command.CorrelationId, validationGates.AllGatesPassed, cancellationToken); LogComplete(logger, command.RunId, validationGates.AllGatesPassed, null); } @@ -190,6 +174,67 @@ public sealed class ShadowRunJob( } } + private async Task EmitShadowRunCompletedEventAsync( + ShadowRunResult result, + Guid correlationId, + bool allGatesPassed, + CancellationToken cancellationToken) + { + try + { + await queries.InsertShadowRunAsync(result, cancellationToken); + + var eventMessage = new OutboxMessage( + MessageId: Guid.NewGuid(), + EventType: "ShadowRunCompleted", + SchemaVersion: 1, + PayloadJson: System.Text.Json.JsonSerializer.Serialize(new + { + result.RunId, + result.ModelId, + CorrelationId = correlationId, + AllGatesPassed = allGatesPassed, + result.Metrics.TotalReturn, + result.Metrics.SharpeRatio, + result.Metrics.ProbOfBacktestOverfit, + result.Metrics.DailySharePercentile, + CompletedAt = clock.UtcNow + }), + CorrelationId: correlationId.ToString(), + OccurredAt: clock.UtcNow, + PayloadHash: GeneratePayloadHash(result.RunId.ToString())); + + await using var connection = await connectionFactory.OpenAsync(cancellationToken); + await using var transaction = await connection.BeginTransactionAsync(cancellationToken); + + try + { + await outboxWriter.AddAsync(connection, transaction, eventMessage, cancellationToken); + await transaction.CommitAsync(cancellationToken); + + logger.LogInformation( + "Shadow run {RunId} completed; event emitted to outbox for async consumers", + result.RunId); + } + catch + { + await transaction.RollbackAsync(cancellationToken); + throw; + } + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to emit ShadowRunCompleted event for {RunId}", result.RunId); + throw; + } + } + + private static string GeneratePayloadHash(string payload) + { + var hash = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(payload)); + return System.Convert.ToBase64String(hash); + } + private static PhaseMetrics ConvertPhaseMetrics(PhaseMetricsDto dto) => new PhaseMetrics( TradingDays: dto.TradingDays, diff --git a/src/KArtSell.Modules.ModelOperations/ShadowRun/Sql.cs b/src/KArtSell.Modules.ModelOperations/ShadowRun/Sql.cs index 928b0f53..fa92f0f1 100644 --- a/src/KArtSell.Modules.ModelOperations/ShadowRun/Sql.cs +++ b/src/KArtSell.Modules.ModelOperations/ShadowRun/Sql.cs @@ -118,51 +118,4 @@ public sealed class ShadowRunQueries(IDbConnectionFactory connectionFactory) private static string SerializeValidationGates(ValidationGates gates) => System.Text.Json.JsonSerializer.Serialize(gates); - /// - /// Emit event to outbox (transactional with shadow run insert). - /// Used for async event-driven downstream consumers. - /// - public async Task InsertOutboxEventAsync( - Guid runId, - Guid modelId, - Guid correlationId, - bool allGatesPassed, - ShadowRunMetrics metrics, - CancellationToken cancellationToken) - { - const string sql = """ - insert into outbox.outbox - (aggregate_id, event_type, payload) - values ( - @RunId, - @EventType, - cast(@Payload as jsonb) - ) - """; - - var eventPayload = System.Text.Json.JsonSerializer.Serialize(new - { - RunId = runId, - ModelId = modelId, - CorrelationId = correlationId, - AllGatesPassed = allGatesPassed, - TotalReturn = metrics.TotalReturn, - SharpeRatio = metrics.SharpeRatio, - ProbOfBacktestOverfit = metrics.ProbOfBacktestOverfit, - DailySharePercentile = metrics.DailySharePercentile, - CompletedAt = DateTimeOffset.UtcNow - }); - - await using var connection = await connectionFactory.OpenAsync(cancellationToken); - await connection.ExecuteAsync( - new CommandDefinition( - sql, - new - { - RunId = runId, - EventType = "ShadowRunCompleted", - Payload = eventPayload - }, - cancellationToken: cancellationToken)); - } }