Repository navigation
get_store_metrics' inventory was blind to 57% of the store it reported on: it now enumerates the continuous aggregates and the named plain tables, reconciles every sweep against pg_database_size and states its coverage, and the owner's job_history count rides the sweep so the managed-mode block is a measurement too (#3582, #3574) - #3610
Conversation
… and the named plain tables it was blind to, reconciles every sweep against pg_database_size and states its own coverage, and the owner's job_history count rides the sweep so the managed-mode block is a measurement too (#3582, #3574) On the largest production store the inventory accounted for 159 GiB of a 415 GiB database: the twenty continuous-aggregate materializations (~235 GiB) never appeared because timescaledb_information.hypertables excludes them by construction (AND ca.mat_hypertable_id IS NULL — verified on 2.28.1; the walk itself has no collector filter), and the product's own plain tables (collect.query_store_text at 15 GiB, query_store_plan_map, config.config_alert_log) were never named. The sweep now writes one continuous_aggregate row per aggregate under its view name, sized through its materialization (hypertable_detailed_size on the mat regclass is byte-exact against root + chunks + compressed chunks — measured), three table rows, and two catch-all rows (other: user-schema relations no named row accounts for; system: catalogs and TimescaleDB bookkeeping), so every byte is attributed to some row or is an honest residual. The reader reconciles one sweep's rows against that sweep's pg_database_size and the tool states enumerated/attributed coverage, the residual, and whether it reconciles (1% or 64 MiB, whichever is larger; on a fresh rig the residual was exactly the directory's non-relation files, 161,471 bytes). Aggregate compression/policy state and the largest un-enumerated relations are read live. #3574's managed-mode half: the hourly sweep runs the SAME evidence SELECT as the owner role and persists what it saw (object_kind job_history; the column mapping is documented once on StoreSelfMetrics), and the block reports it beside the mcp role's own None verdict, with the contradiction computable from the owner's numbers when the reading is fresh. Two truth fixes: the GUC-off notes no longer claim the view "returns zero rows" (FAILED runs are written regardless — measured), and the README no longer calls the mcp role dormant until LAN exposure (the MCP host uses it whenever postgres.managed is true).
| /// <summary> | ||
| /// The relations some NAMED row already sizes, every store shape (#3582): the two payload dimensions | ||
| /// and the three named plain tables, by <c>(schema, relation)</c>. A relation matched here is never in | ||
| /// a catch-all row, or the reconciliation would count it twice. Names are the same compile-time | ||
| /// constants the INSERT arms interpolate, so the census and the rows it excludes cannot drift apart. |
There was a problem hiding this comment.
Minor doc accuracy nit: "Names are the same compile-time constants the INSERT arms interpolate, so the census and the rows it excludes cannot drift apart" is only true for the two payload-dimension tuples. The three plain-table tuples below (('collect', 'query_store_text'), ('collect', 'query_store_plan_map'), ('config', 'config_alert_log')) are hand-typed literals, not {QueryStoreTextStore.TableName} / {QueryStorePlanMap.TableName} / {AlertLogTable} interpolations — those constants are compound ("collect.query_store_text") so they can't be split into the (schema, relation) tuple form directly. If one of those constants' table name ever changed, this predicate wouldn't follow it automatically the way the comment implies; the only thing that would catch the resulting double-count is StoreSelfMetricsTests.TableInsertSql_NamesTheThreeProductTables_AndTheCensusExcludesExactlyThose, not the "cannot drift apart" language here. Given this PR's whole thesis is about exactly this class of silent drift, worth either softening the claim or deriving the three literals from the constants (e.g. split on . at a call site) so the comment's guarantee actually holds.
There was a problem hiding this comment.
Fixed in 36d458e, and the nit was the PR's own thesis applied to itself. The three tuples were hand-typed because the table constants are compound ("collect.query_store_text") and cannot be split at compile time — so rather than soften the claim, the predicate now compares the concatenated (n.nspname || '.' || c.relname) against the five constants themselves ('collect.{QueryTextDimTable}', '{QueryStoreTextStore.TableName}', '{QueryStorePlanMap.TableName}', '{AlertLogTable}', …), and the "cannot drift apart" sentence is true for all five. No product relation name contains a dot, which the doc now says. TableInsertSql_NamesTheThreeProductTables_AndTheCensusExcludesExactlyThose pins that every quoted name in the predicate is one of the five constants and nothing else; the predicate shape was re-verified on a throwaway 2.28.1 container.
|
Reviewed. This is a large, carefully self-verified change (real Docker/TimescaleDB rig measurements, extensive test coverage for both the sweep SQL and the reconciliation/decoding logic). No correctness bugs, SQL-injection surface (all identifiers come from One left an inline nit on Separately: every other |
There was a problem hiding this comment.
LGTM — reviewed the Darling PostgreSQL store-metrics sweep, MCP reader, and MCP tool changes (StoreSelfMetrics.cs, DarlingStoreMetricsReader.cs, DarlingMcpStoreMetricsTools.cs, tests, README/instructions) for correctness, SQL injection, Lite/Darling parity, and performance. No substantive issues found; left one non-blocking inline nit and a note about the CHANGELOG.md omission.
…st the compound constants themselves (no hand-typed tuples), the ObjectKind scanner accepts every StoreSelfMetrics.*ObjectKind const, and the two new formatters join KnownTruncatedRanges
The review's nit was right and this PR's own thesis: NamedRelationPredicateSql claimed the census
"cannot drift" from the INSERT arms while three of its five tuples were hand-typed, because the
table constants are compound ("collect.query_store_text") and cannot be split at compile time. The
predicate now compares (n.nspname || '.' || c.relname) against the five constants directly, so the
claim holds; the test pins that every quoted name in the predicate is one of them.
Two CI guards over the touched file: SerialLoopStoreSizeSourceTests' ObjectKind scanner only knew
StoreSelfMetrics.StoreObjectKind (the one kind const that existed) — its invariant is "no retyped
literal", so it now accepts any StoreSelfMetrics.*ObjectKind and rejects a StoreSelfMetrics member
that is not one (theory rows for both); two `is … or …` patterns in the tool became `==`/`||` so
the scanner reads them. TsqlConventionGuardTests.KnownTruncatedRanges gains Stamp and Window, the
two expression-bodied formatters, whose stranded literals are format strings and not T-SQL.
|
On the CHANGELOG: intentional, not an oversight. Lane PRs in this repo do not touch The two red CI guards were meta-guards over the touched file rather than behaviour (all new tests passed): the |
There was a problem hiding this comment.
LGTM — reviewed the SQL (census anti-joins for the new continuous_aggregate/table/other/system rows, the JobHistoryInsertSql two-parameter bind, reconciliation math in ComputeInventory, and the owner job_history freshness/decode logic), confirmed no SQL injection surface (all new statements are compile-time consts with positional params), confirmed no Lite/Darling parity gap (this is Postgres/TimescaleDB-only catalog introspection with no DuckDB analog), and checked the new test coverage for the boundary cases (fresh/stale cutoff, filtered vs. zero, catch-all-missing, plain-PostgreSQL variant). No correctness, security, or parity issues found.
… job_history evidence read lives on StoreSelfMetrics and has two callers, and an unlogged store still records FAILED runs (#3581 riders, #3582/#3574) (#3619) Text only, no behavior. (1) The heaviest-refresh ceiling paragraph said the one shipped read touching job_history was DarlingStoreMetricsReader.JobHistoryEvidenceSql; #3610 moved the string to StoreSelfMetrics.JobHistoryEvidenceSql (the reader's is an alias) and gave it a second caller — the hourly self-metrics sweep runs it as the owner role and persists the count as an object_kind = 'job_history' row. (2) Both slot-watch alert texts said a store without timescaledb.enable_job_execution_logging "reports nothing there until it heals"; a FAILED run is written to job_history regardless of the GUC and only successes need it, so the lines now say a result holding no successful runs is the gap, not an empty view.
…le, Opus full tier (#3710) (#3718) * REVIEW-TRAPS.md: the repository's recurring defect shapes, one line each with the PR that bit (#3710) Seven layers (SQL, collectors and deltas, analysis, alerting and notifications, storage and MCP payloads, tests and censuses, workflows and CI, repository mechanics), fifty-odd lines, every one citing the pull request or issue that verified it: the >= limit truncation inference (#3594/#3679/#3699), ORDER BY 3 + 4 folding to a constant (#3613), MAX(max_dop) across plans (#3648), integer division in cntr_value_per_second (#3653 Q9), per-collection alert firing against a shorter cooldown (#3628/#3636), restart zeros in deltas and baselines (#3595/#3698/#3705/#3708), ScoreAll skipping amplifiers on a zero base and the non-virtual GetActiveEdges (#3688), the PgTargetFactKeys censuses (#3688), pg_total_relation_size vs the heap and the two TimescaleDB views that hide materializations and history (#3610/#3585), the -infinity crash backoff (#3629), the Slack block budget and the surrogate-pair cut (#3612/#3618/#3625/#3644), LCK_M_SCH_M folding into LCK so no SCH_M fact exists (#3709), one wait profile per run (#3709), and the CRLF round-trip. Each citation was read before it was written down; a wrong citation is worse than none. The review workflow reads this file before the PR body and must say, per trap the diff is near, whether it applies here and why. * Claude review: blind read before the body, callers rule, structured verdict with a ledger, and an Opus/Sonnet tier by changed path (#3710) The prompt runs in two phases. Phase 1 forbids gh pr view / git log / git show until the reviewer has read the diff, every changed region in context, the callers of every changed public symbol (Grep, both SKUs and both test projects) and .github/REVIEW-TRAPS.md, and written its own account. Phase 2 reads the body and grades every claim VERIFIED / UNVERIFIED / REFUTED, then reports the DIVERGENCE between the two accounts. The verdict is a fixed block -- Claims, Riskiest lines with the pinning test or UNPINNED, Traps near this diff, Divergence, Reviewer checklist -- ending in one machine-readable ledger line, posted through gh pr review --body-file - with a quoted heredoc (no Write tool; the allowlist is unchanged and still read-only). The #3650 verdict check now fetches the newest verdict in the window by id and fails the job when the body lacks the "## Verdict:" header or the ledger, or when the ledger's verdict disagrees with the review's API state (a "CHANGES REQUESTED" body submitted with --comment is the shape the guard would wave through). The guard's own arm is untouched and still keys on the state. A dorny/paths-filter@v4 step (the repo's pin style) routes changes under the shared libraries, Darling Storage/Analysis/Service, Lite Services/Mcp/Database, install/ and every .sql file to the full tier -- claude-opus-5[1m], 60 turns -- and everything else to claude-sonnet-5 at 30 turns; one decision step emits tier/model/max_turns, one review step consumes them (two steps would double the "review posted" accounting the guard does), and the prompt carries the tier and model into the ledger. A filter that dies degrades to FULL, not light. Step name "Claude review" and the ALWAYS POST sentinel are unchanged: both are read by claude-review-guard.yml. * Review tier: Alerting and Notifications read at the full tier (#3710)
The lie, in one number
On the largest production store,
get_store_metrics' object inventory accounted for 159 GiB of a 415 GiB database — 38% — and answered "here is the store" with it. A growth investigation that trusted the tool went looking for the missing 256 GiB inside the one object the tool did measure correctly. The bytes were in two places the walk could not see. The twenty continuous-aggregate materializations, ~235 GiB (57%), becausetimescaledb_information.hypertablesnever lists a materialization: the view's own definition on 2.28.1 endsWHERE ht.compression_state <> 2 AND ca.mat_hypertable_id IS NULL. (The issue's stated mechanism — that the view lists them under_materialized_hypertable_Nnames and the sweep's walk keys on collector tables — is wrong on both counts; the walk has no filter at all and the view has no such rows. The effect is exactly as reported.) And the product's own plain tables the walk never named:collect.query_store_textat 15 GiB — V74 stores statement text INLINE by design —collect.query_store_plan_map, andconfig.config_alert_log.A correct query producing a wrong conclusion because the envelope never stated its coverage. Same family as #3541.
What the sweep writes now
Every row is under the one
metric_timea run stamps, so a run's rows join. Four new kinds, each a named constant (StoreSelfMetrics.*ObjectKind), because a drifted spelling returns zero rows to every reader rather than an error:continuous_aggregate— one row per aggregate intimescaledb_information.continuous_aggregates, under its user-facing view name, sized through its materialization:hypertable_detailed_sizeonmaterialization_hypertable_schema.materialization_hypertable_namefor the total,chunk_compression_statson the same regclass for pre/post bytes, and the chunk count fromtimescaledb_information.chunks— NOT a count overchunk_compression_stats, which returns zero rows for a hypertable whose compression is off (measured: a two-chunk materialization yielded no rows until compression was enabled).hypertable_detailed_sizeon a materialization is byte-exact againstpg_total_relation_sizeover root + chunks + compressed chunk relations (measured: 155,648 = 24,576 + 49,152 + 81,920).table—collect.query_store_text,collect.query_store_plan_map,config.config_alert_log:pg_total_relation_sizeplus the planner'sreltuplesestimate asrow_count. An estimate, deliberately, and the doc says why the two kinds differ: a dim's heap is small (its bytes are TOAST), so the dim arm's exactcount(*)is cheap;query_store_text's heap IS the 15 GiB, and an exact count would be a 15 GiB read every hour for a figure whose job is "roughly how many statements have text".-1(never analysed) maps to NULL, not to a count of minus one. Schema-qualified names, unlike every other kind, because the kind spans two schemas and shares its population with the live un-enumerated census, which names relationsschema.relation.other— the sum ofpg_total_relation_sizeover every user-schema relation (relkindr/m/p/S,NOT relisshared) that no named row accounts for, with the relation count inchunk_count(the pieces the sum spans, which is what the column means on a hypertable row too). What is excluded is exactly what other rows already size: the dims and named tables by(schema, relation), every hypertable root thehypertablesview lists, every materialization root the aggregates view names, and every chunk relation in_timescaledb_catalog.chunk— joined by NAME, never by aregclasscast a vanished relation would make raise. So the statement sizes a few hundred relations, not ~40,000 chunks, and its cost does not follow the chunk count.system— the same census on the other side of the user/system line:pg_catalog,information_schema, and every_timescaledb_*schema minus the chunks. Two rows and not one, because folding them would inflate "relations the product should know by name" with sixtypg_catalogtables, and leaving them out would surface as an unreconciled gap of a percent or more on every store every hour — a false finding of exactly the shape this work exists to stop.NOT c.relissharedis load-bearing: the shared catalogs live inglobal/, outsidepg_database_size, and summing them made the rig census EXCEED the database by 512 KiB — wrong in the direction nobody checks.Both catch-all rows
coalesce(..., 0): an empty bucket is a ZERO row, so an absent row means the statement did not run, never that there was nothing to count. A plain-PostgreSQL variant omits the TimescaleDB anti-joins (those catalogs do not exist there); on such a store the collector tables are ordinary tables and land inother, and the note says so in those words rather than flagging a low share.The reconciliation
DarlingStoreMetricsReader.ComputeInventory— pure — takes the rows that share the store row'smetric_time(the same sweep; a sweep that died half-way leaves older rows behind for the kinds it never reached, and summing those against a newer database figure would manufacture a gap) and states two coverages separately: enumerated (bytes under named objects — the issue's "inventory covers N%") and attributed (bytes any row explains).residual_bytes = pg_database_size − attributed;reconciledwhen both catch-all rows are present and the residual is inside the larger of 1% and 64 MiB. The floor is there because on a fresh rig the residual was 161,471 bytes on a 17 MiB database — exactlypg_database_sizeminus the sum of every local relation, i.e.pg_internal.init,pg_filenode.map,PG_VERSION— already 0.9% of that store and fixed per database. Rows from an older sweep are counted (stale_object_rows) and the note says the sweep is not completing.The
inventoryblock is on every response:database_bytes,enumerated_bytes/percent,attributed_bytes/percent,residual_bytes,tolerance_bytes,reconciled,stale_object_rows,bytes_by_kind(where "what is driving growth" is one glance — the 57% would have been the first line),unenumerated {total_bytes, relation_count},system {...},largest_unenumerated(live top 10 by name, over the SAME census fragments the sweep sums with, so the list and the number cannot disagree),aggregate_state, and a note in the issue's words: "named rows account for 409.0 GiB of the 415.0 GiB database (98.55%) … 3.0 GiB sits in 14 un-enumerated user-schema relation(s), the largest being … RECONCILED: every row together accounts for 99.76% …" — or "NOT RECONCILED — a finding: 10.0 GiB of pg_database_size is attributed to NO row of this inventory".Each
continuous_aggregateobject carries, read LIVE from the catalog (the #2813 precedent for state the series does not carry),compression_enabled,materialized_only,source(a hierarchical aggregate's source resolved back to its parent's view name), andrefresh_policy_job_id/compression_policy_job_id/retention_policy_job_id(null where none exists) — the three facts #3581 assembled by hand.timescaledb_information.jobsreports a policy on an aggregate under the aggregate's view name (its ownCOALESCE(ca.user_view_schema, ht.schema_name)), which is what the lookups join on; the compression predicate carries the 2.18+columnstorehedge the rest of the codebase uses. The note adds "N of M continuous aggregate(s) have compression DISABLED and hold X GiB between them" when true. These are STATE, not series, and the table has no column that could hold a bool without bending a byte column's meaning — so they are read live and no migration rung is taken.The managed-mode self-proof (#3574 follow-up, ruled yes)
#3585 made the
job_historyblock sayvisibility: Nonein managed mode — the MCP host reads as the least-privilegemcprole, which the view's ownership filter shows nothing — but it could not COUNT. The hourly sweep runs on the worker's owner pool, the role that created every policy job, so it can.StoreSelfMetrics.JobHistoryInsertSqlembeds the evidence SELECT verbatim (the string moved toStoreSelfMetrics.JobHistoryEvidenceSql;DarlingStoreMetricsReader.JobHistoryEvidenceSqlis now an alias, so every #3585 pin still holds and there is one predicate, not two) and persists anobject_kind = 'job_history'row:object_nameis the role that counted;row_countisrows_observedONLY when the view admits that role to every job's history (the twopg_has_roletests, in SQL) and NULL otherwise, because a count the filter truncated is not a count;total_runsis the unfiltered population half;schedule_interval_msis the window width computed from the two binds;last_run_duration_msis the newest row's age at the sweep in milliseconds. That last one is the single overload that bends a column's name. It is taken rather than a rung because the store has no timestamp column besidesmetric_time, V127 is claimed today, and the reader decodes it back into an absolute instant before anyone reads it — the mapping is documented once on theStoreSelfMetricsclass summary, and a dedicated column is the clean follow-up.$1is the window start (timestamptz,Kind = Utc, the #3574 inverse-discipline bind) and$2metric_time — the one arm where$1is not the stamp, and the doc says why.OwnerJobHistoryEvidence.FromLatest(pure) decodes the row intoObserved/Stale/Filtered/Absent. Stale is older than 3 hours — the first whole hour past the 2.47 h largest-observed sweep gap theLatestStoreSizeSqlparagraph documents; a row that old means two consecutive sweeps did not land and its window no longer speaks for now, so it is shown with its age and no verdict is judged from it. The block gainsowner_evidence,owner_role,owner_observed_at,owner_observed_age_hours,owner_observed_window_hours,owner_rows_observed,owner_newest_row_at,owner_jobs_run_in_window;contradictionis true from EITHER admitted reader's numbers; and the note — appended only where this connection is not itselfAll— says where the number came from: "THE OWNER'S OWN COUNT: the service's hourly self-metrics sweep runs on the owner pool … Reading as 'darling' at …, the sweep saw 2 row(s) … 'recording' is therefore a measurement on this store after all — the service's, taken hourly as the owner — not a GUC echo." Or the CONTRADICTION from the owner's numbers, with the same benign cause and the same way to settle it. Every #3585 field and arm is intact; the two-argumentJobHistoryNoteis the three-argument one withAbsent.Proven live on the rig as an unprivileged role:
visibility: None, rows_observed: 0besideowner_evidence: Observed, owner_rows_observed: 2after the GUC was turned on and one job ran — andowner_rows_observed: 1before, the one row being the telemetry job's failure written with the GUC off.Two truth fixes
job_history"is NOT recording, so a maximum over it returns zero rows". TimescaleDB 2.28.1 writes a FAILED run's row regardless of the GUC (job_stat_history.cgates only the success path; measured: with the GUC off, the telemetry job's one failure was the view's one row). Both Off arms now say the view is recording FAILED runs only, a maximum over it is a census of failures and not of runs, an empty result means no failure was recorded, and a non-empty one is not a sign logging is secretly on — which is what the visibility arm appended right after them already (correctly) said. The reader'sJobExecutionLoggingSqldoc and theOffenum doc carry the same correction; the tool description's "ONLY while … is on" sentence too.Darling/README.mdcalled themcprole "dormant until MCP is exposed on the LAN".DarlingMcpHostServicebuilds the MCP pool from the storedmcp-role credential wheneverpostgres.managed = true, loopback included. Both sentences that said otherwise (the roles table and the BYO paragraph's "the only consumer") now say what the code does; the Background-workers paragraph gains the owner's-count sentence.What this does NOT do
collect.store_metricsis unchanged; the four new kinds and the documented column mapping are the whole on-disk change.compression_enabledand the policy ids; The continuous aggregates are 235 GiB and none are compressed: the rollups outweigh their 9x-compressed source, and they were never on the compression ladder #3581 owns the decision.TimescaleSupport.csorCHANGELOG.md.job_historyrow is deliberately excluded fromobjects[]anddaily[]: it carries no bytes, and rendered raw itslast_run_duration_mswould read as a run's duration.Verification
Rig: Docker Desktop,
timescale/timescaledb:2.28.1-pg18(the CI recipe's versions), torn down after. The realSweepAsyncagainst a migrated, converted, aggregate-bearing scratch store wrote 191 rows across nine kinds; the rows summed to exactly the census of every local relation, residual 161,471 bytes =pg_database_size − Σ pg_total_relation_size. The realGetStoreMetricsran as the owner and as an unprivileged role; both shapes above are its output. The plain variant executed. Every new test's assertions were run through the shipped code in a throwaway harness before CI (this Mac cannot runnet10.0-windows; CI is first execution). All four build targets 0 warnings.Tests:
StoreSelfMetricsTests— the aggregate SQL pins (materialization regclass, view name, chunks-view count, the hypertable arm's lack of any filter), the named-table pins cross-checked against the census predicate by(schema, relation), both catch-all variants over the shared fragments, thejob_historyarm's embedded SELECT and column mapping, the kind vocabulary distinct and written by its arms, the dialect theory over every new statement, and the live end-to-end sweep extended to assert every new kind, the reconciliation through the real reader, and the owner row decoding asObserved.DarlingMcpStoreMetricsToolsTests— the shared-SQL identity, the owner decoder's mapping and freshness bar, the owner contradiction's five flips, the owner note per shape and its exact append rule, the corrected GUC-off notes (with the On arm as positive control),ComputeInventoryover one sweep (enumerated vs attributed, stale rows, the floor, missing catch-all, no store), the inventory note's five shapes, the byte formatter, the aggregate-state and top-N SQL pins, failure isolation to null (never an empty list), and the description asserted with the arms and constants behind it.