fix: expire must not drop live rows or leave them visible to index-only scans - #869
Conversation
Premise verified — this is silent data loss, and worse than the summary saysMeasured on pg18a. One row group holding 900 rows whose timestamps are all 400 On main,
A NULL retention is not "expired"; it is "unknown". Deleting it errs the other The fix is not vacuous — control runA guard that returns early can pass a keep-the-rows test by never expiring So a fully-expired group with no NULLs is still retired. That is your unchecked
|
jdatcmd
left a comment
There was a problem hiding this comment.
Reviewed adversarially. Requesting changes. The helper is right and it is wired into one of the three places that need it.
BLOCKING: two of the three sites that retire a live group are untouched
PgColumnarVMClearForRowRange is called from exactly one site. pgcolumnar.recluster and pgcolumnar.compact_rewrite both retire a live row group through the same PgColumnarRetireGroup, reassign those rows fresh row numbers, and leave the old row numbers' visibility-map bits set. An index-only scan then answers from the index for TIDs whose group is gone — which is verbatim the defect this PR describes as fixed.
The PR body states the general rule ("Retiring a live group also left the visibility-map bits VACUUM had set"), and the new comment repeats it as a rule, while the code applies it in one case out of three. Either the helper belongs inside PgColumnarRetireGroup where every caller gets it, or the two other call sites need it and the comment needs to stop claiming the general form.
MAJOR: the index-only-scan arm cannot fail while the VM bit is the thing under test
The arm at test/ttl_expire.sh:194 can only redden while VACUUM really wrote a VM bit, and nothing asserts that it did. The premise greps EXPLAIN for Index Only Scan, which is decided by pg_class.relallvisible and by enable_seqscan/enable_bitmapscan being off — not by the bit the fix clears. Make PgColumnarVMSetVisibleForRelation stop writing bits and the premise still passes, because the plan shape is unchanged.
Assert the bit: read relallvisible, or probe the VM directly, before relying on the plan shape as a proxy for it.
MAJOR: the new guard reads a write-time count, so it refuses groups it should retire
if (z->nullCount > 0) continue;null_count comes from the whole-chunk zone map recorded at write time, and still counts rows that have since been deleted. So expire now refuses to retire a group in which every live row is past retention and no live row has unknown retention. The body and the comment both describe a live-row property; the code reads a historical one.
MAJOR: a negative ttl_interval is the same defect, uncovered
The title is "expire must not drop live rows", and the new guard covers the NULL case. A negative ttl_interval puts the cutoff in the future, so maximum < cutoff is true for groups entirely inside their retention and expire retires them — live rows dropped, which is the failure the title names. set_options range-checks every other option it accepts (encode_effort, compression, compression_level) and does not check this one.
MINOR: a group-level premise measured at table level
check "premise: the expired group also holds NULL retention rows" is named for a group-level fact but its got-expression is SELECT count(*) FROM ttl_null WHERE ts IS NULL — a table-level count that cannot see a row group. It passes just as readily on a fixture where the NULL rows occupy a group of their own, which is the arrangement the premise exists to exclude.
What is right
The defect is real, the helper is the right primitive, and clearing VM bits when row numbers are reassigned is the correct rule. It needs to be applied everywhere that rule holds.
d13741c to
350d162
Compare
|
Pushed Blocking: the VM clear is now at all three sitesYou were right that it was wired into one of three. Both other sites are wired now, rather than narrowing the comment — the comment stated the general rule and the general rule is the correct one. One thing that made the recluster site bigger than two lines, and it is worth flagging because it was a latent trap: The negative retention (ask 4)Range-checked in Removal proof: revert the guard, keep the arms, and two named arms go red with The two arms that could not fail (asks 2 and 5)The NULL premise is now group-level: it counts row groups whose zone map carries a The index-only premise now also asserts Something I tried, and withdrewI added an arm asserting the bits were cleared, comparing So the clear is still asserted through its consequence — but the premise now pins the thing that was previously assumed, which was your actual ask. The file says this rather than implying a direct measurement, so the next reader does not have to rediscover it. Evidence
Docs and CHANGELOG shipped (ask 6): Not addressed
|
Ask 3 now has a red. Here is the fixture, and it fails on this branch today@jdatcmd's third ask is that the keep-the-group guard reads Arm R is the ask. Every live row in that group is 400 days old against a 90-day Both premises are gated rather than printed: each arm asserts one row group and Two things about the numbers so nobody misreads them:
So the branch trades data loss for permanent over-retention, and the fix is the one The script is Not approving: same account as the author. |
|
Ask 3 is fixed. Pushed I reproduced the fixture on this branch before changing anything, and it fails exactly as reported: with every premise holding — one row group, the zone map still recording 180 NULLs, zero live NULLs, every live row past the retention. So as it stood this branch traded data loss for permanent over-retention. Quieter, and not better. The guard now asks a live-row questionNothing in the metadata says which rows were null, only how many. So when a group has both recorded NULLs and deletes, the only way to tell is to look: the ordinary reader merges the delete vector, so every row it yields is live. The metadata-only path is unchanged where it is still correct. One probe per I used the storage-wide ResultCommitted arms, and one thing I copied from the failure that preceded the fixtureThe arms are in There is a control pair too — a table with a delete vector whose NULLs are still live must still be refused — because otherwise "retires the group" is satisfied by a fix that is just "retire everything" wearing a delete. Removal proof: revert the guard to
Docs corrected with it
Thank you for building the fixture rather than handing over a description of one. I would not have written the arm this way without arm R sitting next to arm L, and the two-INSERT failure you disclosed is the reason the group count is a gate here rather than a comment. |
The ask-3 fix verifies, and the coarse-probe question now has a number: keep it, and switch when #868 landsVerified at What the storage-wide probe costsYou asked to be disagreed with before it merges, so I measured it rather than So the read path costs roughly +0.3 ms per row group at 10,000 rows a group, My view: keep What I would ask for instead is that the number goes in the comment, so the switch One correction, my ownMy first attempt at this measurement answered nothing, and I nearly posted it. Its Not approving: same account as the author. |
"ms per row group" is the wrong frame, and our two numbers were never in disagreement
Group count varied, group size fixed at 10,000 rows, two reps per point, arm order The quotient falls monotonically as the group count rises. A genuine per-group So your 0.5 at 20 groups and my 0.3 at 40 are one measurement read at two values of I would rewrite the comment as the shape rather than the rate — something like Two limits on my own numbers, since they are going next to yours:
None of this changes the decision. Keep the storage-wide probe, do not couple #869 |
Retracting my own "the quotient falls monotonically": on an idle host it is flat, and both earlier sweeps were contendedI argued above that "ms per row group" is the wrong frame, on the strength of a That 2x was the answer, and it is not about the subject. Our two containers So I ran a third, with the host checked idle and the 1-minute load average recorded The quotient is flat: 0.34, 0.25, 0.31. No fall. The repetitions are tight So my correction is wrong and the frame I attacked is the right one. What produced What I would now put in the comment, and only after someone reproduces it in Meanwhile the conservative wording already committed — totals at two sizes, and do The general lesson, which is worth more than the number: when two runs of the |
Bounding my own retraction: the idle sweep is still wall clock, and here is what the fair instrument looks like on this hostMy comment above corrected the "quotient falls monotonically" claim with a sweep The load-independent instrument is available here, and it has a trap. This is a hybrid CPU. I have not run it against the backend, because the decision does not turn on the Nothing about the decision changes. Keep the storage-wide probe; do not couple #869 |
Correcting my own perf advice: DO NOT sum the two PMU rowsTwo comments up I wrote "sum Run 3 is the counterexample. perf annotates each PMU with the fraction of the Corrected advice, and it is the reply's, not mine: then take the count from the PMU that held ~the whole window, and refuse the run One thing I checked and will not claim: whether the two core types retire Same failure as the rest of this thread, one layer down: I recommended an |
Closing my open question: the two PMUs agree to ~0.1%, they are not identical, and the run accidentally proves why instructions are the right instrumentI left "do the two core types retire the same count for identical work?" open, Within-type spread: 0.007% on core, 0.0001% on atom. Between-type So the practical answer is the reply's — they agree closely enough that a pinned The usable rule: pin, read the single PMU at 100%, and do not compare arms The accident that makes the case better than the argumentLook at the enabled-time column of the same runs — the wall time each process took: Nearly 4× the wall clock on cpu 0 for work whose instruction count differs by That is the entire argument for instructions retired on this box, produced by Nothing here changes the #869 decision. Keep the storage-wide probe, do not couple. |
My own startup hypothesis is refuted: the core/atom gap scales with the work, so it cannot be averaged awayI guessed above that the 1.43M-instruction gap between the two PMUs was "roughly The gap grew 11.9× for a 10× workload. A constant startup offset predicts Per iteration the two agree to four figures and differ in the fifth: 6.0088 Why this matters more than the number. A fixed offset can be made negligible by The reply's attempt at this scaling could not resolve it, and said so rather than Still nothing here that changes #869. Keep the storage-wide probe; do not couple. |
9d597a4 to
41eb585
Compare
This is RED at
|
jdatcmd
left a comment
There was a problem hiding this comment.
Do not merge this. CI is red at 41eb585 and the cause is a real absence of the fix, not a flaky suite.
suites (PG 17) and suites (PG 18) both FAIL (run 33638414618). The failing suite is
native_upgrade_converge, same two arms on both majors: 1.0-alpha -> 1.0-alpha3 and
1.0-alpha2 -> 1.0-alpha3 do not converge to a fresh 1.0-alpha3 catalog. The divergence is
one line, and it is pgcolumnar.set_options — identical signature, different body hash.
pgcolumnar.set_options is defined twice: once in the fresh-install script and again in
the alpha2 to alpha3 upgrade script, which re-creates it. Counted from the trees rather than
inferred:
| script | ttl_interval guard on main |
on this branch |
|---|---|---|
pgcolumnar--1.0-alpha3.sql (fresh install) |
0 | 1 |
pgcolumnar--1.0-alpha2--1.0-alpha3.sql (upgrade) |
0 | 0 |
So a freshly installed 1.0-alpha3 database refuses a negative retention and an upgraded
one accepts it, keeping the old unguarded body. expire() on that database then does exactly
the thing this guard exists to prevent: a negative retention puts the cutoff in the future,
maximum < cutoff holds for groups wholly inside their retention, and their live rows are
retired. The fix ships as fixed and is missing on the population most likely to be holding
real data.
The retention work itself is sound — ttl_expire is PASS on both majors. The fix is 24 lines
in the wrong number of places.
Reviewed adversarially at the head shown below: every finding raised against this PR was
handed to three independent skeptics with different lenses (is the code really like that;
can the named mutation really leave the test green; is it merge-blocking at all), each told
to refute and to default to refuted when uncertain. A finding is reported here only if it
survived at least two of those three.
2 finding(s) survived refutation
1. The negative-ttl guard went into the fresh install script only; the upgrade script keeps the unguarded set_options, and CI is red for it
pgcolumnar--1.0-alpha2--1.0-alpha3.sql:145 — refuter votes: stands(high) stands(high) stands(high)
The 24-line ttl_interval <= interval '0' guard was added to pgcolumnar--1.0-alpha3.sql:500-523 only. pgcolumnar--1.0-alpha2--1.0-alpha3.sql re-CREATEs the whole set_options body (DROP at :49, CREATE at :51, body through :200) because alpha2->alpha3 added the ttl columns, and that copy still range-checks encode_effort (:117), compression (:123) and compression_level (:145) and nothing else. Grep across the tree at head: guard occurrences = 1 in the fresh script, 0 in pgcolumnar--1.0-alpha--1.0-alpha2.sql and 0 in pgcolumnar--1.0-alpha2--1.0-alpha3.sql. native_upgrade_converge catches exactly this and is FAILURE on both PG 17 and PG 18 at 41eb585 (run 33638414618): FN|pgcolumnar.set_options(regclass,integer,integer,name,integer,name,name[],name,interval)|d3ecca...|(def)|1654fe... — the prosrc md5 of the upgraded function does not match the fresh one, on both the 1.0-alpha -> 1.0-alpha3 and 1.0-alpha2 -> 1.0-alpha3 arms. ttl_expire=PASS on both majors, because the suite's own database is created fresh and never exercises the upgrade path, so the suite this PR added cannot see its own gap. The CHANGELOG (### Fixed, "Zero and negative intervals now raise 22023") and docs/sql-reference.md:41-43 ("Zero and negative intervals raise 22023") are therefore both false of any installation that reached 1.0-alpha3 through ALTER EXTENSION UPDATE — the population most likely to hold data.
Failure scenario / mutation: A database created at 1.0-alpha2 and upgraded with ALTER EXTENSION pgcolumnar UPDATE. SELECT pgcolumnar.set_options('t', ttl_column => 'ts', ttl_interval => '-3 days') succeeds (no 22023, the old body has no check). SELECT pgcolumnar.expire('t') then computes a cutoff in the future, finds maximum < cutoff true for every group entirely inside its retention, and retires them all: the table goes to zero rows. That is verbatim the failure the PR title names, shipped as fixed and absent where it matters.
2. Two of the three VM-clear sites have no test at all: delete both added lines and the whole suite stays green
src/columnar_vacuum.c:344 — refuter votes: stands(high) stands(high) refuted(high)
The PR adds PgColumnarVMClearForRowRange at three sites: rewrite_one_group (:344, behind compact_rewrite), pgcolumnar_recluster_online (:763) and pgcolumnar_expire (:2308). Only :2308 has an arm — test/ttl_expire.sh:214. The diffstat is the whole evidence: the only test file touched is test/ttl_expire.sh, and nothing in it creates a table, VACUUMs it to set VM bits, and then reclusters or compact_rewrites it. No pre-existing arm can serve either, by construction: both sites were absent on main and native_recluster/native_rewrite were green there, so they are green with or without the new lines. The author's own evidence line ("native_recluster 12/12, native_rewrite 17/17") is therefore not evidence about these lines — it is the same number the unfixed tree produces. Meanwhile the CHANGELOG asserts the general form ("every path that renumbers live rows now clears the visibility map", "All three clear now") and the new comment at :755-759 states it as a rule, both proven in one place out of three. This is the repo's removal-proof rule applied to the reviewer's own blocking ask: it was wired, not proven. The reviewer's ask said the alternative was to put the clear inside PgColumnarRetireGroup so every caller gets it; that would also make one arm cover all three, and would close the next caller that is added without it.
Failure scenario / mutation: Mutation: delete line 344 and lines 763-764. Every suite in the matrix stays green, including native_recluster, native_rewrite and ttl_expire. In production the deleted behaviour is observable: CREATE TABLE t ... USING pgcolumnar, index on t(id), VACUUM t to set the VM bits, then SELECT pgcolumnar.recluster('t','id'). Recluster writes the rows back under fresh row numbers and retires the old groups; the old row numbers' VM bits stay set, so an index-only scan answers from the index for TIDs whose group no longer exists — duplicate/ghost rows, the same defect the PR title names.
Raised and killed (1)
Recorded so nobody re-litigates them:
The reworked ttl_null premise still cannot exclude the arrangement it names, and its comment claims it does— refuted.
Non-blocking
- group_has_live_null decodes every column of the group while its comment, and the 40-line cost note that justifies the coarse probe, say it reads one (src/columnar_vacuum.c:2343):
PgColumnarBeginRead(rel, GetActiveSnapshot(), NULL, NULL, 0, NULL)passesprojectedColumns = NULL.src/columnar_reader.c:89documents that as "NULL means all columns", and :580 setsallColumnsWantedfrom it. So the probe decodes every column of the group to look at one boolean inisnull[attno - 1]. The function's own header comment (:2330-2332) says "This reads one column of one group through the ordinary reader", and the block comment at :2205-2255 that argues for keeping the storage-widePgColumnarStorageHasDeleteVectorprices the read path from measurements taken against this all-columns read. Passingbms_make_singleton(attno - 1)would make the comment true and would shrink the cost the comment spends forty lines negotiating. Not blocking — the answer is correct either way, and the path only runs for a group that has both recorded NULLs and deletes — but the comment as written is false of the code beneath it. - The arm named "seqscan agrees the expired rows are gone" does not assert it got a seqscan (test/ttl_expire.sh:203): Lines 203-207 run
SET enable_seqscan = on; SET pgcolumnar.enable_custom_scan = on; SELECT count(*) FROM ttl_ios;and the arm at 207 calls the result "the catalog truth". Butenable_bitmapscanandpgcolumnar.enable_index_only_scanare still set at the database level (lines 178-181), the query has no WHERE clause, andttl_ios_idcovers it — so the planner may well pick the same index-only scan that is the instrument under test, and noEXPLAINis taken to say which it picked. The arm is meant to be the independent witness that the group really is gone; as written it may be the same witness twice. Cheap fix:SET enable_indexonlyscan = off(or assertSeq Scanin an EXPLAIN) before the count.
…only scans Co-authored-by: Cursor <cursoragent@cursor.com>
…refuse a negative retention Three of the review's asks, and the blocking one first. THE VM CLEAR WAS WIRED INTO ONE OF THREE SITES. PgColumnarRetireGroup is reached by expire (columnar_vacuum.c:2194), by the partial-group rewrite behind compact_rewrite (:315) and by recluster (:720). All three retire LIVE groups and reassign those rows fresh row numbers; only expire cleared the old numbers' visibility-map bits. The other two left an index-only scan answering from the index for a group that is gone, which is verbatim the defect this PR describes as fixed. The rule is that the bits go wherever row numbers are reassigned, not only where rows expire, and the comment said so while the code did it once. Both sites are now wired. rewrite_one_group already receives firstRow and rowCount, so that one is direct. recluster sorted a bare array of group numbers, which would have separated each group from its row range, so the array now carries the range in the same struct and sorts on the number. That is why the diff there is larger than two lines. A NEGATIVE ttl_interval PUT THE CUTOFF IN THE FUTURE. `maximum < cutoff` was then true for groups entirely inside their retention, and expire retired them: live rows dropped, which is the failure this PR is named for. set_options range-checks encode_effort, compression, chunk_group_row_limit, stripe_row_limit and compression_level, and did not check this one. Zero is refused too -- it is not a data-loss shape, but "expire everything older than nothing" has no reading a caller means on purpose. ERRCODE is explicit (22023) for the reason the relkind guard gives: this tree's suites assert SQLSTATE, not message text. TWO ARMS COULD NOT FAIL FOR WHAT THEY NAMED. The NULL premise was named for a group-level fact and measured a table-level count, so it passed just as readily on a fixture where the NULL rows sat in a group of their own -- the arrangement it exists to exclude. It now counts row groups whose zone map carries a null_count, and requires exactly one. The index-only premise asserted a PLAN SHAPE, which is decided by relallvisible and by enable_seqscan/enable_bitmapscan being off, not by the bit the fix clears. It now also asserts relallvisible > 0, so the arm can fail for the thing it names. WHAT I TRIED AND WITHDREW, because it is worth recording rather than quietly dropping: I added an arm asserting the bits were CLEARED, comparing relallvisible before and after. It read "still 2 of 2" on a tree where the clear demonstrably works, because relallvisible is a statistic VACUUM refreshes and a VM clear does not touch it. Reading the fork itself needs pg_visibility, which is not built in this environment. The clear is therefore asserted through its consequence, and the file now says so instead of implying a direct measurement. Removal proof for the range check: revert it, keep the arms, and two named arms go red (`got [] want [22023]`) while both controls stay green, so the guard is not refusing every interval. SQL md5 6040fa4e -> b9487f18, guard count 1 -> 0. ttl_expire 26/26, native_recluster 12/12, native_rewrite 17/17, native_reclaim 9/9 on PG 17.10. STILL OPEN, not addressed here: the keep-the-group guard reads z->nullCount, which is a write-time count that still includes deleted rows, so expire refuses a group whose every live row is past retention and whose NULL rows have since been deleted. That needs a live-row property and nobody has a failing fixture for it yet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WDbfRym2V1sYFmMZ5gnsQL
Ask 3, with the failing fixture that was missing when I deferred it. The
reviewer built it and it goes red on this branch, showing both directions of the
trade at once:
main 53224e4 this branch, before
CONTROL no NULL, all 400d old 1 / 0 1 / 0
ARM L 90 LIVE NULLs in the group 1 / 0 / 0 0 / 900 / 90
^ DATA LOSS ^ correct
ARM R same NULLs, DELETED first 1 / 0 0 / 810
^ by accident ^ REFUSED FOREVER
Arm R is the defect. Every live row 400 days old against a 90-day retention, no
live row holding a NULL, and the group is never retired -- because z->nullCount
is recorded at WRITE time, still counts the deleted NULLs, and nothing rewrites
a zone map on delete. So the branch as it stood traded data loss for permanent
over-retention. Quieter, and not better.
The guard now asks a live-row question. Nothing in the metadata says WHICH rows
were null, only how many, so when a group has both recorded NULLs and deletes
the only way to tell is to look: the ordinary reader merges the delete vector,
so every row it yields is live.
The metadata-only path is unchanged where it is still correct. One probe per
expire, not per group: with no delete vector anywhere in the storage a
write-time null count is still exact, so expire reads nothing at all, which is
what it promises. Only a table that has deletes can have a stale count, and only
groups in such a table are read.
Committed arms, not just the reviewer's probe. ONE INSERT statement per fixture:
his first attempt used two, each flushed its own row group, the NULLs never
shared a group with the rows under test, and both trees produced identical
output. The group count is asserted rather than printed for that reason.
Removal proof: revert the guard to `if (z->nullCount > 0) continue;`, keep the
arms, and the two new arms go red with `got [0] want [1]` and `got [810] want
[0]` -- the over-retention itself. .so 5d83cbb7 -> b18430e8. The control pair
stays green, so the fix is not "retire everything wearing a delete".
ttl_expire 34/34, native_recluster 12/12, native_rewrite 17/17,
native_reclaim 9/9 on PG 17.10.
The docs sentence is corrected with it. It said a NULL pins its group
permanently, which was true of the code as written and is no longer true:
deleting the NULL rows releases the group.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WDbfRym2V1sYFmMZ5gnsQL
… decision
The storage-wide PgColumnarStorageHasDeleteVector probe is deliberately coarse:
one delete anywhere in the table sends every null-bearing group down the read
path, where a per-group deleted count would skip the ones carrying no deletes.
That trade was made without a number beside it, which is how a deliberate choice
becomes something a later reader has to rediscover.
Measured on PG 17.10, 200,000 rows at stripe_row_limit 10000, arms interleaved
and both retiring all 20 groups so they differ only in the path:
read path 16 ms 11 ms 11 ms
metadata path 1 ms 2 ms 3 ms
about +0.5 ms per row group. An independent run at 40 groups gave +0.3 ms per
group, so the figure is the right order and not exact. Neither transfers to the
shipped stripe_row_limit of 150000, where a group holds fifteen times these
rows, and the comment says so rather than letting the number look general.
The probe asserts BOTH arms retired the same number of groups. Without it an arm
that refused every group would time the refusal path and report six plausible
milliseconds about nothing -- which is exactly what happened to the first
version of this measurement on the other side, and what the check caught on mine
when a stray psql meta-command made every reading the string "is off.".
Kept coarse because of what kind of path this is. relation_estimate_size runs on
every plan of every query and a per-group fold there was worth removing; expire
is a maintenance function called by name. The per-group count is also only
exposed in a header by #868, so using it would couple this fix to that PR
landing first.
Comment only. No behaviour change; ttl_expire 34/34 on PG 17.10.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WDbfRym2V1sYFmMZ5gnsQL
…ermined
My previous commit put "+0.5 ms per row group" in that comment. That is a rate,
and I did not measure a rate. I divided one total by one group count.
The tell was there and I explained it away. An independent run at the SAME
geometry gave +0.3 ms per group. Same rows per group, same box, same code -- a
real per-group cost would have agreed. I wrote "the right order and not exact"
and moved on, which is the thing I have spent this session catching elsewhere: a
disagreement between two instruments is evidence about the frame, not a range to
average.
Swept the group count at fixed group size, six repetitions, arm order
alternated, every point asserting both arms retired all N groups:
5 groups read 3-7 ms metadata 1-2 ms
40 groups read 21-37 ms metadata 2-5 ms
Fitting a + b*groups over my points gives about 0.7 ms fixed and 0.59 ms per
group. The same fit over the other sweep's points gives 2.6 ms fixed and 0.27 ms
per group. Same box, same geometry, opposite decompositions -- so the shape is
NOT determined by either dataset, and the reason is visible in the spread: 21 to
37 ms across repetitions at 40 groups is comparable to the difference being
fitted.
So the comment now states the totals and says explicitly not to divide by the
group count. The concrete harm it prevents: a reader multiplying 0.5 ms/group by
a table of several thousand groups gets seconds, and nothing measured supports
that.
What survives is what the decision actually needed: at these sizes the whole
thing is milliseconds, and the read arm is under 40 ms for a 400,000-row table.
Still nothing at the shipped stripe_row_limit of 150000.
Comment only. ttl_expire 34/34 on PG 17.10.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WDbfRym2V1sYFmMZ5gnsQL
…air one The two sweeps in the comment above disagreed about the decomposition. The likeliest reason is not the subject, it is the environment: the same 40 groups timed 21-37 ms in one sweep and 12-21 ms in the other, taken while a second tenant was building and running suites on the same eight cores. That difference is larger than the effect either fit was resolving. So the comment now names the instrument and its limit. The numbers bound the magnitude; they cannot support a shape. If the shape ever matters, the fair instrument on this host is instructions retired by the backend, because contention moves the clock and does not move the instruction count. This one is worth recording plainly. There is a note in my own working memory, written a week ago, that says wall-clock A/B on this machine is not a fair instrument, that a host process pegging a core produced a 10-43% swing that REVERSED when the arm order was flipped, and that instruction counts are the answer. I used wall clock anyway, on a contended host, with another agent actively running suites, and committed the numbers to a source comment. Having the rule did not fire the rule. What kept it survivable was someone else asking why two runs of the same thing differed, twice: first about the quotients, which found that I had reported a total as a rate, and then about the absolute numbers, which found that the frame was contention all along. Comment only. ttl_expire 34/34 on PG 17.10. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WDbfRym2V1sYFmMZ5gnsQL
`pgcolumnar.set_options` is defined TWICE in this tree: once in the
fresh-install script `pgcolumnar--1.0-alpha3.sql`, and once in
`pgcolumnar--1.0-alpha2--1.0-alpha3.sql`, which re-creates the whole
function to add the two new arguments. The previous commit added the
`ttl_interval <= interval '0'` range check to the fresh copy only, so a
database that reached 1.0-alpha3 by `ALTER EXTENSION pgcolumnar UPDATE`
kept the unguarded body -- and a negative retention there still puts the
cutoff in the future and retires groups that are wholly inside it. The
data loss this branch exists to stop was fixed for new databases and left
in place for upgraded ones, which are the ones with data in them.
The same 24 lines, verbatim, into the upgrade script's copy. The two
function bodies are now byte-identical: md5 of the text from
`CREATE FUNCTION pgcolumnar.set_options(` to `$set_options$;` is
7f2f83d585c1 in both files, where it was 7f2f83d585c1 / 2c40e066a137.
test/native_upgrade_converge.sh already held this and was already red on
it -- it diffs `pg_get_functiondef` across the whole pgcolumnar schema of
an upgraded database against a fresh one, and one line of 113 diverged:
FN|pgcolumnar.set_options(regclass,integer,integer,name,integer,name,
name[],name,interval)|44abe8992bafb0acd1e809cdb1b0c3d9 fresh
|d3ecca340c0d7bbd45dde6fb6d884901 upgraded
Removal proof, three cells of the same 2x2, all on pg18a:
guard in fresh only (the head as submitted)
native_upgrade_converge 6 passed + 2 failed <- both legs
ttl_expire 34 passed + 0 failed
guard in neither (mutation, asserted applied: -1192 bytes each,
file md5 af0e7634->8064eca5 and b2388d8d->7bbc2e2f)
native_upgrade_converge 8 passed + 0 failed <- converges, unguarded
ttl_expire 32 passed + 2 failed <- "got [] want [22023]"
guard in both (this commit)
native_upgrade_converge 8 passed + 0 failed
ttl_expire 34 passed + 0 failed
The middle cell is the one worth reading: convergence alone cannot hold
this guard, because deleting it from both scripts converges too. The pair
does -- `ttl_expire` holds that the guard exists, `native_upgrade_converge`
holds that it is on the upgrade path. Neither is redundant and no new arm
is needed, which is why this commit adds no test.
Green on both gate majors: native_upgrade_converge 8/8, ttl_expire 34/34,
docs_style 9/9 on pg18a and pg19a.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EtQbQUiMSpGWembJV1jxob
41eb585 to
118e61f
Compare
…does Two claims that this branch falsifies, one of them a line the branch itself adds three commits earlier. The docs sentence is ours. `docs/sql-reference.md` gains "`expire` works on whole row groups. It never reads or rewrites them" as a `+` line in the same pull request that adds `group_has_live_null()` (src/columnar_vacuum.c:2334) and calls it at :2296 whenever the storage has any delete vector. The branch contradicts itself inside one diff. The rewrite keeps what is still true -- nothing is rewritten, ever -- and states the condition on the read: metadata alone on a table with no deletes, the retention column of a candidate group on a table that has some, because a write-time `NULL` count cannot tell a live `NULL` from a deleted one. The catalog COMMENT is not ours, and this branch falsifies it anyway. "drop row groups ... without reading or rewriting them (#403)" is on main today, so no diff of this branch shows it. "reading or" comes out. IT IS IN BOTH SQL SCRIPTS AND HAD TO CHANGE IN BOTH, which is the same trap the previous commit closed and the same instrument catches it. test/native_upgrade_converge.sh hashes obj_description alongside the function definition. Removal proof: with the corrected COMMENT in both files the suite is 8 passed + 0 failed; revert it in the upgrade script alone (file md5 2ff65ac286cd -> b2388d8db1bc) and both legs go red at line 80 of a 113-line snapshot, 6 passed + 2 failed. The rewritten paragraph also unwraps a line that ran to 108 columns, and the first draft of the new sentence was 39 words, which test/plain_language_check.py refused. Split, and `docs/sql-reference.md` is `ok` again. Green on pg18a and pg19a: docs_style 9/9, native_upgrade_converge 8/8, ttl_expire 34/34, native_reclaim 33/33, native_ios 11/11, index_only 27/27. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EtQbQUiMSpGWembJV1jxob
Rebased onto
|
native_upgrade_converge |
ttl_expire |
|
|---|---|---|
| guard in fresh only (the head you reviewed) | 6 passed + 2 failed | 34 + 0 |
guard in neither (mutation, −1192 bytes each, file md5 af0e7634→8064eca5 and b2388d8d→7bbc2e2f) |
8 + 0 | 32 passed + 2 failed — got [] want [22023] |
| guard in both (this commit) | 8 + 0 | 34 + 0 |
The middle cell is the one worth reading. Convergence alone cannot hold this guard, because deleting it from both scripts converges too. The existing pair does: ttl_expire holds that it exists, native_upgrade_converge holds that it is on the upgrade path. Neither is redundant, and a behavioural "an upgraded database refuses -3 days" arm would have reddened under no mutation the pair does not already catch.
The second commit: this branch says expire never reads a group, and this branch makes it read one
Found in an adversarial pass over my own head, survived two skeptics, and I then confirmed it against the files rather than taking it.
docs/sql-reference.md gains "It never reads or rewrites them" as a + line, in the same pull request that adds group_has_live_null() (src/columnar_vacuum.c:2334) and calls it at :2296 whenever the storage has a delete vector. The branch contradicts itself inside one diff. Rewritten to keep what is true — nothing is ever rewritten — and to state the condition on the read.
The catalog COMMENT is worse, because it is not ours: 'drop row groups ... without reading or rewriting them (#403)' is on main today, so no diff of this branch shows it, and this branch falsifies it. reading or comes out.
It is in both SQL scripts and had to change in both — the same trap the first commit closed, caught by the same instrument. native_upgrade_converge hashes obj_description alongside the definition:
corrected COMMENT in both files 8 passed + 0 failed
reverted in the upgrade script alone 6 passed + 2 failed (file md5 2ff65ac286cd -> b2388d8db1bc)
both legs red at line 80 of a 113-line snapshot
My first draft of the replacement sentence was 39 words and plain_language_check.py refused it. Split; docs/sql-reference.md is ok.
One thing I could not close, reported rather than papered over
The CHANGELOG says of the visibility map that "All three clear now". Two of the three are held by nothing. Mutation, both clears deleted, expire's kept (src/columnar_vacuum.c md5 47a19cd1a67f → ed1877bace13, remaining call sites 3 → 1):
native_recluster 12/0 recluster_extent 17/0 recluster_gate 13/0
native_reclaim 33/0 native_reclaim_frag 4/0 native_reclaim_cycles 12/0
native_reclaim_reconcile 11/0 rewrite_group_scan 5/0 native_rewrite 17/0
native_ios 11/0 index_only 27/0 ttl_expire 34/0 vm_privilege 30/0
sorted_pathkeys 113/0
14 suites, 319 checks, all green with two of the three clears gone.
I tried to build the missing arm and it did not discriminate, so I am not shipping it. Mirroring the ttl_ios idiom — VACUUM to set bits (relallvisible 2, plan is an Index Only Scan), DELETE a quarter of the rows, recluster, then index-only against seqscan — gives identical answers on both trees, distinct .so per arm (182c9c9349c6 vs 8a3f78663df6), mutation asserted applied:
branch mutated
IOS count 6000 6000
seqscan count 6000 6000
IOS sum(id) 24000000 24000000
seqscan sum(id) 24000000 24000000
So: verified that the two clears are uncovered; verified that this fixture cannot tell them apart; unknown whether a fixture that can exists. I did not find one and I am not going to guess at a mechanism I have not observed. Your call whether that blocks — expire's clear, the one the data-loss report was about, is covered by ttl_expire's index-only arm.
Gate
pg18a and pg19a: native_upgrade_converge 8/8, ttl_expire 34/34, docs_style 9/9, native_reclaim 33/33, native_ios 11/11, index_only 27/27.
Rebase checked with a number, not prose — per-file patch md5 over content lines only, ignoring hunk headers: all six non-CHANGELOG files IDENTICAL, CHANGELOG differing by exactly one added blank line, the separator between this entry and main's.
Not approving: same account as the author.
jdatcmd
left a comment
There was a problem hiding this comment.
Approved at 118e61f. This clears my CHANGES_REQUESTED, and the red is gone for the right reason.
The fix is where it needed to be. pgcolumnar.set_options is defined in both the
fresh-install script and the alpha2->alpha3 upgrade script, and the guard had reached only the
first. Extracted both function bodies and hashed them:
| ref | fresh install | upgrade script |
|---|---|---|
main |
136 lines, 14608eff7a8c |
136 lines, 14608eff7a8c |
| this branch | 160 lines, 9d387c84fcb7 |
160 lines, 9d387c84fcb7 |
Identical within each ref, +24 lines on both here. A fresh database and an upgraded one now get
the same body. (My first extractor returned the md5 of empty input in all four cells and I
nearly reported that as agreement — the rebuilt one carries a control asserting it can return
different values, which it does.)
I did not take "no new arm is needed" on trust, because that is the load-bearing claim.
You argue the existing pair holds the guard and that a behavioural arm would be one that cannot
independently fail. I ran the decisive cell myself, on the composed tree, own box, own prefix:
| tree | ttl_expire |
native_upgrade_converge |
|---|---|---|
| guard in both (this PR) | 34 / 0 | 8 / 0 PASS |
| guard removed from both scripts | 32 / 2 FAILED | 8 / 0 PASS |
| guard in fresh only (the original red) | 34 / 0 | 6 / 2 FAIL |
The middle row is the one that settles it: convergence is green with the guard deleted
everywhere, so native_upgrade_converge alone cannot hold it — exactly your point. The pair
does, and the two reds are for different reasons. The mutant fails on
a negative ttl_interval is refused with 22023: got [] want [22023], which asserts SQLSTATE
rather than message text, so it cannot be satisfied by an unrelated error.
Mutation asserted applied before building: guard count 1 -> 0 in each file, with an identical
372-byte delta in both, so the same block went from both and nothing else did.
Agreed on not adding the arm. An arm that only restates what these two already prove would
read as coverage and add none.
Composition, not the branch. Full PG 17.10 matrix on main 381c765 + #867 + #869:
242 verdicts, 237 PASS, 5 SKIP, 0 FAIL, ALL VERSIONS PASSED. Nothing that passed on main or
on the previous composed tree stopped passing; the only new suite is truncate_cleanup, which
is #867's.
Summary
pgcolumnar.expiredecided a row group was fully past retention from the zone-map maximum alone. That maximum ignores NULLs, so a group of expired timestamps plus NULL retention values was retired and the NULL rows disappeared.Test plan
test/ttl_expire.shon PostgreSQL 18 incusor-2604(NULL retention rows kept; index-only scan returns 0 after expire)Made with Cursor