Skip to content

feat: partition_create_ahead keeps a time-partitioned parent ahead of now (#1297) - #1308

Merged
jdatcmd merged 5 commits into
commandprompt:mainfrom
OffgridwithJD:feat/1297-create-ahead
Oct 13, 2026
Merged

jdatcmd merged 5 commits into
commandprompt:mainfrom
OffgridwithJD:feat/1297-create-ahead

Conversation

@OffgridwithJD

Copy link
Copy Markdown
Collaborator

pgcolumnar.partition_create_ahead: the create half of #1297

partition_create_ahead(parent regclass, width interval, ahead int) returns int creates columnar partitions, each width wide and contiguous with the highest existing upper bound, until they reach now plus ahead widths. A second call creates none. It goes into pgcolumnar--1.0-alpha5.sql and the alpha4→alpha5 upgrade script, per the gate change on #1283.

The bug the new arms found in my prototype

For a timestamp key, which has no zone, the prototype read existing bounds as timestamptz in the session's zone and wrote new ones converted from UTC.

  • In America/New_York, partitions had a four-hour gap: 2026-10-11 00:00:00 followed by 04:00:00. A row at that boundary had nowhere to go.
  • In Asia/Tokyo, "daily" partitions started at 15:00.

A timestamp key is now handled in its own type: bounds are read as timestamp, "now" is the session's localtimestamp, and nothing is converted. timestamptz was already right.

Tests (written first; the original 14 arms were red on main)

  • test/partition_lifecycle.sh: 23/23 on PG15–19.
  • test/pytest/test_partition_lifecycle.py, independent of the shell suite: 23/23 on PG15–19. It uses six-hour widths, America/Los_Angeles, and Asia/Kolkata for a half-hour offset. compare_to_bash: 17 literal and 3 template matches, 0 missing.
  • Both continue from hand-made partitions in sessions west and east of UTC. They check contiguity, that a row at every boundary routes, coverage of the session's local now, and alignment to the width in the key's own wall clock.
  • native_upgrade_converge: 21/21. Every released version reaches the same catalog as a fresh install.

Removal proofs, each red in both twins: the prototype's zone handling; CREATE TABLE ... PARTITION OF (the lock arm); no DEFAULT refusal; starting from now instead of the highest bound (the second call then fails); heap partitions; stopping one width short (the coverage arms); and a one-minute gap between partitions (the contiguity and boundary arms).

Registries

🤖 Generated with Claude Code

@jdatcmd jdatcmd left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of 0d8e3f7. The lock design is right: a standalone CREATE then ATTACH, with SHARE UPDATE EXCLUSIVE on the parent. The timestamp-zone fix and its arms are good work. Four inputs break the function, though, each measured.

How I measured

I ran the function body from 0d8e3f7 on PG18 as public.pca, with USING pgcolumnar replaced by USING heap. The DDL paths are core's, so the access method doesn't change these answers. SET TimeZone = 'UTC'.

parent call result
a partition FROM ('2026-10-12') TO (MAXVALUE) pca('m1','1 day',2) ERROR: partition "m1_20261012T000000" would overlap partition "m1_tail"
w int GENERATED ALWAYS AS (v * 2) STORED pca('g1','1 day',1) ERROR: column "w" in child table must be a generated column
a 63-character table name pca(<name>,'1 day',2) NOTICE: identifier ... will be truncated, then ERROR: relation ... already exists
empty, timestamptz pca('s1','1 second',20000) 6 min 37 s, then ERROR: out of shared memory, 0 partitions
an identity column pca('i1','1 day',1) fine: 2 created, and an insert gets id 1

Blocking

  1. Generated columns. LIKE ... INCLUDING DEFAULTS INCLUDING CONSTRAINTS copies a stored generated column as a plain column, and ATTACH refuses that. Any parent with a generated column can't use the function. Add INCLUDING GENERATED, and an arm with a generated column on the parent. Identity is fine as it is (row 5).

  2. Long names. The suffix _YYYYMMDDTHH24MISS is 16 characters, so any parent name over 47 characters truncates, and the second partition collides with the first. Either truncate the prefix to 63 - 16 characters yourself, so the date part always survives, or refuse with a clear SQLSTATE. The arm: a 63-character parent with ahead = 2.

  3. No bound on the work. One call with a small width, or a parent whose newest partition is far in the past, tries to create everything in one transaction. It runs for minutes holding locks, then fails on the lock table with nothing created. Refuse up front when the count would exceed a limit, with 22023 and a HINT naming the count, before any DDL. You can compute the count exactly from lo, target and width, except for month widths, which can use an upper estimate. A fixed cap such as 1000 is simple; tying it to max_locks_per_transaction is more exact. The arm: the refusal fires, nothing is created, and it returns quickly.

Should fix

  1. MAXVALUE (and DEFAULT-like tails). The regex TO \('([^']+)'\) doesn't match TO (MAXVALUE), so that partition is ignored, and the first new one overlaps it. Today that surfaces as an overlap error and nothing is created, so it fails safe, but the message doesn't say why. Refuse with 55000 ("has a partition bounded by MAXVALUE"), as you do for DEFAULT, and add an arm.

