Collect and serve PostgreSQL 18's measured I/O byte totals (#2655) - #2656
Conversation
18 removed op_bytes from pg_stat_io. The collector already guarded that -- it substitutes NULL so a collection cannot fail -- but both byte figures the read serves were derived from op_bytes, so on 18 they came back null with no note, no status, and nothing to separate a version change from a stopped collector. What 18 replaced it with is better than what was lost. op_bytes was the per-operation block size that the read multiplies by a count to ESTIMATE volume; read_bytes / write_bytes / extend_bytes are measured totals. 18 also introduced vectored reads, so one entry in `reads` can cover several blocks and the estimate undercounts -- measured through the running service against a real 18.6 target, client backend/bulkread reported 4,742 reads against 448,724,992 bytes where the estimate would have said 38,846,464. An 11.6x undercount; three combinations ran 10x to 16x. The two quantities are not comparable and must never share a name without saying which is which. V101 adds the three columns as numeric -- what PostgreSQL declares them, beside counts it declares bigint. The collector selects them on 18+ and NULL below, the reader differences them like every other cumulative counter, and the read serves bytes_source on each row and on the envelope: measured, estimated_from_block_size, or unavailable. byte_counters_tracked is what keeps a pre-18 zero from reading as a measurement, the same guarantee write_counters_tracked gives Aurora's write side -- and it probes read_bytes, not extend_bytes, because WAL rows legitimately report no extend_bytes and probing that would call a measured server unmeasured. Verified end to end against two live targets at once, 17.11 and 18.6, through the real service: the registry stamped both majors, the store split cleanly (17 rows carry op_bytes and no read_bytes, 18 rows the reverse), and the read returned estimated_from_block_size for one and measured for the other. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| WriteCountersTracked: false, StatsReset: null), totalReadTimeMs: 1.0); | ||
| WriteCountersTracked: false, StatsReset: null, | ||
| /* A pre-18 server: op_bytes is the byte answer and the measured columns do not exist. */ | ||
| ReadBytes: 0, WriteBytes: 0, ExtendBytes: 0, ByteCountersTracked: false), |
There was a problem hiding this comment.
This PR adds ReadBytes / WriteBytes / ExtendBytes / ByteCountersTracked to DarlingPgIoReader.PgIoRow, but only updates these test call sites to satisfy the new positional-record constructor — it doesn't add corresponding columns to PgDisplay.IoRow or entries in FoldedFields (both in ViewerPostgresTabsTests.cs / ViewerPostgresDisplay.cs).
That means:
EveryFieldTheSharedReadersReturn_ReachesAColumn_OrIsFoldedWithAReason(line ~744) should now fail — it reflects overPgIoRow's public properties and requires each to appear onPgDisplay.IoRowor inFoldedFields; none of the four new properties are listed anywhere.- Functionally, the WPF I/O tab (
PgDisplay.IoinViewerPostgresDisplay.cs) still only setsOpSize = Bytes(row.OpBytes). On a PostgreSQL 18 targetrow.OpBytesis now always0(the collector emitsNULL::bigintfor it and the reader'scoalesce(MAX(op_bytes), 0)turns that into0), andBytes(0)renders"0 B"— not "not tracked"/N-A. So the Viewer will silently show a confident-looking zero-byte I/O reading on 18, which is exactly the "zero masquerading as measurement" failure this PR's own description calls out and fixes for the MCP tool (get_pg_io_stats), but leaves unfixed in Darling's own desktop grid.
Since the MCP surface and the Viewer are meant to show the same PostgreSQL data, this looks like an incomplete port rather than a deliberate omission — worth wiring read_bytes/write_bytes/extend_bytes/bytes_source (or an equivalent) into IoRow the same way WriteCountersTracked was folded into Writes/WriteTimeMs.
| write_bytes = r.ByteCountersTracked | ||
| ? r.WriteBytes | ||
| : (r.OpBytes > 0 && r.WriteCountersTracked ? r.Writes * r.OpBytes : (decimal?)null), | ||
| extend_bytes = r.ByteCountersTracked ? r.ExtendBytes : (decimal?)null, |
There was a problem hiding this comment.
read_bytes and write_bytes both fall back to a count * block_bytes estimate when ByteCountersTracked is false (lines 122-127), but extend_bytes here has no such fallback — it's unconditionally null unless the server is PG18+. That's inconsistent with the envelope's bytes_note a few lines down (line ~172): when bytesEstimated is true it says "Byte totals are ESTIMATED as count x block_bytes, which is exact below PostgreSQL 18..." — implying all three byte figures are estimated — but extend_bytes never receives an estimate, so a caller told "estimated" will see extend_bytes: null even though r.Extends and r.OpBytes (block size) are both available to compute r.Extends * r.OpBytes, the same way write_bytes uses r.Writes * r.OpBytes.
Worth either adding the estimate for parity with read/write, or narrowing the bytes_note wording so it doesn't overpromise for extends.
|
Reviewed the change against Correctness / migration: V101 is schema-qualified ( Two issues posted inline, both worth a look before merge:
No SQL injection/security concerns — |
…e viewer (#2655) Five CI failures, all real. PgSchemaGeneratorTests renders the schema from PayloadColumns and requires every rung to be identical to it, so a bare `numeric` in V101 against a declared Decimal(28, 0) is a build failure -- correctly, and it settles the precision question rather than leaving store and declaration free to drift. V101 now says numeric(28,0). The clamp pin counted 9 differenced columns and there are 12; every differenced column needs its own GREATEST or it goes negative on a stats reset. Two Lite fake readers feed fixed-width rows and needed the three new ordinals, both as NULL because both fixtures are pre-18 servers. The viewer pin was the useful one. It requires every field a shared reader returns to reach a column or be recorded as folded WITH a reason, and the honest answer here was not to fold: the grid showed Op Size and no volume at all, so on 18 that cell would have read 0 B. It now carries Read Volume and Write Volume -- measured on 18, the estimate marked "(est.)" below it, "not measured" when there is neither -- and Op Size reads "n/a" on 18 rather than 0. ExtendBytes is folded with a reason, since Extends already carries the count. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| write_bytes = r.ByteCountersTracked | ||
| ? r.WriteBytes | ||
| : (r.OpBytes > 0 && r.WriteCountersTracked ? r.Writes * r.OpBytes : (decimal?)null), |
There was a problem hiding this comment.
write_bytes's measured branch drops the write-tracked gate. When r.ByteCountersTracked is true (PG18+), this returns r.WriteBytes unconditionally. But WriteBytes is computed in DarlingPgIoReader.PgIoSql as coalesce(SUM(d_write_bytes), 0), and per that file's own comment, GREATEST(NULL, 0) flattens to 0 for any row where the server doesn't track writes at all (e.g. Aurora, where writes/write_time_ms come back NULL). byte_counters_tracked is derived solely from read_bytes IS NOT NULL — it says nothing about whether this row's write side is tracked.
So on a PG18 target with an untracked write side, this serves write_bytes: 0 instead of null — reintroducing exactly the zero-vs-untracked ambiguity this PR exists to fix for reads. The estimated branch (r.OpBytes > 0 && r.WriteCountersTracked) already gets this right; the measured branch should be r.ByteCountersTracked && r.WriteCountersTracked ? r.WriteBytes : ....
Worth checking extend_bytes on the next line too — though there's no ExtendCountersTracked flag anywhere in this codebase to gate it with, so that one may just be inheriting a pre-existing gap rather than a new one.
| ReadVolume = row.ByteCountersTracked | ||
| ? Bytes((long)row.ReadBytes) | ||
| : (row.OpBytes > 0 ? Bytes(row.Reads * row.OpBytes) + " (est.)" : "not measured"), | ||
| WriteVolume = row.ByteCountersTracked | ||
| ? Bytes((long)row.WriteBytes) | ||
| : (row.OpBytes > 0 && row.WriteCountersTracked | ||
| ? Bytes(row.Writes * row.OpBytes) + " (est.)" | ||
| : "not measured"), |
There was a problem hiding this comment.
Same gap as write_bytes in DarlingMcpPgIoTools.cs: WriteVolume's measured branch (row.ByteCountersTracked) never checks row.WriteCountersTracked. On a PG18 target where the write side isn't tracked for a row (Aurora), row.WriteBytes comes back as 0 (not null) from the reader's coalesce(SUM(d_write_bytes), 0), so this cell reads "0 B" instead of "not measured"/"not tracked" — silently passing an untracked counter off as a real measurement. The estimated branch three lines down correctly ANDs row.OpBytes > 0 && row.WriteCountersTracked; the measured branch needs the same row.WriteCountersTracked check.
Separately (minor): (long)row.ReadBytes / (long)row.WriteBytes are unguarded decimal→long casts. Explicit decimal-to-integral conversions are always checked in C# regardless of context, so a windowed SUM that exceeds long.MaxValue (representable in the numeric(28,0) source column) throws OverflowException and crashes the tab render instead of degrading — Bytes() already handles its own out-of-range sentinel gracefully, this cast doesn't.
Review summaryReviewed PR #2656 (PG18 Lite/Darling parity: no gap. The collector ( Correctness bug found (left as inline comments): both Also flagged a minor, low-probability latent issue: the unguarded Everything else — the V101 migration (schema-qualified, nullable, no default/backfill, precision matched to collector), the schema-version probe/mapping ladder, and the extensive test coverage for the new rung — looks solid. |
) A store's tables come from one of two texts depending on when it was created: a fresh store builds them from V1's generated schema, walked from the collector catalog, while an existing store has whatever its rungs built. PgSchemaGeneratorTests enforces that the two say the same thing column for column, and adding the columns to PayloadColumns moved the generated side only. Both places are now updated and neither is redundant. The CREATE is IF NOT EXISTS and never re-runs on a store that already has the table, so the existing population needs V101's ALTER; the ALTER is ADD COLUMN IF NOT EXISTS and is a no-op on the fresh store that just created them. Dropping either would leave one population permanently without the columns -- the invisible, permanent divergence that test exists to catch. Verified locally rather than by another CI round: the generator's CREATE TABLE and the rung's are byte-identical after the normalisation the test applies. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Reviewed the diff (PostgreSQL 18 measured I/O bytes, V101). Overall this is careful, well-tested work — the version-gating in the collector, the differencing/tracked-flag pattern in the reader, and the schema-generator pinning all line up correctly (SELECT ordinals, reader ordinals, and One correctness issue flagged inline: Everything else — Lite/Darling parity, the MCP tool's Minor/non-blocking: six of the modified files ( |
| ReadVolume = row.ByteCountersTracked | ||
| ? Bytes((long)row.ReadBytes) | ||
| : (row.OpBytes > 0 ? Bytes(row.Reads * row.OpBytes) + " (est.)" : "not measured"), | ||
| WriteVolume = row.ByteCountersTracked | ||
| ? Bytes((long)row.WriteBytes) | ||
| : (row.OpBytes > 0 && row.WriteCountersTracked | ||
| ? Bytes(row.Writes * row.OpBytes) + " (est.)" |
There was a problem hiding this comment.
(long)row.ReadBytes / (long)row.WriteBytes narrow a numeric(28,0) value into a type that tops out at ~9.2×10¹⁸ (long.MaxValue). Unlike integer casts, an explicit decimal → long conversion in C# always range-checks regardless of checked/unchecked context, so a value beyond long.MaxValue throws OverflowException here — on the WPF UI thread, while rendering a grid row.
Given the care elsewhere in this PR about not letting the store's numeric promise get broken at the reader (the whole reason 28 was chosen over 38 for the collector's decimal precision), it's worth closing the same gap here: either widen Bytes(...) to accept a decimal directly (it only does simple division/formatting, no need to narrow first), or clamp/guard the cast.
Separately: this is also the only place in the diff where ByteCountersTracked: true (the PG18 "measured" branch) would actually exercise this cast, and no test in ViewerPostgresTabsTests.cs constructs a PgIoRow with ByteCountersTracked: true — the two new tests both pin the pre-18/estimated path (ByteCountersTracked: false). The measured branch of PgDisplay.Io currently has zero coverage.
| ReadVolume = row.ByteCountersTracked | ||
| ? Bytes((long)row.ReadBytes) | ||
| : (row.OpBytes > 0 ? Bytes(row.Reads * row.OpBytes) + " (est.)" : "not measured"), | ||
| WriteVolume = row.ByteCountersTracked | ||
| ? Bytes((long)row.WriteBytes) | ||
| : (row.OpBytes > 0 && row.WriteCountersTracked | ||
| ? Bytes(row.Writes * row.OpBytes) + " (est.)" |
There was a problem hiding this comment.
(long)row.ReadBytes / (long)row.WriteBytes narrow a numeric(28,0) value into a type that tops out at ~9.2×10¹⁸ (long.MaxValue). Unlike integer casts, an explicit decimal → long conversion in C# always range-checks regardless of checked/unchecked context, so a value beyond long.MaxValue throws OverflowException here — on the WPF UI thread, while rendering a grid row.
Given the care elsewhere in this PR about not letting the store's numeric promise get broken at the reader (the whole reason 28 was chosen over 38 for the collector's decimal precision), it's worth closing the same gap here: either widen Bytes(...) to accept a decimal directly (it only does simple division/formatting, no need to narrow first), or clamp/guard the cast.
Separately: this is also the only place in the diff where ByteCountersTracked: true (the PG18 "measured" branch) would actually exercise this cast, and no test in ViewerPostgresTabsTests.cs constructs a PgIoRow with ByteCountersTracked: true — the two new tests both pin the pre-18/estimated path (ByteCountersTracked: false). The measured branch of PgDisplay.Io currently has zero coverage.
Closes #2655.
The defect
PostgreSQL 18 removed
op_bytesfrompg_stat_io.PgIoStatsCollectoralready guarded that — itsubstitutes NULL so a collection cannot fail on "column does not exist" — but both byte figures
get_pg_io_statsserves were derived from it:So on 18 both came back
nullwith no note, no status, and nothing to separate a version change from acollector that had stopped.
What 18 replaced it with is better than what was lost
op_byteswas the per-operation block size, which the read multiplies by a count to estimate volume.read_bytes/write_bytes/extend_bytesare measured totals. 18 also introduced vectored reads, soone entry in
readscan cover several blocks and the old estimate undercounts.Measured through the running service against a real 18.6 target — not reasoned about:
background worker/bulkreadclient backend/bulkreadautovacuum worker/vacuumAn order of magnitude, not a rounding difference. That is why the column was removed rather than renamed,
and why the two quantities must never share a name without saying which is which.
The change
read_bytes/write_bytes/extend_bytesasnumeric— what PostgreSQL declaresthem, verified on 18.6, beside counts it declares
bigint. Nullable, no default, no backfill.NULL::numericbelow, appended to the SELECT so no existingordinal moves. Declared
Decimal(28, 0): whole bytes, and 28 rather than 38 because C#decimaltops outnear 29 significant digits and a declared width the runtime type cannot carry is a promise broken at the
reader.
total since
stats_resetrather than the window.bytes_sourceper row and on the envelope:measured,estimated_from_block_size,or
unavailable, plus abytes_notesaying the two are not comparable.byte_counters_trackedis what stops a pre-18 zero reading as a measurement —GREATEST(NULL, 0)is0and the outer
coalescewould flatten it anyway, so without the flag a pre-18 server reports a perfectlycredible zero bytes. Same guarantee
write_counters_trackedalready gives Aurora's write side. It probesread_bytes, notextend_bytes: WAL rows legitimately report noextend_bytes(verified on 18.6), soprobing that column would answer "bytes are not measured here" for a row that simply does not extend, and
the read would serve a measured server as an unmeasured one.
Verification
Two live targets at once — 17.11 and 18.6 — through the real service, not fixtures:
17and18) via PostgreSQL major version is never persisted, so no read can explain a version-absent column #2653's columnop_bytesand noread_bytes; 79 rows on18 with 56 carrying
read_bytesand none carryingop_bytesget_pg_io_statsreturnedestimated_from_block_sizewithblock_bytes: 8192for the 17 target, andmeasuredwithblock_bytes: nullfor the 18 targetWorth noting the empty case is already right and stayed right: before load, the 18 target answered
no_io_activitywith an accurate explanation rather than a bare empty list.Tests
Pg18IoBytesTestspins the rung, that the columns arenumericand neverbigint, that all three aredifferenced rather than summed as levels, and that the tracked flag probes
read_bytesspecifically — thatlast one is the assertion with a real failure behind it.
PgIoStatsCollectorDefinitionTestsnow pins botharms of the version exchange: 18 loses
op_bytesand gains the three, 17 and below the reverse, same shapeeither way.