Repository navigation
TestOutboxBroker: fake only the storage, publish through the real producer #236
Description
This was generated by AI during triage.
Problem Statement
A developer who tests an app with TestOutboxBroker gets a publish path that doesn't match production. In production every publish goes one way: broker.publish, publisher.publish or an OutboxResponse, then the publish middlewares, then OutboxProducer. The producer builds the envelope, inserts the row on the caller's session, sends the NOTIFY and emits the published event. The test broker swaps out that whole publishing side instead of only the storage underneath it:
- it patches
broker.publishandbroker.publish_batchwith mocks; - it replaces the producer with
FakeOutboxProducer; - it replaces the client with
FakeOutboxClient.
The test module therefore keeps its own copies of the envelope encoding, the published event, the Postgres column checks and the dispatch, and every gap between a copy and production is a bug that users find themselves:
- publish middlewares and their metrics never run in tests (Route TestOutboxBroker publish through _basic_publish #219);
- the caller's session is thrown away, so a rolled-back publish is still delivered (TestOutboxBroker dispatches publishes from rolled-back transactions #218);
- the
publishedevent in tests had different tags until fix: align the fake client with the real one and return fetched rows oldest first #233 copied the production ones a second time.
Each production change to publishing needs a matching hand edit in the test module, so these gaps keep coming back.
Solution
TestOutboxBroker fakes only the storage. Publishing runs the same code in tests and in production: the real publish middlewares and the real OutboxProducer. The producer writes rows through the client. OutboxClient writes them with SQL, as today. FakeOutboxClient keeps them in memory and records which rows can now be dispatched. The test broker wraps the real producer in a thin delegating producer: once the real publish returns, it dispatches the recorded rows in sync mode, or wakes the fetch loop in loop mode. The fake publish patches, the fake producer and the copied encoding, metric and validation code are deleted. A contract test keeps the two clients' insert behaviour in step, the same way it already does for fetch and the terminal writes.
This fixes #219 outright and leaves #218 as a small change confined to the fake client's insert, to be decided separately (see Out of Scope).
User Stories
- As a developer testing with
TestOutboxBroker, I wantbroker.publishto run my publish middlewares, so that middleware behaviour I rely on in production is exercised in tests. - As a developer, I want
publisher.publishunder the test broker to run the same producer as production, so that my tests can't pass on code that production rejects. - As a developer, I want a returned
OutboxResponsein tests to be published through the real producer, so that chained publishes behave the same as in production. - As a developer using the Prometheus or OpenTelemetry publish middleware, I want its publish metrics recorded in tests, so that I can assert on them without a database.
- As a developer using a
PrometheusRecorderorOpenTelemetryRecorder, I want thepublishedevent in tests to come from the production producer, so that its tags (status,exception_type,count,size_bytes,duration_seconds) never drift from production. - As a developer, I want a body that can't be encoded to fail in tests exactly as it fails in production, with the same exception and the same
published{status="error"}event. - As a developer, I want custom serializers (pydantic models, dataclasses) to encode identically in tests and production, so that a handler's input is the same in both.
- As a developer, I want
publish_batchin tests to insert the whole batch before any handler runs, so that a handler never sees a partly inserted batch, the same as production's single INSERT. - As a developer, I want a batch with one invalid row to store nothing in tests, so that atomicity matches production.
- As a developer, I want publishing a row with a
timer_idthat's already live in the queue to returnNonein tests, so that timer dedup matches production. - As a developer, I want a delivered timer row's
timer_idto be reusable in tests, as the glossary defines it. - As a developer, I want queue names and
timer_ids over 255 characters, and headers that can't be stored as JSON, rejected in tests as Postgres rejects them. - As a developer in sync mode, I want the handler to have finished when
publishreturns, so that I can assert straight after publishing, as with other FastStream test brokers. - As a developer in sync mode, I want a timer (
activate_in/activate_at) to be dispatched immediately, so that tests don't sleep. - As a developer using
run_loops=True, I want a published row to wake the fetch loop, so that loop-mode tests don't wait for a poll interval. - As a developer using
run_loops=True, I want timers to stay gated untilnext_attempt_at, so that scheduling tests stay meaningful. - As a developer, I want
feed()on the test broker to keep working for rows I insert directly, so that existing tests that seed rows keep passing. - As a developer, I want
fake_client.rowsandfake_client.dlq_rowsto keep showing stored rows, so that existing assertions keep working. - As a developer, I want publishing to a queue with no subscriber to leave the row in the fake store, as today.
- As a developer, I want
broker.publishin tests to require a session, as production does, with a mockAsyncSessionaccepted, so that a call that fails in production also fails in tests. - As a developer upgrading, I want a ready-made
fake_session()from the testing module, so that the upgrade is passingsession=fake_session()instead of writing my own mock. - As a developer upgrading, I want the release notes to tell me that
session=Noneno longer works under the test broker and to showfake_session()instead. - As a developer, I want the
publishedevent to be emitted before the handler runs in sync mode, and a handler failure never to be recorded as a failed publish, so that publish metrics mean the same as in production. - As a developer with a broker built without an engine, I want
broker.publishto keep working, so that a publish-only service doesn't need one. - As a developer, I want
cancel_timerandfetch_unprocessedunder the test broker to keep behaving as today. - As a maintainer, I want one producer implementation, so that a change to encoding, metrics or validation is made once.
- As a maintainer, I want the row insert behind the client interface next to fetch, the terminal writes and
cancel_timer, so that all SQL on the outbox table lives in one place. - As a maintainer, I want the contract suite to cover inserts, so that
OutboxClientandFakeOutboxClientcan't drift on insert semantics. - As a maintainer, I want the publish benchmark counters to stay the same, so that moving the insert costs nothing in production.
- As a maintainer, I want the test module noticeably smaller, so that there is less copied logic to keep in step.
- As a maintainer, I want TestOutboxBroker dispatches publishes from rolled-back transactions #218 reduced to a change inside the fake client's insert, so that it can be judged on its own.
Implementation Decisions
- The insert moves into the client.
AbstractOutboxClientgains single-row and batch insert operations that take the caller's session.- The single-row insert returns the new row id, or
Noneon atimer_idconflict. - The batch insert returns nothing, matching
publish_batchtoday. - The NOTIFY becomes part of the insert, with the same rules as today: only when a row landed and isn't future-dated, and deduplicated per queue within a transaction.
- The insert operations are public on the client interface, with contract docstrings that point callers to
broker.publish: calling them directly skips publish middlewares and thepublishedevent. OutboxClientgets the SQL unchanged. It takes over the producer's prebuilt INSERT statements, including the variant that sendspg_notifyinRETURNING(perf: send the publish NOTIFY in the INSERT's RETURNING #235), the batch insert followed by its ownSELECT pg_notify, and the per-transaction NOTIFY memo. The statements themselves don't change.OutboxClientis always built, and its engine is optional. Today it only exists when the broker gets an engine, while publishing needs only the caller's session. Inserts work without an engine; fetch, ping and schema validation still need one, and the broker's "not connected" errors key off the missing engine instead of a missing client.OutboxProducerbecomes the only producer. It builds the envelope, calls the client's insert and emits thepublishedevent. It looks the client up from the broker config at publish time, so the test broker's client swap is all it takes to redirect inserts.FakeOutboxClientgets the same insert operations.- It validates rows the way Postgres would: column lengths, JSON-storable headers and live
timer_iddedup. - It allocates ids.
- It records each newly stored row as dispatchable, or the whole batch once all of it is stored. It never runs a handler itself.
feed()keeps working and goes through the same storage.
- It validates rows the way Postgres would: column lengths, JSON-storable headers and live
TestOutboxBrokerstops patching publishing. It removes thebroker.publishandpublish_batchpatches and keeps the client swap. It replaces the producer with a thin delegating producer that calls the realOutboxProducerand, after it returns, takes the recorded rows:- sync mode: find the subscriber for each row's queue, claim the row, and dispatch it, ignoring
next_attempt_at; - loop mode: wake the matching subscriber's fetch loop.
- Dispatching after the real producer returns keeps
publishedahead of the handler's events, keeps handler time out of thepublishedduration, and keeps a re-raised configuration error from being recorded as a failed publish. Running the handler inside the client's insert would break all three.
- sync mode: find the subscriber for each row's queue, claim the row, and dispatch it, ignoring
- Deleted:
FakeOutboxProducer, the fake publish functions, the test-module copy of thepublishedevent, and the Postgres-column copy except what the fake insert needs. - The session is validated as in production:
broker.publishunder the test broker requires anAsyncSession. A mock built withspec=AsyncSessionpasses, as it does forpublisher.publishtoday. This is a breaking change for test code (about 96 call sites in this repo's own tests) and goes in the 0.16.0 upgrade note. fake_session(): a module-level function in the testing module returning a no-op mockAsyncSessionwhosein_transaction()is true, so it passes both the session check and theOutboxResponseopen-transaction guard. Module-level rather than on the test broker, becauseasync with TestOutboxBroker(broker) as bryields the broker, not the test broker.- Unchanged behaviour:
OutboxResponse's open-transaction guard;- the request-reply refusal;
validate_schemaraising under the test broker;- the first-matching-subscriber rule for overlapping subscribers.
- Docs and agent notes:
- The testing guide's sections on sessions and the fake broker are rewritten: one producer, a session is required, and publish middlewares now run in tests.
- The architecture note in the agent guide (the client and fake client implement the same rules twice) is updated so that inserts are part of the contract.
- Delivery: two PRs. The first moves the insert into the client with no user-visible change; the second switches the test broker and carries the breaking change.
- Release: 0.16.0. This closes Route TestOutboxBroker publish through _basic_publish #219. TestOutboxBroker dispatches publishes from rolled-back transactions #218 waits for a separate decision.
Testing Decisions
- What makes a good test here: tests go through the public surface only:
TestOutboxBroker,broker.publish,publisher.publish, a returnedOutboxResponse,publish_batch, handler side effects, the recorder events, andfake_client.rows. No test should depend on the internal hand-off between the fake client and the test broker. - Two seams, both existing:
- The client contract suite, which runs every scenario against
OutboxClienton real Postgres and againstFakeOutboxClient. New insert scenarios:- the returned id;
Noneon a livetimer_idconflict;timer_idreuse after delivery;- batch atomicity when one row is invalid;
- column-length and header validation, where each adapter declares the exception it raises (Postgres and the fake can't raise the same type);
- timers stored with their
next_attempt_at.
- The test-broker behaviour tests, written failing first:
- a publish middleware's publish hook runs for
broker.publishandpublisher.publish(Route TestOutboxBroker publish through _basic_publish #219);OutboxResponsealready runs it and gets a regression test; - the native Prometheus and OpenTelemetry publish metrics are recorded under the test broker;
broker.publishwithout a session raises under the test broker as in production.
- a publish middleware's publish hook runs for
- The client contract suite, which runs every scenario against
- Engine-less publish: a broker without an engine publishes on a real session (integration test).
- Must keep passing: the existing sync-mode, loop-mode, batch, timer, DLQ, metrics and
OutboxResponsetests under the test broker. Tests that relied on the removed patches, such assession=Nonecalls, change to pass a mock session. - Production paths: the existing integration tests for publish, NOTIFY dedup (savepoints, autobegin, batch after single and single after batch, timer conflicts) and the single-statement publish keep passing unchanged against real Postgres. The publish benchmark gate must pass with unchanged counters.
- Prior art:
- the contract-suite harness pattern of one seed, fetch and terminal helper per adapter;
- the existing middleware tests under the test broker for the Prometheus and OpenTelemetry middleware;
- the statement-counting integration tests for NOTIFY dedup.
Out of Scope
- Transaction awareness in the fake (TestOutboxBroker dispatches publishes from rolled-back transactions #218): delivering on commit and discarding on rollback for real sessions. After this refactor it is one change inside the fake client's insert: buffer rows per transaction, deliver on commit, discard on rollback or close, and handle savepoints. Running the handler before
commit()returns needs SQLAlchemy's semi-privateawait_only(renamedawait_in 2.1, old name kept), so whether to do it, or to document real-broker testing and close TestOutboxBroker dispatches publishes from rolled-back transactions #218, is decided separately on TestOutboxBroker dispatches publishes from rolled-back transactions #218. - Production changes: no change to the fetch, terminal-write or DLQ paths, and none to SQL semantics. The insert SQL moves but doesn't change.
- Multiple subscribers on one queue in tests: dispatch stays first-match.
Further Notes
- Origin: this came out of triaging TestOutboxBroker dispatches publishes from rolled-back transactions #218. Its research is on that issue:
- commit hooks under every
join_transaction_mode; await_onlyon SQLAlchemy 2.0.0 and 2.1.5;- prior art from Django's
captureOnCommitCallbacksand Rails 5'safter_commitin tests.
- commit hooks under every
- Effect on the duplication: "the client and the fake implement the same rules twice" is a deliberate duplication that the contract suite polices. This refactor narrows that duplication to storage, the only layer that has to differ, and removes the duplication of the producer, which the contract suite never covered.
Activity
lesnik512 commentedon Oct 17, 2026
Revised the body after checking it against the code on main (356af94):
published, count handler time in thepublishedduration, and record a re-raised_OutboxConfigErroras a failed publish for a row that was stored. The fake client now only records dispatchable rows; a thin delegating producer in the test broker dispatches them after the real producer returns.OutboxClientis always built, with an optional engine. A broker without an engine can publish today, because publishing only needs the session. Routing inserts through the client would otherwise break that.fake_session()added to the testing module, so the breaking session requirement costs users one argument.OutboxResponsealready runs publish middlewares in tests, so onlybroker.publishandpublisher.publishget failing-first tests; delivery is two PRs.feat!: TestOutboxBroker publishes through the real producer
refactor: move the outbox insert and its NOTIFY into the client (#237)
feat!: TestOutboxBroker publishes through the real producer
feat!: TestOutboxBroker publishes through the real producer (#238)