Skip to content

feat(outbox): canonical operation-wide publications - #149

Draft
nicolasburtey wants to merge 1 commit into
mainfrom
poc/atomic-publication-batches
Draft

nicolasburtey wants to merge 1 commit into
mainfrom
poc/atomic-publication-batches

Conversation

@nicolasburtey

@nicolasburtey nicolasburtey commented Sep 11, 2026

Copy link
Copy Markdown
Member

Summary

First-class operation-wide publications, replacing the pre-allocation event stream where consumers had to reconstruct source-transaction boundaries. Rebased onto main (keyed subscribers, 0.10.x) — the publication model now lives alongside the new singleton/keyed vocabulary, with StreamSelection::PublicationBatches as a third delivery kind.

Every source operation's publications — across publish calls, persist chunks, post-persist/repost hooks, and re-entrant commit-hook generations — commit as one indivisible unit. An es-entity finalizer seals the boundary only after all ordinary hooks finish. Cala needs no patch (a 0.26.1-based fork just pins this revision).

  • A transactional <events>_batch_head reserves each operation's contiguous position range at its first persist chunk; the row lock serializes writers until commit.
  • <events>_batches stores only (first_sequence, last_sequence) boundaries, sealed at finalization in the same transaction. Payloads live once, in the event table.
  • Rollback restores positions together with rows: no sequence holes, no placeholder rows, no gap filling, no abandonment proofs — the entire gap-fill subsystem is deleted (gap_fill.rs, contiguous-page cut queries, missing-sequence probes, reactive compensation, the in-transaction notify variant).
  • Bare sqlx::Transaction publishes are rejected loudly instead of writing unsealed rows.
  • One sequence namespace for event and publication consumers; the frontier (highest_known_persistent_sequence) is the committed head, excluding in-flight operations.
  • load_publication_batches(after, limit) returns complete publications, loading member payloads from the event table; interior cursors, missing prefixes, or missing member rows fail closed.
  • SingletonSubscriber::handle_publication_batch receives one whole publication per callback; the checkpoint lands only after the callback's transaction commits.
  • All derive queries touching this PR's schema are runtime-checked (head read, reservation insert, and the keyed-subscriber queries inherited from main), so downstream crates' shipped offline caches stay sufficient without regeneration.

Linked drafts

Validation

  • All 106 tests pass against PostgreSQL 18, including main's 102 keyed/singleton/outbox tests plus the publication suite.
  • Removed machinery's tests were replaced: rollback asserts nothing survives (no placeholder compensation), bare transactions assert rejection, direct-history helpers write explicit sequences through the head.
  • New coverage: JSON-null is delivered as a message; deleting a member inside a sealed range fails the whole load; concurrent appends are commit-ordered with no burned positions.
  • Strict Clippy (--all-targets -D warnings), formatting, and a clean-build offline cache regeneration pass.
  • Downstream: cala fork 14/14 transaction_batch + 16/16 ec_streaming_rollup; Lana relay and Cala-mirror integrations pass against this revision.

Deliberate limitations

  • One head lock per namespace serializes writers for their whole transaction. No throughput claims; not benchmarked. Multiple namespaces in one operation need a canonical seal-lock order before production use.
  • Migrations are edited in place: fresh databases, intentionally no upgrade path (nothing deployed depends on this yet).
  • The publication runner polls every 100 ms and applies one publication at a time; coalescing and notifications are future work.
  • No retention/archival for boundaries; pathologically large single publications are delivered whole, in memory.

See PUBLICATION_BATCHES_POC.md for the full contract. Draft for architectural evaluation, not production rollout.

@nicolasburtey nicolasburtey changed the title feat(outbox): prototype atomic publication envelopes feat(outbox): canonical operation-wide publications Sep 12, 2026
@nicolasburtey
nicolasburtey force-pushed the poc/atomic-publication-batches branch from 02fc288 to 28b1c6d Compare September 14, 2026 17:17
Every source operation's publications — across publish calls, persist
chunks, post-persist/repost hooks, and re-entrant commit-hook
generations — commit as one indivisible publication, sealed by an
es-entity finalizer after ordinary hooks finish.

A transactional head reserves each operation's contiguous sequence
range at its first persist chunk; rollback restores positions together
with rows. Delete the gap filler, placeholder compensation,
abandonment proofs, contiguous-page cut queries, and the in-transaction
notify variant. Bare-transaction publishes are rejected; the frontier
is the committed head. Payloads live only in the event table; event
and publication consumers share one sequence namespace.

