Skip to content

Record what an abandoned collection cycle was doing (Fixes #2864) - #2868

Merged
erikdarlingdata merged 8 commits into
devfrom
fix/2864-abandon-forensics
Sep 4, 2026
Merged

erikdarlingdata merged 8 commits into
devfrom
fix/2864-abandon-forensics

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

Fixes #2864 (items 1-3; item 4 is deliberately not built — see the end).

The gap

A cycle the #2673 wall-clock budget abandons stored ABANDONED and rows_collected = 0 — and that zero is rows stored, which an abandoned cycle never does by definition. So the row could not distinguish a target that never sent row 1 from one that sent 149 and then went silent. Those are a stalled target and a stalled stream, and they want unrelated fixes.

#2859 made the phases queryable and the first production capture read:

procedure_stats  sql=120051  open=104  drain=119945  wm=0  rows=0

That proved the time was in the drain and could say nothing about whether the drain was slow or simply empty.

What V109 adds

drain_rows_read / drain_bytes_read come from a counting decorator around the provider reader, not from editing 66 separate while (await reader.ReadAsync(...)) loops. Decorating means a collector cannot forget to count and cannot drift. Verified before writing it that nothing in PerformanceMonitor.Collectors casts a reader to a provider type, so the wrapper is transparent; every abstract member is forwarded explicitly so a typed getter cannot fall back through GetValue and double-count.

drain_last_read_ms is the column that carries the diagnosis, not the row count. A count alone still cannot separate "streaming steadily but slowly" from "delivered everything then hung" — both end at the budget with a positive count. Subtract this from sql_drain_ms and you have the time the reader sat with nothing arriving. It stores NULL when no row ever arrived, which is unambiguous because drain_rows_read is non-null whenever the group is written: NULL beside a 0 count means nothing came, NULL beside a NULL count means the row predates the rung. That leaves 0 free to mean what it honestly means — row 1 arrived instantly. The counters ride the same stopwatch as the drain figure they are subtracted from, so "time since last row" is not a difference of two nearly-equal numbers from different origins.

drain_bytes_read is the string payload, and the name says so. No DbDataReader exposes wire size, so this counts UTF-16 bytes off the string and binary getters and excludes numerics and protocol framing. That is the honest scope and also the useful one: the collectors this exists for are dominated by one large text column, so string bytes are the payload to within a rounding error, and a number pretending to be the wire size would be a worse measurement wearing a better name.

target_session_id is read off the open connection as a client property (SqlConnection.ServerProcessId / NpgsqlConnection.ProcessID), never with SELECT @@SPID — a round trip per collector per server per cycle is ~25,000 extra queries an hour against the fleet to learn a number the client already holds. It is what makes a stalled run joinable: waiting_tasks, dmv_blocking_snapshot and query_snapshots all carry a session id, so without ours "what was our own stalled session waiting on" cannot be asked even for a window where the answering snapshot was captured.

sweep_peer_max_ms separates two populations that share one stored shape — the slowest non-budgeted collector already completed in the same sweep body. A genuinely large query runs beside peers at or below baseline; sweep-wide degradation shows those same light collectors at 34-47x baseline before the heavy ones burn their budget. Telling them apart previously meant cross-referencing neighbouring rows by hand.

Budgeted collectors are excluded because they are the heavies being explained, and "is this one budgeted" is asked of the catalog rather than a name list kept beside it. That required moving PerItemWallClockBudget to the base ICollectorSchemaInfo, alongside AppliesTo and YieldsOnLockTimeout and for the same reason those live there: the catalog is keyed by name and holds that interface. A hardcoded list would be correct today and silently wrong the moment a fifth collector earned a budget. Making it a required interface member is what made the compiler find all three test fakes rather than leaving them silently defaulted.

Recorded on every row, not only abandoned ones: a ratio needs a denominator, and the baseline has to come from the same column on ordinary bodies. Storing it only on failures would rebuild exactly the cross-referencing this removes.

The shared-statement trap, proven red first

InsertCollectionLogSql is still shared between the per-collector writer and the fleet-wide retention run-record, so both binding blocks widened to 22 together. The #2859 pin that asserts binding-count against placeholder-count across every writer was proven red before green by dropping a single retention binding — reproducing exactly the 08P01: bind message supplies N parameters, but prepared statement requires M that failed silently last time, because both writers are failure-isolated by design and the exception goes to a Debug log.

The literal Assert.Equal(17, placeholders) is now 22. It is deliberately a hand-maintained literal: it is the tripwire that makes widening this statement a conscious act, and bumping it is the moment you are forced to ask whether every writer was widened too.

Verification

The Windows suites cannot run on macOS, so pure logic was verified against the real build with a throwaway net10.0 harness — 27 checks, all passing:

  • the counting reader's row count, UTF-16 byte total, and that a getter counts once rather than twice through a base fallback
  • that an empty drain keeps -1 and never 0 — the distinction the whole change turns on, since 0 is a reachable real answer
  • HasWallClockBudget derives correctly for the budgeted heavies, for an ordinary peer, and degrades to light on an unknown name
  • the ladder is dense, ascending, duplicate-free, and topped by StorageVersion
  • the rung adds all five columns, refreshes the passthrough view, and is nullable with no DEFAULT
  • the statement declares 22 placeholders and both writers bind 22

Not covered: no live Postgres was available in this session, so the rung's application against a real compressed hypertable is asserted from the SQL rather than executed. Darling PostgreSQL tests is the arbiter for that.

Encoding checked after the fact: every pre-existing file kept its BOM state, both new files are BOM-free per the directory convention, all files are CRLF, and CHANGELOG.md staged as 2 insertions / 0 deletions with a plain git add — #2858's renormalization holding.

Not built: item 4

The out-of-band watchdog stays unbuilt and needs its own decision. It is the only proposal that must break the sequential model, and it opens a connection to a target that is already not responding — so it wants deliberate bounding (one shot, hard timeout, never retried) rather than being added alongside instrumentation that touches nothing and issues no new query.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MX6HyjsuDCs15qGB2rh4Gy

A cycle the #2673 wall-clock budget abandons stored ABANDONED and
rows_collected = 0, and that zero is rows STORED -- which an abandoned
cycle never does by definition. So the row could not tell a target that
never sent row 1 from one that sent 149 and then went silent: a stalled
target and a stalled stream, wanting unrelated fixes. #2859 made the
phases queryable and the first production capture read
open:104ms drain:119,945ms rows=0 -- proof the time was in the drain, and
nothing about whether the drain was slow or simply empty.

V109 adds five columns.

drain_rows_read / drain_bytes_read come from a counting decorator around
the provider reader, not from edits to 66 separate read loops. Decorating
means a collector cannot forget to count; nothing in the collectors casts
a reader to a provider type, so it is transparent.

drain_last_read_ms carries the diagnosis, not the row count. A count
alone cannot separate "streaming slowly" from "delivered everything then
hung" -- both end at the budget with a positive count. Subtract it from
sql_drain_ms and you have the time the reader sat with nothing arriving.
NULL means no row ever arrived, unambiguous because drain_rows_read is
non-null whenever the group is written, which leaves 0 free to mean what
it honestly means: row 1 arrived instantly. The counters ride the SAME
stopwatch as the drain they are subtracted from.

drain_bytes_read is the string payload and the name says so. No
DbDataReader exposes wire size; this counts UTF-16 bytes off the string
and binary getters. That is the honest scope and the useful one -- these
collectors are dominated by one large text column.

target_session_id is read off the open connection as a client property,
never SELECT @@spid: a round trip here is ~25,000 extra queries an hour
to learn a number the client already holds. It is what makes a stalled
run joinable to waiting_tasks / dmv_blocking_snapshot / query_snapshots,
all of which record a session id.

sweep_peer_max_ms separates two populations sharing one stored shape: a
genuinely large query runs beside peers at or below baseline, while
sweep-wide degradation shows the light collectors at 34-47x baseline
BEFORE the heavies burn their budget. Budgeted collectors are excluded
because they are the heavies being explained, and "is this budgeted" is
asked of the CATALOG -- which required moving PerItemWallClockBudget to
the base ICollectorSchemaInfo beside AppliesTo and YieldsOnLockTimeout,
since the catalog is keyed by name and holds that interface. A hardcoded
list would be right today and silently wrong at the fifth collector.
Recorded on every row, not only abandoned ones: a ratio needs a
denominator and the baseline must come from the same column.

InsertCollectionLogSql is still shared with the retention run-record, so
both binding blocks widened to 22 together. The #2859 pin asserting
binding-count against placeholder-count was proven red first by dropping
one retention binding, reproducing exactly the 08P01 that failed silently
last time.

No Lite twin: the counting reader is installed by Darling's server-scoped
runner and the peer mark by its sweep body, neither of which Lite has, so
a DuckDB twin would be five forever-NULL columns.

Item 4 of the issue -- the out-of-band watchdog -- is deliberately NOT
built. It is the only proposal that must break the sequential model and
open a connection to a target already not responding, so it needs a
design decision rather than being added alongside instrumentation that
touches nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MX6HyjsuDCs15qGB2rh4Gy
await DarlingObservability.LogCollectionAsync(
_postgres!, runtime, collectorName, status, result.Rows, result.SqlMs, result.StorageMs, result.Note,
result.Fanout, result.ServerPhases, _logger, cancellationToken);
result.Fanout, result.ServerPhases, result.Drain, PeerMaxOrNull(server), _logger, cancellationToken);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sweep_peer_max_ms is stale for exactly the two collectors it's meant to diagnose (query_store, plan_correction).

