using JobTrackerApi.Data; using JobTrackerApi.Models; using Microsoft.EntityFrameworkCore; namespace JobTrackerApi.Services; public static class OperationEventLog { public const int RetentionDays = 30; public static string SafeStatus(string? value) => value is OperationStatuses.Queued or OperationStatuses.Running or OperationStatuses.WaitingForRetry or OperationStatuses.WaitingForExternalFallback or OperationStatuses.Succeeded or OperationStatuses.Failed or OperationStatuses.Cancelled ? value : "unknown"; public static string? SafeStage(string? value) => value is "cv_validating_file" or "cv_extracting" or "cv_identifying_sections" or "cv_analysing" or "cv_validating_details" or "cv_preparing_review" or "cv_review_ready" ? value : null; public static IReadOnlyList FailureCodes { get; } = Array.AsReadOnly(new[] { "timeout", "lease_expired", "deadline_exceeded", "entitlement_changed", "document_unavailable", "sidecar_unavailable", "upstream_unavailable", "invalid_response", "circuit_open", "rate_limited", "permission_denied", "cancelled", "upload_rejected", "parser_failed", "malformed_output", "provider_unavailable", }); public static string? SafeFailure(string? value) => value is null ? null : FailureCodes.Contains(value, StringComparer.Ordinal) ? value : "processing_failed"; public static UserOperationEvent Create(UserOperation operation, OperationEventKind kind, DateTime now, string? status = null, string? stage = null, string? failure = null, int? attempt = null) => new() { OperationId = operation.Id, OwnerUserId = operation.OwnerUserId, AtUtc = now, Kind = kind.ToString(), Status = SafeStatus(status ?? operation.Status), Stage = SafeStage(stage ?? operation.ProgressStage), Attempt = Math.Clamp(attempt ?? operation.AttemptCount, 0, 10), FailureCode = SafeFailure(failure), }; public static async Task AppendAsync(JobTrackerContext db, UserOperation operation, OperationEventKind kind, DateTime now, CancellationToken token, string? status = null, string? stage = null, string? failure = null, int? attempt = null) { var item = Create(operation, kind, now, status, stage, failure, attempt); if (!db.Database.IsRelational()) { db.UserOperationEvents.Add(item); await db.SaveChangesAsync(token); return; } if (db.Database.CurrentTransaction is null) throw new InvalidOperationException("Operation history requires the state transition transaction."); // Parameterized insert avoids flushing unrelated entities tracked by a caller. await db.Database.ExecuteSqlInterpolatedAsync($"INSERT INTO `UserOperationEvents` (`OperationId`,`OwnerUserId`,`AtUtc`,`Kind`,`Status`,`Stage`,`Attempt`,`FailureCode`) VALUES ({item.OperationId},{item.OwnerUserId},{item.AtUtc},{item.Kind},{item.Status},{item.Stage},{item.Attempt},{item.FailureCode})", token); } public static async Task PruneAsync(JobTrackerContext db, DateTime now, CancellationToken token) { var cutoff = now.AddDays(-RetentionDays); var ids = await db.UserOperationEvents.IgnoreQueryFilters().Where(item => item.AtUtc < cutoff) .OrderBy(item => item.Id).Select(item => item.Id).Take(5000).ToListAsync(token); if (!db.Database.IsRelational()) { var items = await db.UserOperationEvents.IgnoreQueryFilters().Where(item => ids.Contains(item.Id)).ToListAsync(token); db.UserOperationEvents.RemoveRange(items); return await db.SaveChangesAsync(token); } return await db.UserOperationEvents.IgnoreQueryFilters().Where(item => ids.Contains(item.Id)).ExecuteDeleteAsync(token); } }