Repository navigation
feat: incremental rollups, phase 1 (append-only) (#1294) - #1319
Conversation
jdatcmd
left a comment
There was a problem hiding this comment.
Review of 62d5fb9. This is the design we settled on #1294, implemented carefully. The single-statement refresh, IS NOT DISTINCT FROM key matching, the SUEL lock, the identity check, and the OFFSET 0 TID-range finding (114 → 15 blocks) are all good. One thing blocks: a hidden coupling that gives silently wrong rows on non-default builds.
Blocking: the row-number → TID mapping hard-codes 291
refresh_rollup builds each new group's TID range as first_row_number / 291 and % 291 + 1. In C, the mapping is COLUMNAR_VALID_ITEMPOINTER_OFFSETS, which is (uint64) MaxHeapTuplesPerPage (columnar.h:168). That's 291 only at the default BLCKSZ of 8192. On a PostgreSQL built with --with-blocksize=16 it's 584, and at 32 it's 1169.
Each new group's ctid range would then cover the wrong rows: some rows folded twice, some never, and nothing errors. A refresh is supposed to equal a full recompute, and this is a silent way for it not to.
Fix: don't restate the constant in SQL. Either expose it from C (pgcolumnar.row_tid(row_number bigint) RETURNS tid calling PgColumnarRowNumberToItemPointer is the most direct), or derive it from current_setting('block_size') with the same formula as MaxHeapTuplesPerPage. Arm: the SQL-side mapping equals the C-side ctid for the first and last row of several groups. That's red if the SQL constant ever disagrees, which a mutation to 290 or 292 shows.
Measured, no change needed
On PG18 assert, built from this head, I ran a 4500-row source in 1000-row groups. Every 7th device id is NULL and every 11th ts is NULL. There were two refreshes, with an append between them, and the rollup was '{count,sum,min,max,avg}' over '{*,v,v,v,v}', grouped by dev, in 1-hour buckets.
full recompute minus rollup 0 rows
rollup minus full recompute 0 rows
groups: full 457, rollup 457; NULL-dev groups 76; NULL-bucket groups 6
Both directions were compared as text, with avg rounded to 9 places.
So NULL group keys and NULL timestamps fold correctly, because IS NOT DISTINCT FROM is used in both the UPDATE and the anti-join. If neither twin has an arm with NULL keys and NULL ts, it's worth one: a future = in place of IS NOT DISTINCT FROM would duplicate the NULL partials, and only such a fixture would notice.
Noting, for the follow-up
With #1314 merged, the daemon's expire (#1307, once merged) retires source groups. In phase 1, every refresh after that is refused with 0A000 until rebase_rollup. That's correct for phase 1, but users who turn on both will hit it. Your keep-history follow-up resolves it; until it lands, a line in the docs would help.
|
All taken, and the retention follow-up is folded in, since #1314 has landed. Committed locally on a rebase onto 953c192. The five-major and mutation runs are going; I'll push when they're in.
So far both twins are 55/55 on PG15 and 16, and every major builds after a clean. That check caught 🤖 Generated with Claude Code |
…andprompt#1294) Against the API proposed on commandprompt#1294. Red on main: 22 checks need the functions. The source load's first draft overflowed int and left the table empty, and three arms passed on emptiness; each now has a premise that requires a real measure. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s 9-12) Structured aggregates (agg_functions, agg_columns) with a 22023 refusal per bad input; refresh_rollup and rebase_rollup take regclass; a rewrite of the source (pgcolumnar.vacuum, measured to renumber every group) refuses a refresh with 55000 until rebase_rollup records the new groups without touching the rollup. Red on main: 30 checks need the functions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
create_rollup / refresh_rollup / rebase_rollup, the design settled on commandprompt#1294. A rollup is a view over a partials table (count, sum, min, max, and avg as sum and count). A refresh folds the rows of the row groups appended since the last one in ONE statement, so the group list, the folded rows and the recorded groups share a snapshot; each new group is read through its own TID range (OFFSET 0 keeps the lateral subquery from becoming a join clause, which read every group: measured 114 blocks of 103 before, 15 of 126 after on a 1,000-row append). Phase 1 refuses deletes and rewrites (0A000); a rewritten source (new storage id) refuses with 55000 until rebase_rollup. READ COMMITTED only, as tier_partition. The catalogs are granted to PUBLIC under row-level security per owner; PUBLIC has no USAGE on schema pgcolumnar today, so a role needs that granted. test/rollup.sh 43/43 on PG16, including a non-superuser owner and a role that sees none of it. pgcolumnar.rollup_pause (superuser, developer) holds a refresh open for the mid-refresh-commit arm. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
commandprompt#1294) test_rollup.py is independent of the shell suite: its own source, 15-minute buckets, aggregates (an integer avg, so exact), zones, row counts and roles; compare_to_bash covers every shell property. Both twins 45/45 on PG15-19. Nine mutants, each red on its arm: no OFFSET 0 (the cost arm), no isolation check, no lock (two refreshes double-fold), no identity check, no changed-group check, the folded groups recorded from a fresh read (rows committed mid-refresh lost), min combined as max, no row-level security, and a rebase that clears the rollup. How-to and sql-reference sections, held by the arms. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…dprompt#1294) Twelve more mutants showed three arms that did not hold their own guard: a shorter columns array was caught by the column check, a missing column under sum by the cannot-be-folded check, and a refused create that succeeds leaves a table -- so that check is an arm, not a premise (commandprompt#1236). The length arm now passes more columns than functions, the column arm uses min. All 21 mutants red. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
commandprompt#1236) 'the paused refresh folded what was there before it began' and 'the zone rollups hold a row per key of the recompute' were reddened by the first-group and ignore-snapshot mutations; 'the rollup table exists with the documented columns' describes the view the code builds. A check a repository change can redden is an arm. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…g from the AM (commandprompt#1319 review) - Retention keeps history (the owner's decision on commandprompt#1294): a recorded group that expire retired (pgcolumnar.retired_group, commandprompt#1314) leaves the refresh's snapshot, and its buckets keep what they folded. Any other missing group is still refused. Arms: after expire, a refresh folds nothing, refuses nothing and keeps every bucket; an append still folds; recent buckets equal a recompute while old ones keep their rows. - pgcolumnar.row_tid(bigint) exposes the AM's row-number-to-TID mapping, and the refresh uses it instead of restating 291 rows a block in SQL, which held only at BLCKSZ 8192 (@jdatcmd). Arm: every row group's TID range holds exactly that group's rows. - NULL group keys and NULL timestamps: an arm in each twin (@jdatcmd measured them right). Both twins' digests used ||, which turns a row with any NULL into NULL and drops it from string_agg; they now concat_ws over coalesce. Both twins 55/55 on PG15 and PG16. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ndprompt#1236) Two were dated by mutations (the history rollup's fold and the NULL-key rollup's second refresh); five more read the rollup or the refresh the same way. The five premises left are fixture and environment facts: the functions exist, the source's rows, vacuum's renumbering, the NULL fixture, and expire's retirement. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…they follow (commandprompt#1294) The ledger keys a check by its name; six checks shared one, so a red in one would have been recorded against the others. The gate refused it. Also cluster_tests 510 -> 511 and the TESTS.md notes for jdatcmd's review. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ommandprompt#1294) Clean runs on this tree (after commandprompt#1307): rollup.sh and test_rollup.py 55/55 on PG15-19. Each mutation log merged one at a time with --mutation and --target. Census 1686 -> 1698; rows 2058; the gate agrees. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
62d5fb9 to
c6f097d
Compare
|
Pushed. Head is c6f097d, rebased onto b6656ac (after #1307). Every point of your review is in it, as listed in my comment above. The gate found something before this push. One check name, Evidence
Separately, found while checking that run: |
jdatcmd
left a comment
There was a problem hiding this comment.
Approved at c6f097d. I verified the fixes on this head's build (PG18 assert), without relying on the description.
The block-size coupling is gone: refresh_rollup contains no 291; pgcolumnar.row_tid() appears four times.
Retention, the keep-history default, measured. The source: 3000 rows 30 days old in three 1000-row groups, plus 1000 current rows with every 11th ts NULL and every 7th device NULL, under a 7-day TTL. The rollup: {count,sum,min,max,avg} by dev, in 1-hour buckets.
refresh 4000 folded
expire 3 groups retired, 3 rows in retired_group
refresh after expire 0 folded, no refusal, rollup identical to before (EXCEPT both ways: 0)
append 1000, refresh 1000 folded
buckets the source still backs full recompute vs rollup: 0 / 0 differences, 210 groups (NULL ts and NULL dev included)
buckets whose raw rows expired 305 kept, still counting their 3000 rows
My first try at this retired nothing: my fixture put NULL timestamps in the old groups, and expire rightly keeps those. So the run above puts the NULLs only in current rows.
The duplicate check name that your gate caught (and the rollup equals a full recompute ×6) is a good find, and so is throwing away every earlier log because of it. I'll merge once CI is green.
|
@OffgridwithJD, CI at c6f097d fails Every SQL entry point has to be placed in one of
My approval stands on the content. I'll merge once the bucketing is in and CI is green, after comparing the new head's other files against c6f097d. |
…assert create_rollup's refusal (commandprompt#1319 CI) CI at c6f097d: entry_point_privilege's coverage arm found create_rollup and row_tid in no privilege bucket. - row_tid is EXEMPT: it maps a row number to a TID by arithmetic, takes no relation and reads nothing. - create_rollup is relation-taking by its SECOND argument, so it joins RELATION_BY_OTHER_MEANS (two -> three, the arm renamed to say so). - It has no SELECT check of its own; it reads the source's storage id through get_storage_id, which refuses a caller that cannot SELECT the table. Both twins now assert that refusal (42501), and the arm was red only under a mutation removing get_storage_id's check. Clean: rollup.sh and test_rollup.py 56/56, entry_point_privilege 31/31, on PG15-19. Ledger 2059 rows, census unchanged at 1698; the gate agrees. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Fixed at 3282b28, one commit on top of c6f097d. The other files' patches are unchanged.
Evidence on this tree: My miss: I ran my own suites and the guards, but not |
jdatcmd
left a comment
There was a problem hiding this comment.
Re-approved at 3282b28. The one commit on top of c6f097d touches only tests and the ledger (entry_point_privilege.sh, both rollup twins, TESTS.md, ledger and budget); git diff --stat c6f097d 3282b28 shows no source file. Run here on PG17 from this head: entry_point_privilege is 31/31, including coverage: no entry point is unbucketed and the relation-by-other-means class is the three that are known.
Both buckets are right. row_tid is EXEMPT because it takes a bigint and reads nothing. create_rollup is relation-by-other-means, because its relation is the second argument. Asserting create_rollup's refusal is also right: it's held by get_storage_id's check, and your arm now proves that, where before it was only assumed. Merging when CI is green.
|
Merging now: approved at 3282b28, 15/15 green. |
Incremental rollups, phase 1: append-only (#1294)
This follows the design settled on #1294, points 1–12, with the owner's decisions. The SQL goes into the alpha5 scripts.
The model. A rollup is a view over a
<rollup>__partialstable, keyed by (date_binbucket, group_by...). The table holds count, sum, min and max, plus a sum and a count for each avg. The view turns those into the aggregates.The refresh. It folds the rows of the row groups appended since the last refresh, in one statement (point 1). The group list, the folded rows and the recorded groups therefore share one snapshot. Each new group is read through its own TID range, which #1306 made possible.
Settled points, each an arm
pgcolumnar.rollup_pause) are folded by the next refresh, exactly once.date_bin(point 4): refreshes from sessions in different time zones build the same rollup. A width with months is refused (22023).float8column is within a relative 1e-12. The two twins cover one case each.25000).55000.rebase_rollupthen records its groups without touching the rollup.regclass.22023, with an arm per refusal.access_methodoption is in, and a heap rollup behaves the same. Your columnar-vs-heap measurement is still to do.0A000), and the rollup is left unchanged. Dirty buckets are phase 2.What the arms found while it was written
OFFSET 0keeps it a scan, at 15 blocks. The cost arm requires a refresh after a small append to read under a fifth of the source, and at least one block.pgcolumnar.vacuumgives a table a new storage id and may keep its group numbers, so the identity check uses the storage id.postgreswent past the policies on the rollup catalogs. So an owner and a stranger are tested as ordinary roles: the owner creates, refreshes and reads its rollup, and the stranger sees none of its catalog rows and can't refresh it. PUBLIC has no USAGE on schemapgcolumnar, by design (docs/administration.md), so the arms grant it as an administrator would. The docs say so.int, leaving the table empty, and three arms passed on emptiness. Each now has a premise that requires a real measure.Tests
test/rollup.shand the independenttest/pytest/test_rollup.py: 45/45 on PG15–19, each.compare_to_bashcovers every shell property.21 mutants, each red on its arm:
OFFSET 0;date_truncbucketing;access_methodignored;Three arms didn't hold their own guard on the first try: a shorter columns array was caught by the column check, a missing column under
sumby the cannot-be-folded check, and a refused create that succeeds leaves a table. Their inputs were sharpened so each now discriminates.Registries
never: 7 premises, plus 2 arms the budget comment names. Census 1705 → 1714, and the gate agrees.native_upgrade_convergePG17: 21/21, so the rollup catalogs, policies and grants converge.harness_selftestPG17: 1223.Next, as agreed on #1314
Once #1314 lands, the refresh will honour
pgcolumnar.retired_group. A recorded group that's missing because retention retired it is dropped from the snapshot, and its buckets are left alone; that's the owner's keep-history default. It needs no dirty buckets, so it comes as a follow-up PR with its own arm.🤖 Generated with Claude Code