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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

* a backfilled window always attaches with writers held at the parent ([#89](https://github.com/bedrock-python/pg-partsmith/issues/89)) ([b1260c1](https://github.com/bedrock-python/pg-partsmith/commit/b1260c1a8bcdd6388c3836d57eb548f8fe5959bb))

## 1.9.0 — an expired partition stays detached for a week by default

A policy that named no drop — every flat config (`create_ahead_count`, `retention_count`),
every bare `TablePartitionConfig(...)` and every YAML table without `drop:` — got
`DropAfter()` with no grace, so an expired partition was detached and dropped in the same
run. A wrong `retention_count` or granularity lost the data on the first scheduled run, with
no window to notice ([#106](https://github.com/bedrock-python/pg-partsmith/issues/106)).

**What changes:** when the policy names no drop it is now `DropAfter(grace=timedelta(days=7))`.
An expired partition stays detached for a week, where it can be inspected or re-attached,
and the first run after that drops it. Disk is freed a week later than before.

**What stays:** an explicit `DropAfter()` keeps its zero grace, so a configuration that asked
for the immediate drop keeps it. `DropNever()` is unchanged.

## 1.8.3 — a refused detach no longer starts the drop grace

A `DETACH … CONCURRENTLY` writes the orphan marker, with its `detached-at` instant, in a
Expand Down
7 changes: 4 additions & 3 deletions docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -358,9 +358,10 @@ lifecycle units — partitions directly under the root — never once per leaf o
(recorded as an issue under `continue_on_error=True`).
7. **`create_ahead_count` and `retention_count` include the current period.**
`create_ahead_count=3` means this month and the next two.
8. **The default lifecycle drops.** `DropAfter()` has a zero grace, so an expired partition
is detached and dropped in the same run. Choose `DropAfter(grace=…)` or `DropNever()`
consciously.
8. **The default lifecycle drops a week after the detach.** A policy that names no `drop`
keeps an expired partition detached for seven days, where it can be inspected or
re-attached, then drops it. `DropAfter()` (zero grace) drops in the same run as the
detach; `DropNever()` leaves detached tables to someone else. Choose consciously.
9. **`lifecycle` is meaningless for a scheme with no progression level** — a root
`HashPartitioning` or a static `ListPartitioning` has a fixed set of partitions, and the
policy is ignored rather than obeyed.
Expand Down
4 changes: 2 additions & 2 deletions docs/concepts/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,8 @@ referencing rows are gone. See [Handle foreign keys](../guide/foreign-keys.md).

| Rule | Effect |
|---|---|
| `DropAfter()` (default: no grace) | dropped in the same run as the detach |
| `DropAfter(grace=timedelta(days=7))` | kept detached for a week, then dropped. The detach instant is recorded on the table's marker; an orphan marked by an older version, or adopted, has no instant and is treated as past its grace |
| `DropAfter()` (no grace) | dropped in the same run as the detach |
| `DropAfter(grace=timedelta(days=7))` (the default when the policy names no drop) | kept detached for a week, then dropped. The detach instant is recorded on the table's marker; an orphan marked by an older version, or adopted, has no instant and is treated as past its grace |
| `DropAfter(grace=…, when=predicate)` | dropped only once the grace has passed *and* the predicate holds — "not while bigger than 150 GB on a weekday" is a `Callback` over the size fact |
| `DropNever()` | detached partitions are left alone; something else owns the drop — an archive pipeline, a DBA. They are never re-attached, and a wanted window whose name such a table holds is reported (`name_unusable`), not recreated over it |

Expand Down
30 changes: 16 additions & 14 deletions docs/getting-started/first-table.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,14 +241,13 @@ plan for public.events at 2027-09-15T03:00:00+00:00
CREATE public.events__2027_11 (create_ahead)
DETACH public.events__2026_08 (retention_expired)
DETACH public.events__2026_09 (retention_expired)
DROP public.events__2026_08 (follows_detach)
DROP public.events__2026_09 (follows_detach)
```

Two things to notice. Retirement is a **detach** followed by a **drop**, two separate
statements: the partition leaves the parent first, and only a table that is detached is
ever dropped. And `events__2026_10` survives: the twelve newest months, counted back from
September 2027, are October 2026 through September 2027.
Two things to notice. Retirement is a **detach**, and the **drop** comes later: the
partition leaves the parent first, and only a table that is detached is ever dropped —
by default a week after its detach (see step 9). And `events__2026_10` survives: the
twelve newest months, counted back from September 2027, are October 2026 through
September 2027.

After `apply()`:

Expand All @@ -261,14 +260,17 @@ events partitioned table PARTITION BY RANGE (created_at)
```

(Only three months were ever created between the two runs, which is why the table looks
sparse. A daily tick fills the calendar continuously.)
sparse. A daily tick fills the calendar continuously. The two detached months are still in
the database, outside the parent, until their week is up.)

## 9. A week between detach and drop
## 9. Between detach and drop

Dropping in the same run as the detach is the default. Most teams want a pause — to
export the partition, to be able to put it back. That is the `drop` half of a lifecycle
policy. The flat fields you used so far are sugar for a scheme and a policy; spell the
policy out to change it:
A detached partition waits a week before it is dropped — a pause to export it, or to put
it back if the retention was wrong. That is the `drop` half of a lifecycle policy, and the
flat fields you used so far leave it at its default. The flat fields are sugar for a
scheme and a policy; spell the policy out to choose the pause yourself (`DropAfter()`
drops in the same run as the detach, `DropNever()` leaves detached tables to someone
else):

```python
from datetime import timedelta
Expand Down Expand Up @@ -325,8 +327,8 @@ meantime, a marked table whose month is wanted is *re-attached* rather than recr
## What you have learned

- A configuration is a **scheme** (monthly partitions of `created_at`) plus a **lifecycle
policy** (three ahead, twelve kept, drop a week after detach). The flat fields are sugar
for both.
policy** (three ahead, twelve kept, drop a week after detach — the default). The flat
fields are sugar for both.
- `plan()` is read-only and explains itself; `apply()` runs the plan under a lock.
- Retirement is detach, then drop; grace periods live on the marker.
- Findings are the planner telling you what it chose not to do, and why.
Expand Down
3 changes: 0 additions & 3 deletions docs/guide/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,14 +174,11 @@ pg-partsmith plan -c partitions.yaml --locks
plan for public.events at 2026-09-01T02:15:00+00:00
CREATE public.events__2026_10 (create_ahead)
DETACH public.events__2025_08 (retention_expired) size=1073741824 rows~4021553
DROP public.events__2025_08 (follows_detach)
locks:
CREATE public.events__2026_10
ACCESS SHARE on the template during CREATE; SHARE UPDATE EXCLUSIVE on the parent and ACCESS EXCLUSIVE on the new partition (and on a DEFAULT sibling) during ATTACH -- EXCLUSIVE on the parent instead, which holds off writes, when rows of the window are taken out of a DEFAULT sibling in the attach's transaction; SHARE ROW EXCLUSIVE on every table referencing the parent through a foreign key
DETACH public.events__2025_08 (outside a transaction block)
SHARE UPDATE EXCLUSIVE on the parent (CONCURRENTLY), ACCESS EXCLUSIVE on the partition and, in the second transaction, on every table referencing the parent through a foreign key; ...
DROP public.events__2025_08
ACCESS EXCLUSIVE on the dropped table only
```

The heaviest lock each operation takes, and on what, as measured on PostgreSQL 15 and 17
Expand Down
4 changes: 2 additions & 2 deletions docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Types are the accepted inputs; defaults are what you get when the field is omitt
| `schema` | `str` | `None` | Schema of the parent table; read back as `db_schema`. Recommended. |
| `table_name` | `str` | required | The parent table, lowercase, at most 63 bytes minus the scheme's longest suffix. |
| `scheme` | `RangePartitioning \| ListPartitioning \| HashPartitioning` | from the flat fields | The root level and everything below it. |
| `lifecycle` | `LifecyclePolicy` | `CreateAhead(count=6)`, `KeepNewest(count=12)`, `DetachMode.AUTO`, `DropAfter()` | When partitions of the progression level are created, detached and dropped. |
| `lifecycle` | `LifecyclePolicy` | `CreateAhead(count=6)`, `KeepNewest(count=12)`, `DetachMode.AUTO`, `DropAfter(grace=timedelta(days=7))` | When partitions of the progression level are created, detached and dropped. |
| `leaves` | `LocalLeaves \| ForeignLeaves` | `LocalLeaves()` | What kind of relation the leaves are. |

Flat fields, accepted instead of `scheme` / `lifecycle` for a time-partitioned RANGE root:
Expand Down Expand Up @@ -103,7 +103,7 @@ literals.
| `creation` | `CreateAhead \| CreateUntil \| CreateNextIf` | `CreateAhead(count=6)` | which windows must exist ahead of the cursor |
| `retention` | any predicate | `KeepNewest(count=12)` | when a window behind the cursor has expired |
| `detach` | `DetachMode` | `AUTO` | `AUTO`, `CONCURRENT`, `BLOCKING` |
| `drop` | `DropAfter \| DropNever` | `DropAfter()` | what happens after the detach |
| `drop` | `DropAfter \| DropNever` | `DropAfter(grace=timedelta(days=7))` | what happens after the detach; `DropAfter()` drops in the same run |

Creation rules: `CreateAhead(count ≥ 1)`; `CreateUntil(position)` — a `datetime`, an
`int`, or their string forms; `CreateNextIf(when)` — a predicate over the newest
Expand Down
2 changes: 2 additions & 0 deletions pg_partsmith/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
# Default configuration values.
DEFAULT_CREATE_AHEAD_COUNT = 6
DEFAULT_RETENTION_COUNT = 12
# How long an expired partition stays detached, and can be re-attached, when the policy names no drop.
DEFAULT_DROP_GRACE_DAYS = 7

# PostgreSQL limits.
MAX_IDENTIFIER_LENGTH = 63
Expand Down
4 changes: 3 additions & 1 deletion pg_partsmith/entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,9 @@ class TablePartitionConfig(BaseModel):
)

The ordinary time-partitioned table keeps its flat spelling, which is sugar
for exactly that::
for exactly that. Neither names a drop, so an expired partition stays
detached for a week before it is dropped; pass ``drop=`` in a
``LifecyclePolicy`` to choose otherwise::

TablePartitionConfig(
table_name="events",
Expand Down
9 changes: 6 additions & 3 deletions pg_partsmith/lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
from pydantic import BaseModel, ConfigDict, Field, field_validator

from .boundaries import Axis, RangeBoundaries, Window
from .constants import DEFAULT_CREATE_AHEAD_COUNT, DEFAULT_RETENTION_COUNT
from .constants import DEFAULT_CREATE_AHEAD_COUNT, DEFAULT_DROP_GRACE_DAYS, DEFAULT_RETENTION_COUNT
from .topology import FactKind, PartitionFacts, PartitionNode
from .types import NonNegativeInt, PositiveInt

Expand Down Expand Up @@ -776,15 +776,18 @@ class LifecyclePolicy(BaseModel):
creation: Which windows ahead of the cursor must exist.
retention: When a window behind the cursor has expired.
detach: How an expired partition is detached.
drop: What happens to it afterwards.
drop: What happens to it afterwards. Unless the policy says otherwise, it
stays detached for a week, where it can still be inspected or
re-attached, and is dropped after that. ``DropAfter()`` drops in the
same run as the detach; ``DropNever()`` leaves it alone.
"""

model_config = ConfigDict(frozen=True, extra="forbid")

creation: CreationPolicy = Field(default_factory=CreateAhead)
retention: RetentionPolicy = Field(default_factory=KeepNewest)
detach: DetachMode = DetachMode.AUTO
drop: DropPolicy = Field(default_factory=DropAfter)
drop: DropPolicy = Field(default_factory=lambda: DropAfter(grace=timedelta(days=DEFAULT_DROP_GRACE_DAYS)))

@property
def required_facts(self) -> frozenset[FactKind]:
Expand Down
13 changes: 10 additions & 3 deletions tests/e2e/support.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,15 +181,22 @@ def write_document(
dsn: str | None = None,
hooks: Mapping[str, Any] | None = None,
) -> Path:
"""One table, monthly, two partitions ahead: the document the guides start from, as JSON."""
"""One table, monthly, two partitions ahead, as JSON.

Expired partitions are dropped in the run that detaches them, as these tests
expect; without ``drop`` a detached partition would wait a week.
"""
payload: dict[str, Any] = {
"tables": [
{
"table_name": table,
"partition_column": "created_at",
"granularity": "month",
"create_ahead_count": 2,
"retention_count": retention,
"lifecycle": {
"creation": {"kind": "create_ahead", "count": 2},
"retention": {"kind": "keep_newest", "count": retention},
"drop": {"kind": "drop_after", "grace": "P0D"},
},
}
]
}
Expand Down
3 changes: 2 additions & 1 deletion tests/integration/aio/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from pg_partsmith.entities import PartitionGranularity, Period, TablePartitionConfig
from pg_partsmith.lifecycle import DetachMode
from pg_partsmith.topology import RangeBounds
from tests.integration.nested_support import drops_at_once

if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncEngine
Expand Down Expand Up @@ -99,7 +100,7 @@ async def build(self) -> PartitioningTestContext:
metadata = PostgresMetadataProvider(self._engine)
locks = PostgresAdvisoryLockManager(self._engine)

config = TablePartitionConfig(
config = drops_at_once(TablePartitionConfig)(
table_name=self._table_name,
partition_column="created_at",
granularity=self._granularity,
Expand Down
7 changes: 5 additions & 2 deletions tests/integration/aio/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,11 @@ def _document(tmp_path: Path, table: str, dsn: str, *, retention: int = 12, hook
"schema": schema or None,
"partition_column": "created_at",
"granularity": "month",
"create_ahead_count": 2,
"retention_count": retention,
"lifecycle": {
"creation": {"kind": "create_ahead", "count": 2},
"retention": {"kind": "keep_newest", "count": retention},
"drop": {"kind": "drop_after", "grace": "P0D"},
},
}
],
}
Expand Down
3 changes: 2 additions & 1 deletion tests/integration/aio/test_sliding_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

from pg_partsmith.aio.metadata import PostgresMetadataProvider
from pg_partsmith.aio.repositories import PostgresPartitionRepository
from pg_partsmith.lifecycle import CreateNextIf, DetachMode, KeepNewest, LifecyclePolicy, SqlPredicate
from pg_partsmith.lifecycle import CreateNextIf, DetachMode, DropAfter, KeepNewest, LifecyclePolicy, SqlPredicate
from pg_partsmith.plan import FindingReason, Reason
from pg_partsmith.topology import ListBounds
from tests.integration.aio.support import (
Expand Down Expand Up @@ -210,4 +210,5 @@ def test__sliding_list_config__is_state_driven() -> None:
assert config.lifecycle == LifecyclePolicy(
creation=CreateNextIf(when=SqlPredicate(sql="SELECT count(*) >= 3 FROM {partition}")),
retention=KeepNewest(count=2),
drop=DropAfter(),
)
2 changes: 2 additions & 0 deletions tests/integration/aio/test_timezone_semantics.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from pg_partsmith.entities import PartitionGranularity, Period, TablePartitionConfig
from pg_partsmith.topology import RangeBounds
from pg_partsmith.utils import qualify
from tests.integration.nested_support import drops_at_once

pytestmark = pytest.mark.integration

Expand Down Expand Up @@ -81,6 +82,7 @@ async def _partition_bound_expr(engine: AsyncEngine, partition: str) -> str:
return str(expr)


@drops_at_once
def _moscow_config(table_relname: str, *, retention: int = 12) -> TablePartitionConfig:
return TablePartitionConfig(
schema="public",
Expand Down
Loading
Loading