6.6 KiB
Events go through a transactional outbox and are delivered idempotently
Domain events used to be published after the write that caused them, from the use case, on a channel the server owned. A crash between the write and the publish lost the event (no poster, no federation delivery, no search index entry), and with NATS the server needed a live broker connection just to log a review. Delivery was also at-least-once without any guard, so a redelivered event ran its handlers twice.
Decision
The outbox is event_queue. The same SQLite table the DB-backed bus already used is now the
outbox for every backend. Migration 0101 adds event_queue.event_id (UUID, unique when set) and
claimed_at, and creates processed_events (event_id, handler, status, started_at,
processed_at). The migration backfills every existing row with a random event id, so legacy rows get dedup too.
The column stays nullable, but the only insert path (enqueue_events) always sets it, and the
consumer still tolerates a NULL id (no dedup) for rows from foreign writers.
Writes are atomic with their events. Repository and command-port methods that change state take
the events they cause and write them in the same transaction as the data. shared::enqueue_events
is the single path that inserts outbox rows. Covered: review log/edit/delete (including orphan
movie deletion and ReviewDeleted/MovieDeleted), watchlist add/remove, movie discovery and
enrichment requests, wrap-up, integration writes, local follow/unfollow, remote review save.
Use cases never open transactions; the adapter does.
The server never talks to NATS. With EVENT_BUS_BACKEND=nats the server still only inserts
outbox rows. The worker runs a relay that claims pending rows and publishes them to JetStream with
Nats-Msg-Id set to the event id, then marks the row done. With db the worker consumes the rows
directly. max_deliver on the JetStream consumer equals EVENT_QUEUE_MAX_ATTEMPTS, so both
backends dead-letter after the same number of attempts.
Three dedup layers.
- Broker. JetStream drops a repeated
Nats-Msg-Idinsideduplicate_window, set explicitly to 1 hour (longer than the relay's retry horizon) rather than the 2-minute server default. - Relay lease. A row claimed by a relay or DB consumer that then died is reclaimed once
claimed_atis older than 600 s, so a crash never strands an event inprocessing. - Processed-event ledger. Before a handler runs,
(event_id, handler)is claimed inprocessed_events(INSERTin_progress, or take over a claim older than the 1800 s lease). Success marks itdone; failure releases it; both are conditioned on the claim's millisecondstarted_atvalue (WHERE ... AND started_at = ?), which acts as the owner token, so a stale worker cannot clobber a newer claim. A delivery that sees a fresh claim held elsewhere is deferred (AckHandle::defer: rescheduled to the lease expiry without consuming an attempt; JetStreamnakwith delay, which does count towardmax_deliver). Adonerow skips the handler. Ledger rows are kept 30 days. A ledger read error skips the handler and nacks.
Guarantees
- No two deliveries run the same handler for the same event concurrently.
- At-least-once after a crash: a lost claim expires and the event is retried.
- Handlers are idempotent, so the remaining duplicate window (crash after the effect, before the
ledger mark) is harmless. The O5 audit made each handler safe to re-run: image cleanup (NotFound
is Ok), image conversion (probes the exact key; missing original means done), follow backfill and
inbound review import (upserts, deterministic Create ids), person/movie enrichment (upserts; the
movie profile write is last because it is the "done" marker), wrap-up (a
Generatingrecord is resumed; completion is atomic soWrapUpCompletedis emitted once), search handlers (keyed upserts and removes), poster sync (skips when the poster is already stored under the movie id, so no secondPosterSynced/ImageStored).
Known gaps
- Remote and block follow events are best-effort.
CompositeSocialAdapterroutes remote follows and blocks through k-ap, which owns that transaction. Its events are published after the fact through an injected publisher, so a crash between the k-ap write and the publish loses them. Fixing it needs transaction hooks in k-ap. - k-ap 0.5.0 mints random ids for some activities. Create, Announce and Like ids are
deterministic (
broadcast.rs:22-35,:283-288; backfillbackfill.rs:208-212). Delete (broadcast.rs:157), Add/Undo (:210), Update (:321), Move (:377), the Undo wrappers for Announce (:81) and Like (:129) and the actor update (:339-344) useUuid::new_v4()viaurl_scheme.rs:52-53with no override; onlybroadcast_raw_to_followers(:395) takes a caller id. A duplicate of those after a crash is a fresh-id activity. They are semantically idempotent for remote servers (deleting twice, updating to the same state), but it is a new activity, not a replay. The ledger makes this rare; closing it means building those activities ourselves or a k-ap change. - Core NATS has no safety net.
NATS_MODE=coreis fire-and-forget: no broker dedup, no redelivery. Rows are marked done after publish; a subscriber that is down misses the event. The ledger still prevents double handling of a repeated id. ImageStoredstays best-effort. It is raised after an object-storage write, which no database transaction can cover.
Consequences
- Existing deployments keep their JetStream stream.
get_or_create_streamapplies config only on creation, so the consumer now compares an existing stream and consumer with what this build needs and updatesduplicate_window(1 h) and the consumer'smax_deliver/ack_waitin place, with a warning in the log. Both are mutable on a live stream/consumer, so no recreate is needed. Each update is a clone of the existing config with only those fields changed, so operator-set fields and the immutable ones (name, filter, deliver policy) are preserved. If an update is rejected the worker logs a warning with the manualnats stream edit/nats consumer editcommand and keeps starting. - Server and worker both migrate at startup (docker compose starts them together).
sqlite::migrateretries with a bounded backoff (100 ms to 1 s, 5 retries), so the loser of a migration race re-reads_sqlx_migrations, skips what the winner applied and succeeds; only a persistent error is returned. - Handler authors must keep new handlers idempotent; the ledger narrows duplicates but cannot remove the crash-after-effect case.