Skip to content

Commit e9cb25b

Browse files
AlexeyShalaevAlexey Shalaev
andauthored
feat: report rows in DEFAULT that no maintenance run would reach (#94)
* feat: report rows in DEFAULT that no maintenance run would reach A row written with a key outside every partition after adoption lands in DEFAULT, and if its window is behind the cursor nothing ever comes back for it: no apply creates that window again, retention cannot drop it, and plan said there was nothing to do. Outpost's publish API takes a client-supplied time, which is how its maintainer found it. plan now reports it as rows_in_default (WARNING), so plan --check exits 3 and apply carries it in result.issues; backfill moves the rows into a partition of their own. The earliest row in DEFAULT decides, read with the index probe partition_data already uses: rows of a window the same plan creates are left to its attach, and rows ahead of the cursor to the run that reaches them. ActualTree.default_earliest carries it. * test: a past window the plan names is left to its attach --------- Co-authored-by: Alexey Shalaev <shalaevad.alexey@gmail.com>
1 parent 976a417 commit e9cb25b

15 files changed

Lines changed: 338 additions & 8 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212

1313
* 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))
1414

15+
## 1.8.0 — rows in DEFAULT that no run would reach are reported
16+
17+
An application that writes its own key values can put a row into DEFAULT after the table
18+
was adopted: an event dated last year lands there because no partition covers it. Creation
19+
walks forward from the cursor, so nothing ever came back for it. `plan` said there was
20+
nothing to do, `plan --check` exited 0, and retention could not drop what was never in a
21+
partition. Found by Outpost's maintainer: its publish API accepts a client-supplied `time`.
22+
23+
`plan` now reports such rows as `rows_in_default`, a WARNING, so `plan --check` exits 3 and
24+
`apply` carries it in `result.issues`; `backfill` moves them into a partition of their own.
25+
What decides is the earliest row in DEFAULT: one in a window the same plan creates is moved
26+
by its attach, and one dated ahead of the cursor by the run that gets there, so neither is
27+
reported. During an adoption the history waiting for `backfill` is reported the same way,
28+
until it has moved. `ActualTree.default_earliest` carries the earliest key, read with the
29+
same index probe `partition_data` uses.
30+
1531
## 1.7.2 — a backfill's last attach no longer rejects a live insert
1632

1733
The attach that ends a backfilled window is documented to hold writers at the parent, and

‎docs/guide/cli.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,12 @@ rows per batch a month's rows were out of sight for up to 0.42 s and inserts wai
347347
128 ms; with batches larger than a month, out of sight for about 30 ms, and inserts waited
348348
up to 247 ms.
349349

350+
Until `backfill` has moved the history, `plan` reports it as `rows_in_default` and `apply`
351+
exits `3` with the same finding. Once it has, the finding means rows arrived in DEFAULT
352+
later: an application that writes its own key values can date a row before every partition.
353+
If yours does, schedule `backfill` ahead of `apply`; with DEFAULT empty it does nothing and
354+
exits `0`.
355+
350356
## Commands around the lifecycle
351357

352358
A document can name a command, or a block of Python, to run before a drop, after a

‎docs/guide/partition-existing-table.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ Each call:
7979
6. with DEFAULT drained, attaches any window's partition a previous run filled and never
8080
attached — the process died in between — which `plan` reports as `unattached_rows`.
8181

82+
Until DEFAULT is drained, `plan` reports what is left in it as `rows_in_default`.
83+
8284
`result.complete` says whether DEFAULT is drained; `result.partitions` lists what was
8385
created; `result.issues` explains anything that could not be handled. A call that runs out
8486
of budget mid-window leaves that partition detached and filled so far; the next call

‎docs/guide/troubleshooting.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,21 @@ backfill back to the window. Run `backfill`: once DEFAULT is drained it looks fo
113113
these tables and attaches them. Only a table whose name is the one the scheme gives that
114114
window under this root counts — its rows are never read otherwise.
115115

116+
## `rows_in_default` — rows no maintenance run will reach
117+
118+
```text
119+
[warning] rows_in_default: public.events_default holds rows of 2025_06, a window behind the cursor that no partition of public.events covers, so no maintenance run will reach them, retention included; backfill (partition_data) moves them into partitions of their own.
120+
```
121+
122+
Creation walks forward from the cursor, so a row in DEFAULT from a window before it stays
123+
there: no `apply` creates that window again, and retention, which drops partitions, never
124+
sees it. Two ways to get there: an adoption whose history `backfill` has not moved yet, and
125+
an application that writes its own key values — an event dated last year, say — after the
126+
table was adopted. Run `backfill`; it creates the window's partition and moves the rows,
127+
and retention then treats that partition like any other. The finding names the earliest such
128+
window. Rows of a window the same plan creates are left to its attach, and rows dated ahead
129+
of the cursor to the run that reaches them, so neither is reported.
130+
116131
## `grace_pending`, `drop_deferred` (INFO)
117132

118133
A detached orphan waiting out its grace period, or one whose `DropAfter(when=…)`

‎docs/reference/findings.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ issues too, with the exception's name in front of the message.
3131
| `grace_pending` | INFO | A detached orphan still within its grace period. | Nothing. |
3232
| `drop_deferred` | INFO | An orphan past its grace whose drop condition does not hold yet. | Nothing. |
3333
| `unattached_rows` | WARNING | A table under a window's partition name holds rows but was never attached — a fill that stopped before its attach — so no query through the root sees them. | Run `backfill` (`partition_data`); it attaches the table. |
34+
| `rows_in_default` | WARNING | The root's DEFAULT partition holds rows of a window behind the cursor that has no partition: history not yet moved, or a row written with a key no partition covers. No maintenance run reaches them, retention included. | Run `backfill` (`partition_data`). |
3435

3536
## Operation reasons
3637

‎pg_partsmith/aio/services/inspection.py‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from __future__ import annotations
44

55
from datetime import UTC, datetime
6-
from typing import TYPE_CHECKING
6+
from typing import TYPE_CHECKING, Any
77

88
from pg_partsmith.boundaries import Axis, CursorSource, Window
99
from pg_partsmith.planner import PlanMode, PlanningContext, fact_targets, unattached_window
@@ -44,6 +44,9 @@ async def inspect(self, config: TablePartitionConfig, *, measure: bool = True) -
4444
unattached = await unattached_tables(self._metadata, config)
4545
if unattached:
4646
tree = tree.model_copy(update={"unattached": unattached})
47+
earliest = await default_earliest(self._metadata, config, tree)
48+
if earliest is not None:
49+
tree = tree.model_copy(update={"default_earliest": earliest})
4750

4851
policy = config.lifecycle
4952
if not policy.needs_facts:
@@ -100,6 +103,22 @@ async def context(
100103
)
101104

102105

106+
async def default_earliest(metadata: PartitionMetadataProvider, config: TablePartitionConfig, tree: ActualTree) -> Any:
107+
"""The smallest leading-key value in the root's DEFAULT partition among rows whose whole key is set.
108+
109+
A row with a NULL in the key is routed to DEFAULT on purpose and stays
110+
there; any other row belongs to a window that has no partition. One
111+
index probe when the key is indexed.
112+
"""
113+
root = config.scheme
114+
if not isinstance(root, RangePartitioning):
115+
return None
116+
default = next((child for child in tree.root.children if child.is_default), None)
117+
if default is None:
118+
return None
119+
return await metadata.get_leading_key_minimum(default.name, root.key)
120+
121+
103122
async def unattached_tables(
104123
metadata: PartitionMetadataProvider, config: TablePartitionConfig
105124
) -> tuple[UnattachedTable, ...]:

‎pg_partsmith/plan.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,10 @@ class FindingReason(StrEnum):
163163
UNATTACHED_ROWS: A partition named for a window of the root holds rows
164164
but was never attached -- a fill stopped before its attach -- so no
165165
query through the root sees them. ``partition_data`` attaches it.
166+
ROWS_IN_DEFAULT: The root's DEFAULT partition holds rows of windows that
167+
have no partition: history not yet moved, or rows written with a key
168+
outside every partition. Retention never reaches them;
169+
``partition_data`` moves them into partitions of their own.
166170
"""
167171

168172
LEGACY_LEAF = "legacy_leaf"
@@ -186,6 +190,7 @@ class FindingReason(StrEnum):
186190
GRACE_PENDING = "grace_pending"
187191
DROP_DEFERRED = "drop_deferred"
188192
UNATTACHED_ROWS = "unattached_rows"
193+
ROWS_IN_DEFAULT = "rows_in_default"
189194

190195

191196
# Reasons that describe a healthy, deliberate state (policy evolution, a

‎pg_partsmith/planner.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,7 @@ def _owns(self, relkind: RelationKind) -> bool:
246246
def run(self) -> None:
247247
self._plan_level(self.config.scheme, self.actual.root, depth=0)
248248
self._report_unattached()
249+
self._report_rows_in_default()
249250

250251
def _report_unattached(self) -> None:
251252
"""A partition that was filled and never attached holds rows no query through the root sees.
@@ -269,6 +270,37 @@ def _report_unattached(self) -> None:
269270
f"through {self.config.qualified_name} sees them; backfill (partition_data) attaches it.",
270271
)
271272

273+
def _report_rows_in_default(self) -> None:
274+
"""Rows in the root's DEFAULT partition from a window behind the cursor that has no partition.
275+
276+
History an adoption has not moved yet, or a row written with a key
277+
that no partition covers. Creation walks forward from the cursor, so
278+
nothing would ever come back for them, retention included. Rows of a
279+
window this plan creates are moved by its attach, and rows at or past
280+
the cursor by a later one, so the earliest row is the one that decides.
281+
"""
282+
earliest = self.actual.default_earliest
283+
root = self.config.scheme
284+
default = next((child for child in self.actual.root.children if child.is_default), None)
285+
if earliest is None or default is None or not isinstance(root, RangePartitioning):
286+
return
287+
boundaries = root.range_boundaries
288+
position = boundaries.decode(str(earliest))
289+
window = boundaries.window_at(earliest if position is None else position)
290+
cursor = self.ctx.now if boundaries.axis is Axis.TIME else self.ctx.cursors.get(root.key[0])
291+
if cursor is not None and not window < boundaries.window_at(cursor):
292+
return
293+
planned = {op.target.rpartition(".")[2] for op in self.creates}
294+
if boundaries.child_name(self.config.table_name, window) in planned:
295+
return
296+
self._record(
297+
default.name,
298+
FindingReason.ROWS_IN_DEFAULT,
299+
f"{default.name} holds rows of {boundaries.describe(window)}, a window behind the cursor that no "
300+
f"partition of {self.config.qualified_name} covers, so no maintenance run will reach them, retention "
301+
"included; backfill (partition_data) moves them into partitions of their own.",
302+
)
303+
272304
# ── Dispatch ────────────────────────────────────────────────────────────────
273305

274306
def _plan_level(self, level: SchemeBase, node: PartitionNode, *, depth: int) -> None:

‎pg_partsmith/sync/services/inspection.py‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from __future__ import annotations
44

55
from datetime import UTC, datetime
6-
from typing import TYPE_CHECKING
6+
from typing import TYPE_CHECKING, Any
77

88
from pg_partsmith.boundaries import Axis, CursorSource, Window
99
from pg_partsmith.planner import PlanMode, PlanningContext, fact_targets, unattached_window
@@ -44,6 +44,9 @@ def inspect(self, config: TablePartitionConfig, *, measure: bool = True) -> Actu
4444
unattached = unattached_tables(self._metadata, config)
4545
if unattached:
4646
tree = tree.model_copy(update={"unattached": unattached})
47+
earliest = default_earliest(self._metadata, config, tree)
48+
if earliest is not None:
49+
tree = tree.model_copy(update={"default_earliest": earliest})
4750

4851
policy = config.lifecycle
4952
if not policy.needs_facts:
@@ -100,6 +103,22 @@ def context(
100103
)
101104

102105

106+
def default_earliest(metadata: PartitionMetadataProvider, config: TablePartitionConfig, tree: ActualTree) -> Any:
107+
"""The smallest leading-key value in the root's DEFAULT partition among rows whose whole key is set.
108+
109+
A row with a NULL in the key is routed to DEFAULT on purpose and stays
110+
there; any other row belongs to a window that has no partition. One
111+
index probe when the key is indexed.
112+
"""
113+
root = config.scheme
114+
if not isinstance(root, RangePartitioning):
115+
return None
116+
default = next((child for child in tree.root.children if child.is_default), None)
117+
if default is None:
118+
return None
119+
return metadata.get_leading_key_minimum(default.name, root.key)
120+
121+
103122
def unattached_tables(metadata: PartitionMetadataProvider, config: TablePartitionConfig) -> tuple[UnattachedTable, ...]:
104123
"""The tables named under the root and attached to nothing, ``holds_rows`` answered where it matters.
105124

‎pg_partsmith/topology.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
from collections.abc import Collection, Sequence
2424
from datetime import datetime
2525
from enum import StrEnum
26-
from typing import Annotated, Literal
26+
from typing import Annotated, Any, Literal
2727

2828
from pydantic import BaseModel, ConfigDict, Field, model_validator
2929

@@ -418,13 +418,18 @@ class ActualTree(BaseModel):
418418
orphans: Marker-tagged detached tables whose marker names the root.
419419
unattached: Tables named under the root, attached to nothing and
420420
unmarked: partitions whose creation stopped before their attach.
421+
default_earliest: The smallest leading-key value among the rows of the
422+
root's DEFAULT partition whose whole partition key is set: where
423+
its rows of windows with no partition begin. None when it holds
424+
none, or when nobody looked.
421425
"""
422426

423427
model_config = ConfigDict(frozen=True)
424428

425429
root: PartitionNode
426430
orphans: tuple[DetachedPartition, ...] = ()
427431
unattached: tuple[UnattachedTable, ...] = ()
432+
default_earliest: Any = None
428433

429434
def find(self, name: str) -> PartitionNode | None:
430435
"""Return the attached node with schema-qualified ``name``, or None."""

0 commit comments

Comments
 (0)