load_publication_batches returns complete publications and a
PublicationBatches stream selection delivers them whole, checkpointing
only after the callback commits. Keep the two schema-dependent derive
queries runtime-checked so downstream offline caches stay sufficient.

Rebased onto keyed subscribers (0.10.x): singleton subscriber carries
the publication runner alongside main's StreamSelection.
@bodymindarts

bodymindarts commented Sep 15, 2026

Copy link
Copy Markdown
Member

This change serializes all publishes to the outbox at commit time with a global lock. Since almost every operation in our domain writes to the outbox this will cause a complete serialization of all operations - no parallelism at all.
This will significantly reduce our throughput.

The outbox is designed to accept concurrent writes without blocking - serialization happens at the read side to keep writes unblocked. Don't think this is the right approach to fix the 'chunking' issue.

@bodymindarts

Copy link
Copy Markdown
Member

I think we can have a much simpler way of signaling 'chunks'

bodymindarts added a commit that referenced this pull request Sep 16, 2026
* feat(out)!: commit-ordered delivery as an opt-in second lane

Add an opt-in delivery lane in which events arrive in commit order, every
source transaction's events are contiguous, and the cursor is a dense,
gap-free `CommitSequence`. The insert-ordered lane (`EventSequence`,
contiguity via gap fill) stays the default and is untouched.

Postgres stamps every event row with its top-level transaction id
(`commit_xid`, a column DEFAULT — no write-side code, no lock, no writer
serialization). A per-process sequencer, fed by the cache loop's contiguity
frontier, appends `(commit_seq, sequence, group_last)` rows to
`persistent_outbox_commit_log` in `(group_max, sequence)` order, where
`group_max` is a transaction's highest insert sequence. One statement per
burst does the whole tick; `FOR UPDATE SKIP LOCKED` on a one-row state table
makes it leaderless, so a loser skips its tick instead of blocking and a
process dying mid-tick leaves committed state untouched.

    OutboxEventJobConfig::new(JOB_TYPE)                      // Insert, default
    OutboxEventJobConfig::new(JOB_TYPE).ordering(Ordering::Commit)

This supersedes the approach in #149, which reserved each operation's
contiguous range in a `<events>_batch_head` row whose lock was held to COMMIT.
That serializes every publisher; deriving the order read-side serializes
nobody.

Two defects in the design spec were found and corrected:

* The scan window was anchored on `low_water`, but an open group pins
  `low_water` at its lowest member, so the window could never widen to reach
  that group's highest member and close it. Every tick recomputed an identical
  window: a permanent livelock whenever a group's span exceeded the page size.
  `scan_water` is a second watermark that advances past logged rows while
  `low_water` stays pinned, carrying the window forward.

* The sequencer's frontier was specified as the cache loop's
  `last_broadcast_sequence`, which only advances when the broadcast send
  succeeds — and tokio's `send` fails precisely when there are no receivers.
  A process hosting only commit-ordered listeners has no insert-lane receiver,
  so its frontier was pinned at zero forever. The cursor now advances before
  delivery is attempted; nothing an insert-lane subscriber can observe
  changes, since with a receiver the send succeeds and without one there is no
  subscriber.

Gap-fill placeholders are written with an explicit NULL `commit_xid` rather
than inheriting the filler transaction's id, so "placeholder implies singleton
group" holds.

BREAKING CHANGE, of two independent kinds:

* Rust API. `PersistentOutboxEvent` and `UndecodableEventError` gain
  `commit_group`, `commit_sequence` and `commit_boundary`;
  `decode_persistent_event` takes a `CommitPosition` instead of loose
  arguments; `MailboxTables` gains six methods. Insert-lane execution state is
  unchanged on the wire — `commit_sequence` is `skip_serializing_if`, and the
  existing `state_without_a_pause_serializes_unchanged` test still asserts
  `{"sequence":7}`.

* Migration checksum. The setup migration is amended in place per this repo's
  convention, so every already-migrated database must be recreated, and
  consumers that copied the SQL by hand must re-copy it. obix exposes no
  `migrate!` re-export, so that drift is invisible until a missing relation at
  runtime.

