fix(auditlog): reconciliation pull serves only unacked rows (PushAbandoned/Rejected push-exit states), central pages within a tick with set-based inserts; refused push rows leave the queue after N attempts (review 2026-10-21 06/PERF-1, 06/COR-3, 06/IMPL-1)
This commit is contained in:
@@ -179,6 +179,7 @@ public class SiteAuditTelemetryActor : ReceiveActor
|
||||
}
|
||||
|
||||
var batch = BuildBatch(pending);
|
||||
var sentIds = batch.Events.Select(e => e.EventId).ToList();
|
||||
|
||||
IngestAck ack;
|
||||
try
|
||||
@@ -203,6 +204,8 @@ public class SiteAuditTelemetryActor : ReceiveActor
|
||||
await _queue.MarkForwardedAsync(acceptedIds, ct)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await RecordRefusalsAsync(sentIds, ack, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
@@ -269,7 +272,7 @@ public class SiteAuditTelemetryActor : ReceiveActor
|
||||
var wireBytes = AuditWireBudget.InitialBytes;
|
||||
var emittedEventIds = new List<Guid>(pending.Count);
|
||||
// Rows whose operational half can never be built. They are marked
|
||||
// Forwarded so they LEAVE this queue — see AbandonUnresolvableAsync
|
||||
// PushAbandoned so they LEAVE this queue — see AbandonUnresolvableAsync
|
||||
// for why that is safe and why leaving them Pending is not.
|
||||
var abandonedEventIds = new List<Guid>();
|
||||
// Counted, not logged per row: a whole batch can fail identically
|
||||
@@ -362,7 +365,7 @@ public class SiteAuditTelemetryActor : ReceiveActor
|
||||
if (batch.Packets.Count == 0)
|
||||
{
|
||||
// Nothing resolvable in this read. Any permanently-unresolvable
|
||||
// rows have just been marked Forwarded above, so the next drain
|
||||
// rows have just been marked PushAbandoned above, so the next drain
|
||||
// sees past them rather than re-reading the same head of queue.
|
||||
return;
|
||||
}
|
||||
@@ -387,6 +390,9 @@ public class SiteAuditTelemetryActor : ReceiveActor
|
||||
await _queue.MarkForwardedAsync(acceptedIds, ct)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await RecordRefusalsAsync(
|
||||
batch.Packets.Select(p => p.AuditEvent.EventId).ToList(), ack, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
@@ -408,33 +414,35 @@ public class SiteAuditTelemetryActor : ReceiveActor
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks cached rows whose operational half can never be built as
|
||||
/// Forwarded, so they leave the cached-drain queue.
|
||||
/// Moves cached rows whose operational half can never be built out of the
|
||||
/// cached-drain queue, as <see cref="SiteAuditForwardStates.PushAbandoned"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why they must leave.</b> Leaving an unresolvable row Pending — the
|
||||
/// previous behaviour — is not a harmless skip. The queue is read
|
||||
/// oldest-first with a fixed <c>BatchSize</c>, so once a batch's worth of
|
||||
/// unresolvable rows collects at the head, every drain re-reads exactly
|
||||
/// those rows, fails identically, and never sees the newer rows behind
|
||||
/// them. The cached-telemetry path stalls permanently, and each pass logs
|
||||
/// once per row (measured at ~2 800 warnings/minute on a rig).
|
||||
/// <b>Why they must leave.</b> Leaving an unresolvable row Pending is not a
|
||||
/// harmless skip. The queue is read oldest-first with a fixed
|
||||
/// <c>BatchSize</c>, so once a batch's worth of unresolvable rows collects at
|
||||
/// the head, every drain re-reads exactly those rows, fails identically, and
|
||||
/// never sees the newer rows behind them. The cached-telemetry path stalls
|
||||
/// permanently, and each pass logs once per row (measured at ~2 800
|
||||
/// warnings/minute on a rig).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Why Forwarded is the honest state.</b> Its role in this state machine
|
||||
/// is "no longer owed by the drain, still eligible for reconciliation" —
|
||||
/// which is exactly the situation here. The audit half is NOT dropped:
|
||||
/// <c>ReadPendingSinceAsync</c> covers Forwarded rows as well as Pending
|
||||
/// ones and central dedups on EventId, so the reconciliation pull still
|
||||
/// delivers them. What is genuinely lost is the operational
|
||||
/// (<c>SiteCalls</c>) half — and that is unrecoverable regardless, because
|
||||
/// the tracking row it would have been built from no longer exists.
|
||||
/// <b>Why PushAbandoned, not Forwarded.</b> These rows were marked Forwarded
|
||||
/// until review 2026-10-21 (06/PERF-1), which only worked because the
|
||||
/// reconciliation pull used to re-serve every Forwarded row — the coupling
|
||||
/// that capped the pull's throughput. Forwarded now means "central acked it"
|
||||
/// and the pull no longer serves it, so an un-acked row needs a state of its
|
||||
/// own: <c>PushAbandoned</c> is served by the pull, retired by a covering
|
||||
/// cursor and never age-purged. The audit half therefore still reaches central;
|
||||
/// what is genuinely lost is the operational (<c>SiteCalls</c>) half, which is
|
||||
/// unrecoverable regardless because the tracking row no longer exists.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A failure to mark is swallowed: the rows simply stay Pending and the
|
||||
/// next drain retries. Escalating here would take down the audit drain over
|
||||
/// a best-effort cleanup.
|
||||
/// A queue that does not implement <see cref="ISiteAuditPushLifecycle"/> (a
|
||||
/// test double) falls back to the old Forwarded flip. A failure to mark is
|
||||
/// swallowed: the rows simply stay Pending and the next drain retries.
|
||||
/// Escalating here would take down the audit drain over a best-effort cleanup.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task AbandonUnresolvableAsync(
|
||||
@@ -442,24 +450,87 @@ public class SiteAuditTelemetryActor : ReceiveActor
|
||||
{
|
||||
try
|
||||
{
|
||||
await _queue.MarkForwardedAsync(eventIds, ct).ConfigureAwait(false);
|
||||
if (_queue is ISiteAuditPushLifecycle lifecycle)
|
||||
{
|
||||
await lifecycle.MarkPushAbandonedAsync(eventIds, ct).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _queue.MarkForwardedAsync(eventIds, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"Cached-telemetry drain: abandoned the operational half of {Count} row(s) with no " +
|
||||
"resolvable tracking snapshot after {Grace}s; marked Forwarded so they no longer block " +
|
||||
"resolvable tracking snapshot after {Grace}s; marked PushAbandoned so they no longer block " +
|
||||
"the queue. The audit half still reaches central via the reconciliation pull. This is " +
|
||||
"expected after tracking retention elapses or the tracking store is reset independently " +
|
||||
"of the audit store.",
|
||||
eventIds.Count, graceSeconds);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Cached-telemetry drain: could not mark {Count} unresolvable row(s) Forwarded " +
|
||||
"Cached-telemetry drain: could not mark {Count} unresolvable row(s) PushAbandoned " +
|
||||
"(sqlite {SqliteError}); they stay Pending and will be retried.",
|
||||
eventIds.Count, SqliteErrorCodes.Describe(ex));
|
||||
}
|
||||
}
|
||||
|
||||
/// <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).
|
||||
/// </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.
|
||||
/// </remarks>
|
||||
private async Task RecordRefusalsAsync(IReadOnlyList<string> sentIds, IngestAck ack, CancellationToken ct)
|
||||
{
|
||||
if (_queue is not ISiteAuditPushLifecycle lifecycle || sentIds.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var accepted = new HashSet<string>(ack.AcceptedEventIds, StringComparer.OrdinalIgnoreCase);
|
||||
var refused = new List<Guid>();
|
||||
foreach (var raw in sentIds)
|
||||
{
|
||||
if (!accepted.Contains(raw) && Guid.TryParse(raw, out var id))
|
||||
{
|
||||
refused.Add(id);
|
||||
}
|
||||
}
|
||||
|
||||
if (refused.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await lifecycle
|
||||
.RecordRejectedPushAttemptsAsync(refused, Math.Max(1, _options.MaxRejectedPushAttempts), ct)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Could not record {Count} refused push attempt(s) (sqlite {SqliteError}); the rows stay Pending.",
|
||||
refused.Count, SqliteErrorCodes.Describe(ex));
|
||||
}
|
||||
}
|
||||
|
||||
private static AuditEventBatch BuildBatch(IReadOnlyList<AuditEvent> events)
|
||||
{
|
||||
var batch = new AuditEventBatch();
|
||||
|
||||
Reference in New Issue
Block a user