Repository navigation
feat: rollups phase 2, dirty buckets: deletes and rewrites are recomputed, history is never (#1294) - #1328
Conversation
OffgridwithJD
left a comment
There was a problem hiding this comment.
Review at 3dd2ebd. I read the whole refresh_rollup and the catalog change, ran the twins on PG17 (rollup.sh 70/70, test_rollup.py green), and probed one case adversarially. The dirty-bucket logic holds up on every path I traced:
- Delete and update: the deleted count moves.
compact_rewriteand VACUUM: the group is gone, its new groups fold only into clean buckets, and the recompute counts their rows once.- The NULL bucket.
- DST: the
wabsseconds width. - The history overlap: dirty spans are bucket-aligned, so any history row inside a dirty bucket overlaps it.
- One snapshot for all of it.
- The data-modifying CTEs on disjoint rows:
updonly touches clean buckets, andclearedonly dirty ones.
One silent wrong answer: a delete, then expire, before the next refresh
If rows are deleted from a group and expire then retires that group, the next refresh treats it as retiring and records its range as history. Its buckets keep counting the deleted rows. Nothing is refused or reported, and the rollup is wrong for good.
Reproduced on PG17 at 3dd2ebd:
CREATE TABLE s (ts timestamptz, v int) USING pgcolumnar;
SELECT pgcolumnar.set_options('s', stripe_row_limit => 1000, chunk_group_row_limit => 1000);
INSERT INTO s SELECT now() - interval '60 days' + g * interval '1 s', g FROM generate_series(1, 1000) g;
INSERT INTO s SELECT now() - interval '1 day' + g * interval '1 s', g FROM generate_series(1, 1000) g;
SELECT pgcolumnar.create_rollup('r', 's', 'ts', '1 day', '2000-01-01', '{}', '{count}', '{*}');
SELECT pgcolumnar.refresh_rollup('r'); -- 2000
DELETE FROM s WHERE ts < now() - interval '30 days' AND v <= 400; -- 400 old rows
SELECT pgcolumnar.set_options('s', ttl_column => 'ts', ttl_interval => '30 days');
SELECT pgcolumnar.expire('s'); -- 1
SELECT pgcolumnar.refresh_rollup('r'); -- 0, no error
SELECT sum(count) FROM r WHERE bucket < now() - interval '30 days'; -- 1000, the source had 600The control is the same delete with no expire in between: the refresh recomputes, and the count is 600. The docs now say "Deletes and updates are recomputed", so this is the case where they silently aren't.
Why it happens: changed excludes every retired group (rg.group_number NOT IN (SELECT ... FROM ret)) before the deleted count is compared. The group is gone, so there's nothing to compare against anyway: pgcolumnar.retired_group records only the group number.
Two ways I can see to close it. You know the design better, so tell me if there's a third.
- Refuse, as for history.
expirealso writes the group's deleted count at retirement intoretired_group(#1314's catalog; that's one column). Aretiringgroup whose count differs from the recordedrollup_group.deleted_countsetsblocked, and the refresh refuses with0A000and the samerebase_rolluphint. The buckets can't be recomputed, because the rows are gone, but the user is told instead of silently miscounted. - Order the daemon. Have it refresh a source's rollups before it expires that source. That narrows the window but doesn't close it, because a manual
expirestill reaches it. So I'd want (1) in any case.
The arm I'd add, in both twins: the repro above, requiring either the 0A000 refusal or a count that equals the source plus history. Without the fix it reads 1000 against 600.
Smaller
- The ledger statement is right on main at 4c72242: 1796 → 1797. I didn't recount the rows.
- Not reviewed: the pytest DST arm's choice of Berlin over New York, beyond reading it; and
rebase_rollup's whole-source read cost on a large table. The docs say it reads everything, and that's fine for a recovery operation.
A delete, an update or a rewrite in a rollup's source must make the buckets of the changed row groups dirty, and a refresh must recompute them from the source and leave every other partial alone. Retained history is never recomputed: a refresh that would recompute a bucket holding a retired group's rows is refused with 0A000, and the rollup is unchanged. Arms, in both twins: delete, an update that moves rows across buckets, a delete plus an append straddling its bucket, compact_rewrite, a deleted NULL-ts row, the two history cases (refused, and folding next to history), history retired by the same refresh, a DST day, and the record kept tidy (recount, forget, retired groups dropped). On main (PG17) the shell twin is 7 red, each 0A000 or a stale rollup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
refresh_rollup no longer refuses deletes and rewrites in the source. In one statement, so one snapshot, it: - treats a recorded group that is gone, or whose row or deleted count moved, as changed, unless expire retired it; - recomputes from the source every bucket a changed group's recorded time range covers, plus the NULL bucket when the group had rows without a ts; - folds the new groups' rows into the clean buckets only, because the recompute already counts their rows in the dirty ones; - refuses with 0A000, writing nothing, when a dirty bucket meets the history of groups expire retired. rollup_group records each group's ts range at fold time, since a group that is gone has no zone maps left. The retired groups' ranges collect in the rollup's new history column (tstzmultirange), not in rollup_group: after a rewrite, the group numbers that rebase records can repeat a retired group's number. rebase_rollup now reads each group's range from its rows. The width is added as plain seconds, because a timestamptz plus '1 day' in the session's zone is 23 or 25 hours across a DST change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The how-to, the SQL reference and the CHANGELOG said a refresh refuses any delete. Phase 2 recomputes the changed buckets instead, and refuses only when a bucket to recompute holds retained history. refresh_rollup now returns the rows it read, folded and recomputed. docs_style 58/58. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The row count was compared too, as a guard. Mutated away, it left all 70 checks green on 15-19, compact_rewrite arm included. No arm needs it, so it is dropped rather than kept untested. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ORIGIN in the pytest twin is 06:00 UTC, so the day bucket the first fixture aimed at began after Berlin's 01:00 UTC switch, and a session-zone '1 day' was 24 hours there: the arm stayed green under the mutation that adds the width in the session's zone. The rows now end at 05:40 UTC in the bucket [03-28 06:00, 03-29 06:00), which the switch is in. Under that mutation the arm is red on PG17; under each of the other eleven, the twin is red at the arm the shell twin names. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…1328 review) Found by @OffgridwithJD: rows deleted from a group that expire retires before the next refresh are folded into history, and their buckets keep counting them (1000 where the source had 600), with no error. The arm, in both twins, requires the refresh to refuse with 0A000 and leave the rollup unchanged. On PG17 before the fix the shell twin read ok and 1000. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… retired (#1328 review) expire now records each retired group's deleted count in retired_group, read before the group's delete vector goes with it. A refresh that retires a recorded group whose count moved since the rollup last folded it refuses with 0A000: the rows are gone, so the buckets cannot be recomputed, and keeping the group's range as history would keep counting them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…1328 review) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@OffgridwithJD, thank you: that was a real silent wrong answer. It's fixed at 9683118, as your option 1. The tree is rebased onto c90976d (#1329). The fix. The arm, in both twins: your repro, requiring
Ledger: 1799 → 1801 against main at c90976d. Phase 2's rows are restated from main's numbers; then the new arm is dated, and its premise is never red. Main's other rows are unchanged. |
3dd2ebd to
9683118
Compare
OffgridwithJD
left a comment
There was a problem hiding this comment.
Approved at 9683118.
The fix. expire records the group's deleted count before the delete vector goes with it. delete_vector_pkey is unique per (storage_id, group_number), so expire's sum over the list and rollup_source_groups' join read the same single count. blocked compares it with the count the rollup last folded or recounted. That is exactly the delete-then-expire window, and nothing else.
Run on PG17 at this head:
| case | result |
|---|---|
| my repro | the second refresh refuses with 0A000, and the old bucket still reads 1000, unchanged |
| delete, refresh, then expire | no refusal, history 600 (the count was recounted before retirement, so they match) |
| delete and expire before the group was ever folded | the first refresh folds only the live group, 1000; the retired group was never recorded, so it is neither history nor refused |
rollup.sh |
72/72 |
ttl_expire.sh |
67/67 |
test_rollup.py + test_ttl_expire.py |
10 passed |
The third row is a choice rather than a bug. Rows retired before a rollup ever saw them never enter it. That reads right to me for retention.
|
Merging: approved by @OffgridwithJD at 9683118, and CI is green on that head. |
Phase 2 of #1294: dirty buckets. With this, the seven time-series features that gate alpha5 lose one more open item. The plan, and the four changes made to it while building, are on #1294 (plan, changes).
What changes for a user
refresh_rollupno longer refuses deletes, updates orcompact_rewritein the source. It recomputes from the source every bucket that a changed row group covered, and leaves every other bucket's row alone.0A000and writes nothing. It is also refused when rows were deleted from a group thatexpirethen retired before the refresh, because those rows can't be recomputed.rebase_rollupis the way out.pgcolumnar.retired_groupgainsdeleted_count, whichexpirerecords (found in @OffgridwithJD's review).refresh_rollupnow returns the rows it read: folded plus recomputed.rebase_rollupnow reads the whole source, to record each group's time range.How it works
The whole refresh is still one statement, so everything below shares one snapshot:
expireretired it. Retirement is read in this same snapshot. Read earlier, anexpirecommitting in between would make a retired group look deleted.[ts_min, ts_max]becomes a span of buckets, merged withrange_agg, plus the NULL bucket when the group had rows without a ts. A bucket's end is its start plus the width in plain seconds: atimestamptzplus'1 day'is 23 or 25 hours across a DST change.tsrange predicate per span so zone maps prune. New groups fold into clean buckets only, because the recompute already counts their rows in the dirty ones.pgcolumnar.rollup.history(atstzmultirange) andhistory_has_null. A dirty bucket that meets them sets a flag, and the function raises after the statement, so everything it wrote rolls back.Catalog:
rollup_groupgainsts_min,ts_maxandts_has_null.rollupgainshistoryandhistory_has_null. The change is in both the alpha5 install script and the alpha4→alpha5 upgrade, with byte-identical patches.Tests: both twins, the same check names
compact_rewriteexpirethen retires: refused (found in review)Red first: on main's refresh, the shell twin had 10 checks red on each of 15–19, with identical failure lists. The pytest twin stopped at the first,
got '0A000' want 'folded'.Green (phase 2 at 3dd2ebd; the review fix re-run at 9683118, below):
rollup.sh: 72/72 on each of 15–19 with the review fix, and 72/72 on PG17 at 9683118.test_rollup.py: green on each of 15–19, at this head.native_upgrade_converge: 21/21 on PG17.extension_upgrade: PASS on PG17, upgraded catalog byte-identical to a fresh install (210 objects).docs_style: 58/58.Mutations, each on 15–19 for the shell twin (with the same red set on every major) and on PG17 for the pytest twin. Each is red at the named arm in both:
rebase_rolluprecording no rangesFound by a mutation: the pytest DST arm was green under the zone mutation at first. That file's
ORIGINis 06:00 UTC, so the bucket I aimed at started after Berlin's switch. The rows were moved into the bucket that contains it (aaaf10d).Not covered by an arm: an
expirecommitting between two statements of one refresh. Nothing can force that deterministically. The fix is structural: there is no second statement.Ledger
checks_never_observed_redgoes from 1799 to 1801 against main at c90976d. The arithmetic is in the budget file. Main's other rows are byte-identical. Three phase-1 rows are removed with the behaviour they tested.🤖 Generated with Claude Code