Skip to content

Persist the server-scoped phase split to collection_log (Fixes #2859) - #2861

Merged
erikdarlingdata merged 4 commits into
devfrom
feature/phase-timings-store
Sep 3, 2026
Merged

erikdarlingdata merged 4 commits into
devfrom
feature/phase-timings-store

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

Fixes #2859

What and why

[#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 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 returning InternalServerException on GetRoleCredentials, 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_ms to collection_log; get_collection_log returns them. Drain share becomes a store query — 96.4% for a procedure_stats row 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) against avg((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 while collection_log holds 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 against sql_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_ms carries no sql_ 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 0 rather 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):

check result
fresh store, V1→V108 in order 0 failures, 3 columns in the view
upgrade path, V107 store + V108 only 0 → 3 columns in the view
idempotency, rung re-run twice clean both times
production shape: 37 compressed chunks, 40 rows applied in ~100 ms, catalog-only
pre-existing compressed rows after the rung sql_open_ms IS NULL — not 0
residual arithmetic sums_to_parent = t on every row

Negative control on the view refresh — the half of this rung that is easy to forget and impossible to notice. With the ALTER alone and the refresh omitted, the columns exist on the table and the MCP read fails:

ERROR:  column "sql_drain_ms" does not exist
LINE 1: SELECT sql_drain_ms FROM collect.v_collection_log LIMIT 1;

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

  • The migration ladder pins are derived (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.
  • V107's TheRungIsRegisteredAtTheTopOfADenseLadder asserts ladder-wide properties, not "I am the top", so it does not need demoting.
  • Probe sentinel is a column-existence check (collection_log.sql_drain_ms) rather than a table one, since the table has existed since V1 and only the columns are new.
  • No Lite parity obligation: CollectionLogEntry, MapProbedSchemaVersion and the rung are all Darling-only, and no cross-app guard pins collection_log's column set. Checked rather than assumed, given that class of guard passes PR CI and breaks dev in the nightly.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MX6HyjsuDCs15qGB2rh4Gy

#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
Comment thread CHANGELOG.md Outdated
[#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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Suggested change
[#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),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

@claude

claude Bot commented Sep 3, 2026

Copy link
Copy Markdown

Review

Reviewed against CONTRIBUTING.md (T-SQL/PG style, Two-Store Parity) with a focus on correctness, Lite/Darling parity, security, and performance.

Main finding: get_collection_log never returns the new phase columns

This is the one that matters. The PR's premise — CHANGELOG: "V108 adds sql_open_ms, sql_drain_ms and watermark_ms to collection_log, and get_collection_log returns them, so avg(sql_drain_ms) / avg(sql_duration_ms) is a store query" — isn't actually true of the MCP tool as shipped.

  • DarlingDataReader.GetCollectionLogAsync (Mcp/DarlingDataReader.cs) correctly reads the three new columns and computes the derived SqlOtherMs on CollectionLogEntry.
  • But DarlingMcpDataTools.GetCollectionLog (Mcp/DarlingMcpDataTools.cs:1182-1198) — the tool an MCP client actually calls — projects only collector, collection_time, duration_ms, sql_duration_ms, store_duration_ms, rows_collected, status, error_message into the JSON response. r.SqlOpenMs / r.SqlDrainMs / r.WatermarkMs / r.SqlOtherMs are never referenced.

The V108 migration's own doc comment says "Nothing in the viewer reads these columns yet — the read is on the MCP surface", but the MCP surface doesn't read them either. Right now the only way to get at this data is a raw SQL query against the store — which is exactly the gap the PR was written to close (the AWS-SSO-outage story in the description is about being unable to reach the box, not about being unable to run SQL against the store, so this doesn't fully defeat the PR's purpose, but it does mean the MCP-reachability half of the stated goal isn't there yet). I couldn't leave an inline comment on DarlingMcpDataTools.cs since it isn't touched by this diff.

Smaller items (left inline)

What looked solid

  • Migration is catalog-only (nullable, no DEFAULT, no backfill) — correct choice for a large/compressed hypertable, matches the V80 precedent.
  • The view refresh (CREATE OR REPLACE VIEW ... v_collection_log) is present, so the columns won't be invisible on an upgraded store (V14/V80 lesson correctly reapplied).
  • INSERT/VALUES column list and parameter count line up (17/17), and the added phases argument is threaded through consistently at all 8 LogCollectionAsync call sites in DarlingWorker.cs.
  • MapProbedSchemaVersion's new arm is placed above V107 (newest-first) and the added GetBoolean(83) matches the probe SQL's new trailing sentinel column — ladder/probe pinning tests look consistent with the change.
  • All-or-nothing write of the phase triple, and the derived (not stored) other: residual, match the reasoning already established by the fanout triple — no drift there.
  • No SQL injection surface (fully parameterized), no secrets, no new file/network/process use.

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
Comment thread CHANGELOG.md Outdated
[#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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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:

Suggested change
[#2859]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2860
[#2859]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2859

@claude

claude Bot commented Sep 3, 2026

Copy link
Copy Markdown

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 (collection_log's column set has no cross-app guard, and Lite has no server-scoped phase-split concept to mirror).

Checked in detail and found correct:

  • V108 migration: nullable columns, no default, no backfill, IF NOT EXISTS, and the view refresh (CREATE OR REPLACE VIEW collect.v_collection_log) — matches the V14/V80 lesson about frozen SELECT * column lists.
  • InsertCollectionLogSql widened to 17 params, and both writers (per-collector + retention sweep) got their binding blocks widened in lockstep — verified parameter counts match at every one of the 8 LogCollectionAsync call sites in DarlingWorker.cs.
  • MapProbedSchemaVersion's new arm and StoreSchemaProbeSql's new sentinel column line up (GetBoolean(83) ↔ the appended sql_drain_ms existence check).
  • DarlingDataReader.CollectionLogSql/CollectionLogEntry column ordinals (8/9/10) match the new SELECT list, and SqlOtherMs is correctly derived (not stored) with zero-clamping for stopwatch skew.
  • All-or-nothing triple gating on ServerPhasesMeasured rather than a non-zero check, so a measured-zero open persists as 0 instead of NULL — matches the PR's stated design intent, and is covered by a test.

One bug found: CHANGELOG.md line 3181 — the new [#2859] link definition points at issues/2860 instead of issues/2859 (left an inline comment with a suggested fix). #2860 is the separately-filed follow-up mentioned in the PR body, not this PR's own issue.

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
Comment thread CHANGELOG.md Outdated
@@ -1,4 +1,4 @@
# Changelog
# Changelog

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 @@
/*
/*

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

@claude

claude Bot commented Sep 3, 2026

Copy link
Copy Markdown

Reviewed the diff (CHANGELOG, PgMigrations/StorageVersion, DarlingCollectorRunner, DarlingObservability, DarlingWorker, DarlingDataReader, ViewerDataService, both test files).

Migration ladder & viewer probe checklist (CONTRIBUTING.md § "Adding a Darling Migration Rung") — all four parts present and correctly done: rung registered at PgMigrations.Scripts[^1], StorageVersion.SchemaVersion bumped to 108, symbolic pins in the new test file, and all four pieces of the viewer probe (sentinel line, reader.GetBoolean(83), trailing hasCollectionLogPhaseSplit = false param, arm placed above V107's). Verified the ordinal count: 84 EXISTS(...) clauses in StoreSchemaProbeSql (indices 0–83) line up with reader.GetBoolean(83) and the new trailing parameter — no off-by-one.

Shared-INSERT arity bug — good catch and good fix. InsertCollectionLogSql is bound in two places (per-collector writer and the retention run-record writer); the second commit widens both binding blocks and adds a regression test (EveryWriterOfTheSharedInsert_BindsExactlyAsManyParametersAsTheStatementDeclares) that counts $n placeholders against Parameters.Add calls across every call site of the shared SQL, not just the two that exist today. That's the right invariant to pin given the failure mode (silent 08P01, swallowed by the failure-isolation catch).

All-or-nothing triple / NULL vs. measured-zero — ServerPhases gates on ServerPhasesMeasured rather than a non-zero check, and the reader/residual (SqlOtherMs) correctly keeps "not measured" (NULL) distinct from "measured and zero." Column indices in GetCollectionLogAsync's reader mapping (0–10) line up with the 11-column SELECT list.

Lite parity — confirmed by grepping Lite/Services/RemoteCollectorService.DefinitionRunner.cs: there's no open/drain phase split on the Lite side at all (the source #2851 feature is Darling-only), so the "no Lite twin" call in the migration's doc comment and the CHANGELOG entry checks out — this isn't a parity drift, it's state that only exists on one side by construction.

One nit (left inline, both non-blocking): CHANGELOG.md and DarlingCollectorRunner.cs each pick up a stray UTF-8 BOM at the very start of the file that wasn't there in the base commit (confirmed via git show <base>:<path> | head -c3). Doesn't affect the build, just diff/encoding hygiene.

No correctness, security, or performance issues found. The migration itself is a nullable-no-default ALTER TABLE ADD COLUMN plus a view refresh — schema-qualified, catalog-only, and the negative-control test proving the view refresh is load-bearing is a nice touch.

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
@claude

claude Bot commented Sep 3, 2026

Copy link
Copy Markdown

Reviewed the diff (CHANGELOG, migration ladder, writers, reader, viewer probe, tests). The four-part Darling migration rung is done correctly — PgMigrations.Scripts/V108Sql, StorageVersion.SchemaVersion = 108, the ladder-pin tests, and all four parts of the viewer probe (sentinel line, GetBoolean(83), trailing bool hasCollectionLogPhaseSplit = false param, and the arm placed above the V107 arm) all line up. Both InsertCollectionLogSql writers (per-collector and the retention run-record) were widened together and the "no Lite twin" scope call is justified — ServerPhasesMeasured/ServerOpenMs/ServerDrainMs only exist on Darling's DarlingCollectorRunner; nothing comparable exists in Lite/.

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:

get_collection_log (the MCP tool) never returns the new columns. Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs, the GetCollectionLog method (~line 1182-1197): the anonymous projection that becomes the tool's JSON output still only has duration_ms, sql_duration_ms, store_duration_ms, rows_collected, status, error_message. sql_open_ms, sql_drain_ms, watermark_ms (and the derived other_ms/SqlOtherMs) are populated on DarlingDataReader.CollectionLogEntry by GetCollectionLogAsync, but that record is never surfaced past this projection.

This directly contradicts the PR description and CHANGELOG claim ("V108 adds sql_open_ms, sql_drain_ms and watermark_ms to collection_log, and get_collection_log returns them, so avg(sql_drain_ms) / avg(sql_duration_ms) is a store query") — the whole stated motivation was reaching this data through the MCP surface without an SSM session, and as shipped that still isn't possible; only a direct SQL query against Postgres gets the new figures. CollectionLogPhaseSplitStoreTests.TheWriterPersistsTheTriple_AndTheMcpReadSelectsItBack doesn't catch this because it only pins the writer's INSERT and DarlingDataReader.CollectionLogSql, never the JSON GetCollectionLog actually returns — and the pre-existing DarlingCollectionLogReadTests, which does assert against that JSON (e.g. runs[0].GetProperty("sql_duration_ms")), wasn't extended for this PR either.

Suggest adding the three new fields plus the derived other_ms to the projection at line ~1193, rounded like the existing duration fields, and extending DarlingCollectionLogReadTests to seed a row with ServerPhasesMeasured: true and assert on them end-to-end.

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.

@erikdarlingdata
erikdarlingdata merged commit 0a7f224 into dev Sep 3, 2026
6 checks passed
@erikdarlingdata
erikdarlingdata deleted the feature/phase-timings-store branch September 3, 2026 22:46
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