The relay claims rows, never a cursor
Parent: events
Status: released in v0.1.76 (2026-09-27) — design and as-built record in docs/design/event-bus-outbox-webhooks.md in the StandMeet repo.
The relay claims un-fanned rows with SELECT … WHERE fanned_out_at IS NULL AND poisoned_at IS NULL ORDER BY seq FOR UPDATE SKIP LOCKED, enqueues their jobs and sets fanned_out_at in one transaction. It never reads by a sequence cursor, because a cursor loses events.
How the relay runs
- The relay is a loop in every process (
internal/infra/events/run.go), not a River job.SKIP LOCKEDalready prevents a double fan-out, so no leader is needed. A frequent periodic job would only fillriver_jobwith rows. NOTIFY standmeet_eventsfrom the trigger and fromRecordwakes it. A 1-minute periodic job,events relay sweep, only pokes it; it covers a NOTIFY lost while the listener reconnects.- It claims at most 200 rows per pass. A 2,000-note import never becomes one huge transaction.
- For each (event, subscriber) match it inserts a River job. It sets
fanned_out_atandfanout(which job each subscriber got) in the same transaction. - Result: fan-out happens exactly once; handling is at least once.
- A failing pass backs off: 2 s, doubling, capped at 1 minute. The failing batch is then retried row by row, so one bad row does not block the others. A row that fails 5 times is poisoned (
poisoned_at), set aside and raises an alert; the owner puts it back in line from the event's detail in the Tasks panel (events.requeue). Delivery failures are retried by their own jobs (retry-has-one-owner). - After the fan-out commits, the relay sends
NOTIFY standmeet_events_fannedwith the event ids. A write receipt waits on it (AwaitFanout, async-response-contract).
The same SKIP LOCKED claim already runs the microsite build queue: builder-claim-skip-locked.
Coalescing is opt-in per subscription
Within one claimed batch, a subscription with Coalesce: true gets one job per subject, for the latest event. Only corpus.index sets it: it re-reads the note's current state, so the latest change covers the earlier ones. Webhooks and mail never coalesce: two events about one subject are two facts.
- River unique jobs were rejected for this. River requires
runningin a unique job'sByState, so a change that arrived while its index job ran would merge into the running job and be lost. - An early build coalesced for every subscriber. It dropped
supplier.connectedwhensupplier.activatedfollowed in the same batch. The UTTestEverySubjectEventReachesANonCoalescingSubscriberholds the fix.
The loss bug a cursor causes
The first relay design read seq > cursor. Sequence numbers are assigned when a transaction starts, but transactions commit in any order. Design review found the loss:
Fix: no cursor. Each row carries fanned_out_at. An unmarked row is always claimed eventually, so nothing can be skipped. A UT runs two interleaved transactions, and both events end up fanned out.
End to end: one corpus write
If the relay crashes
Claim, enqueue and mark share one transaction. A crash mid-batch rolls all three back, and a restart claims the rows again. See message-loss-guarantees.
Tests
The events UTs (about 26 across relay, trigger and recorder): row claiming; interleaved commits lose nothing; batch size; poison marking; concurrent relays never fan out twice; a crash rolls back and re-claims; every event of a subject reaches a non-coalescing subscriber. See events-test-plan.