Releases: modern-python/faststream-outbox
Release list
0.13.0
faststream-outbox 0.13.0 — free-threaded (3.14t) Python support
Minor release. faststream-outbox now officially supports free-threaded
(no-GIL) CPython. There is no change to the installed package's runtime
behavior or public API — the producer, subscriber, lease/terminal-write,
timer, DLQ, and validation paths are identical to 0.12.0. The only shipped delta
is a new trove classifier; everything else is CI and docs.
Breaking changes
None — additive packaging metadata only.
Packaging
- Added the
Programming Language :: Python :: Free Threading :: 2 - Beta
classifier. On apython3.14tinterpreter,pip/uvresolve the
free-threaded (cp314t) wheels of the compiled dependencies (asyncpg,
sqlalchemy,pydantic-core) automatically. No dependency-floor change; GIL
builds are unaffected.
Free-threaded support — what it means
- The package is pure-Python asyncio (one event loop, worker tasks not OS
threads), so nothing in it depends on the GIL. CI now runs the full suite on
the 3.14t interpreter and, in a separate step, asserts that importing the
outbox and its runtime dependencies keeps the GIL disabled — so a future
dependency that silently re-enables the GIL turns the build red. - It does not add cross-core parallelism. The subscriber is single-loop by
design; to use more cores, run more subscriber processes — the same lever as
on a GIL build. - To keep the GIL genuinely disabled, set
DISABLE_SQLALCHEMY_CEXT_RUNTIME=1.
SQLAlchemy's Cython extensions ship 3.14t wheels but don't yet declare
free-thread safety, so importing SQLAlchemy otherwise re-enables the GIL
process-wide (your outbox code runs correctly either way). Foreign-broker
relay clients you install (e.g.aiokafka) can do the same. See the
Free-threaded Python docs. - 3.14t only. The compiled dependencies ship no
cp313twheels, so 3.13t
is not a supported free-threaded target.
Migration
No action needed — no API or behavior change. Existing installs on GIL builds
(Python 3.11–3.14) are unaffected.
Touched surface
pyproject.toml— the new classifier..github/workflows/_checks.yml— the standalonefreethreadedCI job.docs/introduction/installation.md— a "Free-threaded Python" section.planning/— the design change file and two decision records.
See also
- #151 — Free-threaded Python (3.14t) support
0.12.0
faststream-outbox 0.12.0 — architecture-deepening pass (internal refactors, no production behavior change)
Minor release. Every change to the installed package's production runtime is an
internal refactor — the producer, subscriber, lease/terminal-write, timer, DLQ, and
validation behavior are identical to 0.11.0, verified by the unchanged suite at 100%
coverage across Python 3.11–3.14. The batch came out of an architecture review that
deepened shallow modules, named duplicated rules, and removed a dead field. The one
behavior change is test-broker-only (a tightened session argument, below), which is why
this is a minor rather than a patch. Drop-in for production code.
Breaking changes
TestOutboxBroker:cancel_timer/fetch_unprocessednow requiresession. These
two methods moved behind theAbstractOutboxClientseam (see refactors below). Under the
test broker they previously accepted an optionalsession(an internal patch
defaulted it toNone); they now requiresession=..., matching the real broker's
signature and the documented public contract (the argument is still ignored under the
test broker). Production is unaffected —broker.cancel_timer/broker.fetch_unprocessed
always requiredsession. Only test code that called these on aTestOutboxBroker
withoutsessionneeds a one-token edit. (#141)
Internal refactors (no behavior change)
Each deepens the codebase — more behavior behind smaller, single-owner seams — without
changing observable production behavior.
- Schema-drift validator split out of
client.py. The ~285 LOC of Alembic-diff +
pg_catalogprobes moved to a newschema_validation.py; the transport client roughly
halved (796 → 471 LOC) and no longer imports the optionalalembicdependency.
broker.validate_schema()/client.validate_schema()are unchanged.
(#139) - Dead
to_deleteintent flag removed.OutboxInnerMessage.to_deletewas set on every
ack/terminal path but read by zero production code (dispatch branched on other fields).
Removed; tests now assert on the fields the dispatcher actually reads.
(#140) cancel_timer/fetch_unprocessedrouted through the client seam. Both built inline
SQL on the broker's table handle, bypassing theOutboxClientthat already owns the
table. They are now methods onAbstractOutboxClient(real + fake); the broker delegates,
and the test-broker patch machinery for them is deleted. (Carries the test-brokersession
change above.) (#141)- Envelope-managed header keys named once.
content-typeandcorrelation_idwere bare
string literals acrossenvelope.py(write),parser.py(read), and the relay
header-strip. Defined once inmessage.pyasCONTENT_TYPE_HEADER/
CORRELATION_ID_HEADER/ENVELOPE_MANAGED_HEADERS.
(#143) - Lease-token guard named; contract coverage filled. The
NULL = NULLtoken guard the
fake repeated inline is now one_lease_token_matcheshelper. The cross-adapter contract
suite gained the missingmark_pendingunleased-row scenario (the delete path already had
it) and an empty-queues symmetry check.
(#144) - Terminal intent deepened to a canonical
Outcomesum type.OutboxInnerMessagenow
records oneOutcome—Ack|Retry(delay_seconds)|Terminal(reason)— instead of
three loosely-coupled fields. Success is an explicitAck(); "reason and delay both set"
is now unrepresentable.terminal_failure_reason/pending_delay_seconds/state_set
remain as read-only property views, so readers and theDLQFailureReasonpublic
contract are unchanged. (#145)
Design records (no code change)
- The
conn: AsyncConnection | Noneunion on the client seam is deliberate. The review
flagged it as a "type lie"; on inspection it is a documented widening (the fake genuinely
has no connection, the subscriber genuinely producesNoneon the fake path). Recorded as
an accepted decision with a revisit trigger.
(#142) - The two observability seams overlap on the consume/publish series.
architecture/metrics.md
now documents that the recorder adapters and the native middleware emit the same-named
consume/publish metrics — one Prometheus registry raisesDuplicated timeseries, live OTel
meters on both double-count — and why the library can't guard it at construction (separate
registries / middleware-as-source-of-truth stays an operator concern).
(#146)
Migration
- Production code: none. No public-API change, no schema change, no config change.
- Test suites using
TestOutboxBroker: if you call.cancel_timer(...)or
.fetch_unprocessed(...)on the test broker without asession, addsession=...(any
session — it is ignored under the test broker). Everywhere else, no change.
Touched surface
- Package code (internal only):
faststream_outbox/schema_validation.py(new),
client.py,broker.py,message.py,envelope.py,parser/parser.py,
subscriber/usecase.py,testing.py. - Tests:
tests/test_client_contract.py(+2 scenarios),tests/test_unit.py,
tests/test_fake.py. - Living docs / decisions:
CLAUDE.md,architecture/dlq.md,architecture/test-broker.md,
architecture/metrics.md,docs/usage/testing.md,planning/decisions/2026-07-17-conn-union-is-deliberate.md.
See also
- #139 — split schema-drift validator.
- #140 — remove dead
to_deleteflag. - #141 — table ops behind the client seam.
- #142 —
connunion deliberate (decision). - #143 — name envelope header keys.
- #144 — name lease-token guard + contract coverage.
- #145 — canonical
Outcomesum type. - #146 — two-seam overlap hazard (docs).
0.11.0
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
NOTIFYs, 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
NOTIFYdedup — onepg_notifyper(transaction, queue), not per
publish.broker.publish/publish_batchran aSELECT pg_notify(channel, queue)on every call, so N publishes to one queue in one transaction emitted N
NOTIFYstatements — N−1 of them pure round-trip waste, since Postgres already
coalesces identical(channel, payload)NOTIFYs per transaction at delivery. A
transaction-scoped memo (aWeakKeyDictionarykeyed on the innermost active
transaction) now emits exactly onepg_notifyper 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 oneNOTIFY). Default-on,
no knob. (#135)
New features
-
Opt-in batched terminal flush —
terminal_flush_batch_size(default1).
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 toN−1already-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 setsautovacuum_vacuum_scale_factor = 0plus size-independent dead-tuple
/ insert thresholds (eligibility), breaking the stale-reltuplesdeath-spiral that
makes a high-churn queue table bloat. Signature: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_limitare 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=0runs 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 acheck_autovacuumflag (defaultFalse) that asserts the
live table carries the structuralscale_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. DefaultFalsekeepsvalidate_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 omittedinclude_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'sdefault_schema_nameso 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 viapg_stat_statements(split by leading SQL keyword) and
gates the deterministic, machine-independent counts against
benchmarks/baseline.jsonin CI. This is contributor tooling — it drove and now
locks the wins above (the producerNOTIFYdedup shows asselect_calls5000 → 1;
the batched terminal flush as fewerdelete_calls). Not part of the installed
wheel. (#130) - Honest autovacuum docs. The operations and architecture docs were rewritten to
scope the benefit precisely: the shippedscale_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=Non 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; addvacuum_cost_delay/vacuum_cost_limitif
you run a high-churn table and vacuum can't keep pace. - Add
check_autovacuum=Trueto avalidate_schema()CI gate to enforce the above.
- Set
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_sizeplumbed through the subscriber
config, factory, and all three registration entry points. (#131)faststream_outbox/autovacuum.py(new) +__init__.pyexport —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) + CIbenchjob. (#130)docs/operations/,architecture/— batching, autovacuum (throughput-scoped), and producer-NOTIFYdocs. (#132, #134, #135)
See also
planning/changes/2026-07-14.01-benchmark-harness.md(#130)planning/changes/2026-07-15.01-batched-terminal-flush.md(#131, #132)planning/changes/2026-07-15.02-autovacuum-tuning.md(#133)planning/changes/2026-07-16.01-autovacuum-cost-knobs.md(#134)planning/changes/2026-07-16.02-notify-dedup.md(#135)
0.10.5
faststream-outbox 0.10.5 — release pipeline on PyPI Trusted Publishing
No library changes. The package is identical to 0.10.4; this release exercises the new publish path end-to-end.
CI
- Releases now authenticate to PyPI via Trusted Publishing (OIDC) instead of a long-lived
PYPI_TOKENsecret.uv publishauto-detects the GitHub Actions id-token; the release job runs under apypienvironment that scopes the trusted publisher (#123).
Downstream
No action required. Nothing about the installed package changes.
0.10.4 — internal refactors + richer PyPI metadata, no behavior change
faststream-outbox 0.10.4 — internal refactors + richer PyPI metadata, no behavior change
Patch release. Every change to the installed package is an internal refactor —
the runtime behaves identically to 0.10.3 (same producer, subscriber, lease/terminal-write,
timer, DLQ, and validation behavior). The one user-visible change is richer PyPI package
metadata. Drop-in upgrade.
Internal refactors (no behavior change)
These deepen the codebase without changing any observable behavior; they exist to remove
duplication and concentrate logic, verified by the unchanged test suite (and a new
cross-adapter contract suite).
- Outbox rules de-duplicated between the real and fake clients. The genuinely-shared
pure bits are now single-sourced —_scheduling.py(activate-args resolution +
validation, shared by the real and fake publish paths) and_DLQ_PROJECTIONin
schema.py(one declarative outbox→DLQ column map both the real DLQ CTE and the fake
build from). The rules that must run server-side in Postgres (eligibility, lease cutoff,
retry timing) stay as two implementations but are now co-verified by a single
parametrized contract suite run against both the real client and the in-memory fake.
(#109) - Lease-lost telemetry has one home. The byte-identical detect→log→emit block that the
terminal and retry flush paths each carried is consolidated into a single
_emit_lease_lost(row, *, phase)helper. (#110) OutboxSubscriberConfigvalidates itself. Subscriber-knob validation moved from the
factory intoOutboxSubscriberConfig.__post_init__, so every construction path is
guarded, not just@broker.subscriber. Behavior is preserved exactly; warning
attribution (the warning points at your@subscribercall site) is now computed via a
manual stacklevel walk so it's robust across CPython 3.13/3.14 and the FastAPI-router
path. (#111)
Packaging
- Richer PyPI metadata — keywords, classifiers, and project URLs. Better
discoverability and project links on the PyPI page; no install-time or runtime effect.
(#108)
Migration
None. Drop-in upgrade from 0.10.x — no public-API change, no schema change, no config
change. If you don't construct OutboxSubscriberConfig directly (the normal case), nothing
about your code changes; if you do, it now validates on construction (rejecting the same
impossible values the factory always rejected).
Touched surface
- Package code (internal only):
faststream_outbox/_scheduling.py(new),
publisher/producer.py,broker.py,schema.py,client.py,testing.py,
subscriber/usecase.py,subscriber/config.py,subscriber/factory.py. - Packaging:
pyproject.toml(metadata only). - Tests:
tests/test_client_contract.py(new, cross-adapter contract), plus updates
totests/test_unit.py,tests/test_fake.py,tests/test_integration.py. - Living docs:
CLAUDE.md,architecture/dlq.md,architecture/test-broker.md.
See also
0.10.3 — schema validation matches the lease CHECK by predicate, not name
faststream-outbox 0.10.3 — schema validation matches the lease CHECK by predicate, not name
Patch release. One bug fix to validate_schema()'s CHECK-constraint probe,
correcting the 0.10.2 approach. No public-API change. The only change to the
installed package is how the probe identifies the lease CHECK; everything else is
identical to 0.10.2.
Fixes
-
validate_schema()no longer false-fails when the lease CHECK was created
under a literal name while theMetaDatacarries acknaming_convention.
0.10.2 made the probe read the expected name off theTableobject, which
under ackconvention resolves to the doubledck_<table>_<table>_lease_ck.
But a hand-written migration —
op.create_check_constraint('<table>_lease_ck', ...)— creates the literal
name verbatim, because Alembic op functions don't applytarget_metadata's
convention. So the probe demanded a name the migration never produces and
raised a spuriousOutbox schema mismatch: missing CHECK constraint 'ck_<table>_<table>_lease_ck' …on a valid schema. The live constraint name is simply not predictable from
the package side — it depends on how the migration was authored
(MetaData.create_all/ convention-aware autogenerate → doubled name; a
hand-writtenop.create_check_constraint→ literal name)._validate_check_constraints_syncnow matches the lease CHECK by predicate,
not name: it normalizes every live CHECK's definition and passes if one
enforces(acquired_token IS NULL) = (acquired_at IS NULL)under any name.
When none does (including a drifted predicate — that just means the correct one
is absent), it reportsmissing CHECK constraint enforcing 'acquired_token is null = acquired_at is null' (the lease invariant; name it e.g. <table>_lease_ck)This is strictly more correct: a CHECK's name is irrelevant to whether it
enforces the invariant._resolve_check_constraint_name(added in 0.10.2) is
removed.
Compatibility
validate_schema() is opt-in (you call it from a health check / CI gate; it
is never run by broker.start()). If you don't call it, nothing changes.
If you do: any schema whose lease CHECK enforces the correct predicate now
validates regardless of the constraint's name — with no convention, with a ck
convention and the doubled name, or with a ck convention and a literal-named
hand-written migration (the case 0.10.2 broke). Schemas that genuinely lack the
invariant still fail, now with a predicate-describing message instead of a
guessed name.
The "wrong predicate" drift case now reports as "missing CHECK constraint
enforcing …" rather than "has wrong predicate" — a drifted CHECK doesn't enforce
the invariant, so it reads as the correct one being absent. The remediation
pointer (#fixing-drift-autogenerate-cant-see) still fires.
No other behavior change — producers, subscribers, the lease / terminal-write
paths, timers, the index/uniqueness probes, and the dlq_table=None path are all
identical to 0.10.2.
Docs
docs/operations/alembic.md— replaced the "introspect the rendered name"
recipe with "the CHECK name doesn't matter; match the predicate." The literal
op.create_check_constraint('outbox_lease_ck', ...)recipe is now correct even
under a convention.architecture/dlq.md— recorded the match-by-predicate invariant and why
name-prediction was reverted.
Touched surface
faststream_outbox/client.py—_validate_check_constraints_syncmatches by
predicate;_resolve_check_constraint_nameand the now-unusedCheckConstraint
import removed. Only package code change.docs/operations/alembic.md,architecture/dlq.md— recipe + invariant.tests/test_unit.py,tests/test_integration.py— regression coverage
(predicate matched under convention-doubled name, under literal name, missing
describes the predicate, drifted-reads-as-missing, and an end-to-end Postgres
pass for convention metadata + a literally-named constraint).
See also
- Corrects the 0.10.2 approach
(#102).
0.10.2 — schema validation honors naming_convention
faststream-outbox 0.10.2 — schema validation honors naming_convention
Patch release. One bug fix to validate_schema()'s CHECK-constraint probe.
No public-API change. The only change to the installed package is which
constraint name the probe looks up; everything else is identical to 0.10.1.
Fixes
-
validate_schema()no longer false-fails under a SQLAlchemyck
naming_convention. AMetaDatacarrying anaming_conventionwith ack
key re-templates the package's explicitly-named leaseCheckConstraint— the
given name becomes the%(constraint_name)stoken, so the live constraint is
named e.g.ck_<table>_<table>_lease_ck, not<table>_lease_ck. The probe
hard-coded the literal<table>_lease_ck, never found the re-templated name,
and raised a spuriousOutbox schema mismatch: missing CHECK constraint '<table>_lease_ck' …on a perfectly valid schema.
_validate_check_constraints_syncnow reads the
expected name off theTableobject (identifying the lease constraint by
its normalized predicate and using its convention-resolved.name), so the
expectation always matches what SQLAlchemy / Alembic emit from your metadata —
convention or not. The explicitly-named indexes were never affected: the
ix/uqconvention keys only re-template auto-named indexes.
Compatibility
validate_schema() is opt-in (you call it from a health check / CI gate; it
is never run by broker.start()). If you don't call it, nothing changes.
If you do, and you use no naming_convention, behavior is identical to
0.10.1 — the probe still resolves to the literal <table>_lease_ck. If you use a
ck convention, validation that previously raised spuriously now passes,
provided the constraint in your DB carries the convention-rendered name (which it
does when the schema was created via MetaData.create_all or a
convention-aware Alembic migration). Hand-written migrations must create the
constraint under that same rendered name — see the updated
naming_convention guidance
in the Alembic docs.
No other behavior change — producers, subscribers, the lease / terminal-write
paths, timers, the index/uniqueness probes, and the dlq_table=None path are
all identical to 0.10.1.
Docs
docs/operations/alembic.md— replaced the misleading
"wrap each name inop.f('outbox_lease_ck')" caveat with an
introspect-the-rendered-name recipe (the probe now expects the
convention-resolved name, not the literal).architecture/dlq.md— recorded the convention-awareness invariant in the
validate_schema()mechanics section.
Touched surface
faststream_outbox/client.py— new_resolve_check_constraint_name+
_validate_check_constraints_syncreads the expected CHECK name off the
Table. Only package code change.docs/operations/alembic.md,architecture/dlq.md— caveat fix + invariant.tests/test_unit.py,tests/test_integration.py— regression coverage
(convention-resolved name honored, missing-under-convention, literal-name
fallback, and an end-to-end Postgres pass under ackconvention).
See also
- Follow-up to the 0.10.1 schema-validation work
(#99 change
bundle:planning/changes/archive/2026-06-16.01-actionable-schema-drift-error/).
0.10.1 — actionable schema-drift errors
Patch release. One opt-in diagnostic refinement plus docs/branding. No public-API change and no breaking change beyond 0.10.0's. The only change to the installed package is validate_schema()'s error text.
Diagnostics
validate_schema()now tells you how to fix Alembic-blind drift (#99). A missing/altered<table>_lease_ckCHECK and a drifted partial-index predicate cannot be remediated byalembic revision --autogenerate(no check-constraint comparator; the index comparator ignorespostgresql_where). When one of those probes fires, the raisedRuntimeErrornow appends a pointer to the hand-written-migration recipe. Autogenerate-fixable drift (columns, plain indexes, DLQ) gets no pointer.
Compatibility
validate_schema() is opt-in; if you don't call it, nothing changes. If you do, the error gains a trailing pointer block but the "Outbox schema mismatch: " prefix and per-error strings are unchanged, so existing greps / match= assertions still hold. No other behavior change.
Docs
- New "Fixing drift autogenerate can't see" section in
docs/operations/alembic.md+ cross-link (#99). - Org logo, favicon, brand palette (#100).
architecture/deep-dive refresh for 0.10.0 surfaces (#98).
Full notes: planning/releases/0.10.1.md. PRs: #99, #100, #98.
0.10.0 — pass-3 audit closure: a High fix, two features, a hardened tail
Minor release. Full resolution of the 2026-06-14 pass-3 deep audit — one High correctness fix, two additive features, a cluster of robustness/validation fixes, and a large test-hardening + docs sweep. Backward-compatible by default; three opt-in/behavior refinements noted below.
New features
OutboxBroker(..., last_exception_renderer=...)— opt-inCallable[[BaseException], str | None]to redact or drop the DLQlast_exception(default keepsrepr). For deployments whose exceptions can embed payloads/PII/credentials. Also on the FastAPIOutboxRouter. (#93)- FastAPI
OutboxRouterforwardsdlq_table+metrics_recorder— DLQ archival and the recorder-metrics seam were previously unreachable for FastAPI users. (#88) drain_timeoutobservability — astop()drain exceedinggraceful_timeoutnow emits a WARNING + a metric (faststream_outbox_drain_timeout_total/messaging.outbox.drain_timeout). (#92, #96)
Bug fixes
- [High]
propagate_inbound_headers=Trueno longer poisons a successful relay: a chainedOutboxResponseno longer inherits the inboundcontent-type/correlation_id, which previously raised in_encode_payloadand nacked the successful inbound row to retry-exhaustion. (#85) - Completed the eager-validation fix:
OutboxResponseand emptypublish_batchnow reject a badqueue/sessionat the call site (one shared_validate_publish_args) instead of masquerading as a handler failure at dispatch. (#86) fetch_unprocessed(limit<1)rejected;ping()bounded by a timeout. (#89)
Robustness / correctness
validate_schema()flags a non-uniquetimer_id_uq(indisunique). (#92)- 63-byte identifier guard also enforced in
OutboxClient.__init__. (#92) OutboxBroker.stop()setsrunning=Falsebefore the subscriber-stop gather; reconnect backoff measures healthy time from a live connection. (#92)
Behavior notes (read before upgrading in place)
propagate_inbound_headers=True: a chainedOutboxResponseno longer receives the inboundcontent-type/correlation_id(foreign-broker relays unaffected). (#85)OutboxResponse/ emptypublish_batchvalidate eagerly — a badqueue/sessionraises earlier (at the call site). (#86)validate_schema()is stricter (opt-in): raises on a non-uniquetimer_id_uq. No-op if you don't call it. (#92)
Hot paths, lease/terminal-write invariants, and the dlq_table=None path are otherwise unchanged.
Full notes: planning/releases/0.10.0.md. Disposition ledger: planning/audits/2026-06-14-deep-audit-pass3-findings.md. PRs: #85–#97.
0.9.1 — suspected-findings fixes + diagnostics cleanup
faststream-outbox 0.9.1 — suspected-findings fixes + diagnostics cleanup
Patch release. Closes the five "suspected" findings from the 2026-06-12 audit (4 fixed, 1 already resolved in 0.9.0) plus three diagnostics / test-broker cleanups. No new features and no new breaking changes beyond 0.9.0's. One opt-in behavior change worth a look before upgrading: validate_schema() now catches partial-index drift it previously missed (see Behavior change).
Robustness / correctness
- S1 — bounded the LISTEN-connection teardown close. On reconnect/shutdown, a graceful
close()of the raw LISTEN connection ran unbounded — on the same half-dead socket the health probe may have just flagged, it could block on the kernel keepalive and wedge the fetch loop. It's nowwait_for(close, _LISTEN_CLOSE_TIMEOUT)with an immediateterminate()fallback, best-effort so teardown never raises. - S2 —
validate_schema()detects partial-index predicate drift. Alembic's autogenerate diff compares index columns + uniqueness but ignorespostgresql_where, so a{table}_timer_id_uqbuilt with the wrong predicate (WHERE timer_id IS NULL) — or as a plain non-partialUNIQUE (queue, timer_id)— passed validation, then broke the producer'sON CONFLICTarbiter inference at publish time.validate_schema()now probes the live partial-index predicates (pg_get_expr(indpred, …)) for all three indexes and flags both a wrong predicate and a present-but-non-partial index. - S4 —
import faststream_outboxis resilient to upstream module moves. Thefaststream...try_it_outimport (best-effort ASGI registry wiring) sat outside thetry/exceptmeant to tolerate upstream rename/removal, so a module move would have brokenimport faststream_outboxentirely. The import is now inside the guard. - S3 — already resolved in 0.9.0 (recorded for completeness). The
processed_totallease-lost double-count was closed by 0.9.0's P17: outcome metrics emit only after a successful flush, so a lease-lost row emitslease_lostinstead of a pairedacked/nacked.
Diagnostics / test broker
- S5 — sync-mode batch publish mirrors production ordering.
TestOutboxBroker(sync mode) used to dispatch each handler mid-feed — a handler could observe a half-inserted batch (impossible in production) — and emitpublishedafter the handlers ran. It now inserts the whole batch → emitspublished→ then dispatches, matching the production order (atomic batchINSERT→published→ subscriber fetch). - P27 — subscriber-misconfig warnings point at your call site. The
UserWarnings from@broker.subscriber(...)/@router.subscriber(...)misconfigurations used a staticstacklevelthat was wrong through the FastAPI-router path (it landed on a faststream-internal frame). They now useskip_file_prefixes(3.12+) and are attributed to your decorator line on both the direct and router paths. - P29 / P30 — test-broker internals. The four fake publish paths were deduplicated behind two shared helpers (no behavior change). And loop-mode
TestOutboxBrokernow wakes the fetch loop on feed/publish via the subscriber's_notify_event, mirroring productionNOTIFY— so loop-mode tests no longer need a tightmin_fetch_intervalto be responsive.
Behavior change: validate_schema() is stricter
validate_schema() is opt-in (you call it from a health check / startup hook — it isn't run by broker.start()). If you call it and your outbox table's partial indexes were hand-built or migrated with a predicate that differs from what make_outbox_table declares — most importantly a timer_id_uq that isn't … WHERE timer_id IS NOT NULL — it will now raise where 0.9.0 passed. That's the point: such an index silently breaks publish(..., timer_id=…)'s ON CONFLICT at runtime, and this surfaces it at validation time. The fix is to recreate the index with the correct predicate (regenerate your Alembic migration). If you don't call validate_schema(), nothing changes.
No other behavior change. Producers, subscribers, the lease/terminal-write paths, and the dlq_table=None path are all unchanged.
Touched surface
faststream_outbox/subscriber/usecase.py— bounded LISTEN close (S1).faststream_outbox/client.py— partial-index predicate probe invalidate_schema(S2).faststream_outbox/__init__.py— guardedtry_it_outimport (S4).faststream_outbox/subscriber/factory.py—skip_file_prefixeswarning attribution (P27).faststream_outbox/testing.py— sync-batch ordering (S5), fake-publish dedup (P29), loop-mode NOTIFY (P30).
See also
- Audit findings + resolution:
planning/active/2026-06-12-code-audit-findings.md. - PRs: #68 (S1–S5), #69 (P27), #70 (P29/P30).