docs(HT-127): state the event contract's no-ordering consequence - #203
Conversation
Events emitted in one transaction share an occurredAt exactly (now() is transaction start time), so conversation.message_received can reach a consumer before the conversation.created it belongs to. substrate-v1 §4 already granted no cross-event ordering guarantee; it never said what an integrator has to do about it. §4 now names the concrete case and the requirement that follows: treat any event as possibly the first seen for its conversationId, upsert rather than insert, read state from the API rather than from arrival order. Sorting by occurredAt does not recover an order — the ties are exact. §8's guarantee table carries "no ordering" on the emission row too, so it cannot be read as "the outbox is ordered, delivery is not". No tiebreak column is added. event_id is a random v4 uuid and created_at is the same now(), so a tiebreak would take a new sequence — and drainEventOutbox fans each event to one queue_jobs row per endpoint, each retrying independently, so claim order does not survive to the consumer regardless. A deterministic drain would hold in development and break under production retry, which is worse than no guarantee at all. claimBatch's interface doc records the same reasoning, and a test pins the premise the spec text rests on: two events appended in one transaction give count(DISTINCT occurred_at) = 1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughThe PR documents that event ordering is not guaranteed. It adds deterministic ChangesEvent Ordering Contract
Estimated code review effort: 2 (Simple) | ~10 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/store/event-outbox.ts`:
- Around line 153-155: Update claimBatch() to retain a full-precision raw
occurred_at ordering value from PostgreSQL instead of sorting on Date.getTime()
after toDate(). Sort using that raw value, and revise the interface contract and
nearby tie-ordering documentation to define ties according to the preserved
precision while keeping same-timestamp events unordered.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: e6c6545a-e48b-4162-abf6-bef624200830
📒 Files selected for processing (3)
specs/modules/substrate-v1.mdsrc/store/event-outbox.test.tssrc/store/event-outbox.ts
The claimBatch re-sort compares Date.getTime(), which cannot hold the column's microseconds, so events under a millisecond apart tie there too — not only events sharing a transaction. The interface doc named one tie class and implied it was the only one. Documented rather than made exact. The sort is best-effort by construction: spec §4 grants no cross-event ordering guarantee, and per-endpoint retries reorder deliveries downstream regardless, so a microsecond-exact claim order would be precision no consumer can observe. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@coderabbitai review |
|
claimBatch's drain order previously left occurred_at ties (same- transaction events, or any two events under a millisecond apart) unordered by design, since no order reaches the consumer regardless. That left replay non-reproducible for debugging and testing. (occurred_at, event_id) is a stable total order without adding a column: event_id is the primary key, so it never ties. This is a fixed-but-arbitrary tiebreak, not a meaningful one — event_id is a random v4 UUID uncorrelated with write time. Both the claiming SQL and the RETURNING re-sort now use it, matching the interface doc (maintainer decision, 2026-08-08). A sequence column was considered and declined (maintainer decision, 2026-08-08): the published contract already grants no cross-event ordering (substrate-v1.md §4), and drain order cannot reach a consumer regardless — SKIP LOCKED under concurrent drainers, queue claiming ordered only by run_after with no tiebreak, an UPDATE... RETURNING with no result-order guarantee, and independent per- endpoint backoff rescheduling all destroy it downstream.
|
@coderabbitai full review |
✅ Action performedFull review finished. |
The interface doc claimed the drain is "reproducible: the same rows come out in the same order every time" without qualifying that this only holds for repeated calls against a fixed eligible set. Under concurrent drainers, SKIP LOCKED partitions eligible rows between them unpredictably, so no single global order exists across overlapping calls. The safety property (no double-claim) already held; only the reproducibility wording overclaimed.
🟢 SAFE TO MERGE
Gates green on
c93b19b. Both decisions below are authorized, not inferred. Codex: 2 passes, 2 findings, 2 real and fixed.One-way door: adds a consumer obligation to the substrate-v1 event contract.
Jira: HT-127.
What changed
substrate-v1.md§4 already granted "no cross-event ordering guarantee." It never saidwhat an integrator does about it. §4 now names the concrete case — two events emitted in
one transaction share an
occurredAtexactly (now()is transaction start time), soconversation.message_receivedcan reach a consumer before theconversation.createdit belongs to — and states the requirement that follows: treat any event as possibly the
first seen for its
conversationId, upsert rather than insert, read state from the APIrather than from arrival order. §8's guarantee table now carries "no ordering" on the
emission row too, so it cannot be read as "the outbox is ordered, delivery is not."
EventOutboxStore.claimBatch's drain order is now deterministic:(occurred_at, event_id)breaks ties that were previously left unordered by design, usingevent_id(the table's primary key) rather than adding a column. This makes a single caller's
repeated claims against a fixed eligible set reproducible for debugging and replay — it
does not create a global order across concurrent drainers, and it does not change the
public delivery guarantee, which stays "no cross-event ordering."
Decision provenance
event_id) is used insteadVerification
npm run typecheck/npm run lint— clean.npx vitest run src/store/event-outbox.test.ts— 10/10, the only file this change touches. Confirmed the new tiebreak test discriminates: stashed the source fix, re-ran, watched it fail against the unfixed sort (returned insertion order instead ofevent_idorder); restored and re-ran green.Quality (typecheck, lint, test, coverage)(full suite, isolated) — green onc93b19b, the actual merge gate. A local full-suite run under heavy machine contention produced unusable noise (unrelated files failing en masse at 20–80s per simple test) and was discarded in favor of CI's isolated result.Review, three passes across three heads — none blur into the others:
7abd333b(this branch's prior head, doc-comment-only changes) and does not cover this PR's runtime change (ORDER BY occurred_at, event_idand its test). Incremental review is off in this repo, so it never re-ran on a later head despite repeated@coderabbitai full reviewrequests.fc9b8d3— theevent_idtiebreak and its test. Confirmed:event_idis genuinely the table's primary key (migration 023), so(occurred_at, event_id)is a real total order; the public no-ordering contract in §4 is preserved; the new test discriminates with high (not perfect) probability given random UUIDs, which the test's own comment already discloses honestly. Real finding: the interface doc's "reproducible... every time" wording read as claiming one global order across concurrent drainers, whenSKIP LOCKEDactually partitions eligible rows between them unpredictably — fixed inc93b19b.c93b19balone (the doc-comment fix only): confirmed the corrected comment now accurately describesclaimBatchunder both single-caller and concurrent-drainer conditions, verified sentence-by-sentence against the implementation. No remaining inaccuracy.No open findings block this PR.