fix: attach the window a live writer is filling, under one lock - #65
Merged
Conversation
added 6 commits
September 7, 2026 14:04
DEFAULT reconciliation moved the window's rows out in one transaction and retried the ATTACH in another. On a table that is being written to the writer refills the window in the gap, PostgreSQL refuses the attach again, and after DEFAULT_CONFLICT_MAX_RETRIES the rows are moved back -- so the current month, the one every live table is writing into, could never be attached, and each attempt cost a full scan of DEFAULT under ACCESS EXCLUSIVE. The move and the attach now share a transaction: reconcile_and_attach takes EXCLUSIVE on the parent and ACCESS EXCLUSIVE on the partition and the DEFAULT sibling, moves what is left of the window, and attaches. Nothing can reach DEFAULT in between, and no insert is mid-routing when the partition set changes -- a writer waits at the parent and is then routed into the new partition, statement unchanged. The bulk of the window still moves first under the lighter SHARE ROW EXCLUSIVE, so the exclusive window is a tail and a scan. A window that still cannot be attached is now a default_holds_rows issue rather than the driver's exception: apply() records it and the run goes on, and partition_data returns a move issue with complete=False instead of raising out of the drain. Closes #64
Unit tests for reconcile_and_attach's lock order and statement order in both mirrors, for a tail move an incoming foreign key refuses inside it, and for an attach target swapped during the bulk reconcile; the docs the new lock level changes follow.
…ends Following the guide's last step verbatim on a BIGSERIAL table fails: the sequence is owned by the column it was declared on, so it belongs to the renamed legacy table while every partition's default draws from it, and DROP TABLE is refused. Measured on 17, with what CASCADE does instead.
The measurement behind the move-and-attach's EXCLUSIVE lock on the parent, where the semantics page keeps the rest of the ATTACH lock findings.
The block was entered on a task that may not have had the loop yet, so on a slow runner the attach could finish before the first insert went out.
Codecov Report❌ Patch coverage is
📢 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.
The problem
On a table that is being written to, the window the application is inserting into — the current month, every time — could not be attached at all.
_attach_with_reconcilemoved the window's rows out of the DEFAULT partition inreconcile_default_rows' own transaction and retried theATTACHin another. The move holdsSHARE ROW EXCLUSIVEon DEFAULT, so writers queue behind it and are let in the moment it commits — exactly the gap beforeATTACHtakes itsACCESS EXCLUSIVEand scans. One row is enough to fail the scan. WithDEFAULT_CONFLICT_MAX_RETRIES = 2the window bought two full scans of a two-million-row DEFAULT underACCESS EXCLUSIVE, a compensating move-back, and no progress; the tick reportederrorset andissuesempty, andpartition_datalet theIntegrityErrorout instead of reporting the window.Reproduced with the reporter's lab on
postgres:17-alpine, 2M rows, a writer inserting every 5 ms.The design
The move and the attach now share a transaction. A new repository operation,
reconcile_and_attach, takes its locks up front —EXCLUSIVEon the parent,ACCESS EXCLUSIVEon the partition and on the DEFAULT sibling — moves what is left of the window, and attaches. Nothing can put a row into DEFAULT between the move and the scan, because nothing has been able to reach DEFAULT since before the move, and a failed attach rolls the move back with it._attach_with_reconcilekeeps its shape: attempt 1 is still the plain, cheapattach_partition, so a parent with no DEFAULT partition — or one holding nothing for the window — pays for nothing else. A DEFAULT conflict runs the bulkreconcile_default_rowsexactly as before, under the lighterSHARE ROW EXCLUSIVEthat leaves readers of DEFAULT alone, and attempt 2 is the atomic form, which takes only what the writer added while that ran, plus the scanATTACHdoes anyway. The exclusive window is a tail and a scan, not a month.The parent's lock is
EXCLUSIVE, one level aboveATTACH's own, and it is there for the writer. An INSERT picks its partition from the set it saw when it tookROW EXCLUSIVEon the parent: one that got that far and then queued on DEFAULT's lock comes out of the wait still aimed at DEFAULT, and is rejected by the constraint the attach just narrowed — a lost write, and what a plainATTACHdoes to a live writer today. Blocking at the parent means no insert is ever mid-routing while the partition set changes: the writer waits, re-plans, and lands in the new partition with its statement unchanged. I found this the honest way — the first cut usedSHARE UPDATE EXCLUSIVEand the new integration test failed on the writer's insert, not on the attach.EXCLUSIVEdoes not conflict withACCESS SHARE, so readers of the other partitions carry on.The second half of the report: a window that still cannot be attached is now a
default_holds_rowstopology finding rather than the driver's exception.apply()already records those inresult.issuesand goes on — what the failures table inconcepts/execution.mdhas always promised — andpartition_datacatches it and returns amoveissue withcomplete=False. That also covers the one case the lock cannot reach, a foreign DEFAULT partition, whichLOCK TABLErefuses outright.What I rejected
ACCESS EXCLUSIVE.ACCESS EXCLUSIVEon DEFAULT for the length of a month's rows, blocking readers of DEFAULT too.Public surface
reconcile_and_attachis an addition to thePartitionRepositoryprotocol in both mirrors: an implementation written from scratch gains one method, subclasses ofPostgresPartitionRepositoryare untouched. It is the one method with a transaction boundary in its contract, andguide/extending.mdsays so.Also on this page
Following the guide's last step verbatim fails on a
BIGSERIALtable: the sequence is owned by the column it was declared on, so after the swap it belongs toevents_legacywhile every partition'siddefault draws from it, andDROP TABLE events_legacyis refused. The lab reaches step 6 and dies there.ALTER SEQUENCE events_id_seq OWNED BY events.idfirst, and the drop goes through with the ids intact;CASCADEtakes the sequence and every default with it. Measured on 17 and written into the guide as its own note.Verified
The reporter's lab, unmodified, against this branch:
No writer errors in either step, no issues, and nothing raised out of the drain. On master the same run prints
created 0, issues 0, error 'IntegrityError: … updated partition constraint for default partition "events_legacy" would be violated by some row', leaves87 left in DEFAULT, and ends on that exception in step 4.With the
ALTER SEQUENCEabove added to the lab's step 6, the run finishes and prints its tree — twelve full months, the current one live and filling, and the writer's own rows all present:New integration tests drive a writer inserting into the window while the attach runs, in both mirrors — the aio pair with a task, the sync pair with a thread in
test_concurrency.py. Against master's library sources all four fail with the reportedCheckViolation, three runs out of three; against this branch they pass, five runs out of five.Gate:
uv run --extra pydantic-settings pytest --cov=pg_partsmith --cov-report=term --cov-fail-under=90 --cov-report=xml:coverage.xml— 3374 passed, 20 skipped, 97.99% coverage, plusruff check,ruff format --checkandmypy pg_partsmithclean.Closes #64