using Microsoft.Data.SqlClient; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Commons.Types; using ScadaLink.Commons.Types.Audit; namespace ScadaLink.ConfigurationDatabase.Repositories; /// /// EF Core implementation of . See the /// interface for the monotonic-upsert contract; this class adds notes on the /// data-access strategy used by each method. /// public class SiteCallAuditRepository : ISiteCallAuditRepository { // SQL Server duplicate-key error numbers, identical to the AuditLogRepository // race-fix: 2601 = unique-index violation, 2627 = PK/unique-constraint // violation. The IF NOT EXISTS … INSERT pattern has a check-then-act window // and the loser surfaces as one of these; monotonic-upsert semantics demand // we swallow them. private const int SqlErrorUniqueIndexViolation = 2601; private const int SqlErrorPrimaryKeyViolation = 2627; // Monotonic status ordering. Lower rank wins on tie (same-rank upserts are // no-ops, including terminal-over-terminal). Spec from Bundle B3 plan: // Submitted < Forwarded < Attempted == Skipped < Delivered == Failed == Parked == Discarded. private static readonly Dictionary StatusRank = new(StringComparer.Ordinal) { ["Submitted"] = 0, ["Forwarded"] = 1, ["Attempted"] = 2, ["Skipped"] = 2, ["Delivered"] = 3, ["Failed"] = 3, ["Parked"] = 3, ["Discarded"] = 3, }; private readonly ScadaLinkDbContext _context; private readonly ILogger _logger; public SiteCallAuditRepository(ScadaLinkDbContext context, ILogger? logger = null) { _context = context ?? throw new ArgumentNullException(nameof(context)); _logger = logger ?? NullLogger.Instance; } /// /// Two-step: IF NOT EXISTS INSERT then conditional UPDATE with /// an inline CASE rank comparison. Both go through /// /// so the change tracker is bypassed and the value-converted PK column is /// written as the canonical "D"-format GUID string. Duplicate-key violations /// from the insert race are swallowed. /// public async Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default) { if (siteCall is null) { throw new ArgumentNullException(nameof(siteCall)); } var idText = siteCall.TrackedOperationId.Value.ToString("D"); var incomingRank = GetRankOrThrow(siteCall.Status); // Step 1: insert-if-not-exists. Like AuditLogRepository.InsertIfNotExistsAsync // this is check-then-act so a duplicate-key violation may surface under // concurrent inserts on the same id — caught + logged at Debug. // // SourceNode-stamping (Task 14): the column is included in the INSERT // column list / VALUES so a fresh row carries the originating node // name (node-a/node-b for site rows). A null SourceNode (legacy hosts // / unstamped reconciled rows) writes NULL straight through. try { await _context.Database.ExecuteSqlInterpolatedAsync( $@"IF NOT EXISTS (SELECT 1 FROM dbo.SiteCalls WHERE TrackedOperationId = {idText}) INSERT INTO dbo.SiteCalls (TrackedOperationId, Channel, Target, SourceSite, SourceNode, Status, RetryCount, LastError, HttpStatus, CreatedAtUtc, UpdatedAtUtc, TerminalAtUtc, IngestedAtUtc) VALUES ({idText}, {siteCall.Channel}, {siteCall.Target}, {siteCall.SourceSite}, {siteCall.SourceNode}, {siteCall.Status}, {siteCall.RetryCount}, {siteCall.LastError}, {siteCall.HttpStatus}, {siteCall.CreatedAtUtc}, {siteCall.UpdatedAtUtc}, {siteCall.TerminalAtUtc}, {siteCall.IngestedAtUtc});", ct); } catch (SqlException ex) when ( ex.Number == SqlErrorUniqueIndexViolation || ex.Number == SqlErrorPrimaryKeyViolation) { _logger.LogDebug( ex, "SiteCallAuditRepository.UpsertAsync swallowed duplicate-key violation (error {SqlErrorNumber}) for TrackedOperationId {TrackedOperationId}; falling through to monotonic update.", ex.Number, idText); } // Step 2: monotonic update. The CASE expression maps the stored Status // string to the same rank table the caller uses; we only mutate if the // incoming rank is strictly greater. Same-rank (including // terminal-over-terminal) is a no-op — first-write-wins at each rank. // // SourceNode-stamping (Task 14): SourceNode is updated via // COALESCE(@SourceNode, SourceNode). The operator returns @SourceNode // when it is non-null, otherwise the stored value — so the column // behaves protectively: a later packet that carries a null // SourceNode (e.g. a reconciliation pull from an unstamped node) // NEVER blanks out a value the first stamping packet set. A later // packet that DOES carry a non-null SourceNode replaces the previous // value — combined with the monotonic-rank guard this is // "last-non-null-wins on rank advance", which lets a missing // SourceNode be filled in later if Submit happened to be unstamped // and an Attempt/Resolve carries the node identity. Within one // lifecycle every packet should carry the same SourceNode value (one // execution, one node) so the "overwrite" path is in practice // idempotent. await _context.Database.ExecuteSqlInterpolatedAsync( $@"UPDATE dbo.SiteCalls SET Status = {siteCall.Status}, RetryCount = {siteCall.RetryCount}, LastError = {siteCall.LastError}, HttpStatus = {siteCall.HttpStatus}, UpdatedAtUtc = {siteCall.UpdatedAtUtc}, TerminalAtUtc = {siteCall.TerminalAtUtc}, IngestedAtUtc = {siteCall.IngestedAtUtc}, SourceNode = COALESCE({siteCall.SourceNode}, SourceNode) WHERE TrackedOperationId = {idText} AND {incomingRank} > (CASE Status WHEN 'Submitted' THEN 0 WHEN 'Forwarded' THEN 1 WHEN 'Attempted' THEN 2 WHEN 'Skipped' THEN 2 WHEN 'Delivered' THEN 3 WHEN 'Failed' THEN 3 WHEN 'Parked' THEN 3 WHEN 'Discarded' THEN 3 ELSE -1 END);", ct); } /// /// Single FindAsync against the PK. Returns null for unknown ids. /// public async Task GetAsync(TrackedOperationId id, CancellationToken ct = default) { return await _context.Set().FindAsync(new object?[] { id }, ct); } /// /// Builds a parameterised SQL query against dbo.SiteCalls ordered by /// (CreatedAtUtc DESC, TrackedOperationId DESC), with keyset paging. /// Raw SQL is used here (rather than LINQ) because EF Core 10 cannot /// translate the lexicographic string comparison against the value-converted /// column inside an expression tree — the /// converter is applied to equality but not to inequality comparisons /// against the underlying Guid. The keyset tiebreaker is varchar lex order, /// which is deterministic and gives "no overlap, every row exactly once" /// paging without depending on Guid byte ordering. /// public async Task> QueryAsync( SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) { if (filter is null) { throw new ArgumentNullException(nameof(filter)); } if (paging is null) { throw new ArgumentNullException(nameof(paging)); } // FormattableString interpolation parameterises every value (no concatenation) // so this is injection-safe. EF Core resolves the parameter values, the // composed sql is shaped to SQL Server's grammar and projected into the // SiteCall entity via FromSqlInterpolated. The CASE expressions wrap each // optional predicate so a null filter field degrades to a no-op (matches // every row) instead of branching at C# level into N variants. var afterCreated = paging.AfterCreatedAtUtc; var afterIdString = paging.AfterId?.Value.ToString("D"); var hasCursor = afterCreated is not null && afterIdString is not null; var fromUtc = filter.FromUtc; var toUtc = filter.ToUtc; var stuckCutoff = filter.StuckCutoffUtc; // The stuck predicate (TerminalAtUtc IS NULL AND CreatedAtUtc < cutoff) // is pushed into SQL here — both columns are plain (no value converter) // and compose with the keyset cursor, so a StuckOnly page is honest: // never under-filled with a non-null next cursor. Mirrors how // NotificationOutboxRepository.QueryAsync applies NotificationOutboxFilter.StuckCutoff. // // SELECT-list maintenance: EF Core's FromSqlInterpolated requires every // entity-tracked column to appear in the result set. Adding a new column // to the SiteCall entity means extending the list below too — otherwise // every read trips "The required column 'X' was not present" at runtime. FormattableString sql = $@" SELECT TOP ({paging.PageSize}) TrackedOperationId, Channel, Target, SourceSite, SourceNode, Status, RetryCount, LastError, HttpStatus, CreatedAtUtc, UpdatedAtUtc, TerminalAtUtc, IngestedAtUtc FROM dbo.SiteCalls WHERE ({filter.Channel} IS NULL OR Channel = {filter.Channel}) AND ({filter.SourceSite} IS NULL OR SourceSite = {filter.SourceSite}) AND ({filter.SourceNode} IS NULL OR SourceNode = {filter.SourceNode}) AND ({filter.Status} IS NULL OR Status = {filter.Status}) AND ({filter.Target} IS NULL OR Target = {filter.Target}) AND ({fromUtc} IS NULL OR CreatedAtUtc >= {fromUtc}) AND ({toUtc} IS NULL OR CreatedAtUtc <= {toUtc}) AND ({stuckCutoff} IS NULL OR (TerminalAtUtc IS NULL AND CreatedAtUtc < {stuckCutoff})) AND ({(hasCursor ? 1 : 0)} = 0 OR CreatedAtUtc < {afterCreated} OR (CreatedAtUtc = {afterCreated} AND TrackedOperationId < {afterIdString})) ORDER BY CreatedAtUtc DESC, TrackedOperationId DESC;"; var rows = await _context.Set() .FromSqlInterpolated(sql) .AsNoTracking() .ToListAsync(ct); return rows; } /// /// Deletes rows whose is non-null AND /// strictly less than . Non-terminal rows are /// never touched. Returns the number of rows removed. /// public async Task PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) { return await _context.Database.ExecuteSqlInterpolatedAsync( $"DELETE FROM dbo.SiteCalls WHERE TerminalAtUtc IS NOT NULL AND TerminalAtUtc < {olderThanUtc};", ct); } // Terminal status string literals for the interval-throughput KPIs. The // Status column is a plain varchar (no value converter), so these compare // directly in translated SQL. // // NOTE on the "buffered/non-terminal" definition: the SiteCalls operational // mirror stores AuditStatus-derived strings (Attempted/Delivered/Parked/ // Failed/...), NOT the tracking-lifecycle Pending/Retrying names the spec's // KPI section uses. There is therefore no Status string that means // "buffered". The schema-honest predicate for "non-terminal / buffered" is // TerminalAtUtc IS NULL — consistent with PurgeTerminalAsync's terminal // predicate and with the SiteCall entity's own contract ("TerminalAtUtc ... // null while still active"). All buffered / stuck / oldest-pending counts // below key off TerminalAtUtc, not Status. private const string StatusParked = "Parked"; private const string StatusDelivered = "Delivered"; private const string StatusFailed = "Failed"; /// /// Computes the global KPI snapshot with five server-side aggregate queries /// against dbo.SiteCalls. No rows are materialised — every count is a /// translated COUNT and the oldest-pending age is a translated /// MIN(CreatedAtUtc). The Status and CreatedAtUtc/TerminalAtUtc /// columns have no value converter, so the aggregates translate cleanly to /// SQL Server (unlike the NotificationOutbox's DateTimeOffset-converted /// column, which forces an order-and-take). "Buffered" / "stuck" key off /// TerminalAtUtc IS NULL — see the field comments above. /// public async Task ComputeKpisAsync( DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) { var now = DateTime.UtcNow; var bufferedCount = await _context.SiteCalls .CountAsync(s => s.TerminalAtUtc == null, ct); var parkedCount = await _context.SiteCalls .CountAsync(s => s.Status == StatusParked, ct); var failedLastInterval = await _context.SiteCalls .CountAsync(s => s.Status == StatusFailed && s.TerminalAtUtc != null && s.TerminalAtUtc >= intervalSince, ct); var deliveredLastInterval = await _context.SiteCalls .CountAsync(s => s.Status == StatusDelivered && s.TerminalAtUtc != null && s.TerminalAtUtc >= intervalSince, ct); var stuckCount = await _context.SiteCalls .CountAsync(s => s.TerminalAtUtc == null && s.CreatedAtUtc < stuckCutoff, ct); var nonTerminal = _context.SiteCalls.Where(s => s.TerminalAtUtc == null); TimeSpan? oldestPendingAge = null; if (await nonTerminal.AnyAsync(ct)) { var oldestCreatedAt = await nonTerminal.MinAsync(s => s.CreatedAtUtc, ct); oldestPendingAge = now - oldestCreatedAt; } return new SiteCallKpiSnapshot( BufferedCount: bufferedCount, ParkedCount: parkedCount, FailedLastInterval: failedLastInterval, DeliveredLastInterval: deliveredLastInterval, OldestPendingAge: oldestPendingAge, StuckCount: stuckCount); } /// /// Computes the per-source-site KPI breakdown. The five counts are /// GROUP BY SourceSite aggregates; the oldest-pending age is a /// per-site MIN(CreatedAtUtc) over the (bounded) non-terminal set — /// all run server-side. A site appears in the result only if it has at /// least one row matched by one of the count queries. "Buffered" / "stuck" /// key off TerminalAtUtc IS NULL — see . /// public async Task> ComputePerSiteKpisAsync( DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) { var now = DateTime.UtcNow; var buffered = await CountBySiteAsync(s => s.TerminalAtUtc == null, ct); var parked = await CountBySiteAsync(s => s.Status == StatusParked, ct); var failed = await CountBySiteAsync( s => s.Status == StatusFailed && s.TerminalAtUtc != null && s.TerminalAtUtc >= intervalSince, ct); var delivered = await CountBySiteAsync( s => s.Status == StatusDelivered && s.TerminalAtUtc != null && s.TerminalAtUtc >= intervalSince, ct); var stuck = await CountBySiteAsync( s => s.TerminalAtUtc == null && s.CreatedAtUtc < stuckCutoff, ct); // Oldest non-terminal CreatedAtUtc per site — a server-side GROUP BY MIN. var oldest = (await _context.SiteCalls .Where(s => s.TerminalAtUtc == null) .GroupBy(s => s.SourceSite) .Select(g => new { Site = g.Key, Oldest = g.Min(s => s.CreatedAtUtc) }) .ToListAsync(ct)) .ToDictionary(x => x.Site, x => x.Oldest); var siteIds = buffered.Keys .Concat(parked.Keys).Concat(failed.Keys) .Concat(delivered.Keys).Concat(stuck.Keys) .Distinct() .OrderBy(s => s, StringComparer.Ordinal); return siteIds.Select(site => new SiteCallSiteKpiSnapshot( SourceSite: site, BufferedCount: buffered.GetValueOrDefault(site), ParkedCount: parked.GetValueOrDefault(site), FailedLastInterval: failed.GetValueOrDefault(site), DeliveredLastInterval: delivered.GetValueOrDefault(site), OldestPendingAge: oldest.TryGetValue(site, out var createdAt) ? now - createdAt : null, StuckCount: stuck.GetValueOrDefault(site))).ToList(); } /// Counts SiteCalls rows matching , grouped by source site. private async Task> CountBySiteAsync( System.Linq.Expressions.Expression> predicate, CancellationToken ct) { return await _context.SiteCalls .Where(predicate) .GroupBy(s => s.SourceSite) .Select(g => new { Site = g.Key, Count = g.Count() }) .ToDictionaryAsync(x => x.Site, x => x.Count, ct); } private static int GetRankOrThrow(string status) { if (!StatusRank.TryGetValue(status, out var rank)) { throw new ArgumentException( $"Unknown SiteCall status '{status}'. Expected one of: {string.Join(", ", StatusRank.Keys)}.", nameof(status)); } return rank; } }