Repository navigation
Group basis: zero-group GC by the drained prefix, and apply statements that read no table whole (G2b, part of #1100) - #1160
Merged
mmmries merged 4 commits intoOct 19, 2026
Conversation
… the source primary key salesforce-misc#1157's tests were written before salesforce-misc#1144 added the source_pk parameter, so main's trellis lib tests did not compile once both merged. Each PR was fine alone. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…apply statement off whole-table scans (G2b, part of salesforce-misc#1100) Zero-group GC (ADR-0002 R4). A group whose partials are all zero stays in the hidden table after its visible row goes, and nothing removed it, so a definition whose groups churn grew its hidden table without bound (salesforce-misc#1043's JOIN model ran 16x slower with such rows left in place). The tombstone pass now also deletes, per group-basis definition, every zero group that has no basis or whose stamp is at or below the contiguous drained prefix, claimed FOR UPDATE SKIP LOCKED in key order through a partial `where __zero` index. `__zero` is a stored generated column, so it changes only when a group empties or refills and the hot path's updates stay HOT. `__stamp` is now nullable with no default, so a basis written without a stamp keeps its group rather than being collected at stamp 0. Plan control on the apply statement. With nested loops off, the hidden-row update joined `u.ctid = t.__hc` as a hash join over a sequential scan of the whole hidden table, under the page's group locks. It now fetches those rows by a TID scan over `ctid = any(array(...))` (183 ms -> 87 ms at 1M groups, 4,000 a page). The group lookup's NULL branch used `is not distinct from`, which no index serves, so a page naming a group with a NULL scanned the hidden table and the target whole; it is now one `is null` index probe per NULL pattern (up to 4 group columns). Sequential scans are off for the statement too (SET LOCAL, beside enable_nestloop), so an un-analyzed table can't fall back to a scan. Tests: R4 scenarios (churn bounded, RV.gc-n0's owed sum kept, a zero group with a basis kept until the prefix passes its stamp, a stamp-less basis kept, the GC skipping a group a page holds), every NULL pattern over two group columns, and plan-shape tests for the apply statement and the GC. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the GC is deleting (salesforce-misc#1100) The plan-shape test passed on G2a's update form once enable_seqscan was off: on its tiny tables the planner picks a disabled nested loop probing each hidden row by ctid. It now asserts the TID scan is over the page's ctid array, which fails on G2a's form under either setting. A new R4 test holds a GC delete open while a page refills the group: the page's lock statement queues on it and inserts the group afresh. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… GC (part of salesforce-misc#1100) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of #1100 (G2b). This PR also carries the one-commit test fix from #1158 (main's lib tests do not compile until it merges); that commit drops out of the diff once #1158 is in.
Summary
Two pieces of #1100's scope for the group-basis aggregate path, which is still behind the dev knob (
TRELLIS_DEV_GROUP_BASIS). With the knob off, nothing changes: no docs change, and the ledger's tombstone GC runs as before.Zero-group GC (ADR-0002 R4). A group whose partials all reach zero loses its visible row, but its hidden row stayed in
agg_<id>, so a definition whose groups churn (insert-then-delete per group key) grew its hidden table without bound. Research: a group-level basis instead of the per-row ledger for invertible aggregates with no relationships (#897) #1043's JOIN model ran 16x slower with thousands of such rows on hot keys. The maintenance loop's tombstone pass (staging::retire::collect_tombstones) now also deletes, from every hidden table, each zero group that either has no basis or whose stamp is at or below the contiguous drained prefix.FOR UPDATE SKIP LOCKEDin key order, in batches of 5,000, up to 4 batches per table per pass. The ledger's batch sizes and backlog signal are reused. Each batch is a short transaction that takes the hidden table'sROW EXCLUSIVElockNOWAIT, so a concurrent drop skips the table.__zerois a stored generated column (__n = 0 and every __count_i = 0 and every __sum_i = 0) with a partial index on the group columnswhere __zero. The GC runs under the ledger GC'senable_seqscan/enable_bitmapscan = off, so it walks that index.__zeroonly flips when a group empties or refills, so an apply that keeps a group non-zero still changes no indexed column and stays HOT. An index whose predicate tested the partials directly would have made every partial update non-HOT.n = 0with a sum still owed is never collected (Research: a group-level basis instead of the per-row ledger for invertible aggregates with no relationships (#897) #1043'sRV.gc-n0).__stampis nullable, with no default. A future absolute write (#1091 G3: Group-basis build and resume (chunked variant B and the one-GROUP-BY build) #1101/#1091 G4: Group-basis TRUNCATE, recompute producers and quarantine #1102) that sets a basis without a stamp keeps its group. Before, it would have been collected at stamp 0.ON CONFLICTthen inserts the group fresh.Plan control on the apply statement (Leaf correction's Re-derive page can plan a per-key nested loop when the root lookups find nothing (found in #1043's model) #1085's concern, J2's full-scan plans).
update … from t where u.ctid = t.__hcran withenable_nestloopoff. That made it a hash join over a sequential scan of the whole hidden table, under the page's group locks. It now also filtersu.ctid = any(array(select __hc from t where <nonzero>)), which gives a TID scan.x.g is not distinct from alias.g, which no index serves. So every page naming a group with aNULLscanned the hidden table, and the target in the visible-delete lookup, whole. It is now one indexed probe per pattern ofNULLcolumns: equality on the non-null columns,is nullon the others, behind a one-time filter. This covers up to 4GROUP BYcolumns (15 probes). Past that it keeps the old form.enable_seqscanis now off for the statement, alongsideenable_nestloop(bothSET LOCAL, reset after). The statement reaches real tables only through the group identity index or byctid. Without the setting, an un-analyzed or freshly grown table could be planned as a scan. The staging temp table has no index, so it is still scanned sequentially.Planner order / truncate barrier: no change, confirmed. Group basis: source TRUNCATE, the group recompute, and quarantine release (G4, part of #1102) #1144's review found the barrier already holds:
seal::seal_phase1's gate, pinned bytests/sealing.rsseal_gate_blocks_when_the_predecessor_fence_is_not_yet_published).next_claimable_segmentsnever returns a segment past the lowest undrained truncate-bearing one. It returns that segment only once every segment below it has drained.So segments are planned in order and the two-sided barrier holds at claim. Nothing is duplicated here.
JOIN's
P/Mapply (J3) isn't on this branch's base. When it lands, its group statements should get the same treatment: TID-array update and per-NULL-pattern probes. Its zero cells need this GC extended tojoin_<id>_p/_m, whose__stampis stillnot null default 0on that branch and has to become nullable the same way.G4 (#1102, merged as #1144) sets
__basisand__stampin its recompute's one statement (coalesce(max(seg_seq), 0)besidepg_current_snapshot(), after its lock statement), which is what the GC's soundness needs. Rebased onto it:hidden_table_ddlkeeps this PR's nullable__stampwith no default,__zeroand the partial index. No insert relied on G4'sdefault 0: the lock statement inserts only group columns (no basis, so a zero group from it is collectable), and the recompute always writes__stamp.apply_sqlkeeps G4'simage_columnsparameter and sharedvisible_tail_sql, with this PR's TID-array hidden update.group_lookupkeeps the per-NULL-pattern probes, which G4's recompute lookups use too.recompute_sql's hidden update gets the same TID-array form (u.ctid = any(array(select r.__hc from r)) and u.ctid = r.__hc). Its source lookup keepsis not distinct from.recompute_locked_groups) doesn't run underPLAN_SETTINGS, soenable_seqscan = offnever applies to its sourceGROUP BY. Only the apply statement andexplain_applytake it.the_staging_table_name_follows_its_shape(and its sibling) calledGroupTargetPlan::newwithout G4'ssource_pkargument, socargo test --libdidn't compile on main. This PR passes an emptysource_pkthere.G6's self_check (
staging/self_check_groups.rs) reads only the partial columns, never__basis,__stampor__zero, and it treats a group missing from the hidden table as all zeros. A GC'd zero group therefore compares equal, and its test harness never runs the GC. This PR updates that file's comment that the hidden table keeps a zero group's row.explain_applybuilds its plan with #1157's shape-hashed staging table name, as pages do.Plan evidence
Measured on a scratch PG 17 cluster with
shared_buffers = 1GBand a hidden table of 1M groups. Each page touched 4,000 groups × 5 transactions. Times are three runs,EXPLAIN ANALYZE.u.ctid = t.__hc, hash join + Seq Scan on 1M rows)u.ctid = any(array(select __hc from t))(Tid Scan)enable_seqscan = offg = any(array(...))index scan + hash join instead of the lateral probesI rejected the last alternative: it gains about 5%, and doesn't extend to multi-column groups or the
NULLpatterns. After the change, the remaining cost is the lateral index probes (about 30 ms) and the visible upsert's 4,000 conflict arbitrations (about 23 ms).Verification
tests/group_basis_interleavings.rsadds the following. All are deterministic, with no polling (Flaky CI: a_composite_key_backfill_chunks_a_boundary_inside_a_group_exactly_once times out under runner load #297).r4_the_gc_bounds_a_hidden_table_whose_groups_churn: 5 rounds of filling and emptying 1,000 new groups. Each GC collects exactly the 1,000 zero groups, and the hidden table goes back to its one live group.r4_a_group_with_a_nonzero_partial_stays(Research: a group-level basis instead of the per-row ledger for invertible aggregates with no relationships (#897) #1043RV.gc-n0): after out-of-order pages, the group sits atn = 0, sum = -2, and the GC keeps it.r4_a_zero_group_with_a_basis_stays_until_the_prefix_passes_its_stamp(Research: a group-level basis instead of the per-row ledger for invertible aggregates with no relationships (#897) #1043R.recreate-gc-race): a later segment has drained, and the GC still keeps the group while C1's segment hasn't. C1 is then refused, and the group goes once the prefix passes its stamp.r4_a_basis_without_a_stamp_keeps_its_group.r4_the_gc_skips_a_group_a_page_holds: the GC returns 0 while a frozen page holds the zero group, and the page then refills it.r4_a_page_meeting_a_group_the_gc_is_deleting_inserts_it_afresh: the other order. A GC delete is held open when a page refilling the group starts; the page's lock statement queues on it, and once the GC commits it inserts the group afresh, with no basis, and applies its change.every_pattern_of_null_group_columns_finds_its_group: twoGROUP BYcolumns, all fourNULLpatterns, fills, moves and empties, checked against Postgres after every page.the_apply_statement_reads_no_table_whole: a plan-shape test that the hidden update is aTid Scan on agg_*whose TID condition isctid = ANY(...), and that nothing is aSeq Scan on agg*.the_zero_group_gc_walks_its_index: a plan-shape test that the GC uses an Index Scan onagg_*_g_idxand has no Seq Scan.The test-side
recomputenow writes__stamp(the newest segment, read in the same statement as the snapshot, R5).Before/after:
the_apply_statement_reads_no_table_wholefails withSeq Scan on agg_1 uunder the hash join. Withenable_seqscan = offalone, the G2a form gets a disabled nested loop probing each hidden row byctidon the test's tiny tables, so the test also requires thectid = ANYcondition; reverting only the update form fails it.is not distinct fromform fails the plan test (Seq Scan on agg_1 x_1,Seq Scan on agg x_3). Dropping the all-NULLpattern's probe failsevery_pattern_of_null_group_columns_finds_its_group(the(NULL, NULL)group goes missing).skip lockedfrom the GC makesr4_the_gc_skips_a_group_a_page_holdshang behind the frozen page.__n = 0alone fails those two andr4_a_group_with_a_nonzero_partial_stays.early_tombstone_gcplant: there the stamp equals the highest drained segment.Checked by hand on a scratch PG 17 cluster: with the generated
__zeroand its partial index, 1,000 updates that keep groups non-zero were all HOT (n_tup_hot_upd1000/1000, the same as a table with no partial index). With an index predicate on the partials directly, none were HOT (0/1000). The GC's index scan keeps__zero AND (__basis IS NULL OR __stamp <= $1)as a filter underLockRows, so EPQ re-checks it on a row a page refilled after the GC's snapshot.Also checked by hand: with 5
GROUP BYcolumns (past the per-pattern limit), the lookup falls back tois not distinct frombehind its one-time filter, and pages with every mix ofNULLs stay exact. With the partial index dropped, the GC skips the table and pages are unaffected.verify(after the rebase onto main at aaa970d, with G4 and Name the group-basis staging temp table after its shape, so a pooled connection never reuses an old-shape table (part of #1142) #1157):VERIFIED: tree f0198e11f0 green, passed=3489 failed=0 ignored=38.Benchmarks: logbook
20261017T212057-a-bench. The runs are on tmpfs, so they measure CPU only. Each probe ran 8 workers at 400k rows/s offered for 20 s, and all three configurations ran in the same session:5a9b2f1bwith--group-basis;--group-basis.Contamination. Other lanes'
cargo testruns and a fullverifyoverlapped the first series (about 21:28–21:50), and the bench script flagged nearly every probecontended. The first G2a-state runs also crashed at 40,000 groups on a/tmpDSM quota error (infra:Disk quota exceeded/Resource temporarily unavailableresizingpg_dynshmem). I therefore re-ran every cell that wasn't clean, one probe at a time and up to 4 tries each, until the script reported it uncontended. The table uses only uncontended probes.Re-run cells: all of G2a; G2b group-contention at 10, 400, 4,000 and 40,000 and fold-in at 40,000; ledger group-contention at 10, 400 and 40,000 and fold-in at 40,000.
Kept from the first series (already clean): ledger group-contention at 4,000 and fold-in at 400 and 4,000; G2b fold-in at 400 and 4,000.
The 1M-group probes ran in the second re-run series: G2a was clean on its first try, and G2b on its second (its first was flagged contended, at 49.9 µs/row).
Group-contention and fold-in-ratio 10 at 40,000 groups are the same workload: 400,000 rows/s over 40,000 groups. They are two samples of one shape, not two shapes.
All probes drained with the oracle OK, except the G2a probe at 1M groups, which didn't drain.
The 40,000-group case doesn't move. G2a measured 88k vs. the ledger's 115k. Here it is 90k (G2b) vs. 96k (G2a) vs. 112k (ledger) in group-contention, and 89k vs. 90k vs. 121k in fold-in. The hidden table at 40,000 groups is small enough that the whole-table hash join was a minor cost there. The gap is lock serialization: every page locks most groups. Row-lock waiters average 1.9–2.1 backends on the group basis vs. 1.1–1.5 on the ledger, and
insert … on conflict(the lock statement) is the top row-lock statement. Closing it needs shorter lock holds or fewer groups per page, not plan control. I suggest filing it as its own issue if the default flip needs parity at that shape.__zerogenerated column, and the partial index that changes only on empty/refill transitions) don't show: CPU and WAL per row are unchanged at every size.Reviewer focus
collect_zero_groupsinstaging/retire.rsandcollect_zero_groups_sqlinstaging/group_basis.rs:__zero and (__basis is null or __stamp <= $1);order by <index cols> limit $2 for update skip locked(LockRows sits below the Limit, so skipped rows don't count against it);__zeroon a row a page refilled after the GC's snapshot.__stampin their snapshot's statement (R1, R5). The module doc's "Zero groups" section states that contract.group_lookup's per-NULL-pattern branches: the guard is exact (is null/is not nullper column), so at most one branch matches.🤖 Generated with Claude Code