PeerMaxOrNull(server) is read here after await run(...) completes. For the sequential collectors that's fine — they run one at a time within a single RunDueCollectorsAsync invocation, and the -1 reset at line 4810 happens once per body.

But query_store and plan_correction are dispatched via _ = RunDetachedAsync(...) (line 4903) — fire-and-forget, outside the await that keeps RunDueCollectorsAsync "in" a body. RunDueCollectorsAsync itself returns right after the foreach, and the outer per-server loop re-invokes it roughly every 15s (s_sweepInterval), resetting server.SweepPeerMaxMs = -1 and repopulating it from whatever later ticks' due collectors happen to run — all while the detached run is still executing in the background (query_store: p90 65s, up to 230+s; plan_correction: up to 134s).

So by the time the detached collector finishes and this line runs, server.SweepPeerMaxMs reflects the peer high-water mark of some unrelated, much later tick — not the collectors that were actually running concurrently with it. Since these two collectors are (a) the only ones excluded from updating the mark (correctly, as the heavies-being-explained) and (b) the exact motivating case in the PR description/#2840 ("was this one big query, or was the whole body slow"), the column is least trustworthy precisely where it's most needed — it'll typically read as NULL or some small unrelated value, inviting the wrong conclusion ("peers were fine") when no real peer data was ever captured.

Worth considering: snapshot PeerMaxOrNull(server) at dispatch time (before the detached task starts) rather than after it returns, or simply omit sweep_peer_max_ms for detached-collector rows since it can't mean what it's documented to mean.

@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown

Review summary

Focused review of the V109 drain-forensics change (five new collection_log columns, the DrainCountingDataReader decorator, target_session_id, sweep_peer_max_ms).

Correctness issue found (posted inline): sweep_peer_max_ms is computed from a shared ServerLoopState.SweepPeerMaxMs field that's reset every ~15s tick (RunDueCollectorsAsync). query_store and plan_correction run detached (fire-and-forget, up to 230s+), so by the time their own log row is written, the field has been reset/repopulated by unrelated later ticks — the peer value recorded for exactly the two collectors this feature exists to diagnose doesn't reflect their actual concurrent peers. See inline comment on DarlingWorker.cs:5717 for details and a couple of fix options.

Things checked and found sound:

  • DrainCountingDataReader: -1 vs 0 sentinel handling for LastReadElapsedMs is correct and well-tested; no double-counting between GetString/GetValue/GetFieldValue<T> for the call patterns actually used by collectors (verified no collector uses the two-call GetBytes/GetChars length-probe pattern that would inflate PayloadBytes).
  • TargetSessionId is read from the already-open connection (no extra round trip), set before the read/drain begins, and correctly survives the wall-clock-budget abandon path.
  • InsertCollectionLogSql's two writers (per-collector + retention run-record) were both widened to 22 placeholders together, and the retention writer correctly binds DBNull for all five new columns — the exact class of bug (Persist the server-scoped phase split to collection_log so it can be aggregated without an SSM session #2859's silent 08P01) this PR's own test explicitly targets.
  • Migration V109 is nullable/no-default/no-backfill, refreshes v_collection_log, and the viewer's schema-version probe/sentinel/top-arm are all updated consistently and pinned by test.
  • PerItemWallClockBudget moving to the base ICollectorSchemaInfo — all direct implementers repo-wide (test fakes included) were updated; no stragglers found.
  • No Lite/Darling parity gap: Lite never got V108's phase-split columns either (same "Darling-only server-scoped runner" rationale), so the "no Lite twin" call here is consistent with precedent, not a drift.

No SQL-injection, secrets, or missing-index concerns in this change; it's pure instrumentation plumbing.

erikdarlingdata and others added 2 commits September 3, 2026 20:51
All three are pins doing their job, not behaviour that regressed.

DocCommentHygieneTests caught a stranded doc block: inserting
PeerMaxOrNull between RunOneAsync's <summary> and RunOneAsync left that
summary annotating the new helper and RunOneAsync undocumented. The
test's own message warns about exactly this -- "check first whether it
belongs to a DIFFERENT member that an insertion pushed it away from" --
so the fix reorders rather than deletes: the helper and its own summary
move above, and RunOneAsync's summary returns to RunOneAsync.

I initially misdiagnosed this as an orphaned summary in
ICollectorDefinition, where removing the budget declaration did leave its
doc block behind. That one was NOT the failure -- a /* */ comment
separated it, which terminates a run -- but it was still wrong, so its
documentation is now moved to the base interface where the member lives,
rather than dropped.

The other two are source-text anchors this change legitimately moved.
DarlingEmptyEnumerationNoteTests pins the success return's argument list
so a DROPPED argument fails; four were added, so the pin names them.
DarlingPayloadProbeFailureTests anchors on definition.ReadAsync(reader,
now definition.ReadAsync(counting -- the read/probe/write ORDERING it
exists to pin is unchanged.

Verified by replicating the hygiene scan locally against both the
committed tree (reproduces the CI failure at DarlingWorker.cs:5607) and
the fixed one (clean, whole tree), so the scan is known to be able to
fail rather than assumed to pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MX6HyjsuDCs15qGB2rh4Gy
/// ordinary bodies. Storing it only on failures would rebuild the cross-referencing this exists
/// to remove.</para>
/// </summary>
public int SweepPeerMaxMs { get; set; } = -1;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SweepPeerMaxMs is reset to -1 only at the top of RunDueCollectorsAsync (line ~4810), documented there as "describes ONE body... a mark carried across bodies would answer a different question with the same number."

But ServerLoopState (and this field) persists across reconnects rather than being recreated, and RunOneAsync — which reads and folds into this field — is also called from two other loops that never reset it:

  • TryConnectAsync's on-load dispatch loop (await RunOneAsync(server, runner, name, cancellationToken); around line 4724), which runs on every (re)connect.
  • RunSnapshotAsync's snapshot_now loop (totalRows += await RunOneAsync(...) around line 4983), which runs on an operator-triggered snapshot.

Since neither path resets SweepPeerMaxMs first, the first collector they run folds its SqlMs into whatever high-water mark the last scheduled sweep body (or a previous on-load/snapshot pass) left behind, and the sweep_peer_max_ms written to collection_log for those rows mixes bookkeeping from unrelated bodies — exactly the cross-body contamination the reset in RunDueCollectorsAsync is meant to prevent. Worth resetting at the top of both of those loops too (or wherever else the "one body" boundary is defined for them).

Comment on lines +285 to +287
command.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Bigint, Value = drain.HasValue ? drain.Value.RowsRead : (object)DBNull.Value });
command.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Bigint, Value = drain.HasValue ? drain.Value.BytesRead : (object)DBNull.Value });
command.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Integer, Value = drain.HasValue && drain.Value.LastReadMs >= 0 ? (int)drain.Value.LastReadMs : (object)DBNull.Value });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

