Repository navigation
fix(core): deliver domain events at least once - #297
Conversation
…laim outbox rows atomically The Redis Streams broker acks an entry only once every local handler succeeded, reclaims idle pending entries with XAUTOCLAIM and dead-letters to oss:dlq:<topic> after 5 deliveries. Streams trim by MINID (7 days) instead of MAXLEN. The outbox is always bound; the relay claims rows with a leased UPDATE ... SKIP LOCKED, releases a failing row without blocking the batch and prunes old rows. processed_events dedups each plugin handler by (event_id, consumer). Known event payloads are validated on emit and on receive.
Wallet, swap, reconciliation, gaming, bonus lifecycle, KYC, RG, player status and admin geo-rule events are written with emitInTransaction so they exist if and only if the state change commits. Bonus expiry and forfeit are emitted once, by the grant lifecycle service.
|
/codex all |
There was a problem hiding this comment.
Blocker: a transient audit write failure can leave a committed money or RG change without an audit record. Blocker: first-time Redis groups can skip retained events, and a swallowed bonus enqueue failure is recorded as handled.
Reviewed security, compliance, conventions, performance, reliability, rollout, spec, the whole change.
Handlers that fired promises and returned early when their service was not yet constructed were acked as processed even when the work failed. They now return or await the work and throw when a required dependency is missing, so the broker retries and dead-letters instead of dropping the event.
Withdrawal approve/reject and KYC events looked up the player id on a second pool connection while holding row locks. The identity reader now accepts the caller's tx, and the cooling-off sweep resolves player ids before opening its transaction.
The relay keeps claiming while batches come back full and marks a batch published in one update; the lease compares against the DB clock. Reclaim walks the whole pending list per interval instead of one page. processed_events outlives the stream retention by a day so a late redelivery still dedupes.
…ciliation runs gaming.round.started is written after the provider launches the game, as before the outbox change, so a failed launch announces nothing. Reconciliation records its audit entry and threshold alert after the run is marked finished, so a failed alert write no longer rewrites a completed run as failed.
|
/codex all |
There was a problem hiding this comment.
Blocker: startRound can debit a player and launch a game without recording the round-start event. Blocker: a failed event write after a successful game launch can turn a retry into a second bet.
Reviewed security, compliance, conventions, performance, reliability, rollout, spec, changes since ae3b1f2.
|
Not mergeable yet.
Addressed by later commits: audit and bonus enqueue now await (5b6d4c2), player id lookup on the caller's tx (60f8887), relay drain loop and full reclaim walk (99480c2), Still open, no fix or reply yet:
Reply on each thread once fixed. |
# Conflicts: # packages/core/src/engagement/notifications/plugin.ts # packages/core/src/pam/player-management/service/player.service.ts # packages/core/src/server/db/index.ts # packages/core/src/server/db/outbox/drizzle/migrations/meta/0001_snapshot.json # packages/core/src/server/db/outbox/drizzle/migrations/meta/_journal.json # packages/core/src/server/db/outbox/schema.ts # packages/core/src/server/plugin-host/index.ts # packages/core/src/server/runtime/create-app.ts # packages/core/src/wallet/service/wallet.service.ts
# Conflicts: # packages/core/src/compliance/service/rg-self-service.service.ts # packages/core/src/compliance/service/rg.service.ts
Summary
event_outboxin the same transaction as the change they describe, and the outbox relay always runs. 51 call sites in wallet, gaming, bonus, KYC, RG and player management moved fromemit()toemitInTransaction(tx, ...).RedisStreamsBrokerACKs an entry only after every local handler succeeded. A failed entry is redelivered, and if it still fails on its 5th delivery it moves to a dead-letter stream,oss:dlq:<topic>.EventBus.onskips a handler that already handled an event, tracked in a newprocessed_eventstable, so a redelivery doesn't run the same side effect twice.domainEventSchemascheck throws on emit instead of being published.Why
Modules don't call each other for side effects. When the wallet credits a deposit, it publishes
wallet.deposit.completed, and bonus, notifications and AML react to it. The event goes into a Redis stream (one per topic), and each service reads it through its own consumer group. A consumer group is Redis remembering, per service, which entries it already handled, so every service gets every event once and its replicas share the work.This is how it worked before:
sequenceDiagram participant W as wallet participant DB as Postgres participant R as Redis stream participant B as bonus handler W->>DB: COMMIT deposit W-->>R: XADD, fire-and-forget Note over W,R: process dies or Redis blips here, event is gone R->>B: deliver B-->>B: throws (DB timeout) R->>R: XACK anyway, nobody retriesThat flow could drop or repeat an event in six places:
XADD, the deposit was saved and nobody heard about it. The outbox that closes this gap already existed, but it was off unlessOUTBOX_ENABLEDwas set, andemitInTransactionhad no callers.EventBus.oncaught every handler error, so the broker never saw it and ACKed the entry. A bonus handler that timed out on the database got no second try, and the only trace was a log line.XADD ... MAXLEN ~ 10000drops the oldest entries whether a consumer group read them or not. A service that fell 10,000 events behind on a busy topic lost the oldest ones.XACKbrings the entry back. Only two handlers deduplicated oneventId, so anything else could run twice, a bonus grant included.SELECT ... FOR UPDATE SKIP LOCKEDcommitted before publishing, so the lock was gone by the time it published and every replica could pick up the same rows. One failing row also stopped the rest of the batch, and published rows were never deleted.How it works now
sequenceDiagram participant W as wallet participant DB as Postgres participant Relay as OutboxRelay participant R as Redis stream participant B as bonus handler W->>DB: BEGIN, update balance, insert event_outbox row, COMMIT loop every 200ms Relay->>DB: claim pending rows (30s lease) Relay->>R: XADD Relay->>DB: set published_at end R->>B: deliver alt handler succeeds B->>DB: insert processed_events row R->>R: XACK else handler throws R->>R: entry stays pending, redelivered later endemitInTransaction(tx, topic, payload)inserts the event intoevent_outboxinside the caller's transaction. If the transaction rolls back, the event never existed. The relay picks pending rows up every 200ms withUPDATE ... SET claimed_at = now() ... FOR UPDATE SKIP LOCKED RETURNING, so the claim and the lock are one statement. A claim is a 30s lease: if a replica dies mid-publish, another one takes the row once the lease runs out. A row that fails to publish releases its lease and the rest of the batch carries on. Published rows andprocessed_eventsolder than 7 days are pruned once an hour.On the reading side, every entry goes through this:
flowchart TD A[Entry read from stream] --> P{Valid JSON and payload?} P -- no --> J P -- yes --> B{processed_events has this event for this handler?} B -- yes --> S[Skip the handler] B -- no --> C[Run the handler] C -- ok --> D[Insert into processed_events] C -- throws --> E[Leave the entry pending] D --> F{Every handler done?} S --> F F -- yes --> G[XACK] F -- no --> E E --> H{5th delivery?} H -- no --> I[XAUTOCLAIM picks it up after 60s idle] I --> A H -- yes --> J[Copy to oss:dlq:topic, then XACK]The dedup key is
(event_id, consumer). Each plugin handler gets a consumer id<service>/<plugin>/<topic>, with#nadded for a second handler on the same topic in one plugin. One consumer group can run several handlers for a topic, so when one of them fails the whole entry comes back, andprocessed_eventsis what stops its siblings from running again. The DLQ entry keeps the group, stream id, delivery count and last error, so it's possible to tell what broke and replay it.Retention is
MINID ~ now - 7 dayson everyXADD. A slow consumer keeps everything from the last week, however many entries that is.ADR-0044 records the decisions. ADR-0017 and ADR-0030 got update notes where this supersedes them.
Testing
redis-streams-broker.int.test.tscovers a failing handler leaving the entry pending until a redelivery succeeds, dead-lettering after 5 deliveries, bad JSON going straight to the DLQ, the age-based trim, and recent entries surviving past the old 10,000 cap. The two trim tests fail against the oldMAXLENtrimming.event-bus.int.test.tscovers a duplicate delivery running a handler once, only the failed handler re-running on redelivery, an invalid payload throwing onemitandemitInTransaction, an invalid inbound payload landing in the DLQ, and a rolled-backemitInTransactionpublishing nothing.relay.int.test.tscovers two concurrent relays publishing each row once, a failing row not blocking the batch, the lease, and pruning.emitInTransaction.pnpm verifypasses.Alternatives considered
processed_eventsgets the same effect with one table.processed_eventsinsert in one transaction, for exactly-once. Handlers don't get atxtoday and some call external services, so it stays check, run, record.Risks
game-geo-blockingneeded avi.waitFor.oss:dlq:<topic>. The DLQ is never trimmed and has no tooling yet, so for now it's drained by hand withXRANGE.#nconsumer ids come from registration order, so reordering two handlers on one topic in a plugin re-runs in-flight events once.bus.oncalled outside a plugin has no consumer id and isn't deduped.emit()now throws on an invalid payload. On a post-commit emit that happens after the commit, so the caller sees an error for a change that did save.OUTBOX_ENABLEDis gone and the relay always runs. Migration0001_event_deliveryon the outbox history addsprocessed_eventsandevent_outbox.claimed_at, so downstream needsdb:migrateafter the bump.emit(), because their transaction is spread across 4 services. Not included in this PR..changeset/README.md.