using System.Data;
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
namespace ZB.MOM.WW.ScadaBridge.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:
// Submitted < Forwarded < Attempted == Skipped < Delivered == Failed == Parked == Discarded.
// A higher incoming rank always wins. WITHIN an equal NON-terminal rank
// (Attempted/Skipped, rank 2 — and the transient Submitted/Forwarded ranks),
// the newest UpdatedAtUtc wins so a retrying call's live RetryCount/LastError
// no longer freezes at first-write. Equal terminal ranks (rank 3)
// stay immutable — the freshness tiebreaker is deliberately scoped to
// rank < 3, so a later terminal NEVER flips an earlier one (Delivered cannot
// overwrite Parked). Still idempotent (equal stamps are inert) and still
// regression-proof (a lower rank is always a no-op).
private const int TerminalRank = 3;
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 ScadaBridgeDbContext _context;
private readonly ILogger _logger;
///
/// Initializes a new instance of the class.
///
/// The EF Core database context.
/// Optional logger for diagnostic information.
public SiteCallAuditRepository(ScadaBridgeDbContext context, ILogger? logger = null)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
_logger = logger ?? NullLogger.Instance;
}
///
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);
// ONE round trip, INSERT-first. Both statements ship in a single command
// text, so the round-trip saving of the single-command rewrite is preserved — but
// the ORDER is back to insert-then-update, because UPDATE-first LOSES
// DATA under a concurrent first-write.
//
// The UPDATE-first shape was: UPDATE; SET @updated = @@ROWCOUNT;
// IF @updated = 0 AND NOT EXISTS(…) INSERT. Two writers racing the FIRST
// packet of one TrackedOperationId — the cached dual-write and the
// reconciliation pull, which routinely carry DIFFERENT lifecycle states —
// both find no row, so both UPDATEs match nothing. The loser then either
// fails its own NOT EXISTS re-check (READ COMMITTED, after the winner
// committed) and skips the INSERT, or attempts it and eats a 2627 in the
// catch below. EITHER WAY the loser's Status/RetryCount/HttpStatus/
// TerminalAtUtc are silently dropped: it never ran an UPDATE against the
// winner's row.
//
// INSERT-first has no such hole. The loser's INSERT is skipped or faults,
// and the monotonic UPDATE that FOLLOWS it applies its state to whichever
// row won — so the newer lifecycle state survives regardless of
// interleaving, and a stale one is still rejected by the rank guard.
//
// Monotonic update semantics are unchanged: mutate when EITHER the
// incoming rank is strictly greater, OR the incoming rank equals the
// stored rank AND that rank is non-terminal (< TerminalRank) AND the
// incoming UpdatedAtUtc is strictly newer than the stored one — so a
// retrying call's Attempted-phase RetryCount/LastError/HttpStatus stay
// live instead of freezing at the first Attempted packet. Terminal ranks
// are excluded from the tiebreaker, so a later terminal NEVER overwrites
// an earlier one; equal stamps are inert (idempotent replay) and a lower
// rank is always a no-op. That inertness is what makes the UPDATE
// harmless immediately after this same call's own INSERT: equal rank,
// equal UpdatedAtUtc, zero rows changed.
//
// Raw SQL with explicitly-typed parameters (rather than
// ExecuteSqlInterpolated) so the monotonic UPDATE exists as ONE statement
// text shared by the combined batch and the duplicate-key retry below —
// the predicate is far too intricate to keep in two copies. Explicit
// SqlDbType is load-bearing: an untyped DateTime parameter binds as
// `datetime` (3.33 ms rounding), which would corrupt both the stored
// datetime2 stamps and the `UpdatedAtUtc <` freshness tiebreaker. Sizes
// are deliberately left at the value's own length so the server enforces
// the column widths (see AuditLogRepository.AddParameter).
//
// SourceNode-stamping: 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. On the UPDATE leg SourceNode is
// written 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.
try
{
await _context.Database.ExecuteSqlRawAsync(
InsertIfAbsentSql + "\n\n" + MonotonicUpdateSql,
BuildUpsertParameters(siteCall, idText, incomingRank),
ct)
.ConfigureAwait(false);
}
catch (SqlException ex) when (
ex.Number == SqlErrorUniqueIndexViolation
|| ex.Number == SqlErrorPrimaryKeyViolation)
{
// Two concurrent sessions both found the row absent and both raced to
// INSERT; the loser raises 2601/2627 against the TrackedOperationId
// primary key. The winner's row IS the first-write, but it is NOT
// necessarily this packet's lifecycle state — the reconciliation pull
// and the cached dual-write feed this method with different states —
// so the loser MUST still apply its monotonic UPDATE against the
// winner's row. The batch aborted at the faulting INSERT, so re-run
// the UPDATE alone here; it is idempotent and rank-guarded, so
// re-running it is safe even if the batch did reach it.
_logger.LogDebug(
ex,
"SiteCallAuditRepository.UpsertAsync swallowed duplicate-key violation (error {SqlErrorNumber}) for TrackedOperationId {TrackedOperationId}; re-running the monotonic update against the winning row.",
ex.Number,
idText);
await _context.Database.ExecuteSqlRawAsync(
MonotonicUpdateSql,
BuildUpsertParameters(siteCall, idText, incomingRank),
ct)
.ConfigureAwait(false);
}
}
///
/// Rows per batched upsert command. Each row binds 15 parameters, so 128 rows stay
/// under SQL Server's 2 100-parameter limit (1 920) with headroom.
///
internal const int UpsertManyChunkSize = 128;
///
public async Task UpsertManyAsync(IReadOnlyList siteCalls, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(siteCalls);
// The reconciliation pull's write path (review 2026-10-21 06/PERF-2): one command
// per chunk of UpsertManyChunkSize rows instead of one round trip per row — a
// 500-row page is 4 round trips, not 500. Each row runs the SAME two statements
// UpsertAsync runs (InsertIfAbsentSql then MonotonicUpdateSql), with its parameters
// suffixed per row, so the insert-first / monotonic / ownership semantics are
// byte-identical rather than re-derived set-based.
//
// Any SqlException from a chunk — an insert race's 2601/2627 (statement-level,
// the batch runs on) or an ownership THROW 51001 (batch-terminating) — re-runs that
// chunk row by row through UpsertAsync. Re-applying rows the batch already applied
// is inert (equal rank + equal UpdatedAtUtc changes nothing) and UpsertAsync handles
// the race exactly as before. A genuinely bad row does not stop the rest of the
// page: the remaining rows are still applied and the FIRST such failure is
// rethrown at the end, so the caller sees the same exception the old per-row
// loop surfaced (and keeps its cursor), minus the collateral of the rows behind it.
System.Runtime.ExceptionServices.ExceptionDispatchInfo? firstFailure = null;
// Rows UpsertAsync would reject before touching the database (a null row, an
// unknown status) are validated up front and SKIPPED with their failure captured,
// so one malformed row cannot stop the rest of the page from applying.
var valid = new List<(SiteCall Row, int Rank)>(siteCalls.Count);
foreach (var siteCall in siteCalls)
{
try
{
if (siteCall is null)
{
throw new ArgumentException("A null SiteCall row was supplied.", nameof(siteCalls));
}
valid.Add((siteCall, GetRankOrThrow(siteCall.Status)));
}
catch (ArgumentException invalid)
{
firstFailure ??= System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(invalid);
}
}
for (var offset = 0; offset < valid.Count; offset += UpsertManyChunkSize)
{
var chunk = valid.Skip(offset).Take(UpsertManyChunkSize).Select(v => v.Row).ToList();
var ranks = valid.Skip(offset).Take(UpsertManyChunkSize).Select(v => v.Rank).ToList();
var sql = new System.Text.StringBuilder();
var parameters = new List