Skip to content

Add --recompress-plan-dim: convert pre-V54 plan-dim text rows to gzip (#2076) - #2077

Merged
erikdarlingdata merged 2 commits into
devfrom
recompress-plandim-2076
Aug 5, 2026
Merged

Add --recompress-plan-dim: convert pre-V54 plan-dim text rows to gzip (#2076)#2077
erikdarlingdata merged 2 commits into
devfrom
recompress-plandim-2076

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

Closes #2076.

What

An optional operator verb for stores that collected before V54: converts the plan dimension's existing text rows to the gzip form new plans already use (#2069), in bounded batches, while the service runs — no stop, no outage. --dry-run surveys the store and measures the real compression ratio on a sample of its own pending rows before anything is written.

Why a verb (and not the migration)

Attrition converts only rows that die, and a stable plan's row never dies — every sighting refreshes last_seen, so a long-running store keeps a permanent text tail at the lz4 rate (~15 KB/plan vs ~10 KB measured). Rewriting the store's largest table is an operator decision (--collapse-legacy-slices rationale), not a migration side effect.

Safety properties

  • Resumable by construction: the fetch predicate (query_plan_xml IS NOT NULL AND query_plan_gz IS NULL) is the resume point; each 1,000-row batch is one transaction; a second run converges to a no-op (pinned live).
  • Round-trip verified: every row's gzip bytes are decompressed and compared to the original text before the text is nulled; a failing row keeps its text and the run exits 1 with a count.
  • Digest and last_seen untouched: identity rides the uncompressed text (Gzip the plan-XML dimension content at the application level — measured 14.0x vs lz4's 8.9x on live content #2069's rule), and recompression is not a sighting — stamping the watermark would silently extend retention (pinned: the UPDATE contains no last_seen).
  • Concurrent collector writes only ever touch these rows via ON CONFLICT ... SET last_seen; the UPDATE re-checks query_plan_xml IS NOT NULL under the row lock.

Wiring

Verb predicate + IsKnownVerb (the #1581 reflection tripwire picks up the classifier by name), help text, Program dispatch with the same Windows/DPAPI guard and --dry-run parse as the sibling verbs.

Tests

Pure pins: table constant is the one compressed-content dim, fetch/update/survey statement shapes, one-unnest-statement batch, no last_seen in the update, PG dialect. Gated live test: seeds three text rows (incl. non-ASCII) + one gz row, then pins verified conversion, original-text round-trip through DecompressContent, untouched last_seen (t0) and gz bytes, and second-run convergence.

Disclosed limit

Converts live content — the relation file does not shrink (PostgreSQL reuses the freed space internally). Returning space to the volume is a separate one-time VACUUM FULL/repack, the operator's call. New installs never need this verb.

🤖 Generated with Claude Code

erikdarlingdata and others added 2 commits August 5, 2026 19:09
…#2076)

V54 (#2069) gzips new plan content only; existing rows convert by GC
attrition, and a STABLE plan's row never retires (every sighting
refreshes last_seen) -- so a long-running store keeps a permanent text
tail at the lz4 rate. This verb is the operator-paced rewrite the
migration deliberately refused to do implicitly, following the
--collapse-legacy-slices pattern: runs when a person decides, --dry-run
first (which measures the real ratio on a sample of the store's own
pending rows before anything is written).

No outage: the service stays up throughout. 1,000-row batches, one
transaction each, one unnest-array UPDATE per batch; the fetch
predicate (text NOT NULL AND gz NULL) is the resume point, so an
interrupted run continues from the remainder and a completed one
converges to a no-op. Every row's gzip bytes are round-trip verified
against the original text before the text is nulled; failures keep
their text and are reported (exit 1). Digest and last_seen are both
deliberately untouched: identity rides the uncompressed text, and
recompression is not a sighting -- stamping the watermark would push
GC-eligible rows a full retention window forward.

Wiring: verb predicate + IsKnownVerb (the #1581 reflection tripwire
covers the classifier automatically), help text, Program dispatch with
the Windows/DPAPI guard and --dry-run parse the sibling verbs use.

Tests: pure pins (table constant, fetch/update/survey shapes, no
last_seen in the update, PG dialect) and a gated live test seeding
text + gz rows, then pinning verified conversion, original-text
round-trip, untouched last_seen and gz rows, and second-run
convergence.

Disclosed limit (CHANGELOG): converts live content; the relation's
file does not shrink -- freed space is reused internally, and a later
VACUUM FULL/repack returns it to the volume if wanted.

Closes #2076

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tiny fixtures compressed LARGER than their text (gzip framing beats a
60-char payload), failing the smaller-than-text assertion. Real plans
are KBs; the fixtures now carry ~6 KB of operator-block bulk so the
assertion tests the property it names.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment on lines +200 to +203

/* Every fetched row either converted or failed verification. If ALL failed, stop rather than
spin on the same rows forever — the fetch would return them again. */
if (good.Length == 0)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Correctness: a single fully-failing batch permanently stops the whole run, not just that batch — and re-running doesn't recover.

FetchBatchSql has no ORDER BY, and nothing in the loop excludes previously-attempted digests, so if a batch of up to 1,000 rows all fail round-trip verification, good.Length == 0 and the method breaks out of the entire ConvertAsync call — not just that batch. If those failing rows happen to be physically clustered early in the heap (e.g. a bulk-loaded batch that shares some encoding quirk causing lossy UTF‑8 round-tripping, such as unpaired surrogates), every subsequent pending row in the table — potentially millions of good, convertible rows — is never even attempted, in this run or any future run, because:

  • query_plan_xml IS NOT NULL AND query_plan_gz IS NULL still matches the failed rows (they were never converted), and
  • with no ORDER BY, a plain re-fetch after a restart will very likely return the same first-scanned batch again, fail the same way, and abort again at the exact same point.

This contradicts the doc comment on ConvertAsync ("Safe to interrupt and re-run at any point") and the CLI's own guidance ("Investigate before re-running") — re-running doesn't make progress past the bad cluster, it just repeats the same abort.

Suggested fix: don't abort the whole run on a fully-failed batch. Either (a) continue the loop past it while tracking already-verify-failed digests to exclude from subsequent fetches within the same run (e.g. AND NOT digest = ANY($2::bytea[])), or (b) only break when no progress at all has been made across several consecutive attempts, rather than after the very first all-bad batch. As written, one contiguous bad cluster can silently cap conversion of the whole dimension.

Comment on lines +178 to +190
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
await using var update = new NpgsqlCommand(UpdateBatchSql, connection, transaction);
update.Parameters.Add(new NpgsqlParameter
{
NpgsqlDbType = NpgsqlDbType.Array | NpgsqlDbType.Bytea,
Value = good.Select(row => row.Digest).ToArray(),
});
update.Parameters.Add(new NpgsqlParameter
{
NpgsqlDbType = NpgsqlDbType.Array | NpgsqlDbType.Bytea,
Value = good.Select(row => row.Gzip).ToArray(),
});
await update.ExecuteNonQueryAsync(cancellationToken);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Minor: unlike other long-running batch operations in this project (PgMigrations.MigrationCommandTimeoutSeconds, TimescaleSupport.SetupTimeoutSeconds/BackfillTimeoutSeconds, RollupBackfill.SliceTimeoutSeconds, DarlingRetention.DeleteTimeoutSeconds), neither the fetch nor this batch UPDATE sets an explicit CommandTimeout, so both fall back to Npgsql's 30s default. A 1,000-row bytea-array UPDATE moving up to ~130 MB (per the BatchSize doc comment above) plus the full-table COUNT(*)/pg_total_relation_size scan in SurveyAsync on a 100+ GB dimension could plausibly exceed 30s under load/contention, turning a routine batch into a spurious "Conversion failed mid-run" error. Worth an explicit elevated timeout to match the convention used elsewhere for comparable batch/DDL work.

@claude

claude Bot commented Aug 5, 2026

Copy link
Copy Markdown

Review: --recompress-plan-dim (#2076)

Overview

Adds an operator-invoked Darling verb that converts pre-V54 query_plan_dim text rows to the gzip form V54 already writes, in 1,000-row batches, each its own transaction, with round-trip verification before nulling the text column and a --dry-run mode that samples and measures the real compression ratio without writing. Wiring (IsRecompressPlanDimVerb, IsKnownVerb, Program.cs dispatch, Windows/DPAPI guard) follows the exact template of --collapse-legacy-slices/--backfill-rollups line-for-line — no deviations found there.

Lite/Darling parity

No parity issue: this is legitimately Darling-only. Lite has no query_plan_dim, no content-addressed dimension/digest design, and no gzip-of-plan-XML concept at all — it fetches plan XML live from SQL Server DMVs and stores its own history in DuckDB/Parquet, which gets Parquet's own columnar compression. Grepping Lite/ for query_plan_gz, query_plan_dim, CompressContent, PayloadDimensions returns nothing, confirming there's no counterpart to keep in sync.

Correctness issue (see inline comment)

ConvertAsync's loop aborts the entire run — not just the current batch — the first time a fetched batch has zero verified rows (PlanDimRecompression.cs:201-206). Since FetchBatchSql has no ORDER BY and failed rows never leave the pending predicate, a physically-clustered run of bad rows (e.g. a bulk-loaded batch sharing an encoding quirk) can permanently cap conversion at that point: every subsequent pending row — potentially the vast majority of the table — is never attempted, and re-running repeats the identical abort rather than resuming past it. This undercuts the documented "safe to interrupt and re-run at any point" claim. Flagged inline with a suggested fix (track/exclude already-failed digests within the run, or require several consecutive no-progress attempts before giving up rather than aborting on the first).

Secondary/minor

  • Neither the fetch nor the batch UPDATE sets an explicit CommandTimeout, unlike sibling long-running batch work elsewhere in Storage (PgMigrations, TimescaleSupport, RollupBackfill, DarlingRetention), all of which set an elevated timeout for comparable batch/DDL operations. A large multi-hundred-KB batch UPDATE or the full-table SurveyAsync scan could plausibly exceed the 30s Npgsql default on a big/contended store. Flagged inline.
  • No test exercises the all-batch-verification-failure path (the correctness issue above), only the happy-path convert/resume/converge scenario in the gated live test.

Security

Parameterized statements throughout ($1/$2 positional, arrays via unnest), no string concatenation of user/DB input into SQL — no injection concerns. Windows/DPAPI credential guard matches the established pattern for the other privileged verbs.

Other notes

  • PlanDimRecompressionTests.cs pins are solid (statement shapes, last_seen omission, PG dialect, table constant) and the gated live test (PayloadDimensionLiveTests) exercises convert + round-trip + untouched last_seen/gz rows + second-run convergence — confirmed this runs pre-merge via the darling-pg CI job, not just nightly.
  • Digest/GC-watermark handling (no last_seen in the UPDATE, digest computed pre-compression) is correctly reasoned and pinned — recompression genuinely can't disturb dedup, fact references, or retention timing.

@erikdarlingdata
erikdarlingdata merged commit 1d45597 into dev Aug 5, 2026
5 checks passed
@erikdarlingdata
erikdarlingdata deleted the recompress-plandim-2076 branch August 5, 2026 17:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant