fix(controlplane): audit writer drops only already-persisted EventIds, never the whole SQL Server batch

SQL Server's DbUpdateException for one duplicate names every entry in the statement batch, so detaching
ex.Entries dropped the whole flush. On a row-level rejection the writer now asks the database which of the
batch's EventIds it already holds, drops exactly those, re-saves the rest, and falls back to row-by-row only
if a non-duplicate row is still refused. Fresh context + entities per attempt. Pinned against the real
central SQL Server (new SqlServerTestDatabase harness) and a faithful whole-batch interceptor.

Refs #733
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 13:42:29 -04:00
parent f1c52385ed
commit f7e7ba0263
3 changed files with 255 additions and 65 deletions
@@ -17,8 +17,8 @@ namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Audit;
/// Dedup is two-layer: in-buffer (the <see cref="Dictionary{TKey, TValue}"/> below collapses
/// duplicate EventIds before flush) and at the database via the filtered unique index
/// <c>UX_ConfigAuditLog_EventId</c> (cross-restart safety — a retry of an already-flushed
/// batch hits the constraint; <see cref="SaveDetachingRejectedRows"/> detaches the rejected row
/// and re-saves, so the duplicate is dropped without losing the rest of the batch).
/// batch hits the constraint; <see cref="SaveBatch"/> drops exactly the already-persisted EventIds
/// and re-saves the rest, so the duplicate is dropped without losing the rest of the batch).
///
/// Implements the shared <see cref="IAuditWriter"/> seam: <see cref="WriteAsync"/> is a
/// best-effort, never-throwing entry point that simply <c>Tell</c>s this actor and returns
@@ -92,22 +92,7 @@ public sealed class AuditWriterActor : ReceiveActor, IWithTimers, IAuditWriter
try
{
using var db = _dbFactory.CreateDbContext();
foreach (var evt in snapshot)
{
db.ConfigAuditLogs.Add(new ConfigAuditLog
{
Timestamp = evt.OccurredAtUtc.UtcDateTime,
Principal = evt.Actor,
EventType = $"{evt.Category}:{evt.Action}",
NodeId = evt.SourceNode,
DetailsJson = evt.DetailsJson,
EventId = evt.EventId,
CorrelationId = evt.CorrelationId,
Outcome = evt.Outcome.ToString(),
});
}
var dropped = SaveDetachingRejectedRows(db);
var dropped = SaveBatch(snapshot);
_log.Debug("AuditWriter flushed {Count} events ({Dropped} rejected rows dropped)", snapshot.Count - dropped, dropped);
}
catch (Exception ex)
@@ -116,46 +101,93 @@ public sealed class AuditWriterActor : ReceiveActor, IWithTimers, IAuditWriter
}
}
private static ConfigAuditLog ToRow(AuditEvent evt) => new()
{
Timestamp = evt.OccurredAtUtc.UtcDateTime,
Principal = evt.Actor,
EventType = $"{evt.Category}:{evt.Action}",
NodeId = evt.SourceNode,
DetailsJson = evt.DetailsJson,
EventId = evt.EventId,
CorrelationId = evt.CorrelationId,
Outcome = evt.Outcome.ToString(),
};
/// <summary>
/// Saves the batch, detaching and retrying past any row the database rejects (R03:2.6). One
/// statement batch used to carry the whole flush: a single duplicate <c>EventId</c> (any at-least-once
/// producer — the audit seam is Tell-based through a singleton proxy that re-delivers across a
/// hand-over) threw for all 500 rows and the class remarks' per-row dedup claim was not what ran.
/// Persists one flush, dropping ONLY the rows the database will not take (R03:2.6, fix round 1 / I4).
/// </summary>
/// <param name="db">The context holding the batch.</param>
/// <param name="batch">The flush.</param>
/// <returns>How many rows were dropped.</returns>
/// <remarks>
/// Only a <see cref="DbUpdateException"/> that NAMES its entries is handled here — that is the shape a
/// constraint violation takes (SQL 2601/2627 for the unique index). A failure with no entries (the
/// connection died, a deadlock) is the whole-batch failure it always was and propagates to the
/// caller's catch. Bounded by the batch size so a store that rejects every row still terminates.
/// <para>
/// <b>Why not detach <c>ex.Entries</c>.</b> SQL Server inserts the batch as one statement batch, so
/// the <see cref="DbUpdateException"/> raised for ONE duplicate <c>EventId</c> names EVERY entry in
/// the batch. Detaching what it names dropped the whole flush — the exact loss the fix was for
/// (pinned against a real SQL Server by <c>On_sql_server_a_duplicate_event_id_costs_only_that_row</c>).
/// </para>
/// <para>
/// So, on a rejection that names entries (a constraint violation; 2601/2627 for the unique index):
/// (1) ask the database which of the batch's EventIds it already holds and drop exactly those —
/// the at-least-once re-delivery the dedup contract expects; (2) re-save the rest as a batch; (3) if
/// that is still rejected (some OTHER per-row constraint), fall back to row-by-row and drop only the
/// rows that fail on their own. Every attempt uses a fresh context and fresh entities, so no state
/// from a failed save leaks into the next one. A failure that names no entry (connection loss,
/// deadlock) propagates — the whole-batch failure it always was. Bounded: at most 2 + N saves.
/// </para>
/// </remarks>
private int SaveDetachingRejectedRows(OtOpcUaConfigDbContext db)
private int SaveBatch(IReadOnlyList<AuditEvent> batch)
{
var dropped = 0;
var attempts = 0;
var maxAttempts = db.ChangeTracker.Entries<ConfigAuditLog>().Count() + 1;
while (true)
if (TrySave(batch, out var first)) return 0;
_log.Debug(first, "AuditWriter: a batch of {Count} was rejected; dropping only already-persisted EventIds", batch.Count);
HashSet<Guid> existing;
using (var db = _dbFactory.CreateDbContext())
{
try
{
db.SaveChanges();
return dropped;
}
catch (DbUpdateException ex) when (ex.Entries.Count > 0 && ++attempts < maxAttempts)
{
var duplicate = IsUniqueViolation(ex);
foreach (var entry in ex.Entries)
{
var eventId = (entry.Entity as ConfigAuditLog)?.EventId;
if (duplicate)
_log.Debug("AuditWriter: EventId {EventId} already persisted (unique index); dropping the duplicate", eventId);
else
_log.Warning(ex, "AuditWriter: the database rejected EventId {EventId}; dropping that row and keeping the batch", eventId);
entry.State = EntityState.Detached;
dropped++;
}
}
var ids = batch.Select(e => (Guid?)e.EventId).ToList();
existing = db.ConfigAuditLogs
.AsNoTracking()
.Where(r => ids.Contains(r.EventId))
.Select(r => r.EventId!.Value)
.ToHashSet();
}
foreach (var id in existing)
_log.Debug("AuditWriter: EventId {EventId} already persisted (unique index); dropping the duplicate", id);
var remaining = batch.Where(e => !existing.Contains(e.EventId)).ToList();
var dropped = batch.Count - remaining.Count;
if (remaining.Count == 0 || TrySave(remaining, out _)) return dropped;
// Still rejected with no duplicate left to blame: isolate the offending row(s).
foreach (var evt in remaining)
{
if (TrySave([evt], out var rowError)) continue;
dropped++;
if (IsUniqueViolation(rowError!))
_log.Debug("AuditWriter: EventId {EventId} already persisted (unique index); dropping the duplicate", evt.EventId);
else
_log.Warning(rowError, "AuditWriter: the database rejected EventId {EventId}; dropping that row and keeping the batch", evt.EventId);
}
return dropped;
}
/// <summary>Saves <paramref name="events"/> on a fresh context; a rejection that names entries is reported, anything else throws.</summary>
/// <param name="events">The rows to save.</param>
/// <param name="rejection">The row-level rejection, when this returns false.</param>
/// <returns><see langword="true"/> when saved.</returns>
private bool TrySave(IReadOnlyList<AuditEvent> events, out DbUpdateException? rejection)
{
using var db = _dbFactory.CreateDbContext();
foreach (var evt in events) db.ConfigAuditLogs.Add(ToRow(evt));
try
{
db.SaveChanges();
rejection = null;
return true;
}
catch (DbUpdateException ex) when (ex.Entries.Count > 0 || IsUniqueViolation(ex))
{
rejection = ex;
return false;
}
}