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) { return await _context.Notifications .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 GetByIdAsync(string notificationId, CancellationToken cancellationToken = default) => await _context.Notifications.FindAsync(new object[] { notificationId }, cancellationToken); /// public async Task UpdateAsync(Notification n, CancellationToken cancellationToken = default) { _context.Notifications.Update(n); await _context.SaveChangesAsync(cancellationToken); } /// public async Task<(IReadOnlyList Rows, int TotalCount)> QueryAsync( NotificationOutboxFilter filter, int pageNumber, int pageSize, CancellationToken cancellationToken = default) { var query = _context.Notifications.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); } 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); var rows = await query .OrderByDescending(n => n.CreatedAt) .Skip((pageNumber - 1) * pageSize) .Take(pageSize) .ToListAsync(cancellationToken); return (rows, totalCount); } /// public async Task DeleteTerminalOlderThanAsync(DateTimeOffset cutoff, CancellationToken cancellationToken = default) { return await _context.Notifications .Where(n => TerminalStatuses.Contains(n.Status) && n.CreatedAt < cutoff) .ExecuteDeleteAsync(cancellationToken); } /// public async Task ComputeKpisAsync( DateTimeOffset stuckCutoff, DateTimeOffset deliveredSince, CancellationToken cancellationToken = default) { var now = DateTimeOffset.UtcNow; var queueDepth = await _context.Notifications .CountAsync(n => n.Status == NotificationStatus.Pending || n.Status == NotificationStatus.Retrying, cancellationToken); var stuckCount = await _context.Notifications .CountAsync(n => (n.Status == NotificationStatus.Pending || n.Status == NotificationStatus.Retrying) && n.CreatedAt < stuckCutoff, cancellationToken); var parkedCount = await _context.Notifications .CountAsync(n => n.Status == NotificationStatus.Parked, cancellationToken); var deliveredLastInterval = await _context.Notifications .CountAsync(n => n.Status == NotificationStatus.Delivered && n.DeliveredAt != null && n.DeliveredAt >= deliveredSince, cancellationToken); // Oldest non-terminal CreatedAt. The DateTimeOffset value converter makes a SQL // Min aggregate awkward, so order ascending and take the first instead. var nonTerminal = _context.Notifications .Where(n => n.Status == NotificationStatus.Pending || n.Status == NotificationStatus.Retrying); TimeSpan? oldestPendingAge = null; if (await nonTerminal.AnyAsync(cancellationToken)) { var oldestCreatedAt = await nonTerminal .OrderBy(n => n.CreatedAt) .Select(n => n.CreatedAt) .FirstAsync(cancellationToken); oldestPendingAge = now - oldestCreatedAt; } return new NotificationKpiSnapshot( QueueDepth: queueDepth, StuckCount: stuckCount, ParkedCount: parkedCount, DeliveredLastInterval: deliveredLastInterval, OldestPendingAge: oldestPendingAge); } /// public async Task> ComputePerSiteKpisAsync( DateTimeOffset stuckCutoff, DateTimeOffset deliveredSince, CancellationToken cancellationToken = default) { var now = DateTimeOffset.UtcNow; var queueDepth = await CountBySiteAsync( n => n.Status == NotificationStatus.Pending || n.Status == NotificationStatus.Retrying, cancellationToken); var stuck = await CountBySiteAsync( n => (n.Status == NotificationStatus.Pending || n.Status == NotificationStatus.Retrying) && n.CreatedAt < stuckCutoff, cancellationToken); var parked = await CountBySiteAsync( n => n.Status == NotificationStatus.Parked, cancellationToken); var delivered = await CountBySiteAsync( n => n.Status == NotificationStatus.Delivered && n.DeliveredAt != null && n.DeliveredAt >= deliveredSince, cancellationToken); // Oldest non-terminal CreatedAt per site. A SQL Min over the DateTimeOffset // converter is awkward (see ComputeKpisAsync), so project the non-terminal // (site, created) pairs — the live queue, which stays bounded — and reduce // in memory. var oldest = (await _context.Notifications .Where(n => n.Status == NotificationStatus.Pending || n.Status == NotificationStatus.Retrying) .Select(n => new { n.SourceSiteId, n.CreatedAt }) .ToListAsync(cancellationToken)) .GroupBy(x => x.SourceSiteId) .ToDictionary(g => g.Key, g => g.Min(x => x.CreatedAt)); var siteIds = queueDepth.Keys .Concat(stuck.Keys).Concat(parked.Keys).Concat(delivered.Keys) .Distinct() .OrderBy(s => s, StringComparer.Ordinal); return siteIds.Select(site => new SiteNotificationKpiSnapshot( SourceSiteId: site, QueueDepth: queueDepth.GetValueOrDefault(site), StuckCount: stuck.GetValueOrDefault(site), ParkedCount: parked.GetValueOrDefault(site), DeliveredLastInterval: delivered.GetValueOrDefault(site), OldestPendingAge: oldest.TryGetValue(site, out var createdAt) ? now - createdAt : null)).ToList(); } /// public async Task> ComputePerNodeKpisAsync( DateTimeOffset stuckCutoff, DateTimeOffset deliveredSince, CancellationToken cancellationToken = default) { var now = DateTimeOffset.UtcNow; // Exclude rows with NULL SourceNode (legacy / unstamped) — per-node KPIs // are only meaningful when the node identity is known. var queueDepth = await CountByNodeAsync( n => (n.Status == NotificationStatus.Pending || n.Status == NotificationStatus.Retrying) && n.SourceNode != null, cancellationToken); var stuck = await CountByNodeAsync( n => (n.Status == NotificationStatus.Pending || n.Status == NotificationStatus.Retrying) && n.CreatedAt < stuckCutoff && n.SourceNode != null, cancellationToken); var parked = await CountByNodeAsync( n => n.Status == NotificationStatus.Parked && n.SourceNode != null, cancellationToken); var delivered = await CountByNodeAsync( n => n.Status == NotificationStatus.Delivered && n.DeliveredAt != null && n.DeliveredAt >= deliveredSince && n.SourceNode != null, cancellationToken); // Oldest non-terminal CreatedAt per node — same in-memory reduction // pattern as ComputePerSiteKpisAsync (DateTimeOffset converter makes // a SQL Min awkward). var oldest = (await _context.Notifications .Where(n => (n.Status == NotificationStatus.Pending || n.Status == NotificationStatus.Retrying) && n.SourceNode != null) .Select(n => new { n.SourceNode, n.CreatedAt }) .ToListAsync(cancellationToken)) .GroupBy(x => x.SourceNode!) .ToDictionary(g => g.Key, g => g.Min(x => x.CreatedAt)); var nodeNames = queueDepth.Keys .Concat(stuck.Keys).Concat(parked.Keys).Concat(delivered.Keys) .Distinct() .OrderBy(n => n, StringComparer.Ordinal); return nodeNames.Select(node => new NodeNotificationKpiSnapshot( SourceNode: node, QueueDepth: queueDepth.GetValueOrDefault(node), StuckCount: stuck.GetValueOrDefault(node), ParkedCount: parked.GetValueOrDefault(node), DeliveredLastInterval: delivered.GetValueOrDefault(node), OldestPendingAge: oldest.TryGetValue(node, out var createdAt) ? now - createdAt : null)).ToList(); } /// Counts notification rows matching , grouped by source site. private async Task> CountBySiteAsync( System.Linq.Expressions.Expression> predicate, CancellationToken cancellationToken) { return await _context.Notifications .Where(predicate) .GroupBy(n => n.SourceSiteId) .Select(g => new { Site = g.Key, Count = g.Count() }) .ToDictionaryAsync(x => x.Site, x => x.Count, cancellationToken); } /// /// Counts notification rows matching , grouped by source node. /// Only rows with a non-null SourceNode should be included; the predicate is /// responsible for enforcing that guard. /// private async Task> CountByNodeAsync( System.Linq.Expressions.Expression> predicate, CancellationToken cancellationToken) { return await _context.Notifications .Where(predicate) .GroupBy(n => n.SourceNode!) .Select(g => new { Node = g.Key, Count = g.Count() }) .ToDictionaryAsync(x => x.Node, x => x.Count, cancellationToken); } /// public async Task SaveChangesAsync(CancellationToken cancellationToken = default) => await _context.SaveChangesAsync(cancellationToken); }