TCC on RabbitMQ: The Distributed Transaction Pattern That Thinks It's Clever

TCC on RabbitMQ: The Distributed Transaction Pattern That Thinks It’s Clever

Exploring Try-Confirm-Cancel with RabbitMQ FANOUT exchanges and shadow tables, a lightweight alternative to sagas that might actually work.

TCC on RabbitMQ: The Distributed Transaction Pattern That Thinks It’s Clever

Distributed transactions are the architectural equivalent of tax season, everyone dreads them, nobody agrees on the right approach, and the “solutions” often create more problems than they solve. Two-phase commit is a reliability nightmare. Sagas require hours of careful state machine design. And the transactional outbox pattern, while solid, only solves half the puzzle.

So when a developer posted a TCC (Try-Confirm-Cancel) approach that uses RabbitMQ’s FANOUT exchanges to broadcast a single CONFIRM or CANCEL message to all participants, the reaction was predictable: skepticism, genuine technical pushback, and a surprisingly interesting idea buried in the comments.

Let’s dig into whether this pattern is a genuine alternative to sagas, or just another infrastructure solution to a domain modeling problem.

The Core Idea: Shadow Tables and the Art of the Dry Run

The fundamental insight in this TCC approach is refreshingly simple: do all the risky work before you commit to anything.

Traditional sagas mutate the main data state and then compensate if something goes wrong downstream. That means you’re one buggy compensation handler away from decrementing inventory twice or shipping an order to the wrong address. The TCC approach sidesteps this by maintaining a separate set of SHADOW tables during the TRY phase.

Here’s the flow:

  1. Coordinator sends TRY requests to each participant one-by-one
  2. Each participant executes all business validations and updates shadow tables, not main tables
  3. Participants respond with success/failure
  4. If all succeeded: coordinator sends a SINGLE CONFIRM message to a FANOUT exchange
  5. If any failed: coordinator sends a SINGLE CANCEL message using the same approach

The key claim from the original poster:

“Every business validation should happen at TRY stage. CONFIRM or CANCEL should never fail because of business logic. Any other failure would be taken care by RabbitMQ’s at-least-once delivery.”

This is actually a profound (and correct) observation. The most common cause of distributed transaction failures isn’t infrastructure, it’s business rules that change between when you decided to commit and when you execute. By replaying the business logic on shadow tables first, you eliminate that entire failure class.

Why FANOUT Makes the Dual-Write Problem Worse, Not Better

The controversy heats up when we examine the FANOUT exchange claim. A commenter named andrewcairns pushed back with surgical precision:

“I’m not sure a single message on a fanout exchange solves the dual-write problem. I don’t think the number of messages is where the problem exists. The main issue is atomicity between the coordinator deciding CONFIRM or CANCEL and publishing that decision.”

This is the correct critique. Let’s walk through the failure scenario:

  1. Coordinator receives all TRY successes
  2. Coordinator persists “all participants confirmed” to its log
  3. Coordinator crashes BEFORE publishing the CONFIRM message to RabbitMQ
  4. The message never reaches participants

FANOUT doesn’t help here. Whether you send one message or five, the decision-to-publish gap remains. The coordinator’s in-memory state said “commit”, but the message queue never knew.

The original author’s response was actually thoughtful: maintain coordinator logs with a “not sent” status, and retry after recovery. The trans_id makes idempotency trivial, participants can simply check if the event exists in their table before processing.

But this reveals the pattern’s dirty secret: the coordinator is still a state machine. You’re essentially rebuilding a saga’s process manager, just with different terminology and shadow tables.

The Outbox Pattern: The Missing Piece

Here’s where the discussion gets genuinely valuable. The transactional outbox pattern, well documented in Spring Boot Kafka implementations, solves the exact problem FANOUT doesn’t.

The pattern is straightforward:

@Transactional
public void placeOrder(Order order) {
    orderRepository.save(order);
    outboxRepository.save(OutboxEvent.from(order));
}
CREATE TABLE outbox (
  id UUID PRIMARY KEY,
  aggregate_type VARCHAR(255),
  aggregate_id VARCHAR(255),
  event_type VARCHAR(255),
  payload JSONB,
  created_at TIMESTAMP DEFAULT now(),
  published BOOLEAN DEFAULT false
);

The database transaction commits both the domain entity and the outbox event atomically. A relay process then publishes to the broker, retrying until successful. No dual-write problem, no crash window, no lost decisions.

Combine this with TCC’s shadow table approach, and you get something genuinely robust:

  • TRY phase: Business validation on shadow tables within a local ACID transaction
  • Decision persistence: Coordinator writes CONFIRM/CANCEL decision + outbox event transactionally
  • Delivery: Relay publishes to FANOUT exchange with at-least-once delivery guarantees
  • Consumption: Participants use trans_id for idempotent processing

The original poster did acknowledge this: “Yes, you are right. Making a decision and publishing an event is two different steps. That’s why here coordinator maintains its logs.”

But there’s a reason this idea generated only 14 upvotes and a handful of comments. It’s incomplete, but tantalizingly close to something real.

Shadow Tables: Brilliant or Memory-Sink?

The most provocative aspect of this approach is the shadow table strategy. The original author suggests two options:

Option 1: Copy-on-TRY

Create shadow tables with a trans_id primary key, execute all business logic on the shadow data, then either promote to main tables on CONFIRM or DELETE on CANCEL.

Option 1 is elegant for small data volumes. The compensation logic is literally a DELETE statement:

DELETE FROM shadow_orders WHERE trans_id = ?

No complex compensation logic, no reversal calculations, no risk of partial compensation.

Option 2: Dual shadow sets

Maintain two permanent shadow table sets, one for CONFIRM replay and one for CANCEL replay, both pre-computed during TRY.

Option 2 handles the “large data” case but doubles your storage and writes. Every TRY now replicates business logic twice, pricey for write-heavy workloads.

Here’s the uncomfortable question the comments didn’t fully address: when does shadow table overhead exceed the cost of traditional compensation?

For a fintech app processing 10,000 transactions per second, maintaining two shadow sets per transaction isn’t just wasteful, it’s likely infeasible. The storage and write amplification alone could exceed the infrastructure savings from avoiding sagas.

The Saga Comparison: What You’re Actually Trading

Winston_Jazz_Hands correctly pegged this as “a classic saga/process manager problem” and pointed to Gregor Hohpe’s seminal Starbucks story and the saga vs. process manager distinction.

Let’s be honest about the tradeoffs:

Traditional Saga

  • Mutates main state immediately
  • Requires compensation handlers that are production code
  • Compensation logic duplicated and error-prone
  • Business validation CAN fail mid-saga, triggering inversions

TCC with Shadow Tables

  • Main state untouched until CONFIRM
  • CANCEL is a trivial DELETE operation
  • Business logic verified before commit
  • But: roughly 2-3x write amplification from shadow tables

For many systems, the shadow table overhead is a fair price to eliminate the compensation-handler nightmare. But it’s worth noting that the original author freely admits this tradeoff.

One commenter’s frustration was palpable: “If your ‘coordinator’ does not hold state (like a saga), how will you know if both collaborators confirmed?” The answer is that the coordinator DOES hold state, it’s just logging its progress rather than orchestrating compensations.

The Verdict: A Framework Worth Stealing

Here’s my assessment:

The FANOUT claim is largely marketing. It simplifies the publisher code and log table design, but it doesn’t solve the decision-persistence problem. A fanout exchange with an outbox pattern is strictly better.

The shadow table approach has genuine merit. Strategic pre-validation of business logic before commit is the kind of thinking that prevents production incidents. The trans_id-based idempotency and DELETE-based compensation are elegant.

The pattern is best as a hybrid. Use the outbox pattern for decision persistence, use FANOUT for broadcast delivery, and use shadow tables to de-risk the CONFIRM path. Each piece solves a different problem.

If you’re building a system where compensation logic is genuinely painful, complex financial calculations, multi-step inventory adjustments, anything with non-trivial reversal math, the TCC + shadow table approach deserves serious consideration. Just don’t let the RabbitMQ FANOUT claim fool you into thinking it’s the key innovation.

The real insight here is about where you put your trust. This pattern trusts the local ACID transaction for safety, RabbitMQ’s at-least-once delivery for liveness, and shadow tables for business-rule verification. That’s a smart distribution of responsibility.

For more on building resilient distributed systems, check out our analysis of transactional message handling and deduplication, the fundamentals of resilience in message processing, and what actually goes wrong with event-driven systems in production.

The takeaway: steal the shadow table idea. Steal the trans_id idempotency. Steal the “validate everything before commit” philosophy. Just don’t pretend FANOUT solved the dual-write problem. That’s still on you, your database transaction, and, if you’re smart, an outbox table.

Share:

Related Articles