Repository navigation
Repair the Query Store split slices stored before #1907, and disclose what cannot be reached (#1912) - #1931
Conversation
…rling half) Before #1907 the collector stored Query Store's flushed and still-in-memory slice of one runtime_stats_interval_id as two rows. They are ADDITIVE, so the read-side dedup -- which keeps one row per key -- reports a fraction of the interval's work, and the rollups materialized that fraction. #1907 made the choice deterministic; this makes it correct for the rows it can reach. QueryStoreSliceRepair collapses rows carrying the pre-fix signature (two or more sharing the whole dedup key AND collection_time) into the single row the collector would write today: execution_count sums, every avg_ column takes the count-weighted mean, min_/max_ take the extreme. The column classification is DERIVED from QueryStoreCollector.PayloadColumns and the declared column type rather than restated, so a new column cannot silently fall into the pass-through bucket -- and boolean columns take bool_or, because PostgreSQL has no min(boolean). --collapse-legacy-slices is the operator verb, mirroring --backfill-rollups: same Windows/managed-credential guard, same --dry-run, same disclosure sink. THE REFRESH IS CLAMPED TO THE ROWS ACTUALLY COLLAPSED, and that is a safety property rather than an optimization. Measured on PG 18.4 + TSDB 2.28.1: a refresh whose range lies entirely within DROPPED raw chunks DESTROYS the materialization there -- with force AND without (0 of 144 pre-raw buckets survive; a range spanning dropped+live preserves them, which is why a single-variable probe was needed). Aiming at a nominal pre-fix period would therefore blank the 21-day hourly and the indefinitely-kept daily below raw's floor. Collapsed rows are inside raw's extent by definition. The behavior is pinned as a live test so a future TimescaleDB that fixes it fails loudly instead of silently widening the blast radius. The shipped --backfill-rollups was checked for the same exposure and is safe by construction (it converges down to min(collection_time) of raw). Live-verified on PG 18.4 + TimescaleDB 2.28.1: rollup reports a slice before the repair, the survey finds exactly the split cycles, the collapse leaves a correctly-collected interval untouched, a re-run is a no-op, and after re-materializing the rollup reports the interval's true 125 with the weighted mean 1871 (not the 2011 an unweighted average gives). Darling 4031 passed / 0 failed, full solution build 0 warnings / 0 errors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…quet archive Lite gets a FULL repair where Darling gets a bounded one: Darling's long tier is a materialized rollup that cannot be rebuilt from raw that no longer exists, while Lite's long tier IS the parquet archive -- the same rows, rewritable. Measured against a real archive: ~46 MB across three monthly files, 6.6-8.5% of rows carrying the split signature (exactly 2 rows per group, every time), the largest file rewriting in under a second. The collapse key ADAPTS PER FILE. Archive files are monthly and the schema moved underneath them: runtime_stats_interval_id arrived with #1841 tier 2 and replica_role with #1844/#1872, so a real June 2026 file has neither. Naming an absent column fails the rewrite outright; silently assuming the newest schema would group on a key that is not that era's dedup key at all and combine rows that are not slices of one interval. Covered by a fixture written WITHOUT those columns. Two engine behaviors found by running it, both of which would have shipped silently: - DuckDB widens SUM(BIGINT) to HUGEINT and hands back a BigInteger that Convert.ToInt64 cannot convert (the #1839 trap). Cast in SQL. - AN IN-PLACE PARQUET REWRITE POISONS DUCKDB'S EXTERNAL FILE CACHE. The cache keys on PATH at INSTANCE scope, so replacing the bytes at a path already read makes every later read fail with "No magic bytes found at end of file" -- on new connections too -- until the process restarts. That would have left the app unable to read its own archive after a repair. PRAGMA clear_cache does not exist and enable_object_cache is a different cache; toggling enable_external_file_cache off and back on evicts the entries and leaves caching enabled. Verified standalone; the instance scope is the trap, since a "fresh connection" fixes it only on a separate in-memory instance. Archive files are rewritten to a temp sibling and swapped in only after the row count checks out, so an interruption leaves the original in place. COPY carries COMPRESSION ZSTD because that is what wrote them -- omitting it switches codec and inflates the file (28 MB to 69 MB measured). The verb discloses the size change per file, including the case where a row-REMOVING repair still grows the file, because parquet size follows row-group layout rather than row count. ArchiveService.WithRaisedCopyMemoryLimit is now internal and reused rather than copied: the 4GB floor is a known-broken-below-2GB constraint, and a second drifting literal is exactly how #942 broke compaction. Lite 1923 passed / 0 failed, full solution build 0 warnings / 0 errors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ite archive Applies the #1832/v39 migration discipline the lead specified, and two of these came from the tests catching real gaps rather than from foresight. VERIFY BEFORE PROMOTING, on the two CONSERVATION invariants the collapse's own arithmetic guarantees rather than a smoke test: - row count must land on exactly (rows - rowsRemoved), since every split group becomes one row and nothing else moves -- any other number means the GROUP BY grouped something it should not have, which is precisely the damage an era-wrong key does; - SUM(execution_count) must be UNCHANGED, because the collapse sums the counter within a group. This is the check that catches a mis-typed aggregate that still produced the right row count, and it is the number the whole defect is about. No backup copies are kept -- verify-before-promote IS the safety, as it was for v39. PER-FILE ISOLATION, in the rewrite AND in the survey. The mid-rewrite failure test showed the first bad file aborted the whole run, so every LATER file went unrepaired too -- and since files process in name order, one bad old file could permanently block every newer one. A second run then showed the same hole in the survey, where summing execution_count for the conservation baseline threw before any file was reached. Each file is now independent: its original survives its own failure, the failure is logged loudly and returned, and the next startup retries it. The pre-fix signature makes that idempotent, and until a file is repaired the union_by_name view keeps reading it with #1907's read-side tie-break resolving it deterministically -- so a half-repaired archive is a delay, never a corruption. RepairAsync now returns RepairResult(RowsRemoved, Failures) instead of a bare count: a per-file failure is not an exception, and reporting "these were repaired, these were not, here is why" is more honest than one thrown error that hides the rest. Watched red (era schema, as requested): mutating KeyColumnsFor to assume the newest schema fails the unit assertion AND the archive test, the latter with the field symptom exactly -- "Binder Error: Referenced column runtime_stats_interval_id not found in FROM clause". Lite 1924 passed / 0 failed, full solution build 0 warnings / 0 errors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ry marker Option (a) per the lead's call. Consistency between the apps is the same SEMANTICS for the same defect -- same collapse, same weighted math, same disclosure -- not the same invocation surface. Each follows its own precedent: Darling's is #1849's operator verb, Lite's is the automatic startup store migration that v39 and #1832's data-root move established. The decisive argument against a button: a repair gated behind UI discovery leaves the users least equipped to find it holding wrong numbers forever. THE MARKER IS ITS OWN TABLE, not a schema-version bump, for two reasons that are both load-bearing. RunMigrationsAsync DROPS AND RECREATES tables per version step, so a data repair has no business on that path -- a future migration dropping query_store_stats would destroy the rows this exists to fix. And the requirement is that a PARTIAL repair retries next launch, which means withholding the marker on failure; the schema version cannot be withheld, because leaving it behind would re-run those table drops. So: marker written only on a fully successful pass; a store that could not repair one archive file tries again next launch, which is safe because the pre-fix signature makes the whole thing idempotent. A store with nothing to repair records the marker too, so the survey does not run on every launch forever. RepairOnStartupAsync never throws. A store that cannot be repaired must still START -- refusing to launch a monitoring tool over historical Query Store numbers would be a worse failure than the one being fixed. It is fired rather than awaited so a large archive rewrite cannot hold the window closed, and it takes the same read lock every other store operation does, so it serializes with collection rather than racing it. Lite 1927 passed / 0 failed, full solution build 0 warnings / 0 errors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The #1845-style annotation the lead asked for, placed where that precedent put it: in ComposeCompiler's doc, beside the #1849 routing boundary it shares. It records that a SECOND, permanent understatement sits underneath the inflated-rollup one -- rollups materialized from pre-#1907 rows carry ONE SLICE of each split interval, the repair reaches only as far back as raw still does, and re-materializing past that DESTROYS the rollup rather than correcting it. Nothing about it is visible in a panel; the numbers are simply low, which is exactly why it is written down. CHANGELOG Important gets two entries. Lite's is the v39-style one-time note: first launch takes a few extra seconds, the archive is rewritten, and the file may get slightly BIGGER despite losing rows -- 28.0 MB to 31.6 MB while 22,760 rows were removed -- because parquet size follows row-group layout and per-column compression rather than row count. Disclosed rather than pretended away. Darling's is the operator action next to --backfill-rollups, and it leads with the timing because the timing is the unusual part: the verb gets LESS useful every day you wait. It repairs only what raw still holds, so run it right after upgrading and it fixes the recent history you are looking at; run it a week later and it correctly reports nothing to do while the numbers stay wrong. What it cannot reach is permanent. DARLING01 named explicitly. Also fixes a doc-hygiene displacement the guard caught -- my insertion had pushed QueryArchiveAsync's summary onto the wrong member -- and corrects that summary, which still said DuckDB's parquet cache is per-connection. It is per-PATH at INSTANCE scope; a fresh connection from the same store does not escape it, which is the entire trap. Darling 4033 passed / 0 failed against live PG 18.4 + TSDB 2.28.1, Lite 1927 passed / 0 failed, full -t:Rebuild 0 warnings / 0 errors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review: #1912 Query Store split-slice repair (Lite + Darling)Scope: ~2000 LOC across 10 files — a new Darling operator verb ( One concurrency issue worth fixing before merge (inline comment below), plus a minor design-vs-implementation mismatch noted inline. No Lite/Darling parity problems beyond what is already intentionally and clearly documented (Lite = full repair via rewritable archive, Darling = bounded by raw retention) — that asymmetry is explained and justified in both the code comments and CHANGELOG. No SQL-injection concerns: all dynamic SQL is built from fixed collector metadata ( |
| long removed = 0; | ||
| var failures = new List<string>(survey.Unreadable); | ||
|
|
||
| using var readLock = _duckDb.AcquireReadLock(); |
There was a problem hiding this comment.
Concurrency: this should be a write lock, not a read lock.
RepairAsync acquires AcquireReadLock() but then:
- runs
DELETE/INSERTagainst the live hot tablequery_store_stats(lines ~419-431), and - deletes and replaces archive parquet files on disk in-place (
RewriteArchiveFileAsync→File.Delete+File.Move, lines ~532-533), files that the app's ownunion_by_namearchive views actively read.
DuckDbInitializer's own doc comment on the lock says read locks let "unlimited concurrent UI queries" run, and that the lock exists to "coordinate UI readers with maintenance writers (CHECKPOINT, archive DELETEs, compaction)" — explicitly calling out archive file replacement as a write-lock-worthy operation. ArchiveService's retention path follows that: it does the (read-only) export under a read lock but explicitly switches to AcquireWriteLock() just for the DELETE, with the comment "the DELETE modifies table data and the next CHECKPOINT reorganizes the file — readers must not be mid-query when that happens or they get 'Reached the end of the file' errors."
This repair does the equivalent (writes to the hot table, and swaps files that back live views) under only a read lock, which — per ReaderWriterLockSlim semantics — allows any number of other UI readers to run concurrently, including a UI query hitting read_parquet(...) on the exact archive file mid File.Delete/File.Move.
Since MainWindow.xaml.cs fires RepairOnStartupAsync via Task.Run (not awaited) specifically so the window opens and stays interactive while a large archive rewrite runs, this race is concretely reachable, not theoretical — a dashboard tab querying Query Store data can run at the same moment this service is deleting/replacing the file backing it. The MainWindow.xaml.cs comment that the read lock "serializes with collection rather than racing it" is true for the collector (which uses a write lock and would block), but not for concurrent UI readers, which a read lock explicitly permits to run alongside.
Suggest switching the write phase of RepairAsync (hot-table collapse + archive file rewrite/swap) to AcquireWriteLock(), mirroring ArchiveService's pattern of keeping only the read-only survey/rewrite-to-temp under a read lock and taking the write lock just for the parts that mutate what's actually being read (the hot table DELETE/INSERT and the file promotion).
| { | ||
| /* The half-open upper bound has to include the newest collapsed row, hence the one-second nudge: | ||
| the survey reports the newest collection_time itself, not a bound past it. */ | ||
| removed = await QueryStoreSliceRepair.CollapseSliceAsync( |
There was a problem hiding this comment.
Minor: CollapseSliceAsync is invoked here exactly once, over the entire survey span (survey.OldestUtc .. survey.NewestUtc + 1s) — but its own doc comment on QueryStoreSliceRepair.CollapseSliceAsync (Darling/PerformanceMonitor.Darling.Storage/QueryStoreSliceRepair.cs) frames it as "one half-open collection-time slice" and says "Slicing keeps that transaction — and the locks it takes on chunks a compression job may also want — short," implying the caller is meant to loop over multiple bounded slices for a large range. This call site never slices, so the whole collapse (temp-table build + DELETE + INSERT) runs as one transaction for whatever span the survey reports.
In practice this is bounded by raw retention (a few days, per the PR description), so it's unlikely to be a problem with typical settings — but if a store is configured with a longer raw retention window, this becomes one long-held transaction/lock rather than the short, chunked ones the design comment promises. Worth either slicing the call here (e.g., per day) to match the documented intent, or updating the doc comment to reflect that the current caller always passes the whole range in one shot.
|
PR UP — #1931, all checks green, Both halves shipped on the shape you decided: Darling's Three engine findings, each of which would have shipped silently:
Two of the watched-reds fixed the CODE rather than the test — the mid-rewrite failure showed one bad file aborted the whole run, and the survey had the same hole a layer up. Suites: Darling 4033/0 against live PG 18.4 + TSDB 2.28.1, Lite 1927/0, The permanent residue is disclosed in three places (ComposeCompiler doc beside the #1849 boundary, CHANGELOG, and an operator action that leads with the timing). DARLING01 named. No deferrals — nothing further reaches it. |
…struction Per the lead: encode the evict-and-rebuild in the shared temp-sibling-swap helper itself rather than leaving it as a step a caller must remember. PromoteRewrittenFileAsync now owns the whole operation -- delete, move, evict, rebuild views -- because replacing the bytes at a path and making the store able to READ that path are one thing, not two. Any future in-place rewrite that goes through it is correct without its author knowing that DuckDB caches parquet state against the PATH at INSTANCE scope; one that hand-rolls File.Move instead silently breaks the archive it just fixed. The comment also records why the monthly archive cycle is deliberately NOT given the same treatment: that path only writes NEW file names and deletes old ones, never replaces the bytes behind a path that has been read, so it was never exposed -- and adding the eviction there would imply a hazard it does not have. WATCHED RED, and it separates the two halves cleanly: removing the eviction while KEEPING the view rebuild fails both archive tests with the exact production symptom, "No magic bytes found at end of file". The view rebuild alone does not save it, so the eviction is the load-bearing part. Also corrects the class doc, which still claimed the Lite repair is "operator-invoked, never automatic". That was true of the shape before the (a) decision and is now the opposite of what ships: Lite runs automatically once on first launch, Darling keeps the verb, and what the two apps share is the SEMANTICS of the fix rather than the invocation surface. Darling 4033 passed / 0 failed against live PG 18.4 + TSDB 2.28.1, Lite 1927 passed / 0 failed, full build 0 warnings / 0 errors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review: #1931 — Repair pre-#1907 Query Store split slicesScope reviewed: OverviewRepairs the stored-row half of the #1907 Query Store split-slice defect: builds before #1907 stored the flushed and still-in-memory slice of one
The collapse math (count-weighted mean via FindingsPosted as inline comments:
Other notes (not blocking)
(Per instructions, not flagging missing-index DMV recommendations — not applicable here anyway.) |
| long removed = 0; | ||
| var failures = new List<string>(survey.Unreadable); | ||
|
|
||
| using var readLock = _duckDb.AcquireReadLock(); |
There was a problem hiding this comment.
RepairAsync mutates query_store_stats here (CREATE OR REPLACE TEMP TABLE / DELETE / INSERT) but only takes AcquireReadLock(). Every other DuckDB writer in this codebase takes AcquireWriteLock() for mutations — ArchiveService.cs:159/591/681, DuckDbAlertHistoryStore.cs (write lock on every insert/update, read lock only on selects), DuckDbMuteRuleStore.cs, and RemoteCollectorService.cs:414 for CHECKPOINT. The lock's own doc comment (DuckDbInitializer.cs:22-24) says read locks allow unlimited concurrent access and write locks exist specifically to serialize maintenance writers — "archive DELETEs" is the example given — against UI readers.
Because this is a ReaderWriterLockSlim, a read lock does not exclude other read-lock holders, so a UI query can run concurrently with this DELETE+INSERT transaction. Since the repair is kicked off via Task.Run from MainWindow_Loaded right as the UI starts querying (see MainWindow.xaml.cs), that's not just theoretical.
Same issue for the marker writes at lines 250 and 268 (CREATE TABLE IF NOT EXISTS / INSERT into query_store_slice_repair) — those should also be AcquireWriteLock().
Relatedly, the comment in MainWindow.xaml.cs ("it takes the same read lock every other store operation does, so it serializes with collection rather than racing it") isn't accurate for the same reason — a shared read lock doesn't serialize concurrent operations against each other.
| File.Delete(originalPath); | ||
| File.Move(tempPath, originalPath); |
There was a problem hiding this comment.
File.Delete(originalPath) followed by File.Move(tempPath, originalPath) isn't atomic — a crash/kill between these two lines leaves the archive file permanently missing rather than merely "un-repaired." The verify-before-promote check happens before this point, so it doesn't cover this window, and this is a step down from the interruption-safety this PR advertises for the archive repair specifically ("Nothing is at risk if it is interrupted").
Since the target framework is net10.0-windows, File.Move(tempPath, originalPath, overwrite: true) (available since .NET 6) would make the promotion a single atomic call and close this gap. (The delete-then-move pattern already exists elsewhere — ArchiveService.cs:148-150, DataRootMigration.cs — so this isn't a new pattern in the codebase, but it's worth tightening here given the stronger safety claim made for this feature and the fact the framework already supports the fix.)
| output.WriteLine(); | ||
| output.WriteLine(refreshed == affected.Count | ||
| ? $" DONE — {removed:N0} row(s) collapsed, {refreshed} rollup(s) re-materialized." | ||
| : $" PARTIAL — {removed:N0} row(s) collapsed, {refreshed} of {affected.Count} rollup(s) re-materialized. The collapse itself is committed; re-run to retry the rollups."); |
There was a problem hiding this comment.
When some rollups fail to re-materialize, this reports "PARTIAL ... re-run to retry the rollups" — but a re-run won't actually retry them. SurveyAsync (called above at what's now line ~2176) only looks for the pre-fix split-slice signature (two-or-more rows sharing the whole dedup key + collection_time); once the collapse has committed, that signature is gone, so the next invocation hits the "Nothing to repair" branch and returns 0 before ever reaching this re-materialization loop.
So a rollup that failed here (e.g. a transient connection or lock-timeout error on RollupBackfill.RepairAsync) stays stale with no working recovery path through this verb — the operator is told to do something (re-run) that silently does nothing for the affected rollup.
|
PUSHED — Confirming: option (a) landed. There is no Lite UI and none was ever started. The "remaining is the Lite operator UI" phrase was from a message sent before the (a) decision arrived; Lite's half has been the automatic startup migration since, wired into Cache eviction moved inside the swap helper, as directed. Watched red, and it separates the two halves cleanly: removing the eviction while keeping the view rebuild fails both archive tests with the exact production symptom, Also corrected a stale claim the restructure surfaced: the class doc still said the Lite repair is "operator-invoked, never automatic", which was true of the pre-(a) shape and is now the opposite of what ships. Darling 4033/0 against live PG 18.4 + TSDB 2.28.1, Lite 1927/0, build 0 warnings. |
… per day Both bot findings, both real. 1. LOCK CORRECTNESS. RepairAsync ran the hot-table DELETE/INSERT and the archive Delete+Move under AcquireReadLock. The read lock permits concurrent readers BY DESIGN -- it excludes the collector, not the UI -- and RepairOnStartupAsync is fired un-awaited precisely so the UI stays interactive, so a UI reader can be mid-read_parquet on the exact file being swapped. That is a real race, not a theoretical one, and DuckDB answers it with "Reached the end of the file". Mirrors ArchiveService's shipped idiom exactly: read-only work under the read lock, AcquireWriteLock for the mutation. Write-lock spans are TIGHT AND PER FILE rather than one lock around the run -- reading the original and writing the temp sibling touches nothing a reader can see, so only the swap instants need exclusivity, and a store with a large archive must not have its UI frozen for the whole repair when the swaps are the fast part. Guarded by a source pin in the shape of the other #1907/#1912 pins: the mutating phases must appear inside write-lock scopes, the rewrite must not, and nobody may collapse it back to one lock over everything. Watched red -- reverting the hot collapse to the read lock drops the write-lock count from 2 to 1 and fails it. 2. LOCK DURATION (#1564/#1567 family). CollapseSliceAsync's doc promises short per-slice transactions so chunk locks do not fight the compression policy, but the call site handed it the entire survey span in one call -- one long transaction across however much history the store keeps, which is the exact family the doc was written against. Fixed at the CALL SITE: the verb loops per-day slices. A default 4-day raw tier is a handful of slices; a widened retention gets the protection the doc promises. Each slice is its own transaction, so a mid-run failure keeps the committed ones and the idempotent re-run continues -- the output says so. The nothing-left assertion after the loop is unchanged. Darling 4033 passed / 0 failed against live PG 18.4 + TSDB 2.28.1, Lite 1928 passed / 0 failed, full -t:Rebuild 0 warnings / 0 errors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| if (!survey.HasWork) | ||
| { | ||
| output.WriteLine(" Nothing to repair — no Query Store rows carry the pre-fix split-slice signature."); |
There was a problem hiding this comment.
Correctness: a partial run's own "re-run to retry the rollups" advice can't be honored.
The rollup re-materialization window (survey.OldestUtc/NewestUtc, used down at the Floor(...) calls) comes from this SurveyAsync() call — which only finds rows that still carry the pre-fix split signature. That's fine on a clean first run, but consider either of these partial-failure paths:
- The per-day collapse loop (line ~2258
while (sliceStart < collapseEnd)) throws partway through — earlier days are already committed (safe), but the method returns1before ever reaching the rollup-refresh block. - The collapse fully succeeds, but one target's
RollupBackfill.RepairAsyncthrows in theforeach (var target in affected)loop below — you printPARTIAL — ... re-run to retry the rollups.
In both cases, re-running the verb calls SurveyAsync() again. Since the raw rows for the already-collapsed days no longer carry the split signature, survey.HasWork is false, so execution hits this early return and prints "Nothing to repair" — the rollup refresh step is never reached again. The tool's own printed guidance to "re-run to retry the rollups" doesn't actually work once the raw collapse has already succeeded; the operator's only way to fix a stale rollup after this is to fall back to --backfill-rollups (not obviously implied by the message here).
Worth persisting the collapsed range independently of the split-signature survey (or explicitly telling the operator to use --backfill-rollups if a rollup refresh fails), so a retry can still find the range that needs re-materializing even after the raw side is already fixed.
| private async Task PromoteRewrittenFileAsync( | ||
| DuckDBConnection connection, string originalPath, string tempPath, CancellationToken cancellationToken) | ||
| { | ||
| File.Delete(originalPath); | ||
| File.Move(tempPath, originalPath); | ||
|
|
There was a problem hiding this comment.
Non-atomic swap contradicts the "nothing is at risk if interrupted" claim.
File.Delete(originalPath);
File.Move(tempPath, originalPath);If the process dies between these two lines (crash, kill -9, power loss, antivirus lock), the archive file is gone entirely — not merely "un-repaired," but missing, with no original and no promoted replacement. That's a stronger failure than what the CHANGELOG and the class doc above promise: "Nothing is at risk if it is interrupted... A file that fails verification is left exactly as it was." The verification step (checked just before this) does guard against a bad rewrite, but not against a crash during the swap itself.
Since this targets net10.0-windows, File.Move(tempPath, originalPath, overwrite: true) (available since .NET Core 3.0) removes the gap — no delete step, so there's no window where neither file exists at originalPath.
(This mirrors the existing delete-then-move idiom already in ArchiveService.cs — e.g. around its own monthly-file promotion — so it's not a new pattern introduced here, but this feature is the one making an explicit interruption-safety guarantee, so it's the one place where tightening it seems most worth it.)
| itself, not a bound past it — hence the final slice's one-second nudge. */ | ||
| long removed = 0; | ||
| var sliceStart = survey.OldestUtc!.Value.Date; | ||
| var collapseEnd = survey.NewestUtc!.Value.AddSeconds(1); |
There was a problem hiding this comment.
Low-confidence / theoretical: refresh window is derived from a survey taken before the collapse and refresh run, not re-checked against retention at refresh time.
The whole safety argument for the "refresh is clamped to the rows actually collapsed" property (documented above and pinned by QueryStoreSliceRepairLiveTests) rests on "collapsed rows are inside raw's extent by definition." That's true at the instant SurveyAsync measures OldestUtc/NewestUtc. But this command then runs a per-day collapse loop and, afterward, a separate rollup-refresh pass — both against a live store where a retention drop_chunks job could run concurrently (this is a long-lived CLI process talking to a store the Darling service may still be actively managing).
If a retention sweep drops the chunk containing survey.OldestUtc between the initial survey and the later refresh calls (Floor(survey.OldestUtc!.Value, ...)), the refresh would target a range that has, by then, fallen below raw's floor — exactly the "refresh entirely within dropped chunks destroys the materialization" scenario the live test pins as catastrophic. This is a narrow window (command should typically run in seconds), and I don't have certainty about the retention job's actual cadence/locking here, so flagging as a possible edge case worth a second look rather than a confirmed bug.
Review summaryThis PR adds a repair for the pre-#1907 Query Store split-slice defect: a Darling operator verb ( Lite/Darling parity: The invocation-surface asymmetry (Lite = automatic on startup, Darling = explicit verb) is deliberate and explained in the PR description/CHANGELOG - same collapse semantics, same weighted math, same disclosure, different trigger per each app's own established precedent. No unintentional parity drift found; the two Findings posted inline:
Other notes (minor, not filed inline):
Test coverage looks strong - live-Postgres tests pin the destructive-refresh-below-raw's-floor behavior, mutation testing is called out in the description, and the Lite tests assert on real archive rewrites rather than mocks. No SQL-injection or secrets-handling concerns - all identifiers are compile-time constants from the collector's own schema, not user input. |
|
BOTH FIXED — 1. Lock correctness. The mutating phases now take Write-lock spans are tight and per file, not one lock around the run. Reading the original and writing the temp sibling touches nothing a reader can see, so only the swap instants need exclusivity — a large archive repair must not freeze the UI for its full duration when the swaps are the fast part. Guarded by a source pin in the shape of the other #1907/#1912 pins. Watched red: reverting the hot collapse to the read lock drops the write-lock count 2 → 1 and fails it. 2. Lock duration (#1564/#1567 family). Fixed at the call site, not the doc: the verb now loops per-day slices over the survey span. A default 4-day raw tier is a handful; a widened retention gets the protection Darling 4033/0 against live PG 18.4 + TSDB 2.28.1, Lite 1928/0, PostgreSQL on 5619 is down, verified: connection refused, zero listeners. |
Fixes #1912.
#1907 fixed Query Store's split slices at collection and made the read deterministic. It could not touch
what was already stored. This repairs that — fully in Lite, and as far as raw reaches in Darling — and
discloses, plainly, the part that is permanent.
The defect, in the stored rows
sys.query_store_runtime_statsreturns the flushed and the still-in-memory slice of oneruntime_stats_interval_idas two additive rows. Builds before #1907 stored both, so every read thatkeeps one row per interval reports a fraction of that interval's work, and every rollup materialized from
those rows carries that fraction. On the live evidence: 8 executions where 94 was true.
The pre-fix signature is exact — two or more rows sharing the entire dedup key and
collection_time.No row collected since #1907 can match it, because the collector now emits at most one row per interval per
cycle. That single fact is what makes the repair idempotent, safe to re-run, and incapable of touching
correctly-collected data.
Darling:
--collapse-legacy-slicesAn operator verb, mirroring
--backfill-rollups(#1849): same Windows/managed-credential guard, same--dry-run, same compiler-required disclosure sink (#1797). It surveys, collapses, then re-materializes therollups the collapsed rows fed.
Column handling is derived from
QueryStoreCollector.PayloadColumnsand each column's declared typerather than restated —
execution_countsums, everyavg_takes the count-weighted mean,min_/max_takethe extreme. That derivation paid for itself on the first live run: PostgreSQL has no
min(boolean), andbecause the classifier reads the declared type,
bool_orwas a one-line fix rather than a hunt.The refresh clamp is a safety property
Measured on PostgreSQL 18.4 + TimescaleDB 2.28.1, three fresh CAGGs in identical state, one variable each:
force => TRUEforce => TRUEA refresh whose range lies entirely within dropped raw chunks destroys the materialization there, with
forceand without. My first probe said history survived; that was an artifact of the range spanning intolive data, which is why it took a single-variable design to see. Aiming at a nominal "pre-fix period" would
therefore have blanked the 21-day hourly and the indefinitely-kept daily below raw's floor — the one thing
#1759/#1793 forbid. The verb derives its window from the rows it actually collapsed, which are inside raw's
extent by definition, so safety falls out of the data flow instead of a bolted-on check. Pinned as a live
test, so a future TimescaleDB that makes it safe fails loudly rather than quietly widening the blast radius.
The shipped
--backfill-rollupswas checked for the same exposure and is safe by construction — itconverges down to
min(collection_time)of raw and never refreshes below it. No bug to file.Also measured: DML against compressed chunks works on 2.28.1 and leaves them compressed (DELETE 600 rows
6.0 ms, INSERT 6.4 ms, UPDATE 0.6 ms), so the collapse reaches the older half of the raw window without a
decompress cycle.
Lite: automatic, on the first launch after upgrading
Per the lead's call, and the parity argument is that consistency means the same semantics for the same
defect — same collapse, same weighted math, same disclosure — not the same invocation surface. Each app
follows its own precedent: Darling's is #1849's verb, Lite's is the automatic startup store migration that
v39 and #1832's data-root move established. A button would leave the users least equipped to find it holding
wrong numbers permanently.
Lite gets a FULL repair where Darling gets a bounded one, because Lite's long tier IS the parquet
archive — the same rows, rewritable — rather than a materialized rollup that cannot be rebuilt from raw that
no longer exists. Measured against a real archive: ~46 MB across three monthly files, 6.6–8.5% of rows in
split groups with exactly 2 rows per group every time, the largest file (28 MB / 533,109 rows) rewriting in
under a second. A real group verified: (4 execs @ 161093µs) + (10 @ 203358µs) → 14 @ 191282µs, the
count-weighted mean exactly.
Migration discipline (#1832 / v39 pattern)
Each file is rewritten beside the original and promoted only after two conservation invariants — row
count landing on exactly
rows - rowsRemoved, andSUM(execution_count)unchanged, which is theinvariant the collapse's own arithmetic guarantees and the one that would catch a bad aggregate that still
produced the right row count. No backup copies: verify-before-promote is the safety.
Failures are isolated per file, in the rewrite and in the survey. Both of those came from tests catching
real gaps rather than foresight: the mid-rewrite failure test showed the first bad file aborted the whole run
— and since files process in name order, one bad old file could permanently block every newer one — and a
second run showed the same hole in the survey. The marker is written only on a fully successful pass, so a
partial repair retries next launch.
The marker is its own table, not a schema-version bump, for two load-bearing reasons:
RunMigrationsAsyncdrops and recreates tables per version step, so a data repair has no business there; and withholding the
schema version to force a retry would re-run those drops.
The DuckDB finding that would have shipped silently
An in-place parquet rewrite poisons DuckDB's external file cache. It keys on path at instance
scope, so replacing the bytes at a path already read makes every later read fail with
No magic bytes found at end of file— on new connections too — until the process restarts. That would have left Lite unable toread the archive it had just repaired.
The instance scope is the trap: my first isolation used separate in-memory instances, which made it look
per-connection and harmless.
PRAGMA clear_cachedoes not exist andenable_object_cacheis a differentcache; toggling
enable_external_file_cacheoff and back on evicts the entries and leaves caching enabled.Verified standalone.
Two smaller ones, also caught by running it: DuckDB widens
SUM(BIGINT)to HUGEINT and returns aBigIntegerthatConvert.ToInt64refuses (the #1839 trap), cast in SQL; andArchiveService's 4GBmemory_limitraise is nowinternaland reused rather than copied, because that floor is aknown-broken-below-2GB constraint and a second drifting literal is exactly how #942 broke compaction.
What is permanent, and said plainly
Darling's repair reaches only what raw still holds — a few days. Query Store numbers older than that stay
understated for the life of the store, because the daily tiers are kept indefinitely and cannot be rebuilt
from raw retention has dropped. No ordering of these operations reaches further back.
That is disclosed in three places: the #1845-style annotation in
ComposeCompiler's doc beside the #1849boundary it shares, the CHANGELOG, and a CHANGELOG operator action that leads with the timing — the verb
gets less useful every day you wait, so run it right after upgrading. DARLING01 named explicitly.
Testing
Watched red per mutation, each reverted:
KeyColumnsForassumes the newest schemaBinder Error: Referenced column runtime_stats_interval_id not found in FROM clauseThe era-schema fixture is a parquet written without
runtime_stats_interval_idorreplica_role,matching the real June 2026 archive file, so the era-appropriate key has to engage.
Live PostgreSQL 18.4 + TimescaleDB 2.28.1 — rollup shows a slice before the repair; the survey finds
exactly the split cycles; the collapse leaves a correctly-collected interval untouched; a re-run is a no-op;
after re-materializing the rollup reports the interval's true 125 with the weighted mean 1871 (not
the 2011 an unweighted average gives). Plus the clamp-safety pin and the verb's dry-run → repair →
nothing-left cycle.
Live DuckDB — hot-store collapse, old-era archive rewrite, failure isolation, and the startup
marker/retry contract.
Archive discipline: every archive in the tests is a scratch file the test wrote into a temp directory it
deletes. The measurements that justified this work were taken against Erik's real archive by copying it;
the originals still carry their original mtimes.
Suites: Darling 4033 passed / 0 failed against live PG (10 skipped = gated); Lite 1927 passed / 0
failed. Full-solution
-t:Rebuild: 0 warnings, 0 errors. Installer.Tests deliberately not run.Deferred
None. The permanent residue is a disclosure rather than a deferral — no further work reaches it.
🤖 Generated with Claude Code