fix: record the ordered run as a range, not an upper bound (#342) - #344
Conversation
pgcolumnar.sort_status counted concurrently written rows as ordered. record_online_sorted_extent recorded a single upper bound and relied on two things to make it honest: the rewrite's own stripe ids being consecutive from its lowest, and the lowest live group being that lowest id. The first argument only covers a foreign id drawn between two of the rewrite's own draws. An id drawn below the rewrite's first leaves its ids perfectly consecutive, so the run scan walked to the end unbroken and swept the foreign group underneath the mark. The second check could not catch that case at all. It read the group list under the rewrite's own snapshot, taken before it read a row, and ColumnarCatalogSnapshot only advances curcid rather than refreshing xmin/xmax. A concurrent inserter's group was therefore invisible to it whenever that transaction committed, so lowestLive always equalled ours[0]. Demonstrated: a run where the insert committed 4.1 s before the check ran still set the mark to the maximum. The run is now recorded as a range, [ours[0], runEnd], and sort_status tests group_number BETWEEN sorted_from AND sorted_through. That is sound from the single serialized stripe counter alone, with no dependence on visibility: a foreign id strictly inside the range must have been drawn between two of the rewrite's draws, so it breaks the consecutive run and is already truncated; an id below ours[0] falls outside the range by construction. The lowestLive check is deleted along with the comment claiming it closed this. sorted_from is NULL only for a mark written before the column existed, where the old everything-below reading is kept. Found by CI, not by me: the suite failed on PG18 in one run and passed the same commit in another. Reproduced with ground truth by mapping each row's ctid back to its row number and joining to row_group, which showed group 76 holding ids 1500001-1520000 counted as sorted while no rewrite output group contained any id above 1500000. So the rewrite provably never read those rows. Impact is reporting, not data: a decayed table could look more ordered than it is, which is the direction record_online_sorted_extent's own comment says it must never fail in. No data loss and no wrong query results. test/recluster_extent.sh gains a deterministic case for this. The existing concurrent section only hits it when the scheduler cooperates, which is why it failed on CI and not locally. The new one forces the ordering with transaction control instead of racing it: a writer holds its transaction open, drawing its stripe id while buffering but staying under stripe_row_limit so it never flushes and never takes the per-storage advisory lock that would block the rewrite. Removal proof: with sort_status reverted to a bare upper bound the new case fails at sorted 105000 / appended 0 against 100000 / 5000 expected, naming the 5000 rows the rewrite never read. That suite's premise was also unsound, independently of this defect, and is fixed too. It compared client wall clocks (INS_DONE < RECL_DONE), which establishes only that the insert's client returned first and says nothing about the rewrite's work set; with the insert forced entirely before the rewrite the premise passed while the property failed honestly. It now asserts on recluster's own retire count, which is exactly the set it read, so an insert that does land in the work set reports an unmet precondition rather than looking like a product defect. Full 15-19 matrix, all suites, on an otherwise idle machine. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011miCFRSatixeNRw3w5yNq8
|
Related finding while auditing this area, filed as #345: this PR does not fix it, and I want that on the record before you review.
Cause: the projection fan-out reserves from the same base stripe counter but records its group under its own storage id, so the rewrite's own ids come back as 17, 19, 21, ... and the consecutive-run walk at Why it matters for reviewing #344: this PR changes the mark to a range The two are opposite errors in the same function -- #342 claimed groups it should not, #345 refuses groups it should -- and I would rather they be fixed as two changes with two removal proofs than folded together. If you would prefer them in one, say so and I will fold #345 in here. Direction of error is safe in both senses: #345 over-reports decay, so at worst it prompts an unnecessary re-sort. |
|
Reviewed — mergeable; #345 noted as separate and pre-existing. Correctly fixes #342. Verified:
Re #345 — this PR neither fixes nor worsens it. On a projected table the base and projection draws alternate in the shared base counter but One migration note: |
A rewrite's own projection fan-out draws from the same base stripe counter, so 'a gap in my ids means a foreign reservation' was false within the rewrite's own transaction and truncated the ordered run at the first projection flush. sort_status reported ~90% decay on a freshly, fully reclustered table. Reproduced with a control (noproj 200000/0 vs withproj 20000/180000); removal proof fails the withproj arm at 20000/180000 while the control stays green. Opposite error to #342 in the same function, and not fixed by #344. Full 15-19 matrix.
Fixes #342. Please review rather than expect me to merge this -- it changes the catalog, and I have self-merged too much today already.
The defect
pgcolumnar.sort_statuscounted concurrently written rows as ordered. A table with unordered rows appended during an online recluster reported as more ordered than it was -- the directionrecord_online_sorted_extent's own comment says it must never fail in, because it "leaves a decayed table looking ordered and costs every query against it."Impact is reporting and re-sort scheduling. No data loss, no wrong query results.
Found by CI, not by me
The suite failed on PG18 in one run and passed the same commit in another. I had also seen it fail once on PG19 earlier and written that off as environmental, which was wrong.
Reproduced with ground truth rather than inference: each row's
ctidmapped back to its row number and joined topgcolumnar.row_group, so group membership comes from stored data.No rewrite output group contains any
id > 1500000, andreclusterreturned 75 -- its own retire count -- so the rewrite provably never read those 20,000 rows.Cause: two holes, both required
1. The guard reasoned that "a foreign reservation always leaves a gap in the rewrite's own ids." That holds only for an id drawn between two of the rewrite's draws. An id drawn below the rewrite's first leaves its ids perfectly consecutive, so the run scan walks to the end unbroken and sweeps that group underneath the mark.
2. The
lowestLive != ours[0]backstop could not catch it. It read the group list under the rewrite's own snapshot, taken before it read a row, andColumnarCatalogSnapshotonly advancescurcidrather than refreshing xmin/xmax -- so a concurrent inserter's group was invisible to it whenever that transaction committed. Demonstrated by a run where the insert committed 4.1 s before the check ran and the mark was still set to the maximum.Fix: a range, not a boundary
sorted_fromis added alongsidesorted_through; the run is recorded as[ours[0], runEnd]andsort_statustestsBETWEEN.This is sound from the single serialized stripe counter alone, with no dependence on visibility: a foreign id strictly inside the range must have been drawn between two of the rewrite's draws, so it breaks the consecutive run and is already truncated; an id below
ours[0]falls outside by construction. That is exactly what a bare upper bound cannot express.The
lowestLivecheck is deleted, along with the comment claiming it closed this hole.sorted_fromis NULL only for a mark written before the column existed, where the old everything-below reading is kept.Tests
A deterministic case is added. The existing concurrent section only hits this when the scheduler cooperates, which is why it failed on CI and not locally. The new one forces the ordering with transaction control instead of racing it: a writer holds its transaction open, drawing its stripe id while buffering, but stays under
stripe_row_limitso it never flushes and never takes the per-storage advisory lock that would otherwise block the rewrite.Removal proof -- revert
sort_statusto a bare upper bound and the new case fails naming the exact rows:The suite's premise was also unsound, separately
INS_DONE < RECL_DONEcompared client wall clocks. That establishes only that the insert's client returned first, and says nothing about the rewrite's work set -- with the insert forced entirely before the rewrite, the premise passed while the property failed honestly at1550000 / 0. It now asserts onrecluster's own retire count, which is exactly the set it read, so an insert that does land in the work set reports an unmet precondition instead of looking like a product defect.Both defects were real and independent. Fixing only the test would have buried the bug.
Gate
Full 15-19 matrix, all suites,
ALL VERSIONS PASSED, on an otherwise idle machine.recluster_extent,sort_status,cancel_decodeandnative_fetch_positionall 5/5.Worth flagging: an earlier matrix run showed
cancel_decodeandnative_fetch_positionfailing on PG19. That was my own fault -- I was building and testing #343 across four majors concurrently, andcancel_decode's premise requires a scan to outlast 300 ms. Both pass 5/5 on a quiet box. I am noting it rather than omitting it because "I re-ran it and it passed" is exactly the reasoning that let this bug sit.Catalog note
This adds a column to
pgcolumnar.storage. There are no extension update scripts (single1.0-devfile), so there is nothing to migrate, but that is a call you may want to weigh in on.🤖 Generated with Claude Code
https://claude.ai/code/session_011miCFRSatixeNRw3w5yNq8