Skip to content
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ The `ORDER BY` lives on the inner CTE that **selects + LIMITs** the rows; the ou

There is **no `state` column**: a row is "available" iff `acquired_token IS NULL` or `acquired_at < now() - lease_ttl_seconds`. Terminal failures `DELETE` by default; opt in to audit via `dlq_table=make_dlq_table(metadata)`.

`validate_schema()` is **opt-in** (call from `/health` or startup hook, not `broker.start()`) so migrations can run against the same DB without a loop. Beyond the alembic column/index diff it also probes the live partial-index **predicates** (alembic ignores `postgresql_where`), catching a drifted or non-partial `timer_id_uq` that would otherwise break `ON CONFLICT` at publish time (S2), **and probes `pg_constraint` for the `<table>_lease_ck` CHECK** (alembic has no check-constraint comparator), catching a missing or drifted lease pairing. Alembic is optional (`faststream-outbox[validate]`); without it `validate_schema()` raises `ImportError` but every other path works.
`validate_schema()` is **opt-in** (call from `/health` or startup hook, not `broker.start()`) so migrations can run against the same DB without a loop. Beyond the alembic column/index diff it also probes the live partial-index **predicates** (alembic ignores `postgresql_where`), catching a drifted or non-partial `timer_id_uq` that would otherwise break `ON CONFLICT` at publish time (S2), **and probes `pg_constraint` for the `<table>_lease_ck` CHECK** (alembic has no check-constraint comparator), catching a missing or drifted lease pairing. Because these two probes (predicates + CHECK) catch drift `alembic revision --autogenerate` **cannot** remediate (no check-constraint comparator; index comparator ignores `postgresql_where`), the raised `RuntimeError` appends a pointer to `docs/operations/alembic.md#fixing-drift-autogenerate-cant-see` (the hand-written-migration recipe) — but **only** when one of those two probes fired; autogenerate-fixable drift (columns, plain indexes, DLQ) gets no pointer. Message composition lives in `_compose_schema_mismatch_message` (`client.py`), gated on `has_blind_drift`. Alembic is optional (`faststream-outbox[validate]`); without it `validate_schema()` raises `ImportError` but every other path works.

### Opt-in DLQ on terminal failure

Expand Down
63 changes: 63 additions & 0 deletions docs/operations/alembic.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,69 @@ consequence that bites is a missing `server_default=now()` on
`next_attempt_at`; see the server-defaults caveat in
[Schema validation](../usage/schema-validation.md).

## Fixing drift autogenerate can't see { #fixing-drift-autogenerate-cant-see }

Two kinds of drift that
[`validate_schema()`](../usage/schema-validation.md) reports **cannot** be
remediated by `alembic revision --autogenerate` — the same blindness that
let them drift in also stops autogenerate from emitting a fix:

- **The `outbox_lease_ck` CHECK constraint.** Alembic's `compare_metadata`
has no check-constraint comparator, so a missing or altered CHECK never
appears in an autogenerated migration.
- **Partial-index predicates.** Alembic's index comparator ignores
`postgresql_where`, so an index that exists but was created non-partial,
with the wrong `WHERE`, or (for `outbox_timer_id_uq`) non-unique is
invisible to the diff.

When `validate_schema()` raises for one of these, its error ends with a
pointer to this section. Re-running autogenerate produces an empty
`upgrade()` — hand-write the migration instead, then re-run
`validate_schema()` to confirm the drift is cleared.

### Restore the lease CHECK

```python
# Drop first ONLY if the constraint exists with a wrong predicate; skip the
# drop if it is absent entirely.
op.drop_constraint('outbox_lease_ck', 'outbox', type_='check')
op.create_check_constraint(
'outbox_lease_ck',
'outbox',
'(acquired_token IS NULL) = (acquired_at IS NULL)',
)
```

### Restore a partial index

Drop the drifted index and recreate it with its load-bearing predicate. The
three indexes and their expected shape:

| Index | Columns | Unique | `postgresql_where` |
| --- | --- | --- | --- |
| `outbox_pending_idx` | `queue, next_attempt_at` | no | `acquired_token IS NULL` |
| `outbox_lease_idx` | `queue, acquired_at` | no | `acquired_token IS NOT NULL` |
| `outbox_timer_id_uq` | `queue, timer_id` | yes | `timer_id IS NOT NULL` |

```python
op.drop_index('outbox_timer_id_uq', table_name='outbox')
op.create_index(
'outbox_timer_id_uq',
'outbox',
['queue', 'timer_id'],
unique=True,
postgresql_where=sa.text('timer_id IS NOT NULL'),
)
```

Substitute the columns / `unique` / predicate from the table above for
`outbox_pending_idx` and `outbox_lease_idx`.