Switching an established subscription from the insert lane to the commit lane
redelivers the events of any group straddling its cursor. That is refused
unless the caller also calls `.allow_lane_switch_redelivery()`, so the
duplicate window cannot be opened by flipping one enum value. Switching back
is refused outright.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci: allow BRIN in the typo checker

`BRIN` is PostgreSQL's Block Range INdex access method, which the checker
reads as a misspelling of "bring".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(out)!: derive commit order by folding the insert stream

Replaces the DB-side windowed sequencer tick with an in-process fold over
the insert-ordered listener, per handoff rev 3.

The tick re-derived contiguity in SQL (holes/low_water/scan_water) over the
events table -- work the cache loop already does for the insert stream. Both
defects found while implementing rev 2 were consequences of that: the scan
window could not widen past an open group (livelock), and the sequencer was
not a broadcast receiver, so the frontier it depended on pinned at zero in a
commit-only process (silent hang). Making the sequencer a listener dissolves
both.

Groups are now placed at first sight rather than by their highest member.
Every outbox row of a transaction is inserted by PersistEvents::pre_commit,
after every read that transaction made, so if T observed A's commit then
min(T) > max(A): ordering by a group's lowest sequence never inverts a
dependency. group_max was the only thing requiring a lookback window.

One statement per group appends it whole and advances (head, cursor) under a
cursor CAS on the locked state row. Every process folds the same stream from
the same state row and computes the same log including commit_seq, so the CAS
is a duplicate suppressor rather than an election: no leader, no lease, and
neither a commit_seq gap nor a duplicated assignment is representable.

The CAS must be a column of the locked row. Postgres re-evaluates it against
the row version a lock wait resolved to; a NOT EXISTS against the log reads
this statement's snapshot, which predates a concurrent winner's commit, and
appends the group twice (541 rows instead of 200 under four concurrent
folds).

The sequencer is now always on, spawned in Outbox::init, so the commit log
never lags a live process. The commit log is partitioned in lock-step with
the events table by the same maintainer, in one transaction under one lock.

Breaking, and only valid on a fresh deploy: the setup migration is amended in
place, commit_xid is NOT NULL with a btree index, the state row is
(head, cursor), PersistentOutboxEvent::commit_group is no longer optional,
and the commit position moves off the event onto the commit lane's item type
CommitOrderedEvent. The insert-to-commit lane switch is removed: the two
cursors count different things, so an established subscription registers a
new job type instead.

Also fixes an undecodable event acknowledged on the commit lane advancing
only the insert cursor, which redelivered it after every restart.

Delivery order changes from group_max to group_min. Both are valid causal
orders; delivery granularity, checkpoint timing and at-least-once semantics
are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(out): isolate the paging budget from the sequencer's catch-up

`slow_consumer_does_not_inflate_the_rows_read` samples `idx_tup_fetch`,
which is per table and therefore counts every reader of
persistent_outbox_events. The always-on sequencer is now a second reader:
at startup it replays history through its own listener and then looks up
one group per event, which spent most of a budget written for the single
listener under test. Measured 4356 locally and 10418 in CI against a
budget of 8000.

Starting the sequencer already caught up restores what the test measures.
Reads are then 2002-4004 for 2000 events -- one to two passes -- against
the unchanged 4x budget, so the eviction pathology it guards still fails it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(out): keep the sequencer's published head consistent with the log

The sequencer read its resume point with two statements -- the state row,
then the sequences already logged above that cursor. A peer appending
between them returned a head that did not account for rows the seed then
told the fold to skip, and the skip path returns without appending, so
nothing else in the loop had occasion to correct it. A commit-lane listener
takes that head as `latest_known`, so with `last_returned == latest_known`
it neither backfills nor receives a broadcast, and stays that way until this
process appends a group of its own.

Two changes, either of which closes it; together they make the published
head independent of how the resume point was obtained.

Read both values in one statement, so head and the seed cannot disagree.
Every sequence the fold then sees is either in the seed -- logged as of that
snapshot, and therefore already counted by head -- or absent from it, in
which case the fold attempts an append and a peer that got there first
rejects it, which reconciles from the log.

Reconcile the head against the log once before folding. The seeded
sequences are skipped without appending, so this is the only occasion on
which a head a peer has already moved past is corrected.

