Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,11 @@ lifecycle units — partitions directly under the root — never once per leaf o
10. **During `partition_data`, a window's rows are invisible through the parent** between
the first batch and the attach: PostgreSQL will not attach a partition while DEFAULT
still holds rows for it, so no ordering keeps them visible throughout. Rows are never
in two places, and never lost.
in two places, and never lost. The attach takes the rows that arrived during the last
batch *and* goes live in one transaction, so a window the application is writing into
is attached like any other; writers wait at the parent for that commit and are then
routed into the new partition. A window that still cannot be attached comes back as a
`move` issue with `complete=False` — `partition_data` does not raise for it.
11. **Flat and composed spellings do not mix**, and `extra="forbid"` means a misspelled
keyword raises rather than being ignored.
12. **`schema=` goes in, `config.db_schema` comes out** — the field is aliased to avoid
Expand Down
47 changes: 39 additions & 8 deletions docs/concepts/execution.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ statements. There is no long transaction wrapping a run, because `DETACH … CON
cannot run inside a transaction block at all, and because a run that creates three
partitions and fails on the fourth should keep the three.

Two steps are exceptions, and both for the same reason: a lock has to span more than one
statement or the thing it protects can change underneath. A drop takes its lock, revalidates,
drains and drops in one transaction; the last step of [DEFAULT reconciliation](#default-reconciliation)
moves the window's remaining rows and attaches in one transaction. `ATTACH`, unlike
`DETACH … CONCURRENTLY`, may run inside a transaction block.

The consequence for callers: pass the service an **engine**, never a session you are
using elsewhere.

Expand Down Expand Up @@ -50,13 +56,36 @@ belong to the new window, PostgreSQL refuses the attach (`23514`). The executor:

1. moves those rows from DEFAULT into the new partition, naming columns on both sides
(`ATTACH` matches by name, so physical column order may differ) and leaving rows with a
NULL trailing key where PostgreSQL routes them;
2. retries the attach.

If the attach still fails, the rows are returned to DEFAULT rather than left in a table
no query can see. For a nested branch the moved rows are routed onward into its leaves.
A DEFAULT sibling holding rows for a hash or list member is reported
(`default_holds_rows`) rather than moved: only a RANGE window can be selected by its key.
NULL trailing key where PostgreSQL routes them. This is one statement under
`SHARE ROW EXCLUSIVE` on DEFAULT: writers wait, readers do not;
2. moves whatever arrived in the meantime and attaches, **in one transaction**, under
`EXCLUSIVE` on the parent and `ACCESS EXCLUSIVE` on the partition and on DEFAULT — taken
in that order, and taken before the move.

Step 2 is what makes the window a live writer is inserting into attachable at all. Two
transactions cannot do it: the move commits, DEFAULT is free again, and the next insert for
the window lands in it before `ATTACH` scans — under any steady write rate that never
converges, and every attempt is a full scan of DEFAULT under `ACCESS EXCLUSIVE`. Sharing one
lock closes the gap. Step 1 is what keeps that exclusive window short: the bulk of the month
moves before the heavy lock is taken, so what step 2 covers is a tail and a scan.

The parent's lock is one level above what `ATTACH` itself takes, and it is there for the
writer. An INSERT picks its partition from the set it saw when it took `ROW EXCLUSIVE` on
the parent: one that got that far and then queued on DEFAULT's lock would come out of the
wait still aimed at DEFAULT and be rejected by the constraint the attach just narrowed — a
lost write, and what a plain `ATTACH` does to a live writer. Blocking at the parent instead
means no insert is ever mid-routing while the partition set changes: the writer waits,
re-plans, and lands in the new partition, its statement unchanged. `EXCLUSIVE` does not
conflict with `ACCESS SHARE`, so readers of the other partitions carry on.

If the attach still fails, the rows step 1 moved are returned to DEFAULT rather than left in
a table no query can see — step 2's own move rolls back with the attach, and needs no
compensation. A window that still cannot be attached is reported as `default_holds_rows`, an
issue like any other: the run goes on, and `partition_data` returns it rather than raising.
For a nested branch the moved rows are routed onward into its leaves. A DEFAULT sibling
holding rows for a hash or list member is reported rather than moved: only a RANGE window can
be selected by its key. A **foreign** DEFAULT partition cannot be locked at all, so there the
race stays and the report is all there is.

## Detach

Expand Down Expand Up @@ -103,6 +132,8 @@ repository).
|---|---|
| `CREATE TABLE … (LIKE parent)` | `ACCESS SHARE` on the parent |
| `ATTACH PARTITION` | `SHARE UPDATE EXCLUSIVE` on the parent, `ACCESS EXCLUSIVE` on the child and on a DEFAULT sibling; `SHARE ROW EXCLUSIVE` on tables referencing the parent through a foreign key |
| the reconciling row move ([DEFAULT reconciliation](#default-reconciliation), step 1) | `SHARE ROW EXCLUSIVE` on the DEFAULT partition and on the child: writers of DEFAULT wait, readers do not |
| the move-and-attach ([DEFAULT reconciliation](#default-reconciliation), step 2) | `EXCLUSIVE` on the parent (one level above `ATTACH`'s own, so no writer is mid-routing), then `ATTACH`'s `ACCESS EXCLUSIVE` on the partition and the DEFAULT sibling — all taken before the move |
| `DETACH PARTITION` (plain) | `ACCESS EXCLUSIVE` on parent, partition, and every table referencing the parent |
| `DETACH PARTITION … CONCURRENTLY` | `SHARE UPDATE EXCLUSIVE` on the parent; `ACCESS EXCLUSIVE` on the partition and, in its second transaction, on referencing tables |
| `DROP TABLE` of a detached table | `ACCESS EXCLUSIVE` on that table only |
Expand All @@ -117,7 +148,7 @@ it takes on `op.capabilities`.

| What happened | Effect on the run |
|---|---|
| a topology conflict at execution time — a DEFAULT sibling holding rows for a hash bucket, a name taken by a relation with other bounds, a detach PostgreSQL refuses because rows are still referenced | recorded in `result.issues`; the run goes on |
| a topology conflict at execution time — a DEFAULT partition holding rows the attach could not take, a name taken by a relation with other bounds, a detach PostgreSQL refuses because rows are still referenced | recorded in `result.issues`; the run goes on |
| a `PlanStaleError` — the relation is not the one the plan saw | recorded as an issue with `continue_on_error`, otherwise raised |
| any other error — a connection drop, a permission denied, a `before_*` hook raising | aborts the run, unless `continue_on_error`, in which case it is recorded and the next operation runs |
| validation or lock failure | fatal, always |
Expand Down
18 changes: 18 additions & 0 deletions docs/design/postgresql-semantics.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,24 @@ wait is itself a period of rejected writes for that partition.
Hence: create standalone with `LIKE`, attach last; never `CREATE TABLE … PARTITION OF`
against a live parent; a converged tree must issue no DDL at all.

### A queued insert keeps the partition it chose (measured on 17, 2026-09-07)

An `INSERT` through the parent picks its target from the partition set it saw when it took
`ROW EXCLUSIVE` on the parent. `ATTACH` takes only `SHARE UPDATE EXCLUSIVE` there, which does
not conflict — so an insert can route to the DEFAULT partition, queue on the `ACCESS
EXCLUSIVE` the attach holds over it, and come out of that wait still aimed at DEFAULT. The
row is then rejected by the constraint the attach has just narrowed:
`23514 new row for relation "events_legacy" violates partition constraint`. Nothing re-routes
it; the write is lost to the caller.

Taking `EXCLUSIVE` on the parent instead — one level up, conflicting with `ROW EXCLUSIVE` but
not with `ACCESS SHARE` — makes the insert wait *before* it chooses: it re-plans against the
tree the attach left and lands in the new partition, its statement unchanged, while readers
of the other partitions carry on. That is what the move-and-attach of
[DEFAULT reconciliation](../concepts/execution.md#default-reconciliation) does; the
integration suite asserts it on PostgreSQL 15 through 18 with a writer inserting into the
window while the attach runs.

## Foreign tables

- `CREATE FOREIGN TABLE … PARTITION OF parent` and `ATTACH PARTITION` of a foreign table are
Expand Down
13 changes: 12 additions & 1 deletion docs/guide/extending.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,20 +39,31 @@ class PartitionRepository(Protocol):
async def drop_partition(self, partition_name, *, expected_oid=None) -> None: ...
async def adopt_partition(self, table_name, partition_name) -> bool: ...
async def reconcile_default_rows(self, *, default_partition_name, target_partition_name, key_columns, from_value, to_value, limit=None) -> int: ...
async def reconcile_and_attach(self, parent_name, partition_name, bounds, *, key_columns, default_partition_name) -> int: ...
async def move_rows(self, source_name, target_name, *, limit=None) -> int: ...
```

