fix(auditlog): explicit permanent rejections on the ingest ack (IngestAck.rejected_event_ids, field 2) with a dedicated counter; reconciler ignores transient faults for abandonment; persist the served high-water mark; prune only on a non-empty enumeration (review 2026-10-21 06/PERF-1, 06/IMPL-1 adversarial follow-up)

This commit is contained in:
Joseph Doherty
2026-10-22 08:40:42 -04:00
parent 38167bca88
commit b0a08bb63b
23 changed files with 790 additions and 168 deletions
@@ -481,28 +481,30 @@ public class SiteAuditTelemetryActor : ReceiveActor
}
/// <summary>
/// Records a refused push attempt for every row that was SENT in an answered batch but is
/// missing from the ack's <c>AcceptedEventIds</c>; rows refused
/// <see cref="SiteAuditTelemetryOptions.MaxRejectedPushAttempts"/> times leave the push
/// queue as <see cref="SiteAuditForwardStates.Rejected"/> (review 2026-10-21 06/IMPL-1).
/// Records a refused push attempt for every SENT row central explicitly listed in the ack's
/// <c>RejectedEventIds</c> — a permanent, row-intrinsic insert failure. Rows refused
/// <see cref="SiteAuditTelemetryOptions.MaxRejectedPushAttempts"/> times leave the push queue as
/// <see cref="SiteAuditForwardStates.Rejected"/> (review 2026-10-21 06/IMPL-1).
/// </summary>
/// <remarks>
/// Called only after the push RETURNED — a transport fault is not a verdict about any row
/// and never counts. Best-effort: a bookkeeping failure leaves the rows Pending, exactly as
/// before this existed.
/// "Not accepted" is NOT a refusal: central answers with an empty or partial ack during a SQL
/// maintenance window, a timeout, or an exhausted ingest budget, and counting those would flip
/// healthy rows to Rejected. Only the explicit list counts; an older central never sends it, so
/// against it this degrades to the pre-IMPL-1 behaviour (rows stay Pending). Best-effort: a
/// bookkeeping failure leaves the rows Pending.
/// </remarks>
private async Task RecordRefusalsAsync(IReadOnlyList<string> sentIds, IngestAck ack, CancellationToken ct)
{
if (_queue is not ISiteAuditPushLifecycle lifecycle || sentIds.Count == 0)
if (_queue is not ISiteAuditPushLifecycle lifecycle || sentIds.Count == 0 || ack.RejectedEventIds.Count == 0)
{
return;
}
var accepted = new HashSet<string>(ack.AcceptedEventIds, StringComparer.OrdinalIgnoreCase);
var sent = new HashSet<string>(sentIds, StringComparer.OrdinalIgnoreCase);
var refused = new List<Guid>();
foreach (var raw in sentIds)
foreach (var raw in ack.RejectedEventIds)
{
if (!accepted.Contains(raw) && Guid.TryParse(raw, out var id))
if (sent.Contains(raw) && Guid.TryParse(raw, out var id))
{
refused.Add(id);
}