Repository navigation
Normalize query_stats/procedure_stats payloads into hash-keyed dimension tables (#1767) - #1768
Conversation
…ions (#1767) query_text/query_plan_xml stored inline per row were 94% of a 250 GB field store. A measured 1-hour window (514,896 rows) held 3,166 MB of payload across just 23 MB of distinct content, because re-collecting the same cached plan every cycle stores it again every cycle and TOAST compression has no cross-row awareness. Adds collect.query_text_dim / collect.query_plan_dim (plain tables, not hypertables: no time axis, and a hypertable unique constraint must include the partition column) keyed by a CONTENT digest, plus nullable digest columns on the two fact tables. The key is a SHA-256 content digest rather than one of SQL Server's own hash columns, deliberately. query_plan_hash is a plan-SHAPE hash: a recompile with different sniffed parameters carries the same hash and different XML, so a shape-keyed dim with ON CONFLICT DO NOTHING would pin the first XML forever and serve stale ParameterCompiledValues. query_hash is a query-SHAPE hash and would pin one arbitrary literal variant. plan_handle does not determine the XML at all -- query_stats captures the STATEMENT plan via dm_exec_text_query_plan with the statement offsets, and this codebase already recorded that exact failure ("keying on plan_handle alone cross-contaminated multi-statement plans"). A content digest rests on no cache-internals assumption, and preserves the dedup ratio because the repetition being collapsed is byte-identical XML from the same cached plan. Migration is ZERO-REWRITE (the peak-disk-before-relief trap rejected in #1759): the inline columns remain, new rows leave them NULL, readers coalesce, and raw retention ages the inline copies out on its own. Dropping the columns is a later, separate decision. - V38 adds the dims + digest columns and rebuilds v_query_stats as a payload RESOLVING view whose column list is generated from the collector definition, so it cannot go stale the way an explicit list would. Resolving it there fixes every reader that goes through the view and makes future readers correct by default -- the real risk here is that a consumer selecting the raw column does not error, it silently returns NULL. - The write path diverts payloads at the binary-COPY writer, keyed by payload column ORDINAL derived from the definition by name, and flushes the dims in the SAME transaction as the fact COPY. Ordering could not be dims-first: the digests are only known once the rows stream, and WritePayload consumes delta state, so a second pass would report every delta as zero. The transaction gives the stronger property anyway -- no session observes a fact row whose digest has no dim row. - EndPayload pins the positional COPY contract for every collector, not just diverted ones. - Dimension GC rides the existing retention sweep on a last_seen watermark, at the widest effective FACT retention plus a margin for drop_chunks granularity and the watermark's hourly refresh guard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fast pins on the dimension contract: the diversion plan resolves by payload ORDINAL computed from the collector definition (not a hardcoded index) and throws when a declared column disappears, because the silent failure mode is that diversion quietly stops and every row stores its payload inline again. The COPY column list is asserted positionally, the digest is asserted content-addressed on two strings that share a SQL Server query_hash, and the resolving view's select list is compared against the generated CREATE TABLE so it cannot go stale. Gated live legs against a real PostgreSQL 18.4 + TimescaleDB store: migration and idempotency, the write path end to end, dedup across rows and across batches, the pre-#1767 inline row still resolving through the view, the last_seen hourly refresh guard, the GC horizon, and NUL-laden payload round-trip where the digest must key the STORED bytes. Two guards for the failure mode this change actually carries -- reading a payload column off a bare fact table does not error, it returns NULL for every row written since #1767, which looks like a collection outage: - a source-parsing pin over every Darling .cs requiring any SQL literal that reads query_text/query_plan_xml off a bare fact table to also read v_query_stats, name a digest column, or COALESCE the payload - an ordering pin on WriteBatchAsync (complete COPY, flush dimensions, commit, in that order). The live atomic-visibility test cannot cover this: the writer is private, so that test MIRRORS the ordering rather than calling it, and a production reorder leaves it green. The seam needed its own pin. Also excludes v_query_stats from AllPassthroughViews: 'ADD COLUMN, then CREATE OR REPLACE VIEW v_x AS SELECT * FROM x' is the established idiom here (V14, V15, V27, V28) and applying it to v_query_stats would overwrite the COALESCE with no error anywhere. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…1767) The observer is deliberately unmigrated so it cannot serialize behind the writer's DDL, but that also means nothing SETs its search_path. The store-wide default comes from ALTER DATABASE, which reaches only sessions established after it commits -- and Npgsql POOLS sessions, so a physical connection opened before the first migration keeps "$user", public for its whole life and resolves bare query_stats to nothing. On a long-lived store every session postdates that ALTER by days, so this passed locally and failed on CI's fresh cluster, where the pool is full of connections older than the migration. Reproduced locally by running the full suite against a pristine database: 42P01 relation "query_stats" does not exist, the same error CI reported; green on a pristine database with the fix. Test-only. Production sessions connect to a store migrated long before, so every one of them postdates the ALTER. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The dim sweep was one unbounded DELETE routed through PurgeOneAsync, whose drain loop is built for the time-sliced statements its siblings use. Correct, but it forgoes the bound the slicing exists to provide: the first sweep with a full retention window of expired content would hold a lock and generate WAL proportional to the entire backlog in a single transaction. Reuses DarlingRetention.TimeSlicedDeleteSql -- the same chunk-width slicing every other purge already runs -- instead of keeping a second, weaker delete idiom in PayloadDimensions. PayloadDimensions.GcSql is removed rather than reimplemented, so there is one implementation, and the live test now drains through DrainBatchesAsync exactly as production does rather than executing once (a single execution only clears the oldest slice, so it would have reached the test's row by luck of ordering). Found by an adversarial top-down trace of user-clickable surfaces, which also confirmed no consumer is left unresolved. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
darling.sample.json and DarlingConfig both still described plans landing in query_stats.query_plan_xml, and justified keeping them by TOAST compressing the inline copy. Both are stale against this PR: a query_stats plan now lands once in query_plan_dim with a content digest on the fact row, and THAT is what makes plans cheap to keep -- re-collecting the same cached plan costs a hash rather than another copy of the XML. Also corrects two details the old text got wrong independently: the managed store sets default_toast_compression = lz4, not pglz, and query_store_stats.query_plan_text is still stored inline -- it is not deduplicated by this change, which the new text says explicitly rather than leaving the reader to assume the whole sentence still applies. The review flagged the sample.json line; DarlingConfig carried the same claim and is the one a developer reads, so both are fixed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Field-scale correction to the FIELD VERIFICATION RECIPE in this PR body, from the first production acceptance run: the D2b unresolvable-digest query has no time bound on the fact side, so TimescaleDB cannot chunk-exclude and it scans the entire hypertable - it does not complete on a 276+ GB store (even a bare count(*) WHERE query_text_digest IS NOT NULL times out there). The corrected form adds the same collection_time >= <upgrade_time> bound D2a uses to BOTH subqueries; validated live on that store at ~32s with a clean 0/0 result. The operator runbook has been updated. Lesson for future recipes: a query executed against a seeded dev store proves correctness, not scalability - production-runbook queries need the time bound that makes chunk exclusion possible. |
…arlingdata#1782) The dimension GC prunes rows whose last_seen aged past the fact tables' retention plus a margin, on the reasoning that any fact which could still reference that content is gone. The fact purge immediately above it in the same sweep is failure-isolated: a table whose drop_chunks fails, and whose DELETE fallback then also fails, is warned and skipped while the sweep continues straight into the GC. Those rows are still there carrying digests, and their dimension rows get pruned out from under them -- the resolving view then serves NULL payload for real collected data with no error raised anywhere, which reads as a collection outage rather than a retention bug. Keyed to the purge FAILURE, not to the retention policy's held state, because the issue's original premise does not hold. There are two purges on these tables and only the tiered 4-day policy is coverage-gated; Darling's own catalog sweep drops them at their per-collector 30-day horizon with no coverage check and no held check, two days inside the 32-day dimension cutoff, and the ordering survives any override since both sides resolve through the same retentionDaysFor. Guarding on the held state would have deferred the GC indefinitely on a store whose policies are held for unrelated reasons -- unbounded dimension growth, the exact failure erikdarlingdata#1768 exists to prevent -- to prevent something unreachable by that route. The flag is tracked by the sweep rather than inferred afterward because the sweep is the only thing that knows: a failed purge is warned and skipped, leaving no state a later query could read back. Self-ending on the next clean sweep, with no operator step. Verified at the seam: the live test injects a GENUINE total purge failure (the fact table is renamed for the duration, so both statements fail against a relation that does not exist), asserts the referenced dimension row survives, then restores it and asserts the same row is pruned once the purge is healthy -- so it cannot pass merely because the GC never ran. The survival assertion comes FIRST so a mutation lands on the data rather than on log text. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Closes #1767.
query_text/query_plan_xmlstored inline on every collected row were 94% of a 250 GB production field store. This normalizes both into hash-keyed dimension tables, zero-rewrite.Why the key is a content digest, not
query_hash/query_plan_hashThe issue sketched
query_text_dim(query_hash PK, ...). I did not use that, and the departure is the main design decision to review:query_plan_hashON CONFLICT DO NOTHINGpins the first XML forever and serves staleParameterCompiledValues -- the most diagnostic part of a plan for this product's audience.query_hashplan_handlequery_statscaptures the STATEMENT plan viadm_exec_text_query_plan(plan_handle, statement_start_offset, statement_end_offset)and the offsets are not stored. This codebase already recorded that exact failure --QueryStatsCollector's delta key: "keying on plan_handle alone cross-contaminated multi-statement plans (one plan_handle, many statements at different offsets)".A SHA-256 content digest is correct by construction and rests on no SQL Server cache-internals assumption. It costs two
byteacolumns perquery_statsrow -- ~1% of the payload it removes -- and it does not cost dedup ratio, because the repetition being collapsed is the same cached plan re-read each cycle.Measured on a live SQL Server to confirm exactly that: two collections 20 seconds apart, 69 of 89 cached plans hashed byte-identical. The other 20 were plans that entered or recompiled between snapshots on an otherwise idle instance where the measurement itself perturbs the cache. That byte-identity is the property the whole design turns on, so it was verified rather than assumed.
Zero-rewrite migration
Per the trap rejected in #1759, there is no backfill and no column drop. V38 adds the two dimension tables and three nullable digest columns and touches not one existing row (metadata-only). Inline columns REMAIN, new rows leave them NULL, every reader coalesces, and raw retention ages the inline copies out on its own. The hourly/daily CAGGs never carried the payload, so nothing above raw is affected. Dropping the columns is a later, separate decision, deliberately not taken here.
StorageVersion37 to 38, with the viewer probe sentinel (query_plan_dim) and map arm. Service deploys before viewer, per house practice -- and here it genuinely matters: a pre-V38 viewer against a V38 store reads NULL text and plan for every new row, with no error.Consumer inventory (the contract)
Grepped the whole repo per-citation. 21 in-scope citations; everything else dismissed with a reason (other tables carrying same-named columns --
deadlocks,blocked_process_reports,query_snapshots,query_store_stats; the deprecated Dashboard; Lite;install/).Mechanism:
v_query_statsis rebuilt as a payload-RESOLVING view (COALESCE(inline, dim)) whose column list is generated from the collector definition, so it cannot go stale the way a hand-written list would -- the exact defect V14 exists to fix, not re-introduced.query_statsreaders (viewer plans, top queries, heatmap, comparison, FinOps high-impact, MCP stored-plan + data reader, actual-plan resolver, 2 analysis plan advisories)v_query_statsv_query_stats(bad-actor, top-CPU, spilling, parameter-sensitive, bad-actor detail, expensive queries)procedure_statsplan readersLEFT JOIN query_plan_dim,IS NOT NULLguard moved onto the COALESCED expressionhas_query_plan)OR ..._digest IS NOT NULL-- no join needed to test presencewwwrootJSRetentionTierRouter.RawTextHorizonThe residual risk is silence -- a future reader selecting the raw column does not error, it returns NULL for every new row, which looks like a collection outage. So there is now a source-parsing guard over every Darling
.cs: any SQL literal reading a payload column off a bare fact table must also readv_query_stats, name a digest column, or COALESCE the payload. It passes every current site with no allowlist.Dimension GC -- decided with numbers
Orphaned dim rows must be bounded: ~23 MB/hour of distinct plans measured in the field is ~552 MB/day, ~16.6 GB/month, ~200 GB/year if kept forever -- which would re-create the problem this PR removes. The obvious sweep (delete dims no live fact references) is an anti-join against two hypertables per dim row and is not affordable at this size.
Implemented as a
last_seenwatermark: the write path stamps it on every cycle that references a digest, so it is never older than the newest fact referencing it, and GC becomes an index range scan riding the existing retention sweep. The horizon is the widest effective FACT retention, resolved through the same resolver the fact purge uses -- so a raised per-collector override can never outlive the dims and orphan a reader -- plus a margin fordrop_chunksgranularity (a chunk survives until its whole range is past the cutoff) and the watermark's own hourly refresh guard. That guard caps the update churn at one per row per hour instead of one per row per cycle.Autovacuum (issue item 3) -- no gap, demonstrated
The field observation (
pg_stats0 rows for these columns) is the expected, benign case: PostgreSQL never builds inheritance-parent statistics when only children are modified, and a hypertable parent holds no rows of its own. Reproduced on the rig:The planner uses chunk-level stats, which exist and are being maintained. No remediation needed and none added. Darling writes no setting that suppresses autoanalyze (verified: zero hits for
autovacuum = off,track_counts,autovacuum_analyze_*,default_statistics_target); its only table-level knobs are insert-triggered VACUUM thresholds, which are more aggressive than default and do not touch analyze.Lite parity -- investigated, correctly no-op
Lite has the same inline schema (shared collector definitions) but has never stored a single plan:
CollectorContext.CapturePlanXmldefaults false and Lite never sets it -- confirmed empirically,COUNT(query_plan_xml) = 0across 15,490 live rows and a 1,191,596-row monthly parquet archive. Itsquery_textmeasures 1.72 MB compressed per server-month (345x on that column, 5.8% of the file) against a hot store hard-capped at 512 MB by an archive-and-reset trigger. There is no problem to port a mechanism to. Documented rather than mechanized. Lite.Tests: 1580 passed, 0 failed.Cheap levers -- all three dispositions contradict the issue's premise
CompressAfterDaysis 1 (not 24h-and-tunable) and the binding constraint isChunkIntervalDays = 1-- a chunk cannot compress before it CLOSES, so lowering the delay alone is a no-op. Getting the intended effect means a sub-day chunk interval, which is a different change with planning-time anddrop_chunks-granularity consequences, and after this PR the hot window is ~13 GB/day instead of ~89 GB/day, so it is chasing a problem the dedup removes. No change.2674456a(2026-02-23) to widen coverage after a recency filter freed up slots. No rationale for 200 specifically, no storage analysis, and it predates plan capture by 4.5 months.procedure_stats'TOP (150)is pure v1.0.0 inheritance with no rationale at any point. Proposed, not changed -- row caps do not bound bytes (the whole lesson here), and after dedup a marginal row costs numerics only.Tests -- 3,579 Darling + 1,580 Lite, and the mutation table
Every guard verified RED by build-and-run against a real PostgreSQL 18.4 + TimescaleDB store. A green local run is no evidence for which relations a query reads -- those failures are swallowed, not thrown.
Counts corrected after review. Mutation 4 was first reported as 3 tests; re-run properly it is 1, identical under a fast-only run and a gated run. The original 3 came from a filtered run against a store still dirty from the mutation-2 experiment, where two unrelated live tests were failing for that reason. The reviewer caught the discrepancy and was right. Every other row below was re-confirmed.
ON CONFLICTfrom the dim upsert23505: duplicate key value violates unique constraint "query_text_dim_pkey"System.InvalidCastException : Column 'query_text' is null(the exact silent-NULL symptom)Collector wrote 10 payload values but declares 50 payload columnsv_query_statstoAllPassthroughViewsDarlingObservabilityTests.MigrationScripts_AreRegisteredInAscendingOrder_...:Assert.DoesNotContain() Failure: Item found in collection/Found: "v_query_stats"ViewerDataService.Plans.cs:116 -> "FROM procedure_stats"Mutation 5 is the one worth reading. The live atomic-visibility test MIRRORS the write path rather than calling it (
WriteBatchAsyncis private and only reachable through a live SQL Server), so reordering production left it green. The seam between the mirror and the real thing needed its own pin. A test I did not watch fail is not evidence, and that one proved it.31 new tests (20 fast + 7 gated live + the visibility and ordering pins). The fast pins derive expected ordinals FROM the collector definition rather than hardcoding them, so they track a definition change instead of rotting.
Design decisions worth a reviewer's attention:
WritePayloadpass is not available --WritePayloadCONSUMES delta state (context.Deltas.CalculateDelta), so a second pass would report every delta as zero. One transaction gives the stronger guarantee (no session observes a fact whose digest resolves to nothing) and is what the test asserts.EndPayloadon every collector? A payload-count drift silently shifts every later column by one, and with a diversion plan active it would hash the WRONG column. Without the check the symptom is an opaque Npgsql type error several columns downstream of the mistake.Not predicted by the design, flagged not absorbed
v_query_statswas still inAllPassthroughViews-- the listGenerateV14RefreshViews()re-emits asSELECT *. "ADD COLUMN, then refresh the views" is the established idiom (V14, V15, V27, V28); applied to this view it would silently overwrite the COALESCE. Fixed structurally (excluded, with the cross-check widened to the union) plus a tripwire that the LAST migration defining the view is the resolving one.DarlingDataReaderandDarlingTrendReadercarried a factually wrong doc claim -- "the three query-perf tables have NOv_*view". Bothv_query_statsandv_query_store_statsdo exist. Uncorrected, that is precisely the belief that makes the next reader select the raw column and get silent NULLs. Corrected.ExcludedDatabasesStoreLiveTests,ViewerBlockingStatsLivePostgresTests), each green in isolation (3/3) and on re-run. Neither class touchesquery_stats; both use their own fixed server ids and read other tables. Consistent with the known shared-rig live-PG flake class, not introduced here -- recorded so it is not mistaken for this change later.Verification
-t:Rebuild, 0 Warning(s) 0 Error(s) on all 7 affected projects: Darling.Storage, Darling.Analysis, Darling.Service, Darling.Viewer, Darling.Tests, PerformanceMonitorLite, Lite.Tests.Do not enable auto-merge -- a release cut is pending from the current validated lineage and this must not enter
devbefore it ships.Generated with Claude Code
FIELD VERIFICATION RECIPE
Operator-runnable proof that the dedup is working on a real store. Every query below was executed against a live PostgreSQL 18.4 + TimescaleDB 2.28.1 store seeded with both pre-upgrade (inline) and post-upgrade (hash-only) rows — the outputs shown are real, not illustrative. All checks are READ-ONLY.
Substitute
<server_id>and set<upgrade_time>to when the service was restarted on the new build (from thePostgres store ready (schema v38...)log line in E1).PRECONDITION — upgrade the viewer in the SAME step as the service
This is the one way to get a bad outcome from a correct migration. The viewer's connect gate is one-directional: it refuses a store older than it requires, and cannot refuse one that is newer. A pre-#1767 viewer reads a V38 store as 37,
37 < 37is false, so it connects and works — but every text and plan read in that binary hits the bare column and gets NULL.Operator symptom of the skew, in the order it presents: the Query Text column is blank across Top Queries; the "Query Plan" download button is greyed out on every row; and "View Plan" raises a dialog titled
No Plan Availablereading, verbatim:That presentation is indistinguishable from a collection outage — the message itself offers "not collected yet" and "aged out" as the explanations, and neither is what actually happened. So if this is observed, verify the viewer build BEFORE investigating collection, retention, or the store. Checks A and B below settle it in seconds: if the dimensions are populating and no digest is unresolvable, the data is present and the viewer is the problem.
A — the dimensions exist and are populating
PASS: both tables exist,
rows> 0, andnewest_seenis within the last few minutes. Run it twice a few minutes apart —rowsgrows quickly at first, then flattens as the distinct-content set saturates (flattening is the mechanism working, not stalling).FAIL — stop the run: the tables do not exist (migration did not apply — check E1); or
rowsis 0 after two or more collection cycles; ornewest_seendoes not advance between samples. Any of those means payloads are not being diverted.B — new fact rows are hash-only, and every hash resolves
PASS: both
inline_*_still_writtenare 0. Thecarrying_*_digestcounts are normally close torows_since_upgradebut need not equal it — a row whose DMV returned no text or no plan legitimately carries a NULL digest.FAIL: any non-zero
inline_*_still_writtenmeans the writer is not diverting — the old inline path is still live. Do not proceed; the store is still growing at the old rate.Then the resolution check, which must be zero:
PASS: both 0 — every digest on a fact row has its content.
FAIL — stop the run and report it: anything above 0 means fact rows were published whose payload is unrecoverable. That should be impossible (the COPY and the dimension upsert share one transaction), so a non-zero here is a genuine defect, not a tuning issue. Note the dimension GC is pinned to the fact horizon plus a margin, so ageing cannot produce this either.
C — the read path resolves BOTH directions
The single most important check: the transition window has to serve old inline rows and new dimension rows through the same read.
PASS: BOTH queries return a row with non-empty
resolved_textandplan_resolved = t.FAIL: the post-upgrade query returns NULL text or
plan_resolved = f→ the view is not resolving the dimension. If the pre-upgrade query returns no row at all that is not a failure — it just means raw retention has already aged every inline row out, which is the expected end state a few days after the upgrade.Then confirm it through the product, not only SQL (this is what the acceptance test is really for): in the viewer, open Top Queries, pick a row collected after the upgrade, and use View Plan and Download .sqlplan. Both must open the plan. Repeat on Top Procedures (its plans resolve through a different code path — an explicit dimension join, not the view). Then FinOps → Expensive Queries and High Impact Queries, which carry plan XML in-row rather than fetching on click. Blank text or a greyed-out plan button on a post-upgrade row is a FAIL — and re-read the PRECONDITION above before concluding anything, because viewer skew produces exactly that symptom.
D — the growth-rate proof
Inline payload bytes written per hour. The pre-upgrade hours carry the old cost; post-upgrade hours must go to zero.
PASS: hours after the upgrade read 0 bytes while row counts stay in the same range as before. On this instance the baseline was ~3,166 MB/hour; the post-upgrade figure for a comparable hour should be 0 for the fact table, with the real cost now being only the new distinct content added to the dimensions (D3).
FAIL: a post-upgrade hour with non-zero inline bytes (same defect as B), or a collapse in
rows(that is a collection problem, not a dedup success — the dedup must not change how many rows are collected).Chunk-level, the same instrument used for the original finding:
PASS: chunks whose
range_startis after the upgrade showtoastcollapsing toward nothing — that is where the payload lived (the original finding measured 74.15 GB TOAST of an 88.97 GB chunk, and 16.73 GB of the 17.98 GB compressed chunk). Compare a fully post-upgrade chunk against a fully pre-upgrade one; note ranges are UTC-aligned, so a chunk labelled with one date may cover part of the next.Interpretation note: on a store with small query text and no plan capture,
toastreads near zero before the change too, because values under ~2 KB stay in the heap. On this instance plan XML averages several KB and is genuinely TOASTed, which is why TOAST is the right column here.Dimension footprint — sample twice, an hour apart; the delta is the real hourly cost that replaced the ~3,166 MB:
PASS: the hourly delta is small and shrinking as the distinct-content set saturates. FAIL: growth tracking row count linearly (each row producing new distinct content) would mean the digest is not collapsing repeats — check that the same plan re-collected yields one dimension row via check A.
E — log lines to pin
E1 — migration applied, once, on the first start of the new build (
DarlingWorker):A later restart logs
schema v38, 0 migration(s) applied. FAIL: a version below 38, or the line absent — the service did not migrate, and no other check can pass.E2 — collection continuing at normal volume (
DarlingCollectorRunner/DarlingWorker, one line per collector per sweep):PASS: row counts unchanged from before the upgrade (the caps are still 200 / 150 — this change deliberately did not alter collection volume) and
pg:timings in the same range. FAIL:=> 0 rowson either collector, orpg:timings materially worse — the dimension upsert adds two statements per batch and should be a small fraction of the COPY it replaces.E3 — retention sweep, including the dimension GC (
DarlingRetention, daily):PASS:
0 failed. The dimension tables are included in thetable(s) purgedcount. FAIL: a non-zero failed count, or aRetention purge failed for query_text_dim/query_plan_dimwarning.What a fully successful run looks like
A: both dimensions present and growing, then flattening. B: zero inline writes, zero unresolvable digests. C: both directions resolve in SQL and plans open in the viewer on post-upgrade rows. D: post-upgrade hours at 0 inline bytes, TOAST collapsing on post-upgrade chunks, dimension growth small and decelerating. E:
schema v38, unchanged collection volume, clean retention sweep.Any FAIL above should stop the run and be reported rather than narrated past — every one of them distinguishes "the dedup is working" from "the payload is silently missing", and those two look identical from the outside.