Every method takes and returns plain domain objects (`PartitionBounds`, `PartitionBy`,
`DetachMode`, `LocalLeaves`), so an implementation never needs to know how the planner
works.

`reconcile_and_attach` is the one method with a transaction boundary in its contract: it
must take the window's remaining rows out of the DEFAULT partition **and** attach in a
single transaction, holding one lock across both, and roll the move back if the attach
fails. Two transactions cannot do it — a writer refills the window in the gap and
PostgreSQL refuses the attach every time. The bundled implementation locks the parent
`EXCLUSIVE` (so no insert is mid-routing when the partition set changes) and the partition
and DEFAULT sibling `ACCESS EXCLUSIVE`, then moves, then attaches. It is called only after
`attach_partition` has failed on a DEFAULT conflict and the bulk of the window has been
moved by `reconcile_default_rows`.

One thing to know about errors: the executor recognises a failed `attach_partition` by the
**SQLSTATE the exception carries**, not by its type. It reads `sqlstate` or `pgcode` off
the exception, or off its `orig` if it wraps one — which covers a SQLAlchemy error, a bare
`psycopg.Error` and an `asyncpg.PostgresError` alike. Let the driver's exception through
rather than replacing it with one of your own, and three things keep working: a lost race
with another worker is treated as benign, a DEFAULT partition holding rows for the new
window triggers the reconcile-and-retry, and rows already moved out of DEFAULT are put back
window triggers the reconcile-and-attach, and rows already moved out of DEFAULT are put back
if the attach ultimately fails. An exception carrying no SQLSTATE is still safe — the rows
are restored and it propagates — but it cannot be recognised as a race or a conflict.

Expand Down
44 changes: 36 additions & 8 deletions docs/guide/partition-existing-table.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,12 @@ await maintainer.run_maintenance_safe(config)

The tick creates the current month and the two after it. As each is attached, the rows
of that month move out of `events_legacy` into it — ordinary DEFAULT reconciliation.
From now on new rows land in real partitions; the old ones are still in the DEFAULT.
The current month is the one the application is writing into, and it attaches on this
first tick like the empty future ones: the bulk of its rows move first, then the rows that
arrived while that ran move *and* the partition goes live in one transaction. Inserts wait
at the parent for that commit and are then routed into the new partition, their statement
unchanged. From now on new rows land in real partitions; the old ones are still in the
DEFAULT.

## 3. Drain the DEFAULT partition

Expand All @@ -65,7 +70,10 @@ Each call:
3. moves the window's rows into it in batches of `batch_rows` — one
`DELETE … RETURNING` / `INSERT` per batch, each committing on its own, so a row is in
exactly one place at every commit point;
4. attaches the partition once nothing of that window is left in DEFAULT;
4. takes whatever landed during the last batch and attaches the partition, in one
transaction and under one lock — so a window still being written to is attached on the
same pass as a quiet one, and what the lock covers is a tail and a scan rather than a
month;
5. moves on to the next window, until DEFAULT holds only rows no window can take (rows
with a NULL key), or `max_batches` is spent.

Expand All @@ -80,14 +88,19 @@ finds it, finishes it and attaches it.
a partition cannot be attached while DEFAULT still holds rows for it. Run the drain in
a maintenance window, or with small batches during a quiet hour and readers that can
tolerate a month's rows appearing a little later. Rows already in real partitions, and
rows still in DEFAULT for other windows, stay visible throughout.
rows still in DEFAULT for other windows, stay visible throughout. The attach that ends
a window blocks writers at the parent and holds `ACCESS EXCLUSIVE` on the DEFAULT
partition, for the length of the last batch's tail plus one scan of DEFAULT — so
inserts stall for that long rather than fail, and readers of the partitions already
drained carry on.