The recipes pass literal names (`'outbox_lease_ck'`, `'outbox_timer_id_uq'`) —
the exact names the package emits. If your `MetaData` carries a SQLAlchemy
`naming_convention`, wrap each name in `op.f('outbox_lease_ck')` so Alembic
treats it as final and does not re-template it.

## DLQ retention via partition drop { #dlq-retention-via-partition-drop }

Plain `DELETE FROM outbox_dlq WHERE failed_at < now() - interval '90
Expand Down
6 changes: 6 additions & 0 deletions docs/usage/schema-validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ schema (`add_*` / `modify_*` ops). `remove_*` ops are silently dropped so
you can attach your own audit columns or additional indexes without the
validator complaining.

Some drift cannot be fixed by re-running `alembic revision --autogenerate` — a
missing/altered `outbox_lease_ck` CHECK or a drifted partial-index predicate.
For those, the `RuntimeError` ends with a pointer to
[Alembic migrations § Fixing drift autogenerate can't see](../operations/alembic.md#fixing-drift-autogenerate-cant-see),
which holds the hand-written migration recipe.

!!! warning "Server defaults are not checked"
The diff runs with `compare_server_default=False` — Alembic's
server-default comparison is flaky against Postgres' normalized
Expand Down
44 changes: 33 additions & 11 deletions faststream_outbox/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -409,20 +409,21 @@ async def validate_schema(self) -> None:
"""
async with self._engine.connect() as conn:
errors = await conn.run_sync(_validate_schema_sync, self._table)
# S2: alembic's autogenerate diff compares index columns + uniqueness but NOT
# the partial-index WHERE predicate, so a wrong postgresql_where slips through
# and later breaks the producer's ON CONFLICT arbiter. Probe the predicates
# directly against the live catalog.
errors.extend(await conn.run_sync(_validate_index_predicates_sync, self._table))
# Alembic's compare_metadata has no check-constraint comparator, so a missing
# or altered <table>_lease_ck (the half-set-lease guard) passes the diff above
# silently. Probe pg_constraint directly, mirroring the partial-index probe.
errors.extend(await conn.run_sync(_validate_check_constraints_sync, self._table))
# S2 / lease_ck: these two probes catch drift that `alembic revision
# --autogenerate` cannot remediate — its index comparator ignores
# postgresql_where and it has no check-constraint comparator at all.
# Collect them separately so the raised error can point operators at
# the hand-written-migration recipe (_AUTOGEN_BLIND_HINT) only when
# one of them actually fired.
blind_errors = await conn.run_sync(_validate_index_predicates_sync, self._table)
blind_errors.extend(await conn.run_sync(_validate_check_constraints_sync, self._table))
errors.extend(blind_errors)
if self._dlq_table is not None:
errors.extend(await conn.run_sync(_validate_dlq_schema_sync, self._dlq_table))
if errors:
msg = "Outbox schema mismatch: " + "; ".join(errors)
raise RuntimeError(msg)
raise RuntimeError(
_compose_schema_mismatch_message(errors, has_blind_drift=bool(blind_errors)),
)

async def ping(self) -> bool:
# Bound the probe: an unwrapped connect+SELECT 1 against a half-dead TCP socket
Expand Down Expand Up @@ -576,6 +577,27 @@ def _validate_check_constraints_sync(connection: "Connection", table: "Table") -
return errors


# The published docs anchor for hand-written migrations that fix drift
# `alembic revision --autogenerate` cannot emit (no check-constraint comparator;
# the index comparator ignores postgresql_where). Appended to the RuntimeError
# only when an Alembic-blind probe actually fired — see validate_schema().
_SCHEMA_MISMATCH_PREFIX = "Outbox schema mismatch: "
_AUTOGEN_BLIND_HINT = (
"These (CHECK constraints and partial-index predicates) are invisible to "
"'alembic revision --autogenerate' — hand-write the migration: "
"https://faststream-outbox.modern-python.org/operations/alembic/"
"#fixing-drift-autogenerate-cant-see"
)


def _compose_schema_mismatch_message(errors: list[str], *, has_blind_drift: bool) -> str:
"""Build the validate_schema RuntimeError text; append the remediation pointer for Alembic-blind drift."""
msg = _SCHEMA_MISMATCH_PREFIX + "; ".join(errors)
if has_blind_drift:
msg += "\n\n" + _AUTOGEN_BLIND_HINT
return msg


def _validate_schema_sync(connection: "Connection", table: "Table") -> list[str]:
"""Run the outbox-table validation pass; see :func:`_run_validate` for the diff machinery."""
return _run_validate(connection, table, make_outbox_table)
Expand Down
5 changes: 5 additions & 0 deletions planning/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@ _None._

### Archived (shipped)

- **[actionable-schema-drift-error](changes/archive/2026-06-16.01-actionable-schema-drift-error/design.md)**
(#99, 2026-06-16) — `validate_schema()` appends a hand-written-migration
pointer to its `RuntimeError` for Alembic-blind drift (the `outbox_lease_ck`
CHECK and partial-index predicates that `--autogenerate` can't remediate);
recipe lives in `docs/operations/alembic.md`.
- **[portable-planning-convention](changes/archive/2026-06-13.01-portable-planning-convention/design.md)**
(#77, 2026-06-13) — Two-axis OpenSpec-shaped convention: `architecture/`
truth + `changes/` folder bundles, `.NN` intra-day tiebreak, three lanes,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
---
status: shipped
date: 2026-06-16
slug: actionable-schema-drift-error
supersedes: null
superseded_by: null
pr: "99"
outcome: shipped in #99
---

# Design: Actionable error for Alembic-blind schema drift

## Summary

`validate_schema()` detects schema drift that `alembic revision --autogenerate`
**cannot** generate a migration for — the `<table>_lease_ck` CHECK constraint and
the load-bearing partial-index predicates. Today the operator gets a bare
`RuntimeError` and a dead end: re-running autogenerate produces nothing. This
change appends a one-line pointer to that error — only when an Alembic-blind
drift is present — directing the operator to a new docs section that holds the
exact hand-written migration recipe. No comparator hook; no new exception type.

## Motivation

`make_outbox_table` declares three partial indexes (each with a load-bearing
`postgresql_where`) and a `<table>_lease_ck` CHECK
(`(acquired_token IS NULL) = (acquired_at IS NULL)`). On a **fresh**
`create_table`, autogenerate renders all of it. On an **incremental** migration
onto a pre-existing table, Alembic's `compare_metadata` has no check-constraint
comparator and its index comparator ignores `postgresql_where`, so a missing or
drifted CHECK — and a non-partial / wrong-predicate / non-unique index — ships
silently. `validate_schema()` backstops this with direct `pg_catalog` /
`pg_constraint` probes (`_validate_index_predicates_sync`,
`_validate_check_constraints_sync`), so the drift *is* caught at runtime.

But the resulting error — e.g.

> `RuntimeError: Outbox schema mismatch: missing CHECK constraint 'outbox_lease_ck' (expected 'acquired_token is null = acquired_at is null')`

— is a dead end. The operator runs `alembic revision --autogenerate` expecting a
remediation migration and gets an empty `upgrade()`, because the same blindness
that let the drift through also prevents autogenerate from fixing it. The
detection is correct; the remediation path is missing.

The third validation pass (the Alembic `compare_metadata` diff for
tables/columns/plain indexes) reports drift that autogenerate *can* fix, so it
needs no special handling — the operator there just re-runs autogenerate.

## Non-goals

- An Alembic autogenerate comparator hook ("make autogenerate emit it"):
explicitly declined — we are not registering a `comparators.dispatch_for`
extension or asking users to wire one into `env.py`.
- A structured exception type (`SchemaMismatchError` with a `.remediation`
list): out of scope; the raised type stays `RuntimeError`.
- Embedding the full copy-pasteable DDL in the error text: the error carries a
short pointer; the DDL lives in docs.
- Server-default drift: still not validated (`compare_server_default=False`,
unchanged).

## Design

### 1. Error composition in `validate_schema()` (`client.py`)

`validate_schema()` runs the same three probe groups as today, but tracks
whether the **Alembic-blind** probes contributed any errors. The blind probes
are exactly `_validate_index_predicates_sync` and
`_validate_check_constraints_sync`, both run only on the outbox table
(`self._table`) — the DLQ table declares no partial-index predicates or CHECK
constraints, so it is validated solely by the Alembic diff. The Alembic
`compare_metadata` diff (`_validate_schema_sync` / `_validate_dlq_schema_sync`)
is autogenerate-fixable and does **not** set the blind flag.

The message is built through a new **pure** helper so the append logic is
unit-testable without a live engine (the 100 % coverage gate forbids an
untested branch, and `test_unit.py` runs with no Postgres):

```python
_SCHEMA_MISMATCH_PREFIX = "Outbox schema mismatch: "
_AUTOGEN_BLIND_HINT = (
"These (CHECK constraints and partial-index predicates) are invisible to "
"'alembic revision --autogenerate' — hand-write the migration: "
"https://faststream-outbox.modern-python.org/operations/alembic/"
"#fixing-drift-autogenerate-cant-see"
)


def _compose_schema_mismatch_message(errors: list[str], *, has_blind_drift: bool) -> str:
msg = _SCHEMA_MISMATCH_PREFIX + "; ".join(errors)
if has_blind_drift:
msg += "\n\n" + _AUTOGEN_BLIND_HINT
return msg
```

`validate_schema()` collects the blind-probe errors separately, ORs their
presence into `has_blind_drift`, and raises
`RuntimeError(_compose_schema_mismatch_message(errors, has_blind_drift=...))`.

The prefix `"Outbox schema mismatch: " + "; ".join(errors)` and the per-error
strings are unchanged, so every existing `pytest.raises(..., match=...)`
substring assertion keeps passing. The pointer is appended on its own line
(`\n\n`) after the joined errors.

### 2. Docs section (`docs/operations/alembic.md`)

New section `## Fixing drift autogenerate can't see { #fixing-drift-autogenerate-cant-see }`,
placed after "Drift detection in CI" and before "DLQ retention via partition
drop". It states the two Alembic-blind classes and why autogenerate misses them
(no check-constraint comparator; index comparator ignores `postgresql_where`),
then gives exact hand-written `op.*` recipes:

- **Missing or drifted `lease_ck` CHECK** — drop first only if it exists but
drifted, then create:

```python
# only if it exists with a wrong predicate:
op.drop_constraint('outbox_lease_ck', 'outbox', type_='check')
op.create_check_constraint(
'outbox_lease_ck', 'outbox',
'(acquired_token IS NULL) = (acquired_at IS NULL)',
)
```

- **Non-partial / wrong-predicate / non-unique index** — drop and recreate with
the load-bearing `postgresql_where` (and `unique=True` for the timer-id
index):

```python
op.drop_index('outbox_timer_id_uq', table_name='outbox')
op.create_index(
'outbox_timer_id_uq', 'outbox', ['queue', 'timer_id'],
unique=True, postgresql_where=sa.text('timer_id IS NOT NULL'),
)
# outbox_pending_idx: postgresql_where=sa.text('acquired_token IS NULL')
# outbox_lease_idx: postgresql_where=sa.text('acquired_token IS NOT NULL')
```

The recipe names the three index suffixes (`_pending_idx`, `_lease_idx`,
`_timer_id_uq`) and their expected predicates, matching
`_EXPECTED_INDEX_PREDICATES` in `client.py`.

A one-line cross-link is added from `docs/usage/schema-validation.md` to this
anchor.

### 3. Anchor stability

The error URL is `https://faststream-outbox.modern-python.org/operations/alembic/#fixing-drift-autogenerate-cant-see`
(site_url from `mkdocs.yml`, directory-URL form matching the existing
`#dlq-retention-via-partition-drop` anchor). The explicit `{ #... }` attr-list
anchor pins the slug so a later heading reword can't silently break the link.

## Operations

None. No infra, DNS, or external-account changes.

## Testing

- **`test_unit.py`** (no Postgres):
- `_compose_schema_mismatch_message(errors, has_blind_drift=True)` contains the
pointer URL and the `;`-joined prefix.
- `has_blind_drift=False` omits the pointer entirely.
- Prefix and `; ` join format are intact in both cases.
- **`test_integration.py`**:
- Extend an Alembic-blind case (e.g.
`test_validate_schema_fails_when_lease_check_constraint_missing`) to assert
the pointer URL is in the raised message.
- Assert an autogenerate-fixable case (e.g.
`test_validate_schema_fails_when_columns_missing`) does **not** contain the
pointer.
- **Docs**: `just docs-build` (`mkdocs build --strict`) passes — the new anchor
resolves and the cross-link from `schema-validation.md` does not 404.
- **Lint**: `just lint-ci` clean.

## Risk

- **Low — link rot.** If the docs site_url or page path changes, the error URL
goes stale. Mitigation: the explicit attr-list anchor + the `--strict` docs
build (which fails on a broken in-repo cross-link) catch the in-repo half; the
hostname is the one piece a strict build can't verify, and it is the published
canonical domain.
- **Low — flag plumbing.** `has_blind_drift` must be ORed from the two blind
probes specifically, not from the full error list, or the pointer would also
fire on pure column drift. Covered by the autogenerate-fixable negative test.
- **Negligible — message contract.** Appending a trailing line preserves the
prefix and per-error substrings, so existing `match=` assertions and any
operator log-greps on the prefix are unaffected.

## On merge

Promote into `architecture/`: the schema-validation / drift behavior is
described in `CLAUDE.md`'s "User-owned schema" section (and any
`architecture/` deep-dive that covers `validate_schema`). Add a sentence noting
that an Alembic-blind drift error now carries a remediation pointer to
`docs/operations/alembic.md#fixing-drift-autogenerate-cant-see`.
Loading