fix(audit): let an unresolvable cached row leave the drain queue #24

Open
dohertj2 wants to merge 1 commits from fix/cached-telemetry-drain-hot-loop into main
Owner

Summary

Fixes the cached-telemetry drain hot-loop (docs/known-issues/2026-07-20-cached-telemetry-drain-hot-loop.md).

A cached-telemetry audit row whose OperationTracking snapshot could not be resolved was skipped and left Pending. Nothing ever removed it, so the next drain re-read it, failed identically, and logged again — forever.

The severity was worse than the issue was filed as. The queue is read oldest-first with a fixed BatchSize (default 256), so once a batch's worth of permanently-unresolvable rows collected at the head, every drain re-read exactly those rows and never reached the newer rows behind them. That is a permanent stall of the cached-telemetry path, not the "log flood, no data loss" originally recorded. Measured on the rig at ~2,800 warnings/minute, surviving both a process restart and a container restart.

The fix

After a grace period (CachedTrackingGraceSeconds, default 300 s) the operational half is abandoned and the row marked Forwarded. A row with no CorrelationId is abandoned immediately — it can never resolve.

Marking Forwarded does not drop audit data. That state means "no longer owed by the drain, still eligible for reconciliation", which is exactly this situation: ReadPendingSinceAsync covers Forwarded as well as Pending, and central dedups on EventId, so the reconciliation pull still delivers the audit half. What is lost is the operational (SiteCalls) half — unrecoverable anyway once the tracking row is gone.

Three deliberate boundaries, each with a test:

Case Behaviour Why
Snapshot missing, past grace Abandon Gone for good; retrying wedges the queue
Snapshot missing, inside grace Retry Normally a brief write race — abandoning immediately would discard the operational half of every cached call that lost it
Tracking store throws Never abandon, any age A throw is a store fault (locked, corrupt, mid-restore), not a verdict about the row

Logging moved to per drain pass rather than per row. Deferred rows dropped to Debug — being inside the grace window is normal operation, not a warning.

Test Plan

  • CachedDrain_OrphanRow_NoTrackingSnapshot_IsSkipped_DoesNotCrash retargeted, not deleted — it pinned the defect ("skipped and stays Pending"), so its orphan assertion is inverted while the half that still holds is kept verbatim
  • 3 tests added, including CachedDrain_FullBatchOfUnresolvableRows_DoesNotStarveTheQueue — the actual regression
  • Both "must NOT abandon" tests carry a positive control on the read count, so they cannot pass by virtue of a drain that never ran
  • Non-vacuity verified: with abandonment disabled the two abandonment tests go red (2 failed / 10 passed); the two guards stay green in both directions
  • Build 0 warnings; AuditLog 358, Host 330, SiteRuntime 512, StoreAndForward 130, Communication 312, Commons 684, Integration 94 — all pass

Origin

Found while cleaning the rig after the LocalDb Phase 2 live gate. Two production triggers need no operator error: the tracking retention window elapsing before the drain catches up (long central outage, large backlog), or restoring one store independently of the other — easy to do because the two live in different places (auditlog.db is inside the container on the docker rig; tracking is in the bind-mounted LocalDb database).

https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts

## Summary Fixes the cached-telemetry drain hot-loop (`docs/known-issues/2026-07-20-cached-telemetry-drain-hot-loop.md`). A cached-telemetry audit row whose `OperationTracking` snapshot could not be resolved was skipped and left `Pending`. Nothing ever removed it, so the next drain re-read it, failed identically, and logged again — forever. **The severity was worse than the issue was filed as.** The queue is read **oldest-first with a fixed `BatchSize` (default 256)**, so once a batch's worth of permanently-unresolvable rows collected at the head, every drain re-read exactly those rows and **never reached the newer rows behind them**. That is a permanent stall of the cached-telemetry path, not the "log flood, no data loss" originally recorded. Measured on the rig at **~2,800 warnings/minute**, surviving both a process restart and a container restart. ## The fix After a grace period (`CachedTrackingGraceSeconds`, default 300 s) the operational half is abandoned and the row marked `Forwarded`. A row with no `CorrelationId` is abandoned immediately — it can never resolve. **Marking `Forwarded` does not drop audit data.** That state means *"no longer owed by the drain, still eligible for reconciliation"*, which is exactly this situation: `ReadPendingSinceAsync` covers `Forwarded` as well as `Pending`, and central dedups on `EventId`, so the reconciliation pull still delivers the audit half. What is lost is the operational (`SiteCalls`) half — unrecoverable anyway once the tracking row is gone. Three deliberate boundaries, each with a test: | Case | Behaviour | Why | |---|---|---| | Snapshot missing, past grace | Abandon | Gone for good; retrying wedges the queue | | Snapshot missing, inside grace | Retry | Normally a brief write race — abandoning immediately would discard the operational half of every cached call that lost it | | Tracking store **throws** | Never abandon, any age | A throw is a store fault (locked, corrupt, mid-restore), not a verdict about the row | Logging moved to per drain pass rather than per row. Deferred rows dropped to Debug — being inside the grace window is normal operation, not a warning. ## Test Plan - [x] `CachedDrain_OrphanRow_NoTrackingSnapshot_IsSkipped_DoesNotCrash` **retargeted, not deleted** — it pinned the defect ("skipped and stays Pending"), so its orphan assertion is inverted while the half that still holds is kept verbatim - [x] 3 tests added, including `CachedDrain_FullBatchOfUnresolvableRows_DoesNotStarveTheQueue` — the actual regression - [x] Both "must NOT abandon" tests carry a **positive control** on the read count, so they cannot pass by virtue of a drain that never ran - [x] **Non-vacuity verified**: with abandonment disabled the two abandonment tests go red (2 failed / 10 passed); the two guards stay green in both directions - [x] Build **0 warnings**; AuditLog 358, Host 330, SiteRuntime 512, StoreAndForward 130, Communication 312, Commons 684, Integration 94 — all pass ## Origin Found while cleaning the rig after the LocalDb Phase 2 live gate. Two production triggers need no operator error: the tracking retention window elapsing before the drain catches up (long central outage, large backlog), or restoring one store independently of the other — easy to do because the two live in different places (`auditlog.db` is inside the container on the docker rig; tracking is in the bind-mounted LocalDb database). https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
dohertj2 added 1 commit 2026-07-20 06:29:45 -04:00
A cached-telemetry audit row whose OperationTracking snapshot could not be
resolved was skipped and left Pending. Nothing ever removed it, so the next
drain re-read it, failed identically, and logged again — forever.

The severity was worse than the original write-up said. The queue is read
oldest-first with a fixed BatchSize (default 256), so once a batch's worth of
permanently-unresolvable rows collected at the head, every drain re-read
exactly those rows and NEVER REACHED the newer rows behind them. That is a
permanent stall of the cached-telemetry path, not the "log flood, no data loss"
the issue was first filed as. Measured on the rig at ~2,800 warnings/minute,
surviving both a process restart and a container restart.

Fix: after a grace period (CachedTrackingGraceSeconds, default 300 s) the
operational half is abandoned and the row marked Forwarded. A row with no
CorrelationId is abandoned immediately — it can never resolve.

Marking Forwarded does not drop audit data. That state means "no longer owed by
the drain, still eligible for reconciliation", which is exactly this situation:
ReadPendingSinceAsync covers Forwarded as well as Pending and central dedups on
EventId, so the reconciliation pull still delivers the audit half. What is lost
is the operational (SiteCalls) half, which is unrecoverable anyway once the
tracking row is gone.

Three deliberate boundaries:
- Inside the grace window the row is still retried — a missing snapshot is
  normally a brief write race, and abandoning on the first failed lookup would
  discard the operational half of every cached call that lost that race.
- A tracking-store THROW never abandons, however old the row: a throw is a
  store fault (locked, corrupt, mid-restore), not a verdict about the row.
- Logging is per drain pass, not per row. Deferred rows dropped to Debug —
  being inside the grace window is normal operation, not a warning.

CachedDrain_OrphanRow_NoTrackingSnapshot_IsSkipped_DoesNotCrash was RETARGETED
rather than deleted: it pinned the defect ("skipped and stays Pending"), so its
orphan assertion is inverted and the half that still holds is kept verbatim.
Three tests added, including the starvation regression. Both "must NOT abandon"
tests carry a positive control on the read count, so they cannot pass by virtue
of a drain that never ran.

Verified non-vacuous: with abandonment disabled the two abandonment tests go
red and the two guards stay green. Build 0 warnings; AuditLog 358, Host 330,
SiteRuntime 512, StoreAndForward 130, Communication 312, Commons 684,
Integration 94 — all pass.

Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
This pull request can be merged automatically.
This branch is out-of-date with the base branch
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin fix/cached-telemetry-drain-hot-loop:fix/cached-telemetry-drain-hot-loop
git checkout fix/cached-telemetry-drain-hot-loop
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: dohertj2/ScadaBridge#24