Checked, no change

  • Invoker rights, SET search_path = pg_catalog, pg_temp, and parent rendered through regclass: safe. %I is used for the new names.
  • Day widths across DST: contiguity follows from adding to the highest existing bound, and your west/east arms cover it.
  • The drop half staying out: agreed. It belongs with the daemon (#1307), once both have landed.

@OffgridwithJD

Copy link
Copy Markdown
Collaborator Author

All four are taken, and each has an arm I've seen red against 0d8e3f7 on PG17 for the reason you measured:

a parent with a generated column gets its partitions, and the column stays generated   got [42804|]          want [2|42]
a parent with a 63-character name gets partitions whose names keep their dates         got [NOTICE: 42622 ...]
a call that would create more than 1000 partitions is refused before any DDL           got [53200|0|took 666300 ms]  want [22023|0|prompt]
a parent with a partition bounded by MAXVALUE is refused, and nothing is created       got [42P17|1]         want [55000|1]

The 20,000-partition call took 11 minutes here, then ran out of shared memory, which confirms your 6 min 37 s.

The fixes:

  1. INCLUDING GENERATED.
  2. The name prefix is shortened by whole characters until prefix + the 16-byte date fits in 63 bytes. The twin's version of the arm uses 31 two-byte characters, so the truncation must never split a character.
  3. A dry run of the loop counts the partitions, capped at 1001, before any DDL; over 1000 it raises 22023 with a HINT. That handles month widths exactly too.
  4. A TO (MAXVALUE) bound is refused with 55000.

Both twins are 27/27 on PG15–19. Mutation runs are going now: the seven earlier mutants, plus one per fix, each red where aimed, in both twins for the four new ones. I'll push once they and the ledger replay are done.

🤖 Generated with Claude Code

jdatcmd and others added 5 commits October 12, 2026 23:57
…nt ahead of now (commandprompt#1297)

Creates columnar partitions, each width wide and contiguous with the highest
existing upper bound, until they reach now plus ahead widths. Standalone CREATE
then ATTACH, so the parent is held SHARE UPDATE EXCLUSIVE, never ACCESS
EXCLUSIVE. A DEFAULT partition is refused (55000).

Found by new arms against the prototype: a timestamp key's bounds were read as
timestamptz in the session's zone and written converted from UTC, so in
America/New_York partitions had a four-hour gap (a row at the boundary had
nowhere to go), and daily partitions made from Tokyo began at 15:00. A timestamp
key is now reasoned about in its own type, with localtimestamp as now.

partition_lifecycle.sh and its independent pytest twin: 23/23 on PG15-19 each.
Five mutants red in both (the prototype's zone handling, PARTITION OF, no
DEFAULT refusal, starting from now, heap partitions). Upgrades from every
released version converge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The pytest twin covers every shell property (compare_to_bash: 17 literal, 3
template, 0 missing). Ledger: 23 rows from a five-major run, 17 dated by seven
mutations merged one log at a time with --mutation and --target; the six
'never' rows are premises. One check a mutation reddened is no longer named a
premise (commandprompt#1236). Census 1674 -> 1680. TESTS.md section 92; cluster_tests 499 by
collection. Guard half 413 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…work bound and MAXVALUE (commandprompt#1308 review)

Four inputs @jdatcmd measured breaking the function, each an arm red against
0d8e3f7 on PG17 for the reason measured:
- a stored generated column: LIKE now INCLUDING GENERATED (was 42804 at ATTACH);
- a 63-character parent: the prefix is shortened by whole characters until
  prefix + the 16-byte date fits in 63 bytes (was a truncated name colliding);
- no bound on the work: a dry run counts the partitions before any DDL and
  refuses more than 1000 with 22023 (one call ran 11 minutes, then 53200);
- a MAXVALUE upper bound: refused with 55000 (was an overlap, 42P17).
The pytest twin's long name is 31 two-byte characters, so truncation must never
split one. Both twins 27/27 on PG15-19; one mutant per fix, red in both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ith the commandprompt#1308 review's arms

27 rows from a five-major run, 21 dated by eleven mutations merged one log at a
time with --mutation and --target; the six 'never' rows are premises. Census
1694 -> 1700. TESTS.md section 93; cluster_tests 501 by collection. Docs and
CHANGELOG name the MAXVALUE and 1000-partition refusals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@OffgridwithJD

Copy link
Copy Markdown
Collaborator Author

All four are fixed at 9dcbd59, rebased onto d7c9951 (after #1312).

The fixes (dba7f5b): INCLUDING GENERATED; a byte-safe name prefix that always keeps the 16-byte date suffix; a dry-run count before any DDL that refuses more than 1000 partitions with 22023 and a HINT; and TO (MAXVALUE) refused with 55000. The sql-reference and how-to entries name both new refusals.

Red, then green, in both twins:

  • Each new arm was red against 0d8e3f7 on PG17 for the reason you measured (42804, the truncation collision, 53200 after 11 minutes, 42P17).
  • Both twins now pass 27/27 on PG15–19.
  • The twin's long name is 31 two-byte characters, so the shortening must work in whole characters.

Removal proofs: eleven mutants on PG17, each red on its arm: the seven from the first round, plus one per fix (no_generated, no_prefix_trunc, no_count_bound, no_maxvalue_check). The four new ones are red in the pytest twin too, each under its own named arm.

Registries:

  • Ledger replayed onto main's (1945/1694): 27 rows, 21 dated, and the six never rows are all premises. No premise is dated. Census 1700.
  • TESTS.md section 93; cluster_tests 501 by collection.
  • Guard half: 413. harness_selftest PG17: 1223.

One honest note on the ledger: the logs ran before #1306 and #1312 merged. Those touched only columnar_tableam.c's TID range and cursor paths, which this suite never reaches, and the budget comment says so.

🤖 Generated with Claude Code

@OffgridwithJD
OffgridwithJD force-pushed the feat/1297-create-ahead branch from 0d8e3f7 to 9dcbd59 Compare October 13, 2026 00:03

@jdatcmd jdatcmd left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved at 9dcbd59. I re-ran my original probe against this head's function body, without relying on the PR text. It's the same harness as before: PG18, USING heap substituted, TimeZone = UTC.

case 0d8e3f7 9dcbd59
a partition TO (MAXVALUE) overlap error ERROR: public.m1 has a partition bounded by MAXVALUE, refused before any DDL
a stored generated column column "w" in child table must be a generated column 2 partitions; INSERT (ts, v) = (now(), 21) gives w = 42
a 63-character parent, ahead = 2 truncation, then already exists 3 partitions, ..._abc_20261013T000000 through ..._20261015T000000, with the date suffix intact
'1 second', ahead = 20000 6 min 37 s, then out of shared memory refused in 1.2 ms, 0 partitions
'1 minute', ahead = 1001 (1002 partitions) refused in 0.7 ms, 0 partitions

I accept your ledger note: #1306 and #1312 touched only the TID range and cursor paths, which this suite never reaches. I'll merge once CI is green.

@jdatcmd

jdatcmd commented Oct 13, 2026

Copy link
Copy Markdown
Collaborator

Merging now: approved at 9dcbd59, 15/15 green.

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.

2 participants