Repository navigation
feat: a ctid range reads only the row groups that hold it (TID range scans) - #1306
Conversation
jdatcmd
left a comment
There was a problem hiding this comment.
Review of e568782. The range arithmetic, the restriction and the parallel path hold up. One regression is blocking: a scroll cursor over a ctid range now errors on a backward fetch, where on main it works.
Blocking: a backward fetch through a TID range scan errors
Measured on PG16, built from this PR's head, with a 5000-row table in 1000-row groups:
BEGIN;
DECLARE c SCROLL CURSOR FOR SELECT id FROM st WHERE ctid >= '(0,1)';
FETCH LAST FROM c;
-- ERROR: columnar tables do not support backward scans
-- DETAIL: A scrollable cursor over relation "st" asked for a backward fetch, which native storage cannot serve.The same cursor with SET enable_tidscan = off, which gives main's plan shape (main has no TID range callbacks, so it can't plan one), returns 5500, 5499, 5498 for FETCH LAST then FETCH BACKWARD 2.
Why it happens: ExecSupportsBackwardScan returns true for T_TidRangeScan (execAmi.c, beside T_SeqScan). So the planner puts no Material under a scroll cursor, and the backward fetch reaches pgcolumnar_scan_getnextslot. A sequential scan avoids this only because our custom scan replaces it, which is what the error's HINT relies on. A TID range scan isn't replaced, so this PR opens a path the HINT doesn't cover. The HINT's advice, "leave enable_custom_scan on", is already followed here and doesn't help, so the user has no way out but an ORDER BY.
Possible fixes, your call:
- In the planner hook, when
cursorOptions & CURSOR_OPT_SCROLL, drop TID range paths on columnar rels, or make sure a Material goes above them. This is the narrowest fix. - Serve backward in
getnextslot_tidrangeby buffering the in-range rows. The range can be large, so I'd not.
Either way, add an arm that is red on e568782: a scroll cursor over a ctid range, FETCH LAST and FETCH BACKWARD, compared with the same cursor under enable_tidscan = off. Both twins.
Verified, no change needed
- Rows inserted earlier in the same transaction are seen. In
BEGIN; INSERT 500 more; SELECT count(*) ... WHERE ctid >= '(0,1)'the plan isTid Range Scanand the count is 5500, equal to the filter's 5500. If that's not already an arm, it's worth one:set_tidrangereads the row-group catalog, and the arm proves the pending writes are in it by then. - PG19 parallel composes correctly. PG19 plans partial TID range paths for any AM with the callbacks (tidpath.c,
AMFLAG_HAS_TID_RANGE). Each worker callsset_tidrangeunder the same restored snapshot, so every worker builds the samerangeGroups. The claim loop (reader.c:2784) skips a claimed group outside the restriction, so each group is claimed by exactly one worker and read only if it's in range. Your parallel arm, with its PG15–18 skip, covers it. - The offset edge cases (offset 0, past
COLUMNAR_VALID_ITEMPOINTER_OFFSETS, and below row 0 at block 0) match the comment, and not stopping at the first out-of-range row is right after compaction moves rows.
I'll re-review once the cursor arm is in and shown red.
…scans) scan_set_tidrange maps the bounds to row numbers, clamping offsets no row uses (0, and past the 291 a block holds: the executor turns ctid < '(B,0)' into (B-1,65535)), and restricts the reader to the intersecting row groups. getnextslot_tidrange filters the boundary groups' rows. A range over the last 2.5% of a 400k-row table reads 28 blocks where the table is 923. PostgreSQL 19's parallel TID range scan shares the reader's group claim. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test_tid_range_scan.py is independent of the shell suite: its own table, row count, group size and ranges, on a private database. Every shell property is covered (compare_to_bash: 13 literal, 2 template, 0 missing). The parallel test is UNSUPPORTED_MAJOR below 19 and listed in expected_unrunnable.txt. Ledger: 31 rows from a five-major run, replayed onto commandprompt#1286's ledger; 15 dated by eight mutations merged one log at a time with --mutation and --target; census 1674 -> 1690. TESTS.md section 92; cluster_tests 499 by collection. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tch never reaches the AM (commandprompt#1306 review) PostgreSQL counts a TID range scan as able to scan backward, so it puts no Material above one under a cursor that may scroll, and the access method, which reads forward only, refused the backward fetch (0A000). Found by @jdatcmd: a SCROLL cursor's FETCH LAST errored where main's plan answered. A plain DECLARE hit it too, because the portal makes a cursor scrollable when its plan claims backward support. A planner hook wraps a cursor's plan (SCROLL, or a DECLARE without NO SCROLL) in a Material when it holds a TID range scan of a columnar table, as standard_planner does for a SCROLL cursor over a plan that cannot run backward. PostgreSQL 19's hook takes an ExplainState as well. Arms, red on e568782 in both twins: a SCROLL cursor's FETCH LAST, BACKWARD and ABSOLUTE match the filter's plan; a plain cursor answers a backward fetch correctly. Plus jdatcmd's verified case as an arm: rows inserted earlier in the same transaction are in the range. Removal proofs: no hook (both cursor arms), SCROLL only (the plain-cursor arm). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…th the cursor arms (commandprompt#1306) 36 rows from a five-major run on this tree; 17 dated by ten mutations of columnar_tableam.c (no planner hook and SCROLL-only among them); census 1674 -> 1693. TESTS.md and CHANGELOG describe the cursor fix. cluster_tests 500 by collection. harness_selftest PG17 1223 passed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
e568782 to
23a25ab
Compare
|
Thanks, that was a real regression. Fixed at 23a25ab, rebased onto dd08f4e (#1304). The fix (fb2f2aa) is your option 1, done after planning. A The plain-DECLARE case was broken too. Arms, in both twins, red on e568782 for the right reason:
Each plan premise is taken on the very cursor query that's compared. Removal proofs: without the hook, both cursor arms are red; with the hook covering SCROLL only, the plain-cursor arm is red. One mistake of mine, caught by the evidence run: PostgreSQL 19's Evidence on 23a25ab:
🤖 Generated with Claude Code |
jdatcmd
left a comment
There was a problem hiding this comment.
Approved at 23a25ab. The planner-hook fix is the right shape, and catching the plain-DECLARE case was a good find: PerformCursorOpen makes a cursor scrollable when ExecSupportsBackwardScan says so, which my review missed.
Checked
materialize_finished_planis exported on every major: present inoptimizer/planmain.hfor 15, 16, 17, 18 and 19 in our installs.- Hook chaining:
pgcolumnar_prev_planner_hookis saved and called before ours, and the PG19ExplainStateparameter is behind the version macro. - Cursor options: SCROLL, or FAST_PLAN without NO_SCROLL. Anything else returns unchanged, so a plain query still plans the TID range scan.
- The walker's coverage: lefttree, righttree, Append, MergeAppend, SubqueryScan and CustomScan's
custom_plans. A TID range scan under a CteScan or a SubPlan is read forward by its tuplestore or by re-execution, so not walkingstmt->subplansfor the search is right.
Not blocking, please fix in a follow-up commit or here: the Material's plan_node_id can collide
mat->plan_node_id = maxid + 1, where maxid is the largest id in the main tree only. In PG19's planner.c, set_plan_references numbers the top plan first (line 643) and the subplans after it (line 651), from the same lastPlanNodeId counter (setrefs.c:650). So whenever the cursor's query has a SubPlan or InitPlan, its first node already holds maxid + 1, and the Material duplicates it.
plannodes.h documents plan_node_id as unique across the final plan tree. I found no core consumer that this breaks today: the Material sits above any Gather, so it never enters a worker's DSM toc or instrumentation array. That's why this isn't blocking. Extensions that key on node ids would see the duplicate, though.
The fix is to also run the max over stmt->subplans, about three lines. Example query: DECLARE c SCROLL CURSOR FOR SELECT id, (SELECT max(id) FROM st) FROM st WHERE ctid >= '(0,1)'. SQL can't observe the id, so I wouldn't ask for an arm. A one-off assert-build check that the ids in the final tree are unique, red before the fix and green after, would be the proof.
Evidence
I didn't re-run your suites: this box's five majors are busy with #1290's mutation runs. I'm relying on your posted runs (34/34 + SKIP on PG15–18, 36/36 on PG19, both twins) and on CI, which has 13 passed and 2 pending at the time of this review. I'll merge only when all 15 are green.
|
Merging now: approved at 23a25ab, 15/15 green. The |
…1306 follow-up) set_plan_references numbers the main tree and then the subplans from one counter, so the main tree's largest id is not the largest: a cursor whose query had a subplan got a Material whose id repeated the subplan's first node. Found by @jdatcmd reviewing #1306. An assert build now checks every id in the final plan is unique; the arm 'a SCROLL cursor whose query has a subplan fetches backward as the filter's plan does' was red (XX000) on PG16 in both twins before the fix, and is green on PG15-19 after. Ledger 1943 -> 1945, census 1694. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fix: the cursor Material's plan node id sits above the subplans' ids (#1306 follow-up)
A ctid range reads only the row groups that hold it
Follows from the #1294 design note. Before this, the access method had no
scan_set_tidrange, so a ctid range was a per-row filter over the whole table. On #1294,WHERE ctid >= '(600000,0)'on a 10M-row table matched 0 rows and still read all 67 row groups. The rollup design there needs "the newest row groups only".What changed (
src/columnar_tableam.c, no SQL change):scan_set_tidrangemaps the bounds to row numbers and restricts the reader to the row groups that intersect them, through the existingPgColumnarReadRestrictToGroups.scan_getnextslot_tidrangefilters the rows of the boundary groups. It does not stop at the first row past the upper bound, because compaction moves rows into later groups.Measured: on 400,000 rows, the last 2.5% by ctid reads 28 blocks, against 923 for the whole table. The instrument is pg_statio under a forced decode (
max(v || 'x')), because an unfiltered max is answered from zone maps.The bounds no row has. A block holds 291 rows, at offsets 1 to 291, but a bound can name offset 0 or anything up to 65535. The executor turns
ctid < '(B,0)'into an upper bound of(B-1,65535). The first build read those offsets unclamped and returned rows from the next block. Arms written for those bounds went red, which is how it was found. The fix clamps them.Tests (written first; red on main at the cost arm)
test/tid_range_scan.sh: 29/29 on PG15–18 plus one named SKIP, 31/31 on PG19. The SKIP is the parallel arm, since PG15–18 have no parallel TID range scan.test/pytest/test_tid_range_scan.py, independent of the shell suite: 30/30 on PG19, 26/26 + 1 UNRUN on PG17. The parallel test isUNSUPPORTED_MAJORbelow 19 and listed inexpected_unrunnable.txt.compare_to_bash: 13 literal and 2 template matches, 0 missing.enable_tidscan = off. Every plan premise is taken on the very query compared. A draft whose lateral output went unreferenced planned no scan at all, and its premise caught that.Removal proofs, each red on its named arm in both twins: no group restriction (the cost arm); an unclamped upper offset past 291, an unclamped lower offset past 291, an unclamped lower offset 0, and a block-0 upper bound read as the last row (the parity arms for those bounds); no boundary-row filter; a reader kept across a rescan (the rescan arm); and no shared parallel claim on PG19 (it returned every row twice, 480002 vs 240001).
One unexplained result, recorded rather than dropped: an early mutant of the offset-0 upper bound did not go red. A later mutant of the same branch (C4) does, so the arm reaches that code, but I never explained why the first one survived.
Known limit, a follow-up: the custom scan still treats ctid as a per-row filter. When parallelism is cheap, the planner prefers a parallel custom scan and reads everything. At default settings the TID range scan is chosen, and a premise checks that.
Registries
--mutationand--target. Census 1674 → 1690. The 16neverrows are 9 premises and 7 parity arms that no mutant of this change reddens.suites_not_covered 249 > 247with my suite-list extraction, and gives the same 249 on main. This change adds one registered and one covered suite, so it doesn't move that number. I'm flagging it in case my extraction differs fromrun_all_versions.sh's.🤖 Generated with Claude Code