BitBootcampBitBootcamp ← all explainers 🔄 Review ⚙ Settings

Drill 86 — The Ordering That Wasn't Guaranteed

Theme: Queues & Backpressure · Format: 20-min drill · Difficulty: core

The story

A broker-dealer's post-trade settlement pipeline consumes a stream of lifecycle events for every trade: SETTLEMENT_CREATED, then zero or more SETTLEMENT_AMENDED, and sometimes a SETTLEMENT_CANCELLED. A trading system publishes those events to a message log; a fleet of consumers reads them and drives each trade through a small state machine — created → amended → settled, or created → cancelled. It has run clean for two years. The team's mental model is simple and, they believe, obviously true: events arrive in the order they happened.

To handle volume, the log is split across twelve partitions and eight consumer instances read them in parallel. Nobody thinks hard about which event lands on which partition — the producer was configured long ago to spread load evenly, round-robin, so no single partition gets hot. And because the network occasionally hiccups, the producer retries any publish it isn't sure landed, and consumers retry any event they fail to process.

One Tuesday a trader books a trade and cancels it four hundred milliseconds later — a fat-finger, caught fast. Two events, create then cancel, four hundred milliseconds apart. But round-robin put the create on partition 7 and the cancel on partition 2, and partition 2's consumer happens to be idle while partition 7's is chewing through a backlog. The cancel is processed first. The state machine gets SETTLEMENT_CANCELLED for a trade it has never heard of. A moment later the SETTLEMENT_CREATED arrives and the trade is dutifully created — live, open, settling — a trade the desk cancelled, now marching toward the settlement window.

⏸ Stop here and ask the room: the events were published in the right order, four hundred milliseconds apart. So how did the cancel get processed before the create?

Run it — facilitator script

Timing for a 20-minute session: 2 min story · 10 min staged questions · 3 min reveal · 5 min transfer.

The producer published create-then-cancel. Where did the order get lost?

What makes this worse than a transient glitch — why does the state stick?

Where should the ordering guarantee actually live?

The reveal

Ordering was never guaranteed — the team had confused "published in order" with "delivered in order," and a partitioned log only ever promised the former within a single partition. The fix was layered. They partitioned by settlement_id so every event for one trade is totally ordered on one partition. They added monotonic sequence numbers per key and a small reorder buffer so a consumer applies events in sequence and holds a gap briefly rather than applying out of order. They made the write commutative (version-stamped, last-writer-wins) so a replay can't clobber newer state. And they hardened the state machine to reject invalid transitions — a CANCELLED for an unknown trade is no longer auto-created; it is rejected and dead-lettered. Resurrected trades went to zero.

Why this happens — the mechanics

A partitioned message log gives you exactly one ordering guarantee: total order within a partition, no order across partitions. That is the whole deal — it is how the log scales, by letting independent partitions be read in parallel. The moment you route two causally-related events (a create and its cancel) to different partitions, you have thrown away their relative order, because two consumers reading two partitions run at independent speeds. Add retries — a redelivered or reprocessed event can land arbitrarily late — and "the order things happened" and "the order the consumer sees them" become different sequences. Round-robin partitioning is the trap: it optimizes for even load and silently sacrifices per-entity ordering, which for most events is fine and for lifecycle events is catastrophic.

The second half of the failure is the state machine trusting its input. A create-then-cancel that arrives cancel-then-create is only dangerous if the consumer accepts a cancel for a trade it has never seen. A strict transition guard — "you may only cancel a trade that exists and is in a cancellable state" — turns an ordering bug into a rejected, dead-lettered, observable event instead of a silent corruption. Ordering guarantees at the transport and defensive logic at the consumer are not alternatives; the transport reduces how often disorder happens, and the consumer makes disorder harmless when it does.

Diagrams

Problem — how the failure unfolds:

sequenceDiagram
    autonumber
    participant PR as Producer
    participant CA as Consumer A
    participant CB as Consumer B
    participant SM as State machine
    PR->>CA: CREATE trade 4821<br/>(partition 7)
    PR->>CB: CANCEL trade 4821<br/>(partition 2)
    Note over CA,CB: two partitions,<br/>no shared order
    CB->>SM: apply CANCEL first
    Note over SM: no such trade -<br/>auto-created instead
    CA->>SM: apply CREATE later
    SM->>SM: trade now OPEN
    Note over PR,SM: a cancelled trade<br/>is live and settling

Solution — where the guardrails go:

flowchart TD
    A["Lifecycle events<br/>(create, amend, cancel)"] --> B["Partition by<br/>settlement_id key"]
    B --> C["Reorder buffer:<br/>apply in sequence order"]
    C --> D["State machine:<br/>guard each transition"]
    D -->|"valid"| E["Apply · advance state"]
    D -->|"invalid"| F["Reject to D.L.Q."]
    E --> G["Commutative write:<br/>version wins, replay-safe"]
    C -.->|"gap over 30s"| AL["Alarm: out-of-order<br/>or missing sequence"]
    F -.-> AL

What good looks like

The lesson

A partitioned log orders events within a partition, never across the topic — so "published in order" is not "delivered in order." Route same-entity events to one partition by key, and make the consumer robust to disorder anyway with sequence numbers, commutative writes, and a state machine that rejects impossible transitions.

Named concepts: partition-key ordering, reorder buffer with sequence numbers, commutative and idempotent operations, state-machine transition guards.

Real-world echoes

Transfer

Which of your streams carry causally-ordered events for the same entity — and are they partitioned by that entity's key, or round-robined for load? "It's probably fine" doesn't count — name the key that keeps one entity's events in order, and the transition guard that catches it if they aren't.

Make it a ticket: inventory every topic that carries lifecycle/state-change events; for each, partition by the entity key, add a state-machine transition guard that dead-letters invalid transitions, and add a create-then-cancel-out-of-order chaos test — starting with anything that moves money.

Related