fix: expire must not drop live rows or leave them visible to index-only scans - #869
fix: expire must not drop live rows or leave them visible to index-only scans#869OffgridwithJD wants to merge 1 commit into
Conversation
…only scans Co-authored-by: Cursor <cursoragent@cursor.com>
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.
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