Skip to content
Merged
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
133 changes: 133 additions & 0 deletions planning/releases/0.11.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# faststream-outbox 0.11.0 — a performance pass: write-path, terminal-path, and autovacuum tooling

**Minor release.** A throughput-focused release built from a performance review
of the transactional-outbox transport: the producer stops emitting redundant
`NOTIFY`s, the subscriber gains an opt-in batched terminal flush, and a new
`outbox_autovacuum_ddl` helper (plus an opt-in `validate_schema` probe) makes the
recommended table hygiene one function call. A reproducible benchmark harness
underpins all of it. **Backward-compatible by default** — every new knob defaults
to today's behavior, so an in-place upgrade changes nothing until you opt in.

## Performance

- **Producer `NOTIFY` dedup — one `pg_notify` per `(transaction, queue)`, not per
publish.** `broker.publish` / `publish_batch` ran a `SELECT pg_notify(channel,
queue)` on every call, so N publishes to one queue in one transaction emitted N
`NOTIFY` statements — N−1 of them pure round-trip waste, since Postgres already
coalesces identical `(channel, payload)` `NOTIFY`s per transaction at delivery. A
transaction-scoped memo (a `WeakKeyDictionary` keyed on the innermost active
transaction) now emits exactly one `pg_notify` per distinct queue per
transaction. **Behavior-preserving** — the subscriber still gets the same
"≥1 wake per queue per commit" it did before, fired inline at the first publish;
delivery, ordering, and the transactional producer contract are unchanged. On a
bulk publish (5000 rows, one queue, one transaction) the write path measured
**~1.9× throughput / ~46% less wall-clock** on loopback Postgres, and the
absolute saving grows with database round-trip latency. Single-publish-per-
transaction workloads are unaffected (one row still emits one `NOTIFY`). Default-on,
no knob. (#135)

## New features

- **Opt-in batched terminal flush — `terminal_flush_batch_size` (default `1`).**
The subscriber deletes/DLQs each terminally-handled row individually by default
(`terminal_flush_batch_size=1`, identical to prior behavior). Set it higher to
batch terminal writes into one statement per N rows, cutting the terminal-path
statement count under high throughput. This **widens the at-least-once
redelivery window** — a crash between handler success and the batched flush
redelivers up to `N−1` already-processed rows on recovery — so it is opt-in and
off by default; only raise it when your handlers are idempotent. Available on the
broker subscriber, `OutboxRouter`, and the FastAPI router. (#131, #132)

- **`outbox_autovacuum_ddl(...)` — the recommended per-table autovacuum settings
as one call.** A new top-level export that renders the
`ALTER TABLE … SET (autovacuum_*)` statement for an outbox table — drop it into
an Alembic migration (`op.execute(outbox_autovacuum_ddl("outbox"))`) or run it via
psql. It sets `autovacuum_vacuum_scale_factor = 0` plus size-independent dead-tuple
/ insert thresholds (eligibility), breaking the stale-`reltuples` death-spiral that
makes a high-churn queue table bloat. Signature:

```python
outbox_autovacuum_ddl(
table_name="outbox", *, schema=None,
vacuum_threshold=1000, insert_threshold=1000,
vacuum_cost_delay=None, vacuum_cost_limit=None,
) -> str
```

`vacuum_cost_delay` / `vacuum_cost_limit` are the vacuum **throughput** knobs
(off by default → cluster default emitted). A churn measurement showed throughput
— not eligibility — is the binding constraint under heavy sustained load, so these
let vacuum keep pace; `vacuum_cost_delay=0` runs it unthrottled (fast, but
I/O-heavy on a shared cluster — opt in deliberately). (#133, #134)

- **`validate_schema(check_autovacuum=True)` — opt-in autovacuum probe.** The
schema validator gains a `check_autovacuum` flag (default `False`) that asserts the
live table carries the structural `scale_factor = 0` + threshold settings, so a CI
gate can catch a table created without the recommended hygiene. The situational
throughput knobs (`cost_delay` / `cost_limit`) are **not** enforced — they are
tuning, not a structural requirement. Default `False` keeps `validate_schema()`
identical to 0.10.x. (#133)

## Bug fixes

- **`validate_schema()` now reflects a table in a named schema correctly.** The
validator's Alembic reflection omitted `include_schemas=True`, so a table created
under a non-default schema (`make_outbox_table(..., schema="app")`) failed
validation spuriously. Reflection now filters by the target schema, and an
explicit-default-schema table (`MetaData(schema="public")`) is normalized against
the connection's `default_schema_name` so it validates as unqualified. (#133)

## Internals / tooling

- **Benchmark harness for the outbox transport (`benchmarks/`, `just bench` /
`just bench-check`).** A reproducible producer/consumer workload sweep that records
per-message DB counters via `pg_stat_statements` (split by leading SQL keyword) and
gates the deterministic, machine-independent counts against
`benchmarks/baseline.json` in CI. This is contributor tooling — it drove and now
locks the wins above (the producer `NOTIFY` dedup shows as `select_calls` 5000 → 1;
the batched terminal flush as fewer `delete_calls`). Not part of the installed
wheel. (#130)
- **Honest autovacuum docs.** The operations and architecture docs were rewritten to
scope the benefit precisely: the shipped `scale_factor=0` + threshold settings
control vacuum *eligibility* (necessary, not sufficient); under heavy churn,
*throughput* (`cost_delay`/`cost_limit`) is the binding lever. Framed as standard
queue-table hygiene and insurance, not a measured silver bullet. (#134)

## Breaking changes

None. Every new parameter defaults to prior behavior:
`terminal_flush_batch_size=1` (per-message flush), `check_autovacuum=False`, and the
`outbox_autovacuum_ddl` cost knobs default to `None` (cluster default). The
`NOTIFY` dedup is behavior-preserving. An in-place upgrade requires no code or
schema change.

## Migration

- **No action required to upgrade.** Optional adoption:
- Set `terminal_flush_batch_size=N` on your subscriber(s) **only if your handlers
are idempotent** — it trades a wider redelivery window for terminal-path
throughput.
- Apply `outbox_autovacuum_ddl("<table>")` in an Alembic migration to adopt the
recommended autovacuum settings; add `vacuum_cost_delay`/`vacuum_cost_limit` if
you run a high-churn table and vacuum can't keep pace.
- Add `check_autovacuum=True` to a `validate_schema()` CI gate to enforce the above.

## Touched surface

- `faststream_outbox/publisher/producer.py` — per-`(transaction, queue)` `NOTIFY`
dedup memo. (#135)
- `faststream_outbox/subscriber/` + `registrator.py`, `router.py`,
`fastapi/router.py` — `terminal_flush_batch_size` plumbed through the subscriber
config, factory, and all three registration entry points. (#131)
- `faststream_outbox/autovacuum.py` (new) + `__init__.py` export — `outbox_autovacuum_ddl`. (#133, #134)
- `faststream_outbox/client.py`, `broker.py`, `testing.py` — `validate_schema(check_autovacuum=...)` + named-schema reflection fix. (#133)
- `benchmarks/` (new) + `Justfile` (`bench` / `bench-check`) + CI `bench` job. (#130)
- `docs/operations/`, `architecture/` — batching, autovacuum (throughput-scoped), and producer-`NOTIFY` docs. (#132, #134, #135)

## See also

- [`planning/changes/2026-07-14.01-benchmark-harness.md`](../changes/2026-07-14.01-benchmark-harness.md) ([#130](https://github.com/modern-python/faststream-outbox/pull/130))
- [`planning/changes/2026-07-15.01-batched-terminal-flush.md`](../changes/2026-07-15.01-batched-terminal-flush.md) ([#131](https://github.com/modern-python/faststream-outbox/pull/131), [#132](https://github.com/modern-python/faststream-outbox/pull/132))
- [`planning/changes/2026-07-15.02-autovacuum-tuning.md`](../changes/2026-07-15.02-autovacuum-tuning.md) ([#133](https://github.com/modern-python/faststream-outbox/pull/133))
- [`planning/changes/2026-07-16.01-autovacuum-cost-knobs.md`](../changes/2026-07-16.01-autovacuum-cost-knobs.md) ([#134](https://github.com/modern-python/faststream-outbox/pull/134))
- [`planning/changes/2026-07-16.02-notify-dedup.md`](../changes/2026-07-16.02-notify-dedup.md) ([#135](https://github.com/modern-python/faststream-outbox/pull/135))