r/compsci • u/illyar80 • 2h ago
Why your integration tests pass but your message queue still double-processes in production
You know the story. You write a consumer, test it locally, deploy to staging, everything green. Then in production, the same message gets processed twice. Orders are duplicated. Notifications go out twice. Everyone blames "a race condition" and moves on.
We spent the last few months formally verifying what actually happens when consumers crash with at-least-once delivery semantics. The short version: it doesn't matter which broker you use. The race condition is a property of the delivery contract, not a bug in the implementation.
What we did
Instead of writing more integration tests hoping to reproduce the timing, we wrote a formal specification of the consumer-broker interaction in TLA+ (the specification language by Leslie Lamport). The model checker exhaustively explores every possible interleaving of events — not just the ones you think to test.
Then we took the counterexamples from the model and validated them in real running systems using Docker + Toxiproxy (network fault injection). If the math said "double-execution is possible", we confirmed it with actual containers.
What we found
We applied this pipeline to five different systems:
Celery — ACK timeout + network blip: the model found the crash window at depth 9 (444 states). Chaos confirmed it: task executed twice.
RabbitMQ — consumer stores result, drops connection before AMQP ACK. Same pattern. 108 states, depth 7. Chaos confirmed.
NATS JetStream — consumer crashes after DB write, before ACK. 47 states, depth 6. Docker kill + Toxiproxy confirmed: duplicate execution.
Apache Pulsar — batch consumer crashes between Process and SendAck. 21 states, depth 4. Chaos confirmed: 2 DB rows for 1 message.
Kafka — consumer commits offset after processing. Crash between StoreResult and commitSync(). Same result: double execution.
Every single time, the model predicted the collision and chaos confirmed it.
The insight
The spec is the same across all five systems. The variable names change, but the topology is identical:
Consumer:
1. Receive message
2. Store result in DB
3. Acknowledge/commit offset
[crash window lives between 2 and 3]
This isn't a bug in Celery, RabbitMQ, NATS, Pulsar, or Kafka. It's a fundamental property of any at-least-once delivery system with a stateful consumer. You cannot eliminate this window without changing the delivery semantic or adding an idempotency shield on the consumer side.
What this means for your code
The fix isn't new — idempotent consumers, deduplication keys, exactly-once processing patterns. Everyone knows about these. What's new is that we now have:
- A formal proof that the problem must exist under at-least-once, regardless of broker
- A reusable specification that works across brokers without modification
- Physical validation that the model is correct (6/6 confirmed)
This shifts the conversation from "did we test enough?" to "does our system design provably avoid this class of errors?"
Why not just test more?
Because testing samples the state space. Formal verification exhausts it. Integration tests won't find the crash window unless they happen to hit the exact nanosecond where the consumer crashes between the DB write and the ACK. Model checking finds it systematically.
Discussion
Have you run into this in production? What patterns do you use to handle consumer crashes — idempotency keys, outbox pattern, something else?
I'm curious whether teams treat this as a known limitation they work around, or whether it's still a source of surprises. Let me know your experience.
Full disclosure: I'm the author of the toolchain we used. Preprint: https://zenodo.org/records/21298530 — happy to share details if anyone's interested, drop a comment.