drain_last_read_ms is correctly guarded (drain.Value.LastReadMs >= 0) so the in-memory "-1 = unmeasured" sentinel never gets stored as a literal value — but drain_rows_read/drain_bytes_read (lines 285-286) aren't: they store drain.Value.RowsRead/.BytesRead directly whenever drain.HasValue, with no >= 0 check.

CollectorContext.ServerScopeRowsRead/ServerScopeBytesRead default to -1 and are only overwritten in the finally around definition.ReadAsync(counting, ...) in DarlingCollectorRunner (the block that constructs DrainCountingDataReader). If the per-item wall-clock budget fires while still inside command.ExecuteReaderAsync(itemToken) — i.e. before counting is even constructed — the catch (Exception ex) when (EnumeratedCollectorDriver.ItemBudgetExpired(...)) arm returns a CollectorRunResult with ServerPhasesMeasured: true but ServerRowsRead/ServerBytesRead still at their -1 default (only ServerOpenMs was stamped from its own finally).

That reaches here with drain.HasValue == true and RowsRead == BytesRead == -1, so drain_rows_read/drain_bytes_read get written as literal -1 in a bigint column — contradicting the documented invariant that these are "non-null whenever the group is written" (i.e. always a real non-negative count) and that -1 is "a value no real count can take" that must never be misread as a measurement. Consider guarding these two the same way LastReadMs is guarded (>= 0 ? ... : DBNull.Value).

@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown

Review summary

Reviewed the V109 drain-forensics change (Darling only — DrainCountingDataReader, target_session_id, sweep_peer_max_ms, the shared InsertCollectionLogSql widening to 22 placeholders, and the PerItemWallClockBudget move to ICollectorSchemaInfo).

Left 2 inline findings:

  1. DarlingWorker.cs — SweepPeerMaxMs is reset per-body only in RunDueCollectorsAsync, but TryConnectAsync's on-load pass and RunSnapshotAsync's snapshot_now loop also call RunOneAsync (which reads/folds the field) without resetting it, so those runs mix peer-max bookkeeping across unrelated bodies.
  2. DarlingObservability.cs — drain_rows_read/drain_bytes_read lack the >= 0 guard that drain_last_read_ms has, so a wall-clock-budget abandon that fires during ExecuteReaderAsync (before the counting reader is constructed) can persist the in-memory -1 "unmeasured" sentinel as a literal value in these bigint columns.

Checked and found OK:

  • Migration V109 is idempotent (ADD COLUMN IF NOT EXISTS), nullable, no default, and refreshes v_collection_log (avoids the frozen-SELECT *-view trap from V14/V80/V108).
  • Both InsertCollectionLogSql writers (per-collector + fleet-wide retention) were widened together and consistently to 22 bound parameters; the retention writer correctly binds NULL for all five new columns.
  • PerItemWallClockBudget moving from ICollectorDefinition<TRow> to the base ICollectorSchemaInfo — all direct implementers (prod + test fakes) were updated; no orphaned implementations found.
  • target_session_id is read from the already-open connection (SqlConnection.ServerProcessId / NpgsqlConnection.ProcessID) after the connection is confirmed open, not via a SELECT @@SPID round trip — matches the stated intent.
  • MCP reader (DarlingDataReader.cs) column-to-ordinal mapping for the 5 new collection_log columns is correct, and ViewerDataService's schema-version probe/sentinel/top-arm were updated consistently.
  • Lite/Darling parity: this feature has no Lite twin, and that's called out explicitly and justified in the CHANGELOG/migration comment — Lite's RemoteCollectorService.DefinitionRunner has no server-scoped phase-split (ServerPhasesMeasured/open/drain) machinery for this to hook into, so a DuckDB twin would just be five permanently-NULL columns. This looks like a legitimate, well-reasoned exception rather than parity drift.
  • No SQL-injection/secrets/input-boundary concerns — this PR touches internal instrumentation plumbing only, no new user-facing SQL text or external input handling.

erikdarlingdata and others added 2 commits September 3, 2026 21:16
DarlingEmptyEnumerationNoteTests pins the success return's arguments as
one whitespace-collapsed run, so that a DROPPED argument fails there
rather than reaching the store as a null. My explanatory comment sat
BETWEEN two of those arguments, which broke the run and failed the pin on
a change that dropped nothing -- the exact conflation of "reformatted"
with "regressed" that #2851 already had to fix once by collapsing
whitespace.

The comment moves above the return, where it still documents why the
forensics are recorded on the success path and not only on abandon, and
the note about why it lives there is now in the file so the next person
does not re-inline it.

Verified by running all three source-scan pins locally against the real
file before pushing, rather than inferring from a green build: the
collapsed argument list, the read/probe/write ordering, and the test's
OWN literal extracted from the test file and matched against the source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MX6HyjsuDCs15qGB2rh4Gy
await DarlingObservability.LogCollectionAsync(
_postgres!, runtime, collectorName, status, result.Rows, result.SqlMs, result.StorageMs, result.Note,
result.Fanout, result.ServerPhases, _logger, cancellationToken);
result.Fanout, result.ServerPhases, result.Drain, PeerMaxOrNull(server), _logger, cancellationToken);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sweep_peer_max_ms is read from live, mutable server.SweepPeerMaxMs state at the moment each run finishes — which is correct for the synchronous body, but wrong for query_store and plan_correction, the two collectors dispatched via RunDetachedAsync (fire-and-forget, line ~4903).

RunDueCollectorsAsync resets server.SweepPeerMaxMs = -1 on every invocation (line 4810), and that method runs once per s_sweepInterval (15s) per server — far more often than the 1-minute/5-minute collector cadences. A detached query_store run can take 100–230+ seconds (per the comment at line ~4872), so by the time it completes and calls LogCollectionAsync here, server.SweepPeerMaxMs has been reset and rebuilt by several unrelated, later sweep ticks. The peer value persisted alongside that query_store/plan_correction row therefore reflects whatever collectors happened to be due in the current tick when the detached task happens to finish — not the peers that ran in "the same sweep body" as documented in the migration comment and CHANGELOG ("the slowest non-budgeted collector already completed in the same sweep body").

This is exactly backwards for the feature's stated purpose: query_store and plan_correction are two of the four budgeted "heavies" this diagnostic exists to explain, and they're precisely the ones whose own sweep_peer_max_ms is attributed to the wrong body. Since neither RunDetachedAsync nor RunOneAsync captures a body-local snapshot of the peer max at dispatch time, there's no way to recover the correct value from the current design — it would need to be captured before firing the detached task and threaded through to this call instead of being re-read from live server state at completion.

Comment on lines +151 to +152
public override object this[int ordinal] => _inner[ordinal];
public override object this[string name] => _inner[name];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These two indexers forward straight to _inner, which bypasses byte counting entirely for a string column read via reader[ordinal] / reader["name"] — as opposed to double-counting, which is what the comment above (line ~1216: "so the wrapper cannot silently fall back to DbDataReader's default implementations, which route through GetValue and would double-count") is guarding against.

The safe fix for both problems at once is to route the indexers through this.GetValue(ordinal) / this.GetOrdinal(name) + this.GetValue(...) instead of _inner[ordinal] — that counts exactly once (via the overridden GetValue) rather than zero times.

No collector in PerformanceMonitor.Collectors currently reads via the indexer (verified — none use reader[...]), so this doesn't corrupt today's numbers. But it quietly breaks the class's own central claim ("a collector cannot forget to count") for any future collector that uses the idiomatic indexer syntax instead of GetString/GetValue — the byte count would silently undercount with no error or signal.

@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown

Reviewed the diff (Darling-only: DrainCountingDataReader, V109 migration, sweep_peer_max_ms/target_session_id/drain forensics wiring, and the PerItemWallClockBudget interface move). No T-SQL files are touched here — all schema changes are Postgres DDL strings for the Darling store — so the CONTRIBUTING.md T-SQL style items don't directly apply, but I checked the embedded SQL and it's consistent with existing conventions.

Parity: the PR explicitly documents "no Lite twin" for this rung (both in the CHANGELOG and the V109 migration doc comment), reasoning that the counting reader and peer mark are installed by Darling-only call sites and a DuckDB twin would be five forever-NULL columns. That's a reasonable call and is the kind of explicit parity-drift justification the review is meant to catch — noting it here for visibility rather than flagging it as a defect.

Left two inline comments:

  1. Correctness bug: sweep_peer_max_ms is captured from live, frequently-reset server state (RunDueCollectorsAsync resets it every 15s sweep tick) rather than a body-local snapshot. For the two collectors actually dispatched via RunDetachedAsync (query_store, plan_correction — both multi-minute, budgeted heavies), the value read at completion time reflects whichever unrelated later tick happens to be running when the detached task finishes, not "the same sweep body" the feature is documented and tested to describe. This affects exactly the two collectors the feature is most aimed at explaining.
  2. Latent gap: DrainCountingDataReader's indexer overrides (this[int], this[string]) forward directly to the inner reader, bypassing byte counting rather than routing through the (counted) GetValue. Not triggered by any of the 66 existing collectors today (none use indexer syntax), but it's a silent hole in the "a collector cannot forget to count" guarantee the type's own docs claim.

Everything else — the DrainForensics/ServerPhaseCost NULL-vs-0 sentinel discipline, the V109 migration (nullable, no default, view refresh), the shared InsertCollectionLogSql binding-count widening (17→22, both writers), and the PerItemWallClockBudget move to the base interface (all three test fakes updated) — looks correct and well-tested.

All five inline findings were legitimate; three needed code.

**The peer mark was wrong for the two collectors it exists to explain.**
query_store and plan_correction are dispatched fire-and-forget and run
100-230s, while RunDueCollectorsAsync resets SweepPeerMaxMs every 15s.
Reading PeerMaxOrNull(server) at COMPLETION therefore attributed those
rows to whatever unrelated later tick happened to be in flight -- and
those two are among the budgeted heavies the whole diagnostic exists to
explain, so it was least trustworthy exactly where it mattered most.

The mark is now captured at DISPATCH and threaded through RunOneAsync as
a parameter. That is also correct for the sequential arm, where dispatch
and completion are the same body.

**Two callers were never a body at all.** TryConnectAsync's on-load
dispatch and RunSnapshotAsync's snapshot_now loop both call RunOneAsync
without resetting the mark, so their rows folded in a previous body's
bookkeeping -- the cross-body contamination the reset exists to prevent.
Both now pass null: an operator snapshot is not a sweep body, and saying
so with NULL beats reporting a number that means something else.

**The -1 sentinel could reach the store as a literal.** The budget can
fire INSIDE ExecuteReaderAsync, before the counting reader is constructed
-- the abandon arm then returns with phases measured (the open stamped
from its own finally) but the counts at their -1 default. Only
LastReadMs was guarded, so rows and bytes wrote a literal -1 into a
bigint column, breaking this rung's own documented invariant that a
stored count is always non-negative and -1 is a value no real count can
take. All three are now guarded independently.

**The indexers bypassed counting entirely.** reader[ordinal] forwarded
straight to the inner reader, so a string read that way counted zero
rather than once. No collector uses the indexer today, but "a collector
cannot forget to count" is this decorator's central claim and it would
have been quietly false for the first one that did.

Three tests added for the reachable cases, and every source-scan
assertion replicated locally against the real files before pushing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MX6HyjsuDCs15qGB2rh4Gy
Comment on lines +279 to +284
drain_last_read_ms is the one that carries the diagnosis: subtracted from sql_drain_ms it is
the time the reader spent with nothing arriving. It stores NULL when the run read no row at
all, which is NOT ambiguous with 'not recorded' here because drain_rows_read is non-null
whenever this group is written - a NULL beside a 0 count says 'nothing ever arrived', and a
NULL beside a NULL count says the row predates this rung. 0 is left free to mean what it
honestly means: row 1 arrived instantly. */

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "NULL beside a NULL means the row predates this rung" claim isn't reliable, and the comment immediately below it (lines 285-290) documents the counter-example: an abandon that fires inside ExecuteReaderAsync, before the counting reader is ever constructed, leaves RowsRead/BytesRead/LastReadMs at their -1 default. That gets guarded to NULL here for all three columns — so a genuine V109 row (ServerPhasesMeasured: true, sql_open_ms populated) ends up with drain_rows_read IS NULL AND drain_last_read_ms IS NULL, the exact signature this comment says means "predates the rung." CollectionLogDrainForensicsStoreTests.TheUnmeasuredSentinelIsNeverWrittenAsALiteral even constructs this scenario directly (ServerOpenMs: 120051, ServerDrainMs: 0), so it's not a hypothetical.

It's broader than that one edge case, too: any collector that takes the per-database enumerated path (RunsPerDatabase(target) == true — query_store, procedure_stats/deadlocks/blocked_process_report on Azure SQL DB, all the Pg*BloatStats/PgColumnStats/PgPredicateStats/PgAutovacuumStats/PgIndexUsageStats collectors) never sets serverPhasesMeasured = true at all, so Drain is always null for those and every one of their rows will read drain_rows_read IS NULL forever — indistinguishable from "predates the rung" even on a store that's fully current at V109.

