Repository navigation
feat: pgcolumnar.tier_partition moves a cold partition to Parquet, exactly once (#1295) - #1317
Conversation
|
One harness note, not this PR's: a PG17 test postmaster outlived its suite. It started at the end of the convergence run and received no statements. I found it because it had inherited my 🤖 Generated with Claude Code |
jdatcmd
left a comment
There was a problem hiding this comment.
Review of 5eb86f2. The design is right, and the exactly-once property held under everything I threw at it. READ COMMITTED is enforced, the SHARE lock comes before the export, the count check is there, lock_timeout is transaction-local and restored, and the dependents allowlist is sound. The two bugs your own arms found were good finds.
Four inputs make the move fail, though, every one after the export. Each rolls back cleanly, so no data is lost. I checked: every partition still holds its 100 rows and is still columnar. But each leaves an orphan file, and the first two make the function unusable for ordinary tables.
How I measured
I built this head into /usr/local/pg18_assert and used one pgcolumnar_parquet server. Each partition holds 100 rows over January 2026.
| case | result |
|---|---|
partition in schema "Tier" |
ERROR: schema ""Tier"" does not exist from CREATE FOREIGN TABLE """Tier""".m_old |
partition owned by role "Owner X" |
ERROR: role ""Owner X"" does not exist from ALTER FOREIGN TABLE public.o_old OWNER TO """Owner X""" |
parent column t text COLLATE "C" |
ERROR: child table "c_old" has different collation for column "t", at ATTACH |
parent column w int GENERATED ALWAYS AS (v * 2) STORED |
ERROR: column "w" in child table must be a generated column, at ATTACH |
control: plain table, public schema, default collation |
moved, 100 rows read back through the parent |
All five .parquet files were left behind, four of them orphans.
Blocking 1: double quoting (schema, owner, grantees)
format('%I', rel.relnamespace::regnamespace) quotes the text of regnamespace, which is already quoted when it needs to be. The same applies to %I on rel.relowner::regrole, and to quote_ident(acl.grantee::regrole::text) in the GRANT loop. Any schema, owner or grantee whose name needs quoting breaks the move: mixed case, a space, a reserved word.
Fix: use %s for the reg* casts, which quote themselves, or %I on nspname/rolname read from the catalogs. Arms: one with a quoted schema, one with a quoted owner, one with a quoted grantee, each red on 5eb86f2.
Blocking 2: collation and generated columns
The cols list carries type and NOT NULL only, and ATTACH checks both of these.
- Collation: add
COLLATE %Ifromattcollationwhen it differs from the type's default. - Generated columns: a foreign table can carry
GENERATED ALWAYS AS (expr) STORED, built frompg_get_expr(adbin, adrelid). The Parquet file already holds the stored values, so reads are right as they are. - Or refuse these parents up front, as you do for UNIQUE. Either way, the error must come before the export.
- PG18's virtual generated columns (
attgenerated = 'v') need a decision too: support them, or refuse them up front. - Arms: a
COLLATE "C"column and a stored generated column, each red on 5eb86f2.
Not blocking
- The path check is string equality (
'path=' || path = ANY(ftoptions))./x/./a.parquet,//x/a.parquetor a symlink reach the same file under another spelling. Normalising both sides, or documenting it, would do. - Every failure after the export leaves an orphan file, as your design note says. With these four, the common cause was a late refusal. Moving every refusal before the export, as the UNIQUE check already is, makes orphans rare. Could the docs say "a failed move may leave the file at
path; a retry overwrites it"?
I haven't re-run your sixteen mutants; I'll rely on your report and CI. The probes above are mine. I'll re-review the four arms red-then-green.
|
All four are fixed locally, each with an arm I've seen red on 5eb86f2 and green after. On PG18, the shell suite fails 6 before the fix and is 71/71 after; the twin is also 71/71:
The fixes:
I measured on PG17 and 18 that ATTACH accepts a foreign table with a stored or a virtual generated column, and that a parent identity column needs nothing. So virtual columns are supported, not refused. The two non-blocking points: the how-to now says the path check compares the text of the path, so a symlink or another spelling isn't recognised. It already said a retry over the same path works. Still running: the five-major runs and 19 mutants on the rebased tree (now on d208bce), including three new ones (quoting twice, no collation, no generation clause). I'll push when they're in, with the ledger replayed. 🤖 Generated with Claude Code |
5eb86f2 to
663bf84
Compare
… generated columns (commandprompt#1317 review) Four inputs @jdatcmd measured failing after the export, each leaving an orphan file, each now an arm red on 5eb86f2 and green: - a schema, owner or grantee whose name needs quoting: %I over regnamespace or regrole quoted them twice (3F000, 42704); reg* casts now go through %s; - a column with its own collation (42P21 at ATTACH): COLLATE from attcollation when it is not the type's own; - a stored generated column (42804 at ATTACH), and on PostgreSQL 18 a virtual one: the foreign table declares the same generation expression. Measured on 17 and 18 that ATTACH accepts both, and that an identity column needs nothing. The docs say the path check compares the text of the path. tier_partition.sh 71/71 and the twin 71 on PG18; the virtual arm is a named SKIP / UNRUN below 18. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… five majors and nineteen mutations (commandprompt#1295) 71 rows; 38 dated, no premise among them; 33 never (15 premises, 18 arms the budget comment names). Census 1705 -> 1738. cluster_tests 503 -> 506 by collection: the virtual-column test is the third in the file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rms and the virtual-column test Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Pushed at 663bf84, rebased onto 7e6fd48 (after #1318). On this tree:
🤖 Generated with Claude Code |
jdatcmd
left a comment
There was a problem hiding this comment.
Approved at 663bf84. I re-ran my probe against this head's build (PG18 assert, one pgcolumnar_parquet server, 100 rows per partition over January 2026), plus two cases I hadn't tried before:
| case | 5eb86f2 | 663bf84 |
|---|---|---|
partition in schema "Tier" |
schema ""Tier"" does not exist |
moved (100); now relkind f; 100 rows through the parent |
text column COLLATE "C" |
collation conflict at ATTACH | moved; 100 rows read back |
stored generated w = v * 2 |
must be a generated column |
moved; sum(w) = 10100 = 2 × 5050 |
owner "Owner X", plus a grant to "Reader Y" (new) |
role ""Owner X"" does not exist |
moved; relowner is "Owner X", and relacl carries both the owner's and "Reader Y"'s entries |
PG18 virtual generated w = v * 3 (new) |
moved; sum(w) = 15150 = 3 × 5050 |
Exactly-once held throughout, here and in the first round. I'll merge once CI is green.
|
@OffgridwithJD, heads-up: my approval at 663bf84 stands on the content, but #1314 and #1313 have merged since, so this now conflicts with main, most likely in the ledger budget and the cluster count. Once you rebase, I'll re-approve by comparing each file's +/- lines against 663bf84 and checking the restated census, then merge when CI is green. |
|
Rebased onto 024e737 (after #1319) and fixed the red CI. Head is now 1346113, which replaces 663bf84. The CI failure.
The rebase. #1319 added code in the same places as this PR: the SQL scripts, the docs, and a GUC in
Re-run on this exact tree, PG15–19:
The skip below 18 is the virtual-generated-column arm, named in |
663bf84 to
1346113
Compare
|
Correction to my last comment: I pushed 1346113 to |
…at every failure, written first (commandprompt#1295) Against the API proposed on commandprompt#1295. Red on main: 21 arms need the function or the fault GUC; every fixture premise is green. Each fault arm first requires that the injected fault is the error raised, and the rollback arm that the call ran. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…one transaction (commandprompt#1295) The owner's decision on commandprompt#1295: atomic, with a brief ACCESS EXCLUSIVE on the parent. Export under the partition's SHARE lock, then detach, drop, create the foreign table with the partition's name, columns, owner, grants and comment, and attach it with the partition's own bounds. pgcolumnar.tier_fault_after (superuser, developer) raises an error after a named step, or pauses, so exactly-once and the race are tested. @jdatcmd's review of the proposal, each point an arm red against the first version: READ COMMITTED only (at REPEATABLE READ the first version dropped a row committed between the caller's snapshot and the lock); a UNIQUE parent and dependents of the partition (view, policy, own trigger, own foreign key) refused before anything moves; grants and comment carried; every lock bounded by lock_wait, with the caller's lock_timeout restored; a path a foreign table in this database reads refused, and a retry over a failed attempt's file allowed. The deadlock the review predicted is real; one side gets 40P01 and every row is exactly once. pgcolumnar_parquet has no AnalyzeForeignTable, so no ANALYZE. tier_partition.sh and its pytest twin: 65/65 on PG15-19 each. Fourteen mutants, each red on its arm; the new seven red in the twin too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ommandprompt#1236) 'the cold partition was moved with its grant and comment set' and 'the other partition is still local after the refusals' are reddened by the suffix and path-check mutations; 'inside the transaction the call moved the cold partition' and 'the move ran inside the caller's transaction' by return_zero. A check a repository change can redden is an arm. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cluster_tests 500 -> 502 by collection. The ledger rows come from a five-major run and sixteen mutations on this tree, in a later commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ons on this tree (commandprompt#1295) 65 rows; 32 dated by sixteen mutations merged one log at a time with --mutation and --target, no premise among them; 33 'never' (15 premises, 18 arms the budget comment names). Census 1694 -> 1727. The twin's loops iterate literals, so the loop-coverage guard sees them non-empty by construction. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… generated columns (commandprompt#1317 review) Four inputs @jdatcmd measured failing after the export, each leaving an orphan file, each now an arm red on 5eb86f2 and green: - a schema, owner or grantee whose name needs quoting: %I over regnamespace or regrole quoted them twice (3F000, 42704); reg* casts now go through %s; - a column with its own collation (42P21 at ATTACH): COLLATE from attcollation when it is not the type's own; - a stored generated column (42804 at ATTACH), and on PostgreSQL 18 a virtual one: the foreign table declares the same generation expression. Measured on 17 and 18 that ATTACH accepts both, and that an identity column needs nothing. The docs say the path check compares the text of the path. tier_partition.sh 71/71 and the twin 71 on PG18; the virtual arm is a named SKIP / UNRUN below 18. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… five majors and nineteen mutations (commandprompt#1295) 71 rows; 38 dated, no premise among them; 33 never (15 premises, 18 arms the budget comment names). Census 1705 -> 1738. cluster_tests 503 -> 506 by collection: the virtual-column test is the third in the file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rms and the virtual-column test Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…dprompt#1317 CI) CI's suites leg: server_file_privilege's coverage arm found tier_partition (it takes a path) without a boundary entry. It writes its file through export_parquet, so its gate is pg_write_server_files. The fixture is a columnar partition both test roles can SELECT and UPDATE (UPDATE for the SHARE lock taken first), so the role gate is the only thing between them; past it, the privileged role writes the file and is stopped by DETACH. Red first on PG17 (45 + 1, the coverage arm). Each gate arm is red under its own mutation: tier_partition SECURITY DEFINER (the unprivileged role is no longer refused) and tier_partition refusing on entry by naming the role (the privileged role no longer gets past). server_file_privilege 48/48 on PG15-19. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jdatcmd
left a comment
There was a problem hiding this comment.
Approved at 1346113.
- Per file against 663bf84 (my own check, base 7e6fd48 then versus 024e737 now): the code, SQL, docs and both tier twins are identical. Four files differ: the budget, TESTS.md and expected_tests are restatements, and
server_file_privilege.shis the CI fix. - The CI fix, run here:
server_file_privilegeis 48/48 on PG17 from this head, includingserver-file function is covered by the boundary test: tier_partitionand both gate arms.
It conflicts with current main again, after #1311 and #1322, so one more rebase is needed. I'll re-approve by the same per-file comparison and merge when CI is green.
1346113 to
99680fb
Compare
|
@jdatcmd I rebased onto 2e34f90 (the #1322 merge). The head is now 99680fb. Per-file patch identity against the head you approved (1346113 on 024e737). All 16 files are listed. 13 are byte-identical, including every
On the rebased tree:
|
|
Merging now: approved at 99680fb, CI green. |
pgcolumnar.tier_partition: move a cold partition to Parquet, exactly once (#1295)This follows the owner's decision on #1295: the move is atomic, with a brief ACCESS EXCLUSIVE on the parent. It goes into
pgcolumnar--1.0-alpha5.sqland the alpha4→alpha5 upgrade script, per the gate change on #1283. In one transaction, at READ COMMITTED, it:export_parquet, then checks the row count against acount(*)taken under the lock;Every lock waits at most
lock_wait(55P03), and the caller'slock_timeoutis restored afterwards.pgcolumnar.tier_fault_after, a superuser-only developer GUC, raises an error after a named step, or pauses, so exactly-once and the race are tested rather than argued.@jdatcmd's review of the proposal, point by point (each an arm, red against the first version)
16992|0where16993|1was owed. Those levels are now refused (25000).0A000). The first version failed at ATTACH and left an orphan file.pg_dependallowlist and refused (2BP01): a view, a row-level policy, a trigger of its own, or a foreign key of its own. The first version moved a partition with a policy and dropped the policy silently. Grants and comment now carry over.40P01, and every row is exactly once.58P02), and a retry over a failed attempt's orphan file works.pgcolumnar_parquethas noAnalyzeForeignTable.Findings from my own arms
pg_stat_file(...) IS NOT NULLis false for a file that exists, becausecreationis NULL on Linux. So the first existing-path check never fired, and a second move overwrote a file another partition was reading.-cis one transaction, and the failed CREATE rolled back the previous case's DROP. Each dependent is now made in statements of its own, with a premise that it exists. A control arm shows the same partition moves once its dependents are gone.Tests
test/tier_partition.shand the independenttest/pytest/test_tier_partition.py, which uses threads for its concurrent sessions: 65/65 on PG15–19, each.compare_to_bash: 42 literal and 6 template matches, 0 missing.Removal proofs: sixteen mutants, each red on its arm. They are:
no_drop(rename aside instead of drop: the catalog-cleanup arms) andreturn_zero;lock_wait, the caller's timeout not restored, and the old blanket path refusal.The seven new ones are red in the twin too.
Not reachable by any arm, and stated plainly:
neverin the ledger. The ledger lists which arms arenever.Registries
never: 15 premises, and 18 arms the budget comment names (transactional, core behaviour, orexport_parquet's own refusal). Census 1700 → 1733.native_upgrade_convergePG17: 21/21, now that both Native partition lifecycle for time-partitioned columnar tables: create ahead, drop past retention #1297's and this function are in the alpha5 scripts.harness_selftestPG17 is in the follow-up comment.🤖 Generated with Claude Code