Repository navigation
Persist the server-scoped phase split to collection_log (Fixes #2859) - #2861
Conversation
#2851 decomposes a server-scoped collector's sql_duration_ms into open: and drain: and reports the watermark read beside them, and all of it lived only in an app-log line -- so the question it was built to answer, which phase owns a collector's cost across servers and over time, needed an SSM session and a per-server log scrape. On 2026-09-03 an investigation into procedure_stats' 4,724ms drain stalled outright when AWS SSO started refusing GetRoleCredentials: the store was answering fine, the numbers were only on the box. V108 adds sql_open_ms / sql_drain_ms / watermark_ms to collection_log and get_collection_log returns them, so drain share is a store query. Columns rather than jsonb: the phase set on this path is fixed, so jsonb buys nothing and costs the cheap aggregation that is the point. The shape that does vary -- #2811's per-database fetch split -- is N:1 against this table and needs its own rollup decision (#2860), not a half answer here. other: is deliberately not stored. It is a residual defined against sql_duration_ms, and a stored copy can drift from the parent it completes; sum-to-parent is what makes a large residual a finding rather than noise, so readers subtract. watermark_ms keeps no sql_ prefix because on this path it genuinely is outside that sum. All three write together or not at all, gated on #2851's MEASURED flag rather than a non-zero test, so a genuinely instant open records 0 rather than vanishing into NULL. Verified against a real store at the production shape: 37 compressed chunks, rung applied in ~100ms as a catalog-only change, pre-existing rows read back NULL not 0, and the view refresh proven load-bearing by negative control -- with the ALTER alone the MCP read fails with column "sql_drain_ms" does not exist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MX6HyjsuDCs15qGB2rh4Gy
| [#2845]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2845 | ||
| [#2851]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2851 | ||
| [#2854]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2854 | ||
| [#2859]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2860 |
There was a problem hiding this comment.
Link/label mismatch: [#2859] is defined pointing at issue 2860, not 2859. #2860 is the separate jsonb-rollup follow-up issue mentioned in the entry above ("Filed as #2860 rather than half-answered here"), while this PR/entry is "Fixes #2859" per the PR description. Clicking the [#2859] link in this entry will take a reader to the wrong issue.
| [#2859]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2860 | |
| [#2859]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2859 |
| new Migration(105, "collector-cost", V105Sql), | ||
| new Migration(106, "pg-cpu-utilization", V106Sql), | ||
| new Migration(107, "plan-force-actions", V107Sql), | ||
| new Migration(108, "collection-log-phase-split", V108Sql), |
There was a problem hiding this comment.
Lite/Darling parity note: per CONTRIBUTING.md's "Two-Store Parity" section, a Darling migration rung plus a reader for it is supposed to get a Lite twin (bump DuckDbInitializer.CurrentSchemaVersion, an idempotent upgrade block, Schema.GetAllTableStatements(), the table-count assertion in DuckDbSchemaTests) — otherwise "state added to one store reads as permanently empty on the other, and nothing fails to build." This PR doesn't touch Lite/ at all.
That said, the underlying instrumentation (ServerOpenMs/ServerDrainMs/ServerWatermarkMs on CollectorRunResult, from #2851) is itself Darling-only — Lite/Services/RemoteCollectorService.DefinitionRunner.cs has no equivalent open/drain phase split to persist, so a Lite twin here would just be three permanently-NULL columns. If that's a deliberate, already-settled scope decision (Lite's collector runner doesn't have this concept and isn't expected to), it'd be worth a line in the migration's doc comment saying so explicitly, since this is the exact pattern the parity rule calls out.
ReviewReviewed against Main finding:
|
InsertCollectionLogSql is shared between the per-collector writer and the fleet-wide retention run-record. V108 widened the column list and only one binding block, so the retention write raised 08P01: bind message supplies 14 parameters, but prepared statement "" requires 17. It failed silently. Both writers are failure-isolated by design -- an observability write must never break the collection loop -- so the exception went to Debug and the run-record just never appeared. It surfaced only because a live-Postgres test asserts on that record. The retention sweep runs against the store, so its phases are NULL: it has no monitored-server open or drain and no watermark read, and three zeros would claim a measured instant open on a sweep that never opened a target. Pinned as an invariant over every writer of the shared statement rather than the two that exist today, since a third would fail just as quietly. Proven red before green: 17 placeholders against [17, 14] bindings is detected, [17, 17] passes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MX6HyjsuDCs15qGB2rh4Gy
| [#2845]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2845 | ||
| [#2851]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2851 | ||
| [#2854]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2854 | ||
| [#2859]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2860 |
There was a problem hiding this comment.
Link mismatch: the [#2859] reference (used in the new Unreleased entry above, and this is the PR that "Fixes #2859") resolves to issues/2860, which is the separately-filed follow-up for the #2811 fetch-split rollup — not this PR's own issue. Should be:
| [#2859]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2860 | |
| [#2859]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2859 |
|
Reviewed the diff (V108 migration, the writer/reader widening, and the tests). This is Darling-only and doesn't touch Lite — the PR's claim that there's no Lite parity obligation checks out ( Checked in detail and found correct:
One bug found: |
Two findings from the inline review. The CHANGELOG link ref pointed [#2859] at issues/2860. Self-inflicted: I filed the issue after writing the entry, guessed #2860, and corrected it with a blanket replace that rewrote the link TARGET as well as the label, so the reference resolved to the follow-up issue instead of this one. And the migration's doc comment now says out loud why there is no Lite twin, rather than leaving it to inference. The parity rule exists so state added to one store does not read as permanently empty on the other, and it does not bind here because the source of these figures is Darling-only -- Lite's runner has no open/drain split to report, so a DuckDB twin would be three forever-NULL columns, the exact outcome the rule is meant to prevent. Worth stating because this rung otherwise looks exactly like the shape that rule catches. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MX6HyjsuDCs15qGB2rh4Gy
| @@ -1,4 +1,4 @@ | |||
| # Changelog | |||
| # Changelog | |||
There was a problem hiding this comment.
Nit: this commit prepends a UTF-8 BOM (EF BB BF) to the file — the base version had no BOM (# Changelog started directly with #). Looks like an artifact of whatever tool/editor produced the diff rather than an intended change. Worth stripping so the file's encoding doesn't silently flip.
| @@ -1,4 +1,4 @@ | |||
| /* | |||
| /* | |||
There was a problem hiding this comment.
Nit: same stray BOM insertion as CHANGELOG.md — the base file started with /* directly, no BOM. Other C# files in this PR's diff (e.g. DarlingDataReader.cs, the new test file) pick up the same BOM. Doesn't break the build (C#/Roslyn tolerates a BOM fine), but it's unrelated to the feature and worth dropping for a clean diff/consistent encoding.
|
Reviewed the diff (CHANGELOG, Migration ladder & viewer probe checklist (CONTRIBUTING.md § "Adding a Darling Migration Rung") — all four parts present and correctly done: rung registered at Shared-INSERT arity bug — good catch and good fix. All-or-nothing triple / NULL vs. measured-zero — Lite parity — confirmed by grepping One nit (left inline, both non-blocking): No correctness, security, or performance issues found. The migration itself is a nullable-no-default |
Four files gained a UTF-8 BOM they did not have on dev: my Python edit helper wrote utf-8-sig, which adds one. The repo convention is no BOM (39 of 40 files in Darling.Tests, 27 of 30 in the service directory), and the six other files in this diff legitimately had one already, so only these four changed encoding. Harmless to Roslyn, but unrelated to the feature and it flips a file's encoding silently. Verified after: every changed file's BOM state now matches its base, and all are still CRLF. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MX6HyjsuDCs15qGB2rh4Gy
|
Reviewed the diff (CHANGELOG, migration ladder, writers, reader, viewer probe, tests). The four-part Darling migration rung is done correctly — One real gap, left as an inline comment attempt that GitHub rejected because the file isn't part of this diff, so posting it here instead:
This directly contradicts the PR description and CHANGELOG claim ("V108 adds Suggest adding the three new fields plus the derived Everything else — nullable/no-default column shape, view refresh, residual-not-stored design, all-or-nothing NULL/zero gating, skew clamping — looks correct and well covered by the new store tests. |
Fixes #2859
What and why
[#2851] decomposes a server-scoped collector's
sql_duration_msintoopen:anddrain:and reports the watermark read beside them — and all of it existed only as an app-log line. So the question the split was built to answer (which phase owns a collector's cost, across servers and over time) needed an SSM session onto the box and a log scrape per server, and could not be trended or reached through the MCP at all.On 2026-09-03 an investigation into where
procedure_stats' 4,724 ms drain actually goes stalled outright on exactly that: AWS SSO began returningInternalServerExceptiononGetRoleCredentials, SSM went unavailable, and the numbers were unreachable even though the store itself was answering normally the whole time.V108 adds
sql_open_ms,sql_drain_ms,watermark_mstocollection_log;get_collection_logreturns them. Drain share becomes a store query — 96.4% for aprocedure_statsrow in the verification rig.The three design calls
Columns, not jsonb. The phase set on this path is fixed (open, drain, watermark), so jsonb's flexibility buys nothing and costs the cheap aggregation that is the entire point —
avg(sql_drain_ms)againstavg((phases->>'drain')::bigint), the latter unindexable over a 30-day hypertable. The shape that genuinely does vary — [#2811]'s fetch split with its per-chunk and per-id counts — is emitted once per DATABASE whilecollection_logholds one row per RUN. It is N:1 against this table in either encoding, so it needs a rollup decision of its own, exactly as the V80 fan-out did. Filed as #2860 rather than half-answered here.other:is not stored. It is a residual defined againstsql_duration_ms, and a stored copy could drift from the parent it completes. Sum-to-parent is precisely what makes a large residual a finding rather than noise, so readers subtract — the same reason the C# property is derived rather than a field.watermark_mscarries nosql_prefix. On this path it genuinely is outside that sum (it runs before the stopwatch starts). Folding it in would print a permanent zero and teach every reader that a store read [#2796] clocked at 50 s cold is free — the exact misreading [#2851] added it to prevent. The naming carries the semantic.All-or-nothing
The triple writes together or not at all, gated on [#2851]'s MEASURED flag rather than a non-zero test — so a genuinely instant open records
0rather than vanishing into NULL, and "not measured" stays distinguishable from "measured zero". Same discipline as V80's fanout triple, where a slowest item without its count is worse than no answer.Verification
The Windows suites can't run on macOS, so this was verified two ways instead of asserted.
Against a real Postgres + TimescaleDB store, applying the shipped migration strings dumped from the built assembly (not retyped copies):
sql_open_ms IS NULL— not 0sums_to_parent = ton every rowNegative control on the view refresh — the half of this rung that is easy to forget and impossible to notice. With the
ALTERalone and the refresh omitted, the columns exist on the table and the MCP read fails:With the refresh line the rung ships, 3 columns. So it is load-bearing, proven rather than assumed.
18 logic checks against the real build (ladder density and ordering, rung SQL shape, the all-or-nothing gate including the measured-zero case, and the derived residual including skew-clamping and the NULL-parent case) — all passing.
Reviewer notes
Assert.Equal(StorageVersion.SchemaVersion, ...)) rather than hardcoded literals, so no literal-pin hunt was needed. Worth knowing if you are working from older notes that describe three literal forms across four files.TheRungIsRegisteredAtTheTopOfADenseLadderasserts ladder-wide properties, not "I am the top", so it does not need demoting.collection_log.sql_drain_ms) rather than a table one, since the table has existed since V1 and only the columns are new.CollectionLogEntry,MapProbedSchemaVersionand the rung are all Darling-only, and no cross-app guard pinscollection_log's column set. Checked rather than assumed, given that class of guard passes PR CI and breaksdevin the nightly.🤖 Generated with Claude Code
https://claude.ai/code/session_01MX6HyjsuDCs15qGB2rh4Gy