The concurrent repro needs groups that straddle the cursor: with singleton
groups the cursor always sits at or above every logged sequence, the seed is
always empty, and the invariant is vacuous. With straddling groups, split
reads fail the snapshot-consistency check in roughly two runs in three; the
reconcile is covered deterministically by building the stale pair directly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(out): drop a redundant gap-fill sender clone

The cache loop is the only consumer of the sender once the commit lane no
longer holds one, so `init` can move it instead of cloning. The clone inside
the backfill dispatch stays: each spawned request takes ownership, so it has
to survive the loop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(out)!: name the sequencer state columns by what they count

`head` and `cursor` gave no hint that they count different things: one is a
commit_seq, a position in the commit log, and the other an insert sequence,
a position in persistent_outbox_events. Appending a group of three advances
the first by three and sets the second to that group's lowest member.

They become `last_commit_seq` and `logged_through_sequence`, so the suffix
names the space. `logged_through_sequence` also states the stronger
invariant the queries rely on: every payload-bearing event at or below it is
in the log, and the seed is what sits above it because a group straddled it.

`id SMALLINT PRIMARY KEY` documented "exactly one row" without enforcing it
-- nothing stopped id = 2. `singleton BOOLEAN PRIMARY KEY CHECK (singleton)`
admits one row and no other: the check rejects FALSE, the key rejects a
second TRUE. Verified both rejections against the live schema.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(out)!: hand subscribers the event Arc instead of deref'ing it away

The outbox decodes each persistent event once and broadcasts it as an
Arc, but `handle_persistent` / `handle` took `&PersistentOutboxEvent<P>`,
so the runners deref'd that Arc away at the call site. A handler could
therefore not retain an event past the call — every collect/flush
projection had to either pre-derive a smaller item or deep-clone the
payload, and payload enums that are not `Clone` could do neither.

Both trait parameters (and `handle_ephemeral`, for one rule across the
trait) now take `&Arc<…>`. The call sites are unchanged — they already
passed `&event` where `event: Arc<…>` — and since `Arc<T>` derefs to
`T`, no handler body in the suite needed editing: the entire diff
outside the traits is signature lines.

`SubscriptionDef::wake_keys` keeps its plain reference. It classifies
and retains nothing, and the runner's `&Arc` coerces at the call site.

The new test file is generic over a payload enum that deliberately does
not derive `Clone`, with `Batch = Vec<Arc<PersistentOutboxEvent<_>>>` —
a batch that could not be written before this change. It also pins that
`flush` receives the very allocations the invocations were handed, not
copies of them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(out)!: name the commit-lane wrapper for what it is, and file the row types with the queries

`CommitOrderedEvent` was not an event — it carries one. Renamed to
`CommitOrderedEnvelope`, which says that the commit sequence and the
boundary flag are wrapped around the event rather than part of it.

`CommitLogRow` and `CommitGroupAppend` move from `out::event` to
`tables`, next to `CommitRestartState`. They are the result shapes of
`load_commit_ordered_page` and `append_commit_group` — query output, not
event vocabulary. `CommitDelivery` stays in `out::event`: it is the
commit lane's counterpart to `PersistentDelivery` and belongs beside it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(deps): bump job to 0.14.0

Also pulls es-entity 0.12.17 -> 0.12.21 as a transitive update.

No source changes were needed: the major bump did not touch any API obix
uses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(migrations)!: re-vendor job's schema, which was pinned at 0.13.0

obix vendors job's `20250904065521_job_setup.sql`, and that copy was last
refreshed at the 0.13.0 bump (2e7a14b). job revised its schema twice
since — once during 0.13.1..0.13.5 and again during 0.13.14..0.13.16 —
so the file has been four schema revisions behind while Cargo.toml said
0.13.16. It is byte-identical to job 0.14.0's copy again.

Two differences, both of which had been missing from every obix database:

- `job_events.id` and `job_executions.id` no longer carry a foreign key
  to `jobs(id)`.
- The partial pending index leads with `job_type`:
  `(execute_at, id)` -> `(job_type, execute_at, id)`. Every consumer
  probes per type, so without the leading column each probe filter-scans
  the whole pending set instead of its own type's slice.

Requires recreating the database, like the amended obix migration in this
branch: `make clean-deps && make start-deps`. Otherwise sqlx reports
`migration 20250904065521 was previously applied but has been modified`.

Verified against the live database after recreation: the index is
`(job_type, execute_at, id)` and no foreign keys remain on either table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants