Skip to content

fix: record the ordered run as a range, not an upper bound (#342) - #344

Merged
jdatcmd merged 1 commit into
mainfrom
fix/342-sorted-extent-range
Aug 3, 2026
Merged

fix: record the ordered run as a range, not an upper bound (#342)#344
jdatcmd merged 1 commit into
mainfrom
fix/342-sorted-extent-range

Conversation

@ChronicallyJD

Copy link
Copy Markdown
Collaborator

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_status counted concurrently written rows as ordered. A table with unordered rows appended during an online recluster reported as more ordered than it was -- the direction record_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 ctid mapped back to its row number and joined to pgcolumnar.row_group, so group membership comes from stored data.

76      :20000: id 1500001-1520000 : SORTED   <-- foreign, under the mark
77..151 : 75 groups, max id = 1500000         <-- rewrite output, zero concurrent rows
152,153 : the inserter's other two stripes    : appended
sorted_through = 151

No rewrite output group contains any id > 1500000, and recluster returned 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, and ColumnarCatalogSnapshot only advances curcid rather 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_from is added alongside sorted_through; the run is recorded as [ours[0], runEnd] and sort_status tests BETWEEN.

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 lowestLive check is deleted, along with the comment claiming it closed this hole.

sorted_from is 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_limit so it never flushes and never takes the per-storage advisory lock that would otherwise block the rewrite.

Removal proof -- revert sort_status to a bare upper bound and the new case fails naming the exact rows:

sorted appended
fix reverted 105000 0
fix in place 100000 5000

The suite's premise was also unsound, separately

INS_DONE < RECL_DONE compared 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 at 1550000 / 0. 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 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_decode and native_fetch_position all 5/5.

Worth flagging: an earlier matrix run showed cancel_decode and native_fetch_position failing on PG19. That was my own fault -- I was building and testing #343 across four majors concurrently, and cancel_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 (single 1.0-dev file), 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

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
@jdatcmd

jdatcmd commented Aug 3, 2026

Copy link
Copy Markdown
Owner

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.

sort_status reports ~90% decay on a table that was just fully reclustered, whenever the table has a projection. Reproduced with a control (same fixture, only difference is add_projection):

arm sorted_rows appended_rows
no projection 200000 0
with a projection 20000 180000

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 src/columnar_vacuum.c breaks at the first step. The comment there says a gap means "a foreign reservation took this id", which is false when the rewrite is competing with its own projection writes.

Why it matters for reviewing #344: this PR changes the mark to a range [ours[0], runEnd], which closes the over-claim in #342, but runEnd still comes from that same consecutive-run walk. So the under-claim survives untouched. I verified #345 against a tree that has this fix in it.

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.

@ChronicallyJD

Copy link
Copy Markdown
Collaborator Author

Reviewed — mergeable; #345 noted as separate and pre-existing.

Correctly fixes #342. Verified:

  • The range [ours[0], runEnd] is sound: a foreign id below the run is excluded (< sorted_from), one inside is impossible because the consecutive-run walk breaks at the first gap, and one above is excluded (> sorted_through).
  • Empty-run guarded (if (nOurs <= 0) return;) before ours[0].
  • No C consumer of sorted_through/sorted_from outside the writers — the sole reader is the SQL sort_status, so the range fix is complete.
  • The new "id-drawn-below" test forces the race deterministically (held-open txn draws its id, doesn't flush) rather than relying on the scheduler, and asserts its premise so it can't pass vacuously.

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 ColumnarWriteStateStripeIds returns only the base reservations, so ours is non-consecutive and the walk breaks at the first step → this PR records [ours[0], ours[0]], the same single-group mark sorted_through alone would record on main. Same ~90% symptom, no regression. #345 wants its own fix — making ours include the projection write state's reservations so the walk breaks only on a genuinely foreign gap — best stacked on this, since it's the same function.

One migration note: sort_status now references st.sorted_from, so a dev install that reloads the function without recreating pgcolumnar.storage would error on the missing column. Fine if dev installs always recreate; worth confirming there's no in-place path.

@jdatcmd
jdatcmd merged commit d5c37ca into main Aug 3, 2026
11 checks passed
jdatcmd added a commit that referenced this pull request Aug 3, 2026
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.
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.

recluster_extent fails intermittently: extent mark claims concurrently inserted rows (sorted 1520000 > base 1500000)

2 participants