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
8.0 KiB
Cached-telemetry drain hot-loops forever on a row whose tracking snapshot is gone
Date: 2026-07-20 · Status: RESOLVED (2026-07-20) · Severity: High — revised up from Medium on investigation (permanent stall of the cached-telemetry path, not just a log flood) · Area: AuditLog / Site Telemetry
Resolution (2026-07-20)
Fixed by letting an unresolvable row LEAVE the queue.
SiteAuditTelemetryActor.OnCachedDrainAsync now abandons the operational half of a cached row
whose tracking snapshot is still unresolvable after a grace period
(SiteAuditTelemetryOptions.CachedTrackingGraceSeconds, default 300 s) and marks it
Forwarded. A row with no CorrelationId at all is abandoned immediately — it can never resolve.
Marking Forwarded does not drop the audit data. That state's role in this machine is "no
longer owed by the drain, still eligible for reconciliation", which is exactly the situation:
ISiteAuditQueue.ReadPendingSinceAsync 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 (SiteCalls) half — unrecoverable regardless, since the tracking row it would
have been built from no longer exists.
Three further behaviours, each deliberate:
- Inside the grace window the row is still retried. A missing snapshot is normally a brief write race (the audit row lands microseconds before the tracking row); 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. A throw is a store fault (locked, corrupt,
mid-restore), not a verdict about the row. Those rows stay
Pendinghowever old they are. - Logging is per drain pass, not per row. One summary line each for abandoned, lookup-failed (with the first exception attached) and deferred rows. The deferred case dropped to Debug — being inside the grace window is normal operation, not a warning.
Severity was revised UP during the fix
The original write-up called this a log flood with "no data loss", which understated it. The queue
is read oldest-first with a fixed BatchSize (default 256), so once a batch's worth of
permanently-unresolvable rows collects at the head, every drain re-reads exactly those rows, fails
identically, and never reaches the newer rows behind them. The cached-telemetry path stalls
permanently. The audit half still reached central by reconciliation, so "no data loss" held — but
"Medium" did not.
Fixed in: SiteAuditTelemetryActor.cs (AbandonUnresolvableAsync + the rewritten skip
branches), SiteAuditTelemetryOptions.cs (CachedTrackingGraceSeconds).
Tests: SiteAuditTelemetryActorTests — CachedDrain_OrphanRow_PastGrace_IsAbandoned_...,
..._InsideGrace_IsRetried_NotAbandoned, ..._FullBatchOfUnresolvableRows_DoesNotStarveTheQueue,
..._TrackingStoreThrow_DoesNotAbandonTheRow. The pre-existing
CachedDrain_OrphanRow_NoTrackingSnapshot_IsSkipped_DoesNotCrash was retargeted, not deleted —
it had pinned the defect ("skipped and stays Pending"), so its orphan assertion is inverted while
the half that still holds is kept verbatim. Verified non-vacuous: with abandonment disabled the two
abandonment tests go red; the two must-NOT-abandon guards stay green in both directions.
The diagnosis below is retained as the record of how it was found.
Summary
SiteAuditTelemetryActor's cached-telemetry drain reads Pending audit rows, looks up each row's
tracking snapshot by CorrelationId, and pushes the combined packet to central. When the lookup
returns null the row is skipped and deliberately left Pending
(SiteAuditTelemetryActor.cs:307), on the reasoning that "central reconciliation will pick it up".
Nothing ever removes such a row from the local drain queue. The next tick re-reads it, fails the same lookup, logs the same warning, and leaves it Pending again — forever. With a batch of unresolvable rows the actor spins at its non-idle rate and emits one warning per row per pass.
Measured on the docker rig: ~2 800 warnings/minute, sustained, surviving both a process restart and a container restart, until the audit database itself was discarded.
[09:57:41 WRN] [Site/scadabridge-site-a-a] Cached-telemetry drain: no tracking snapshot for
a5392796-291f-4f5b-9fbf-5817c1ec76c7 (TrackedOperationId 59bd4bf8-…); skipping.
Why the rows became unresolvable
Two independent stores must agree:
- the audit rows live in
auditlog.db(site-local, and on the docker rig inside the container at/app/auditlog.db, not on the bind-mounted data volume); - the tracking rows live in
OperationTracking, which LocalDb Phase 1 moved into the consolidatedLocalDb:Pathdatabase (bind-mounted).
Anything that resets one without the other strands every audit row that referenced it. The code comment already anticipates the cause — "possible if the audit row is older than the tracking retention window, or the tracking store was reset" — so this is a known-and-accepted input, not an exotic one.
Two realistic production triggers, neither requiring operator error:
- Tracking retention expiry. If the tracking retention window elapses before the audit drain catches up — a long central outage, a large backlog — the snapshots are pruned out from under still-Pending audit rows and every one of them becomes a permanent hot-loop entry.
- Restoring or resetting one store independently of the other, e.g. rebuilding a node's
LocalDb file from its peer while its container-local
auditlog.dbsurvives.
It was hit here by (2): the two site-a LocalDb databases were dropped during a rig cleanup while
auditlog.db — being inside the container — survived.
Why the current handling is not enough
Skipping the row is correct; leaving it Pending with no other state change is not. The row is now in a state it can never leave:
- no attempt counter, so an unresolvable row is indistinguishable from a transiently-failing one;
- no backoff, so the actor runs at full non-idle rate against a queue that can never shrink;
- no terminal state, so it is retried for the life of the database;
- one Warning per row per pass, which buries every other log line on the node.
The "central reconciliation will pick it up" comment is about the audit half reaching central by another path. That may well be true — but it does not release the row from the local drain queue, which is what actually loops.
Suggested fix
Give an unresolvable row somewhere to go. Roughly, in increasing order of effort:
- Bound the retries. Add an attempt count; past a threshold mark the row terminal
(
TrackingUnavailable) and stop re-reading it. Emit a single summary Warning with the count rather than one per row per pass. - Rate-limit the warning to one per drain episode regardless of row count — the same pattern
MaintenanceBackgroundServicealready uses for the oplog caps-exceeded warning (_snapshotFlagWarned). - Push the audit half alone when the tracking snapshot is missing, rather than skipping the row entirely, so the row can be marked emitted and leave the queue. Needs a decision on whether central accepts a packet with no tracking half.
(2) alone would remove the operational damage; (1) or (3) is needed to stop the wasted I/O.
Reproduction
- Run a site node until it has cached-call audit rows with tracking correlations.
- Stop the node; delete its consolidated LocalDb database (which holds
OperationTracking); leaveauditlog.dbin place. - Start the node. The drain warning repeats indefinitely; the rate does not decay.
Notes
- No data loss. The audit rows are intact and still reach central by the reconciliation path; what is broken is the local drain's ability to ever finish.
- Discovered while cleaning the rig after the LocalDb Phase 2 live gate
(
docs/plans/2026-07-19-localdb-phase2-live-gate.md), which is also where the related "deleting an instance orphans its buffered messages" observation is recorded.