feat(cli): backfill from the command line, and a plan that names the blocking detach - #58
Merged
Merged
Conversation
added 2 commits
September 4, 2026 08:32
…blocking PostgreSQL refuses DETACH ... CONCURRENTLY while the parent holds a DEFAULT partition, so AUTO there was the blocking form discovered on the failure: one statement per partition that could only fail, and a WARNING whose reason never reached the operator. The planner reads the DEFAULT from the same catalog snapshot it plans from, so it decides this up front -- plan --locks now names the ACCESS EXCLUSIVE the run will really take, and the detail on the operation says why. CONCURRENT is untouched: being refused is the answer it asks for.
… line partition_data was library-only because a one-shot command had no progress story. --max-batches with exit 2 is one: the run stops after N statements per table, every row already moved stays moved, and a Job can be repeated until it exits 0. --output metrics carries pg_partsmith_backfilled_rows and pg_partsmith_backfill_incomplete for the same reason. This is the half of adoption creation cannot reach. A table partitioned around data already in it holds that data in DEFAULT, behind the cursor, where no create-ahead will ever arrive -- so for an installation that is not new, apply had nothing useful to do until somebody wrote Python. It no longer needs any. Hooks fire during backfill as they do during apply, because it creates partitions through the same executor: the --allow-hooks gate covers both rather than letting a declared after_create pass unnoticed.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
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.
Why
Both changes come from one place: a real integration attempt against
hookdeck/outpost(main@f67ae011), whose twopartitioned tables —
eventsandattempts, bothRANGE (time)— ship with a permanentDEFAULT partition and nothing else. Running the released 1.4.0 against their unmodified
schema found exactly two things this library could not do for them, and both are generic.
question on the growth issue is "is there a recommended migration path for existing
DEFAULT partitions?" — and it was the one step an operator of a Go service could not
run from the image. Creation walks forward from the cursor; the rows are behind it.
Draining that DEFAULT needed a Python script, which is not something a Go project's
operators are going to write.
schema logged
DETACH PARTITION CONCURRENTLY failed; falling back— PostgreSQL refusesthe concurrent form outright while a DEFAULT partition exists. The plan's lock text said
ACCESS EXCLUSIVE on the parent when a DEFAULT partition forces the blocking form, aconditional whose answer was already in the catalog snapshot the plan was made from. A
DBA approving a maintenance window on an ingestion table should not learn that from a
runtime WARNING with no reason in its message.
What changed
fix(planner)— when the parent holds a DEFAULT partition, anAUTOdetach is plannedas
BLOCKING, and the operation'sdetailsays why.plan --locksthen names theACCESS EXCLUSIVEthe run will really take, and the executor issues one statement insteadof one that fails and one that works.
CONCURRENTis untouched: being refused is theanswer that mode exists to give. The runtime fallback stays for every other reason a server
can refuse the concurrent form.
feat(cli)—pg-partsmith backfillispartition_datafrom the command line.docs/guide/cli.mdsaid these verbs were library-only "because both want a progress storya one-shot command does not have yet". This is that story:
--max-batches Nstops after Nstatements per table and exits
2, every row already moved stays moved, and a Job can berun until it exits
0:--output metricscarriespg_partsmith_backfilled_rowsandpg_partsmith_backfill_incompletefor the same reason. Hooks fire duringbackfillasthey do during
apply— it creates partitions through the same executor — so the--allow-hooksgate now covers both rather than letting a declaredafter_createpassunnoticed.
unpartitionstays library-only: it has a destination table to name and adrop_emptiedto decide, and neither has an obvious spelling on a command line yet.
The design I did not take
backfillcould have been abackfill: Nfield onLifecyclePolicy— "also desire the Nwindows behind the cursor" — composing with every creation policy and showing up in
plan/applylike anything else. I chose the command because draining a DEFAULT partitionis a one-off migration rather than a standing policy, because
partition_datais what thedocs already recommend for a large DEFAULT (bounded batches, resumable) and a policy field
would have reached for
ensure_partitionsinstead, and because it adds no new documentsemantics to get wrong. Easy to redirect if you would rather have the field.
Evidence
Outpost's ten
internal/migrator/migrations/postgres/*.up.sqlapplied verbatim toPostgreSQL 17, seeded with 965 events and 965 attempts spanning 2026-01-05 → 2026-09-03,
every row in
events_default/attempts_default. Ten lines of YAML, monthly, keep 6,create 3 ahead. Run from the image built off this branch, nothing else installed:
965 → 621 rows, exactly the 344 of 2026-01..03;
events_defaultempty; no fallbackWARNING on stderr. Resumability checked separately:
--max-batches 3moved 344 rows andexited 2 with 621 left, and the loop above finished it with all 965 rows intact.
Tests
make checkclean.pytest— 3358 passed, 20 skipped, coverage 97.96%. New:AUTOunder a DEFAULT is planned blocking with the reason and the locktext;
CONCURRENTis not downgraded; the existing test assertingAUTOstaysAUTOwithout a DEFAULT is the negative control.
absent and the outcome unchanged, plus a new no-DEFAULT case.
OK/DRIFT/FINDINGS, the flags reachingpartition_data, themetrics, and the
--allow-hooksrefusal.--max-batchesexits 2 and thenext run carries on with nothing lost.
make test-e2enot run locally (CI builds the image); the image was built from this branchby hand for the evidence above.
One behaviour change worth naming
tests/integration/{aio,sync}/test_lifecycle_policies.py::test__detach_mode_auto__with_a_default_partition__falls_back_to_the_blocking_formasserted the WARNING. It is rewritten to assert its absence. Anyone alerting on that log
line for DEFAULT-partitioned tables will stop seeing it — the detach they were watching now
succeeds first time. Same end state, one fewer failed statement per partition.