using Microsoft.Data.SqlClient; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.Commons.Types.Notifications; namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories; /// /// EF Core data access for the central notification outbox. See /// for the behaviour contract. /// public class NotificationOutboxRepository : INotificationOutboxRepository { // SQL Server duplicate-key error numbers, matching the AuditLogRepository // and SiteCallAuditRepository race-fixes. 2601 is a unique-index violation; // 2627 is a primary-key/unique-constraint violation. The IF NOT EXISTS … // INSERT pattern has a check-then-act race window — two sessions can both // pass the EXISTS check and then both attempt the INSERT — and the loser // surfaces as one of these. The site→central handoff is documented // at-least-once with insert-if-not-exists, so the collision IS the expected // contention mode; idempotency demands we swallow them rather than let the // site retry the same NotificationId forever. private const int SqlErrorUniqueIndexViolation = 2601; private const int SqlErrorPrimaryKeyViolation = 2627; private readonly ScadaBridgeDbContext _context; private readonly ILogger _logger; // Statuses that represent a finished notification lifecycle. Non-terminal is the complement. private static readonly NotificationStatus[] TerminalStatuses = { NotificationStatus.Delivered, NotificationStatus.Parked, NotificationStatus.Discarded, }; /// Initializes a new instance of with the given EF Core context. /// The EF Core database context. /// Optional logger instance. public NotificationOutboxRepository(ScadaBridgeDbContext context, ILogger? logger = null) { _context = context ?? throw new ArgumentNullException(nameof(context)); _logger = logger ?? NullLogger.Instance; } /// public async Task InsertIfNotExistsAsync(Notification n, CancellationToken cancellationToken = default) { if (n is null) { throw new ArgumentNullException(nameof(n)); } // Enum columns are stored as varchar(32) (HasConversion()); convert // in C# rather than relying on parameter type inference (SqlClient would // otherwise bind enums as int by default and break the column conversion). var type = n.Type.ToString(); var status = n.Status.ToString(); // Provider-aware idempotent insert. The production path is SQL Server; the // NotificationOutbox integration tests run over an in-memory SQLite database, // and SQLite does not understand the T-SQL `IF NOT EXISTS … INSERT` form // (it raises 'near "IF": syntax error'). Detect SQLite by provider name so // ConfigurationDatabase needs no Sqlite package reference; the SQL Server // path below stays byte-identical. if (_context.Database.ProviderName == "Microsoft.EntityFrameworkCore.Sqlite") { // SQLite's idempotent insert: INSERT OR IGNORE skips the row when the // NotificationId primary key already exists (no schema prefix in SQLite). // Same parameterised columns/values as the SQL Server branch — the // FormattableString interpolation still parameterises every value // (no concatenation), so this is injection-safe. var sqliteRowsAffected = await _context.Database.ExecuteSqlInterpolatedAsync( $@"INSERT OR IGNORE INTO Notifications (NotificationId, Type, ListName, Subject, Body, TypeData, Status, RetryCount, LastError, ResolvedTargets, SourceSiteId, SourceNode, SourceInstanceId, SourceScript, OriginExecutionId, OriginParentExecutionId, SiteEnqueuedAt, CreatedAt, LastAttemptAt, NextAttemptAt, DeliveredAt) VALUES ({n.NotificationId}, {type}, {n.ListName}, {n.Subject}, {n.Body}, {n.TypeData}, {status}, {n.RetryCount}, {n.LastError}, {n.ResolvedTargets}, {n.SourceSiteId}, {n.SourceNode}, {n.SourceInstanceId}, {n.SourceScript}, {n.OriginExecutionId}, {n.OriginParentExecutionId}, {n.SiteEnqueuedAt}, {n.CreatedAt}, {n.LastAttemptAt}, {n.NextAttemptAt}, {n.DeliveredAt});", cancellationToken); // rowsAffected == 1 -> we inserted; 0 -> the row was IGNOREd because the // NotificationId already existed. Preserves the true=inserted / false=already-existed // contract. No SqlException catch needed — INSERT OR IGNORE never raises a // unique-constraint violation. return sqliteRowsAffected == 1; } // FormattableString interpolation parameterises every value (no concatenation), // so this is safe against injection even for the string columns. try { var rowsAffected = await _context.Database.ExecuteSqlInterpolatedAsync( $@"IF NOT EXISTS (SELECT 1 FROM dbo.Notifications WHERE NotificationId = {n.NotificationId}) INSERT INTO dbo.Notifications (NotificationId, Type, ListName, Subject, Body, TypeData, Status, RetryCount, LastError, ResolvedTargets, SourceSiteId, SourceNode, SourceInstanceId, SourceScript, OriginExecutionId, OriginParentExecutionId, SiteEnqueuedAt, CreatedAt, LastAttemptAt, NextAttemptAt, DeliveredAt) VALUES ({n.NotificationId}, {type}, {n.ListName}, {n.Subject}, {n.Body}, {n.TypeData}, {status}, {n.RetryCount}, {n.LastError}, {n.ResolvedTargets}, {n.SourceSiteId}, {n.SourceNode}, {n.SourceInstanceId}, {n.SourceScript}, {n.OriginExecutionId}, {n.OriginParentExecutionId}, {n.SiteEnqueuedAt}, {n.CreatedAt}, {n.LastAttemptAt}, {n.NextAttemptAt}, {n.DeliveredAt});", cancellationToken); // rowsAffected == 1 -> we inserted; 0 -> a prior row was already there // (IF NOT EXISTS short-circuited the INSERT). return rowsAffected == 1; } catch (SqlException ex) when ( ex.Number == SqlErrorUniqueIndexViolation || ex.Number == SqlErrorPrimaryKeyViolation) { // Two concurrent sessions both passed IF NOT EXISTS and both // attempted the INSERT — the loser raises 2601/2627 against the // NotificationId primary key. First-write-wins idempotency is the // documented contract (the site→central handoff is at-least-once, // and the actor discards the return value), so the race outcome is // semantically a no-op. Returning false here matches the // "row already existed" branch of the success path. _logger.LogDebug( ex, "InsertIfNotExistsAsync swallowed duplicate-key violation (error {SqlErrorNumber}) for NotificationId {NotificationId}; treating as no-op.", ex.Number, n.NotificationId); return false; } } /// public async Task> GetDueAsync( DateTimeOffset now, int batchSize, CancellationToken cancellationToken = default) { // AsNoTracking: the dispatcher mutates each row's delivery state in // memory and persists it through UpdateAsync, which is now a targeted // server-side ExecuteUpdate and needs no change tracker. Tracking a batch // of notifications — each carrying an nvarchar(max) Body and TypeData — // paid for a full snapshot copy per row plus a DetectChanges scan of the // whole batch on every save. return await _context.Notifications .AsNoTracking() .Where(n => n.Status == NotificationStatus.Pending || (n.Status == NotificationStatus.Retrying && n.NextAttemptAt != null && n.NextAttemptAt <= now)) .OrderBy(n => n.CreatedAt) .Take(batchSize) .ToListAsync(cancellationToken); } /// public async Task UpdateAsync(Notification n, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(n); // Targeted server-side UPDATE of the seven mutable delivery-state columns. // The predecessor called DbSet.Update(n) + SaveChanges, which // marks EVERY property modified and rewrites all 21 columns — including // the immutable nvarchar(max) Body/TypeData payloads — on every single // delivery attempt. ExecuteUpdate also bypasses the change tracker // entirely, so it composes with the untracked GetDueAsync read. // // Immutable-by-contract columns (NotificationId, Type, ListName, Subject, // Body, TypeData, Source*, Origin*, SiteEnqueuedAt, CreatedAt) are // deliberately absent — see the interface contract: nothing in the // notification lifecycle ever changes them, and omitting them is what // makes the write narrow. var status = n.Status; var retryCount = n.RetryCount; var lastError = n.LastError; var resolvedTargets = n.ResolvedTargets; var lastAttemptAt = n.LastAttemptAt; var nextAttemptAt = n.NextAttemptAt; var deliveredAt = n.DeliveredAt; var notificationId = n.NotificationId; // The row count is the not-found signal and MUST NOT be discarded. // DbSet.Update + SaveChanges used to throw DbUpdateConcurrencyException // when the row had vanished (the daily retention purge deletes terminal // rows); ExecuteUpdate just reports "0 rows" and returns, which made an // operator Retry/Discard of a purged notification answer "success". var rowsAffected = await _context.Notifications .Where(row => row.NotificationId == notificationId) .ExecuteUpdateAsync( setters => setters .SetProperty(row => row.Status, status) .SetProperty(row => row.RetryCount, retryCount) .SetProperty(row => row.LastError, lastError) .SetProperty(row => row.ResolvedTargets, resolvedTargets) .SetProperty(row => row.LastAttemptAt, lastAttemptAt) .SetProperty(row => row.NextAttemptAt, nextAttemptAt) .SetProperty(row => row.DeliveredAt, deliveredAt), cancellationToken); return rowsAffected > 0; } /// public async Task TryUpdateFromParkedAsync(Notification n, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(n); // Same seven mutable delivery-state columns as UpdateAsync, but the WHERE // clause below ALSO requires the row's current Status to still be Parked // -- a single-statement compare-and-swap. // Two round trips (GetByIdAsync then this write) with a status check in // between is not a correctness guard by itself: a detached PipeTo task // can race the dispatcher's own write (or a second concurrent operator // action) in that window. Mirrors SecuredWriteRepository.TryMarkApprovedAsync's // conditional ExecuteUpdate/ExecuteSql pattern, expressed via LINQ // ExecuteUpdateAsync (like UpdateAsync above) so this stays portable // across the SQL Server production provider and the SQLite provider the // repository tests run against. var status = n.Status; var retryCount = n.RetryCount; var lastError = n.LastError; var resolvedTargets = n.ResolvedTargets; var lastAttemptAt = n.LastAttemptAt; var nextAttemptAt = n.NextAttemptAt; var deliveredAt = n.DeliveredAt; var notificationId = n.NotificationId; var rowsAffected = await _context.Notifications .Where(row => row.NotificationId == notificationId && row.Status == NotificationStatus.Parked) .ExecuteUpdateAsync( setters => setters .SetProperty(row => row.Status, status) .SetProperty(row => row.RetryCount, retryCount) .SetProperty(row => row.LastError, lastError) .SetProperty(row => row.ResolvedTargets, resolvedTargets) .SetProperty(row => row.LastAttemptAt, lastAttemptAt) .SetProperty(row => row.NextAttemptAt, nextAttemptAt) .SetProperty(row => row.DeliveredAt, deliveredAt), cancellationToken); return rowsAffected > 0; } /// public async Task GetByIdAsync(string notificationId, CancellationToken cancellationToken = default) { // AsNoTracking + a keyed predicate rather than FindAsync: the write that // follows this read is ExecuteUpdate, which bypasses the change tracker // entirely, so tracking the entity bought nothing but a snapshot copy of // the nvarchar(max) Body/TypeData columns — and left a stale tracked // instance in the context that a later read of the same id would return // in preference to the database. FindAsync cannot be made no-tracking. return await _context.Notifications .AsNoTracking() .FirstOrDefaultAsync(row => row.NotificationId == notificationId, cancellationToken); } /// public async Task<(IReadOnlyList Rows, int TotalCount)> QueryAsync( NotificationOutboxFilter filter, int pageNumber, int pageSize, CancellationToken cancellationToken = default) { // AsNoTracking: this is the Central UI's read-only list page. The // rows are projected to the wire and never saved, so tracking them cost a // snapshot copy of every nvarchar(max) Body in the page for nothing. var query = _context.Notifications.AsNoTracking().AsQueryable(); if (filter.Status is { } status) { query = query.Where(n => n.Status == status); } if (filter.Type is { } type) { query = query.Where(n => n.Type == type); } // Exact match on one notification — the ?correlationId= deep link. if (!string.IsNullOrEmpty(filter.NotificationId)) { query = query.Where(n => n.NotificationId == filter.NotificationId); } if (!string.IsNullOrEmpty(filter.SourceSiteId)) { query = query.Where(n => n.SourceSiteId == filter.SourceSiteId); } // SourceNode is exact-match like SourceSiteId. Rows with NULL // SourceNode (legacy / unconfigured) are excluded when the filter is set. if (!string.IsNullOrEmpty(filter.SourceNode)) { query = query.Where(n => n.SourceNode == filter.SourceNode); } if (!string.IsNullOrEmpty(filter.ListName)) { query = query.Where(n => n.ListName == filter.ListName); } if (!string.IsNullOrEmpty(filter.SubjectKeyword)) { query = query.Where(n => n.Subject.Contains(filter.SubjectKeyword)); } if (filter.StuckOnly && filter.StuckCutoff is { } stuckCutoff) { query = query.Where(n => (n.Status == NotificationStatus.Pending || n.Status == NotificationStatus.Retrying) && n.CreatedAt < stuckCutoff); } if (filter.From is { } from) { query = query.Where(n => n.CreatedAt >= from); } if (filter.To is { } to) { query = query.Where(n => n.CreatedAt <= to); } var totalCount = await query.CountAsync(cancellationToken); // NotificationId breaks CreatedAt ties so the OFFSET window is deterministic — // without it two rows sharing a CreatedAt could appear on both pages or on // neither. (This page keeps OFFSET paging rather than the sibling repos' // keyset cursor because its contract surfaces a page number and a total // count, neither of which a keyset cursor can express.) var rows = await query .OrderByDescending(n => n.CreatedAt) .ThenByDescending(n => n.NotificationId) .Skip((pageNumber - 1) * pageSize) .Take(pageSize) .ToListAsync(cancellationToken); return (rows, totalCount); } /// public async Task DeleteTerminalOlderThanAsync(DateTimeOffset cutoff, CancellationToken cancellationToken = default) { // Time-sliced batches: each DELETE covers at most one hour of terminal rows, // capping the lock/log footprint per statement — mirrors // KpiHistoryRepository.PurgeOlderThanAsync (an unbounded // single-statement ExecuteDeleteAsync would otherwise turn a long-idle catch-up // of the default 365-day window into one giant transaction against // dbo.Notifications). Pure EF LINQ (no raw SQL) so this stays portable across // the SQL Server production provider and the SQLite provider the repository // tests run against. var total = 0; var floor = await _context.Notifications .Where(n => TerminalStatuses.Contains(n.Status) && n.CreatedAt < cutoff) .MinAsync(n => (DateTimeOffset?)n.CreatedAt, cancellationToken); while (floor is not null && floor < cutoff) { var ceiling = floor.Value.AddHours(1) < cutoff ? floor.Value.AddHours(1) : cutoff; total += await _context.Notifications .Where(n => TerminalStatuses.Contains(n.Status) && n.CreatedAt < ceiling) .ExecuteDeleteAsync(cancellationToken); floor = await _context.Notifications .Where(n => TerminalStatuses.Contains(n.Status) && n.CreatedAt < cutoff) .MinAsync(n => (DateTimeOffset?)n.CreatedAt, cancellationToken); } return total; } /// public async Task ComputeKpisAsync( DateTimeOffset stuckCutoff, DateTimeOffset deliveredSince, CancellationToken cancellationToken = default) { var now = DateTimeOffset.UtcNow; // One conditional-aggregation pass replaces four sequential COUNT round trips: // each metric is a COUNT(CASE WHEN THEN 1 END) over the same scan // (arch-review 04). GroupBy(_ => 1) yields a single group (no rows → no group). // // The aggregation is PREDICATE-RESTRICTED instead of scanning the // whole table. Every KPI here is about the live queue (Pending/Retrying), the // parked backlog, or the last delivery interval; the overwhelming bulk of the // Notifications table is historical Delivered and Discarded rows that // contribute to NONE of them. The pre-filter below is the union of the // metric-contributing predicates, which lets the optimizer seek the status // index for the live/parked legs and the filtered // (DeliveredAt) WHERE Status='Delivered' index for the interval leg, // instead of paying a full scan whose cost grows with retained history. // Mirrors the pre-filter ComputePerSiteKpisAsync/ComputePerNodeKpisAsync // already use — the global snapshot was the odd one out. var counts = await _context.Notifications .Where(n => n.Status == NotificationStatus.Pending || n.Status == NotificationStatus.Retrying || n.Status == NotificationStatus.Parked || (n.Status == NotificationStatus.Delivered && n.DeliveredAt != null && n.DeliveredAt >= deliveredSince)) .GroupBy(_ => 1) .Select(g => new { QueueDepth = g.Count(n => n.Status == NotificationStatus.Pending || n.Status == NotificationStatus.Retrying), StuckCount = g.Count(n => (n.Status == NotificationStatus.Pending || n.Status == NotificationStatus.Retrying) && n.CreatedAt < stuckCutoff), ParkedCount = g.Count(n => n.Status == NotificationStatus.Parked), DeliveredLastInterval = g.Count(n => n.Status == NotificationStatus.Delivered && n.DeliveredAt != null && n.DeliveredAt >= deliveredSince), }) .FirstOrDefaultAsync(cancellationToken); // Oldest non-terminal CreatedAt — TOP(1) via ordered FirstOrDefault, so the // live queue is never materialized. The nullable cast lets FirstOrDefault // return null (no non-terminal rows) instead of throwing. var oldestCreatedAt = await _context.Notifications .Where(n => n.Status == NotificationStatus.Pending || n.Status == NotificationStatus.Retrying) .OrderBy(n => n.CreatedAt) .Select(n => (DateTimeOffset?)n.CreatedAt) .FirstOrDefaultAsync(cancellationToken); return new NotificationKpiSnapshot( QueueDepth: counts?.QueueDepth ?? 0, StuckCount: counts?.StuckCount ?? 0, ParkedCount: counts?.ParkedCount ?? 0, DeliveredLastInterval: counts?.DeliveredLastInterval ?? 0, OldestPendingAge: oldestCreatedAt is null ? null : now - oldestCreatedAt.Value); } /// public async Task> ComputePerSiteKpisAsync( DateTimeOffset stuckCutoff, DateTimeOffset deliveredSince, CancellationToken cancellationToken = default) { var now = DateTimeOffset.UtcNow; // One grouped conditional-aggregation query per scope: counts AND the oldest // non-terminal CreatedAt (server-side MIN over a CASE, so the live queue is // never materialized) computed in a single scan. Pre-filter to rows that // contribute to at least one metric so a site with only fully-uncounted rows // (e.g. Discarded, or out-of-window Delivered) does not surface as an // all-zero row — preserving the prior union-of-metric-keys semantics. var rows = await _context.Notifications .Where(n => n.Status == NotificationStatus.Pending || n.Status == NotificationStatus.Retrying || n.Status == NotificationStatus.Parked || (n.Status == NotificationStatus.Delivered && n.DeliveredAt != null && n.DeliveredAt >= deliveredSince)) .GroupBy(n => n.SourceSiteId) .Select(g => new { Site = g.Key, QueueDepth = g.Count(n => n.Status == NotificationStatus.Pending || n.Status == NotificationStatus.Retrying), StuckCount = g.Count(n => (n.Status == NotificationStatus.Pending || n.Status == NotificationStatus.Retrying) && n.CreatedAt < stuckCutoff), ParkedCount = g.Count(n => n.Status == NotificationStatus.Parked), DeliveredLastInterval = g.Count(n => n.Status == NotificationStatus.Delivered && n.DeliveredAt != null && n.DeliveredAt >= deliveredSince), OldestCreatedAt = g.Min(n => (n.Status == NotificationStatus.Pending || n.Status == NotificationStatus.Retrying) ? (DateTimeOffset?)n.CreatedAt : null), }) .ToListAsync(cancellationToken); return rows .OrderBy(r => r.Site, StringComparer.Ordinal) .Select(r => new SiteNotificationKpiSnapshot( SourceSiteId: r.Site, QueueDepth: r.QueueDepth, StuckCount: r.StuckCount, ParkedCount: r.ParkedCount, DeliveredLastInterval: r.DeliveredLastInterval, OldestPendingAge: r.OldestCreatedAt is null ? null : now - r.OldestCreatedAt.Value)) .ToList(); } /// public async Task> ComputePerNodeKpisAsync( DateTimeOffset stuckCutoff, DateTimeOffset deliveredSince, CancellationToken cancellationToken = default) { var now = DateTimeOffset.UtcNow; // Same single-scan grouped aggregation as ComputePerSiteKpisAsync, keyed by // SourceNode. Rows with a NULL SourceNode (legacy / unstamped) are excluded — // per-node KPIs are only meaningful when node identity is known. var rows = await _context.Notifications .Where(n => n.SourceNode != null && (n.Status == NotificationStatus.Pending || n.Status == NotificationStatus.Retrying || n.Status == NotificationStatus.Parked || (n.Status == NotificationStatus.Delivered && n.DeliveredAt != null && n.DeliveredAt >= deliveredSince))) .GroupBy(n => n.SourceNode!) .Select(g => new { Node = g.Key, QueueDepth = g.Count(n => n.Status == NotificationStatus.Pending || n.Status == NotificationStatus.Retrying), StuckCount = g.Count(n => (n.Status == NotificationStatus.Pending || n.Status == NotificationStatus.Retrying) && n.CreatedAt < stuckCutoff), ParkedCount = g.Count(n => n.Status == NotificationStatus.Parked), DeliveredLastInterval = g.Count(n => n.Status == NotificationStatus.Delivered && n.DeliveredAt != null && n.DeliveredAt >= deliveredSince), OldestCreatedAt = g.Min(n => (n.Status == NotificationStatus.Pending || n.Status == NotificationStatus.Retrying) ? (DateTimeOffset?)n.CreatedAt : null), }) .ToListAsync(cancellationToken); return rows .OrderBy(r => r.Node, StringComparer.Ordinal) .Select(r => new NodeNotificationKpiSnapshot( SourceNode: r.Node, QueueDepth: r.QueueDepth, StuckCount: r.StuckCount, ParkedCount: r.ParkedCount, DeliveredLastInterval: r.DeliveredLastInterval, OldestPendingAge: r.OldestCreatedAt is null ? null : now - r.OldestCreatedAt.Value)) .ToList(); } /// public async Task SaveChangesAsync(CancellationToken cancellationToken = default) => await _context.SaveChangesAsync(cancellationToken); }