`partition_data` takes the table's lock, so it does not race the scheduled tick. It
refuses a window it cannot create (an unmanaged partition overlaps it), and any move an
incoming foreign key's `ON DELETE` action would corrupt, with a `move` issue and
`complete=False` rather than loop. A window whose partition already exists *detached*
with this library's marker — retention retired it, and late rows for it landed in
DEFAULT — is filled and re-attached rather than given up on.
refuses a window it cannot create (an unmanaged partition overlaps it), any move an
incoming foreign key's `ON DELETE` action would corrupt, and a DEFAULT partition it could
not clear, with a `move` issue and `complete=False` rather than loop — and never by
raising at the caller. A window whose partition already exists *detached* with this
library's marker — retention retired it, and late rows for it landed in DEFAULT — is
filled and re-attached rather than given up on.

## 4. Afterwards

Expand All @@ -103,6 +116,21 @@ DROP TABLE events_legacy;
Without a DEFAULT partition, `DETACH … CONCURRENTLY` becomes available, which is what
`DetachMode.AUTO` prefers.

!!! note "A `serial` key's sequence still belongs to the old table"
`BIGSERIAL` makes the sequence **owned by** the column it was declared on, so after the
swap it belongs to `events_legacy` — while every partition's `id` default draws from it.
`DROP TABLE events_legacy` is refused for exactly that (`2BP01`, *other objects depend
on it*, listing one default per partition). Move the ownership to the live parent first:

```sql
ALTER SEQUENCE events_id_seq OWNED BY events.id;
```

Then the drop goes through and the ids carry on where they were. Do **not** reach for
`DROP TABLE … CASCADE` here: measured on 17, it takes the sequence with the table and
every default that drew from it, and the next insert fails
`null value in column "id" … violates not-null constraint`.

## The way back

`unpartition` empties every partition into one plain table, oldest first, in the same
Expand Down
49 changes: 48 additions & 1 deletion pg_partsmith/aio/protocols.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,15 @@
from pg_partsmith.leaves import LocalLeaves
from pg_partsmith.lifecycle import DetachMode, SqlPredicate
from pg_partsmith.plan import PartitionBy
from pg_partsmith.topology import ActualTree, FactKind, PartitionBounds, PartitionNode, PartitionType, RelationKind
from pg_partsmith.topology import (
ActualTree,
FactKind,
PartitionBounds,
PartitionNode,
PartitionType,
RangeBounds,
RelationKind,
)

__all__ = [
"LockManager",
Expand Down Expand Up @@ -239,6 +247,45 @@ async def reconcile_default_rows(
"""
...

async def reconcile_and_attach(
self,
parent_name: str,
partition_name: str,
bounds: RangeBounds,
*,
key_columns: tuple[str, ...],
default_partition_name: str,
expected_oid: int | None = None,
expected_parent_oid: int | None = None,
expected_default_oid: int | None = None,
) -> int:
"""Take the window's last rows out of DEFAULT and attach, in one transaction.

The pair cannot be two transactions on a table that is being written
to: the move commits, the DEFAULT partition is free again, and a row
for the window lands in it before ``ATTACH`` scans -- which fails the
attach with the very conflict the move was clearing. An implementation
must hold one lock across both, and must roll the move back with a
failed attach.

Args:
parent_name: Partitioned relation to attach to.
partition_name: Table to attach.
bounds: The RANGE window the partition owns, and the rows to take.
key_columns: The parent's partition key, leading column first. Rows
with a NULL in a trailing column stay in DEFAULT, where
PostgreSQL routes them.
default_partition_name: Qualified name of DEFAULT partition.
expected_oid: The catalog identity of the partition, checked the
way ``attach_partition`` checks it.
expected_parent_oid: The catalog identity of the parent.
expected_default_oid: The catalog identity the rows must come from.

Returns:
Number of rows moved out of the DEFAULT partition.
"""
...

async def move_rows(self, source_name: str, target_name: str, *, limit: int | None = None) -> int:
"""Move rows from one relation into another, whatever their keys.

Expand Down
Loading