Worth either fixing the disambiguation (e.g. cross-check sql_drain_ms/sql_open_ms being non-null, or a dedicated sentinel for "this code path doesn't measure it") or at minimum correcting the comment (here, in PgMigrations.cs's V109Sql doc, and in DarlingDataReader.cs) so a future reader/dashboard author doesn't build a query on the "NULL/NULL = pre-rung" assumption and silently misclassify real open-stalls or whole collector families as "no data."

@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown

Review summary

Scoped review of the V109 drain-forensics rung (#2864 items 1-3). This is Darling-only plus the shared PerformanceMonitor.Collectors library — no Lite files touched, which is correct here: the PR's own migration doc explains why (No Lite twin, deliberately — the counting reader and peer mark are installed by Darling-specific runner/worker code Lite's collector runner doesn't have), and I couldn't find anything that contradicts that. The ICollectorSchemaInfo.PerItemWallClockBudget move to the base interface is a required (non-defaulted) member, and every implementer/test fake in both the production code and the test suite was updated — grepped for any Lite-side direct implementer of ICollectorSchemaInfo and found none, so this doesn't put Lite's build at risk.

Verified as correct:

  • The 17→22 placeholder-count bump and both InsertCollectionLogSql writer binding blocks (per-collector + fleet-wide retention) were widened together.
  • DrainCountingDataReader forwards every abstract/virtual member explicitly rather than falling through DbDataReader's default GetValue-based implementations, so a typed getter can't double-count.
  • The -1 sentinel (never 0) for 'no row arrived yet' is threaded consistently from the reader through CollectorContext through CollectorRunResult.Drain through the >= 0 write guards — no path found where an in-memory -1 reaches the store as a literal.
  • The peer-mark (sweep_peer_max_ms) fold-into-SweepPeerMaxMs is gated on !CollectorCatalog.HasWallClockBudget(...), and the two fire-and-forget detached collectors (query_store, plan_correction) are always budgeted — so the detached/sequential concurrency within one sweep body can't race on that field. Combined with the per-server CollectionGate (blocking acquire for on-demand snapshots, non-blocking try-acquire for the scheduled sweep) and the reset-on-acquire at the top of RunDueCollectorsAsync, an on-demand snapshot or the on-load dispatch loop can't leak its own timings into a scheduled body's mark either.
  • target_session_id is read as a client-side property (ServerProcessId/ProcessID), never SELECT @@SPID, and only after the connection is already open.

One finding posted inline on DarlingObservability.cs: the documented NULL-disambiguation rule ('drain_rows_read is non-null whenever this group is written; NULL beside a NULL means the row predates the rung') doesn't actually hold — it collides with a legitimate post-rung case (an abandon that fires inside ExecuteReaderAsync, before the counting reader exists) and, more broadly, with every per-database-enumerated collector, whose rows never set ServerPhasesMeasured and so will read NULL/NULL forever regardless of schema version. Worth fixing the disambiguation or the comment (it's repeated in PgMigrations.cs and DarlingDataReader.cs too) before someone builds a query or dashboard on the stated invariant.

No missing-index-DMV suggestions, no security issues found (all writes are parameterized; no user input reaches SQL text), no T-SQL touched by this PR.

Review caught that "NULL beside a NULL count means the row predates this
rung" is false, and it was stated in three code sites plus the CHANGELOG.

Two reachable counter-examples, one of which my own previous commit
created. An abandon that fires inside ExecuteReaderAsync never constructs
the counting reader, so all three figures sit at their -1 default and the
guard added last commit writes them as NULL -- on a genuine V109 row with
sql_open_ms populated. And no per-database ENUMERATED collector sets the
measured flag at all, so query_store and the whole Pg*Stats family will
read NULL here forever on a store that is fully current at V109.

A dashboard built on the claim would have silently misclassified both a
real open-stall and entire collector families as "no data" -- worse than
having no disambiguation, because it reads as one.

So the claim is withdrawn rather than rescued with a new sentinel. NULL
means NOT RECORDED and the docs now say only that. The one safe inference
survives and is still stated: a NULL last-read beside a 0 row count means
nothing ever arrived.

Pinned so it cannot come back, and proven red first by reintroducing the
phrase into one file's V109 region and watching the scan catch it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MX6HyjsuDCs15qGB2rh4Gy
{
await DarlingObservability.LogCollectionAsync(
_postgres!, runtime, collectorName, "ERROR", 0, 0, 0, ex.Message, fanout: null, phases: null, _logger, cancellationToken);
_postgres!, runtime, collectorName, "ERROR", 0, 0, 0, ex.Message, fanout: null, phases: null, drain: null, sweepPeerMaxMs: null, _logger, cancellationToken);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sweepPeerMaxMs: null is hardcoded here and in the six other early-return LogCollectionAsync calls in this method (lines 5788, 5812, 5830, 5851, 5874, 5909), even though peerMaxAtDispatchMs is already in scope as a method parameter and costs nothing to pass through.

This seems to cut against the column's own stated design goal. The V109 migration comment says sweep_peer_max_ms is "Recorded on EVERY row rather than only abandoned ones: a ratio needs a denominator, and the baseline has to come from the same column on ordinary bodies. Storing it only on failures would rebuild exactly the cross-referencing this removes." A YIELDED (lock-timeout) or ERROR row happening mid-sweep is exactly the kind of row where "were this sweep body's other collectors also slow" is useful context — e.g. correlating a lock-timeout yield with a sweep-wide slowdown — but these rows will always read sweep_peer_max_ms = NULL regardless of what was known at dispatch, silently losing that context for every non-SUCCESS/non-ABANDONED status.

Only the main success/abandon path (line 5733) actually threads peerMaxAtDispatchMs through. Was withholding it on these early-return/exception paths intentional (e.g. "the peer mark should only describe a row that actually measured something"), or should these pass peerMaxAtDispatchMs like the main path does?

@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown

Review summary

Independent re-review of the V109 drain-forensics rung (Darling-only + the shared PerformanceMonitor.Collectors library). This PR has already been through several review rounds — I cross-checked the fixes those introduced and they hold up:

  • Peer mark is captured at dispatch (peerMaxAtDispatchMs) rather than re-read at completion, so query_store/plan_correction's detached (100–230s) runs no longer pick up an unrelated later tick's value.
  • drain_rows_read/drain_bytes_read/drain_last_read_ms are all independently guarded on >= 0 at the write, so the in-memory -1 sentinel (open-stall abandon, before the counting reader is constructed) can never land as a literal in a bigint/integer column.
  • The "NULL beside NULL means pre-rung row" claim was removed from all three source comments (DarlingObservability.cs, PgMigrations.cs, DarlingDataReader.cs) and is now pinned by a test (NullMeansNotRecordedAndTheDocsDoNotClaimMore) — correct, since a genuine V109 row can still read NULL/NULL both from an open-stall abandon and from any per-database ENUMERATED collector.
  • DrainCountingDataReader's indexer (this[int]/this[string]) now routes through the counted GetValue/GetOrdinal rather than bypassing it.
  • Both InsertCollectionLogSql writers (per-collector + fleet-wide retention run-record) were widened together to 22 placeholders; the retention writer binds DBNull for all five new columns.
  • server.SweepPeerMaxMs mutation is safe against the on-load dispatch / snapshot_now paths: those never overlap a scheduled sweep body in time (pre-existing Runtime-null invariant + CollectionGate), and the scheduled sweep always resets the field before its own foreach starts, so no cross-body leakage reaches a live read.
  • PerItemWallClockBudget's move from ICollectorDefinition<TRow> to the base ICollectorSchemaInfo — all three test fakes (SyntheticCollector, FakeSchema, TruncatedSchema) and every real implementer were updated; nothing orphaned.
  • No Lite/Darling parity drift: the "no Lite twin" call is explicit and justified (the counting reader and peer mark hook into Darling-only runner/worker code Lite's collector runner doesn't have) — consistent with the same exception V108 already established.
  • No SQL-injection, secrets, or file/process/network handling concerns — this is internal instrumentation plumbing with parameterized writes throughout; no missing-index-DMV suggestions.

One new finding, posted inline on DarlingWorker.cs:5947: the seven early-return/exception LogCollectionAsync calls in RunOneAsync (SESSION_MISSING, RDS/PI authorization failures, YIELDED, PERMISSIONS, the generic Postgres-permission arm, and the catch-all ERROR) all hardcode sweepPeerMaxMs: null, even though peerMaxAtDispatchMs is already available as a method parameter at each of those sites. Only the main success/abandon path threads it through. This seems to work against the column's own stated intent ("recorded on every row... storing it only on failures would rebuild exactly the cross-referencing this removes") — a YIELDED lock-timeout row in particular is a case where sweep-peer context would be useful. Flagging for confirmation of whether this is deliberate scope-narrowing or an oversight.

Review: the seven early-return LogCollectionAsync calls in RunOneAsync
(YIELDED, PERMISSIONS x3, ERROR, SESSION_MISSING) hardcoded
sweepPeerMaxMs: null while peerMaxAtDispatchMs sat in scope as a
parameter, costing nothing to pass.

That contradicted the column's own stated rationale, which this rung
argues in its migration doc: recorded on EVERY row rather than only
abandoned ones, because a ratio needs a denominator and the baseline has
to come from ordinary rows. A lock-timeout YIELD during a sweep-wide
slowdown is a different finding from one on an otherwise healthy body,
and those rows would have read NULL forever -- rebuilding exactly the
cross-referencing the column removes.

drain STAYS null on those arms, and that is not an oversight: an early
return never drained anything, so there is nothing to describe. The two
peerMaxAtDispatchMs: null callers also stay -- TryConnectAsync's on-load
dispatch and RunSnapshotAsync's operator snapshot are not sweep bodies,
so for them NULL is the honest answer rather than a missed pass-through.
Both distinctions are now pinned so the next reader does not "fix" one
into the other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MX6HyjsuDCs15qGB2rh4Gy
@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown

Review

Went through the diff in full (DrainCountingDataReader, the CollectorContext/ICollectorSchemaInfo changes, DarlingCollectorRunner, DarlingWorker's sweep-peer tracking, DarlingObservability's two InsertCollectionLogSql writers, the V109 migration, the MCP reader, and the viewer's schema probe) plus the new/updated tests. No correctness, security, or parity issues found. A few things I specifically checked and want to note for the record:

  • Lite/Darling parity: this PR touches only Darling (no Lite/* files). That's consistent with precedent — Lite's collection_log INSERT never picked up the V108 phase columns either, because the fleet/sweep-body/wall-clock-budget machinery this instrumentation hangs off (DarlingWorker's sweep body, the server-scoped counting-reader install point) doesn't exist on Lite's single-target runner. The migration's own doc comment and the CHANGELOG entry both call this out explicitly and correctly apply CONTRIBUTING.md's "no Lite twin for a Darling-only source" exception rather than the "state added to one store reads as empty on the other" rule. PerItemWallClockBudget, which is shared, was correctly moved on the interface (ICollectorDefinition<TRow> → base ICollectorSchemaInfo) with no behavior change — CollectorDefinitionBase already implements it virtually, and all three direct ICollectorSchemaInfo test fakes (in PayloadDimensionTests, CollectorEngineCapabilityDerivationTests, PgSchemaGeneratorTests) were updated.
  • DrainCountingDataReader double-counting: verified every abstract DbDataReader member is forwarded explicitly (so nothing falls through to a base implementation that routes through GetValue), and that GetString/GetValue/GetFieldValue<T>/indexer paths each count exactly once per call rather than compounding. GetBytes/GetChars are wired to add to PayloadBytes on every call including a hypothetical length-probe pre-read (buffer: null), which isn't currently exercised since no collector in this repo streams via those getters today — worth keeping an eye on if one ever does, but not a live bug.
  • Sentinel handling: -1 (unmeasured) is consistently distinguished from 0 (real, instant) at every hop — DrainCountingDataReader → CollectorContext → CollectorRunResult.Drain → the >= 0 guards in DarlingObservability before the Postgres bind. The abandon-inside-ExecuteReaderAsync case (counting reader never constructed) is handled correctly, including in tests.
  • Peer high-water mark timing: sweep_peer_max_ms is captured at dispatch and threaded through as a parameter rather than re-read from server.SweepPeerMaxMs at completion, which matters for the two fire-and-forget collectors (query_store, plan_correction) that outlive several sweep-body resets — correctly handled and tested.
  • Shared-statement widening: both InsertCollectionLogSql binding blocks (per-collector writer and the fleet-wide retention run-record) were widened together to 22 parameters, and the hand-maintained placeholder-count pin was bumped and (per the PR description) proven red-then-green.

Nothing to request changes on. Nice test coverage on the sentinel/NULL-semantics edge cases in particular.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant