Deep dive

The transaction stops at the network

A database commit and an HTTP call cannot share one rollback. See what outbox, idempotency, inbox, sagas, and two-phase commit can prove after a crash.

Share

A database transaction cannot commit an HTTP request.

That fact is easy to forget when the calls sit next to each other.

Order.transaction do
  order.update!(state: "confirmed")
  Payments.capture(order.payment_id)
end

PostgreSQL can roll back the row. It cannot reach across a socket and uncharge a card. Payments.capture may succeed while the database rolls back. A timeout may hide whether the payment service did anything at all.

Moving the call below the transaction changes the failure.

Order.transaction do
  order.update!(state: "confirmed")
end

Payments.capture(order.payment_id)

Now the database can commit and the process can die before it sends the request.

Code order isn't the bug. We ask one process to remember the gap.

One operation becomes four durable states

Two independent resources produce four possible outcomes. Code usually handles the first two and quietly hopes the other two never happen.

DatabaseRemote systemResult
committedacceptedthe intended outcome
rolled backrejectednothing happened
committedrejected or never calledlocal state moved without its consequence
rolled backacceptedthe consequence escaped its cause

Code order still leaves those last two rows.

A remote-first order lets the effect escape a later database rollback. The opposite order can strand remote work after a process exit. Keeping HTTP inside the transaction only holds database locks while the application waits on another system. A rollback still cannot reverse the request.

Timeouts add a harder state: unknown. A client may lose the response after the server commits. Retrying might finish the work or repeat it. Treating the timeout as failure invents certainty that the network never supplied.

Atomicity needs one commit decision. Adjacent lines do not create one.

after_commit moves the gap

Framework callbacks solve a real ordering problem. They do not make the next system part of the transaction.

Rails runs after_commit only after the database transaction succeeds.1 That makes it the right place for work that must never observe a rolled-back row. An email job should not enter a queue while its record can still disappear.

Order.transaction do |transaction|
  order.update!(state: "confirmed")

  transaction.after_commit do
    ChargeOrderJob.perform_later(order.id)
  end
end

One gap remains. PostgreSQL commits. The process exits before the callback publishes the job, or the queue connection fails during the publish. No database row says the job still needs to exist.

after_commit gives us commit-before-publish ordering. It does not give us recovery after commit-before-publish failure.

What can PostgreSQL commit?

Pick the second operation.

PostgreSQL local commit
orders state = 'confirmed' inside

Drop the second write here.

Commit 1 durable row
Second operation
Pick the second operation.

What else can it make durable?

The outbox makes intent durable, not the remote effect. The receiver still has to remember the operation ID.

HTTP does not fit because the payment service owns it. An outbox row fits because PostgreSQL owns that row too. Repeating op_481 shows the other half of the design: the receiver, not the sender, prevents a second effect.

Make the intent atomic

A transactional outbox records the business change and the need to publish in one database commit.2

BEGIN;

UPDATE orders
SET state = 'confirmed'
WHERE id = 481;

INSERT INTO outbox_events (id, kind, aggregate_id, payload)
VALUES ('op_481', 'order_confirmed', 481, '{"order_id":481}');

COMMIT;

A rollback removes both rows. A commit preserves both. The request handler can die one instruction later and a relay still finds op_481.

One database commit now carries state and intent. It says nothing about whether the broker or remote API accepted anything.

A polling relay can claim outbox rows, publish them, and record progress. Change data capture can read committed outbox inserts from the database log instead. Debezium's outbox router, for example, maps an outbox ID into the emitted event header and uses an aggregate ID as the event key.3

Pollers and log-tailers still hit the same seam. A relay can publish op_481, lose the acknowledgement, then restart before it records completion. Publishing again works only when the next boundary recognizes op_481.

Outbox tables are production queues, even when nobody calls them queues. I watch their oldest undispatched row, retry count, failure reason, and total depth. Cleanup also needs a rule. An outbox that grows forever turns delivery history into a storage incident.

The receiver needs a memory

Retries become safe when the receiver stores the caller's operation ID with the effect it controls.

A request body hash is usually the wrong identity. Two customers can make identical purchases. One customer can intentionally repeat the same purchase tomorrow. AWS describes caller-provided request identifiers for this reason: identity should express intent rather than infer it from matching parameters.4

A receiver can process op_481 like this:

begin local transaction
  insert idempotency record for op_481
  apply the charge
  store the result
commit
return the stored result

A unique constraint settles concurrent duplicates. A later request with the same ID returns the first result. The server must store the ID and effect in one local transaction, or it creates its own version of the original gap.

Stripe exposes this contract directly. A client sends an idempotency key with a mutating request. Stripe stores the first status and body, then returns that result for later requests with the same key.5 Its documented retention window also matters. A retry that can arrive after the receiver forgets the key is a new operation again.

APIs without idempotency keys force the caller to query by a durable business identifier. Payments often need reconciliation against provider records. Emails cannot be unsent. Unknown outcomes belong in an explicit state such as confirmation_pending, not in a retry loop that guesses.

An inbox protects the consumer's database

A consumer can store the message ID and its business update in one database transaction. People often call that ID table an inbox or processed_messages table.

BEGIN;

WITH accepted AS (
  INSERT INTO processed_messages (consumer, message_id)
  VALUES ('billing', 'op_481')
  ON CONFLICT DO NOTHING
  RETURNING message_id
)
UPDATE invoices
SET paid_at = now()
WHERE order_id = 481
  AND EXISTS (SELECT 1 FROM accepted);

COMMIT;

Consumers acknowledge the broker after this commit. A crash between commit and acknowledgement causes redelivery. The second insert returns no row, so the invoice update touches nothing. The consumer can acknowledge the duplicate.

This closes one boundary: broker delivery into one database. A handler that updates two databases still owns two commits. A handler that calls an external API still needs that API's idempotency contract.

Kafka's delivery boundary has the same shape. A committed offset records where a consumer group resumes. It cannot prove that another database or a card processor agrees.

A broker changes the wait, not the commit

Message brokers can give remote work a durable place to wait. They add backpressure and redelivery between workers. They do not join the transaction that created the message.

A RabbitMQ confirm proves broker handling, not useful routing by itself. RabbitMQ can confirm an unroutable publish after an exchange finds no queue. Publishers that need a durable handoff must also detect returns with mandatory or provide alternate routing. Persistent messages still need durable queues. Quorum queues confirm after a quorum accepts the message.6

Even that stronger handoff knows nothing about the application database commit. Consumer acknowledgements cover a separate consumer-to-broker boundary.

Direct database-then-publish code still has the original gap, even with confirms. The outbox supplies the missing recovery record. A relay can keep publishing until the broker confirms, and consumers can handle a duplicate event by ID.

Queue transactions do not automatically solve this either. A transaction inside the broker governs broker operations. A transaction inside PostgreSQL governs PostgreSQL rows. Shared vocabulary does not create a shared coordinator.

Sagas make partial success part of the model

Long workflows should record progress instead of pretending every step can roll back.

A checkout might reserve inventory, authorize payment, and create a shipment. Each service commits locally. The workflow must leave durable evidence of completed steps. A later failure can trigger compensation, such as releasing inventory or refunding a payment.7

Compensation is another business operation. A refund does not erase a charge from history. It creates a second financial event. That refund can time out, retry, or need manual review.

Saga recovery depends on the step:

  • An inventory reservation can be released later.
  • An email has no honest undo, so the workflow should send it after the decision settles.
  • A payment endpoint can accept one operation ID until it returns a known result.

Sagas trade immediate atomicity for an explicit path back to consistency. That trade makes sense when services own their own data and a business process can tolerate intermediate states. It makes less sense when every write already belongs in one database transaction.

Two-phase commit has a narrow job

Two-phase commit works when every resource participates in the same commit protocol and an external coordinator owns recovery.

Phase one asks each resource to prepare. A prepared participant durably promises that it can commit later. Phase two records one commit or rollback decision and sends it to every participant. A coordinator that restarts must recover that decision and finish the protocol.

PostgreSQL supports this through PREPARE TRANSACTION, COMMIT PREPARED, and ROLLBACK PREPARED.8 Its documentation says the feature exists for external transaction managers. It also warns that prepared transactions keep locks and interfere with VACUUM when they remain open.

An ordinary HTTP API is not a participant. POST /charges does not understand the coordinator's prepare vote, global transaction ID, or recovery log. Wrapping that call in a library named transaction changes nothing.

I would consider two-phase commit when a small set of transactional resources supports the protocol, one coordinator can recover every decision, and synchronous consistency matters more than losing availability during coordinator or participant failures. That is a real design. It is not the default answer for service APIs.

Pick the proof that matches the boundary

I start with one question: which system can durably prove the next action after this process disappears?

WorkUseful mechanismWhat it proves
several writes in one databaselocal transactionevery local write commits or rolls back together
database change plus event publishtransactional outboxlocal state and publish intent share one commit
broker message plus database updateinbox or processed-message IDone database applies one message ID once
database change plus idempotent APIdurable intent, stable request ID, stored resultretries converge on one remote effect
multi-service business workflowsaga with recorded stepsthe workflow can resume or compensate
compatible transactional resourcestwo-phase commitparticipants follow one coordinator decision

Database locks stop at the connection boundary. Redis can assign worker ownership, but an owner token says nothing about whether an external effect occurred. Queues retain work for another attempt. They cannot roll back the row that created it.

Atomicity belongs to a commit protocol, not to code proximity.

Independent systems need a shared coordinator or enough durable evidence to finish the work after a crash. I usually choose durable evidence. The database records what still needs to happen. A receiver returns the first result for repeated IDs. Operators reconcile what the network never settled.

That gap remains. It no longer dies with the process.

Footnotes

  1. Ruby on Rails Guides, Active Record Callbacks. Transaction callbacks run after a successful database commit and do not run after rollback.

  2. AWS Prescriptive Guidance, Transactional outbox pattern. The pattern stores an application change and its outgoing notification in the same database transaction, then publishes from committed outbox state.

  3. Debezium documentation, Outbox Event Router. The router captures outbox inserts and can expose the event ID in a message header and the aggregate ID as its key.

  4. Amazon Builders' Library, Making retries safe with idempotent APIs. Caller-provided request IDs distinguish a retry from a new request with the same parameters.

  5. Stripe API Reference, Idempotent requests. Stripe records the first result for an idempotency key and returns that result for matching retries within its retention rules.

  6. RabbitMQ documentation, Consumer acknowledgements and publisher confirms. Unroutable messages can still receive confirms. Durable handoff also requires routing checks, persistent messages, and durable or replicated queue semantics.

  7. Azure Architecture Center, Saga distributed transactions pattern. A saga coordinates local transactions and uses retryable or compensating actions when later steps fail.

  8. PostgreSQL 18 documentation, PREPARE TRANSACTION. PostgreSQL reserves prepared transactions for external transaction managers and warns about locks and VACUUM when prepared work remains open.

Share

Comments