Skip to main content
José David Baena

On this page

A Kafka Transaction Cannot Roll Back Your HTTP Request

By José David Baena

Published on
/8 mins read

A consumer charges a card, then loses its connection before it commits the Kafka offset. The group delivers the record again. The second handler cannot tell whether the first charge completed.

Aborting a Kafka transaction cannot pull the first HTTP request back across the network. Committing the offset first would avoid redelivery, but it could lose the charge when the process dies between the commit and the request.

The design question is not “Do we have exactly once?” It is: which identity and which transaction protect this specific effect?

This article uses five independent states:

  1. source business intent;
  2. Kafka publication occurrence;
  3. consumer progress;
  4. sink identity/result;
  5. caller knowledge.

The positive examples protect local database state. External providers require their own retained operation-key and lookup contract.

A local outbox closes the source dual-write gap

Suppose a service updates an order row and publishes OrderAccepted.

OrderCrash windowResult
Commit DB, then publishProcess dies after DB commitBusiness state changes but no durable notification intent exists
Publish, then commit DBDB transaction later failsKafka can contain an event for business state that never committed
Commit business row + outbox row togetherRelay fails laterIntent survives; relay publication can repeat

A transactional outbox writes the business change and a publication intent in one local database transaction. The AWS pattern description states both the local atomicity benefit and the duplicate-publication obligation. It does not promise exactly one Kafka append.

A polling relay can publish, crash before marking its row complete, restart, and publish again. Producer idempotence does not join two new application sends or two producer sessions under one batch sequence. Keep a stable event and business-operation identity in the outbox row.

Change data capture can move the relay checkpoint into a database-log/connector contract. It also adds snapshots, source offsets, schema history, connector tasks, failover, and retention. The Debezium 3.3 outbox router source documentation defines event ID, aggregate key, and routing for that profile. It does not certify every connector, worker mode, or downstream sink.

Event identity and operation identity answer different questions

Use three labels:

  • source occurrence: cluster/topic/partition/offset under a specific incarnation;
  • event ID: one published fact or envelope;
  • operation ID: one business action that may be represented by several events or attempts.

Two event IDs can request the same operation. An inbox keyed by event ID can correctly process each event once and still apply the business operation twice. The opposite error also happens: reusing one operation key for two intentional operations suppresses valid work.

The key therefore needs a semantic fingerprint. When a duplicate key arrives:

  1. compare the fingerprint that defines the intended operation;
  2. reuse the stored result only when the intent matches;
  3. reject or reconcile a mismatch;
  4. never call a mismatching key “deduplicated successfully.”

The AWS Builders' Library uses caller-provided request identity and semantic equivalence for the same reason.

An atomic inbox protects a local DB mutation

A transactional inbox stores a unique identity and the protected database mutation in one local transaction. Checking first and inserting later leaves a race; two consumers can both observe “missing” before either commits.

The following PostgreSQL-shaped example is contract pseudocode. It shows the required transaction boundary and conflict path; it is not a schema-independent library:

BEGIN;
 
INSERT INTO applied_operations (
  consumer_scope,
  operation_id,
  intent_fingerprint,
  result_json
)
VALUES (:consumer_scope, :operation_id, :fingerprint, NULL)
ON CONFLICT (consumer_scope, operation_id) DO NOTHING
RETURNING operation_id;                                      -- ①
 
-- If ① returned one row, this transaction owns the operation:
UPDATE account_balance
SET cents = cents + :delta_cents
WHERE account_id = :account_id;                              -- ②
 
UPDATE applied_operations
SET result_json = :result
WHERE consumer_scope = :consumer_scope
  AND operation_id = :operation_id;                          -- ③
 
COMMIT;

① The unique, scoped key chooses one local winner. PostgreSQL INSERT ... ON CONFLICT ... RETURNING provides the needed winner signal.

② The business mutation shares the same transaction. If the process or transaction fails before commit, PostgreSQL rolls back both the claim and mutation under its transaction semantics.

③ Store the fingerprint and result. A later duplicate must compare the fingerprint before reusing the result.

After local commit, the consumer can advance Kafka progress. A crash between the DB commit and offset commit permits redelivery, but the matching committed operation identity prevents another protected DB mutation.

Deterministic teaching model · apache-java-4.3.1

E03: Inbox claim and DB mutation commit together

Can two concurrent deliveries apply the mutation twice?

Choose a Kafka teaching scenario
Change bounded local inputs

Changing these controls creates a custom local example. The share link keeps only the original curated preset; exports can include your bounded local values.

CDC remains a declared contract, not a connector certification.

Changing DB to API must remove the local atomicity conclusion.

Record occurrence, event, and business operation are different identities.

Provider behavior is separate from Kafka transactions.

Synthetic day from 0 through 365.

A replay at or after expiry needs an explicit contract.

Current outcome · step 0 of 6

No source transaction, Kafka publication, consumer progress, or business effect exists yet.

curated preset

Default synthetic example loaded.

Source transaction

Business change
absent
Outbox intent
absent
Recovery path
declared
Relay progress
0

Kafka publication and progress

Publication occurrences
0
Consumer deliveries
0
Stored next offset
10
Ordering
partition-local fixture

Sink identity and effect

Protected unit
business-operation
Committed inbox claims
0
DB / provider effects
0 / 0
Caller provider knowledge
UNKNOWN
Identity conflicts
0

Invariant results

  • Source business change and outbox intent share a local transaction

    holds in model

    The reducer commits or omits both local rows together.

  • The selected local identity protects at most one DB mutation

    holds in model

    Committed unique identity and DB mutation remain one local transaction.

  • The provider contract protects at most one effect

    not applicable

    No external provider is selected.

  • A mismatching intent is never silently treated as a duplicate

    holds in model

    No conflicting fingerprint was reused.

Ordered trace

The state table shows model truth. Each observer result says what the current caller can establish at that step.

Ordered deterministic actions through step 0. Print and exports contain the complete bounded trace.
StepActorActionObserver result
10 mssource databaseCommit business change and outbox intentThe source transaction atomically commits the business change and outbox intent.
210 msrelayPublish outbox event to KafkaKafka publication occurrence 1 is acknowledged in the teaching trace.
320 msconsumerDeliver the Kafka event to attempt AConsumer delivery 1 carries event event-a, operation operation-a, and source offset 10.
430 msconsumer AAttempt A stages operation identityAttempt attempt-a stages inbox identity operation-a; neither the claim nor DB mutation is committed yet.
531 msconsumer BAttempt B races for the same identityA concurrent attempt already stages the unique identity. Only one local transaction can win.
640 mssource databaseAttempt A commits identity and DB mutationThe inbox identity, local DB mutation, and stored result commit in one local transaction.

Evidence and limits

Claims

Primary sources

Assumptions and known limits
  • Synthetic event, operation, and aggregate identities remain separate.
  • A local database transaction cannot roll back an already completed external effect.
  • No SQL isolation scheduler, provider, CDC connector, schema registry, or compensation engine runs in the browser.
Default synthetic example loaded.

Switch E03 to the check-then-insert variant. Two effects appear because the identity and mutation no longer share one winning transaction.

An inbox row cannot enclose an external provider

Move the mutation in the previous section to an HTTP API:

claim inbox identity
call provider
commit inbox
commit Kafka progress

No ordering closes every crash window:

Crash pointWhat can repeat or disappear
Before provider callSafe to retry only if the claim can roll back or expire correctly
Provider succeeds, response lostCaller effect state is UNKNOWN
Provider succeeds, process dies before inbox commitRedelivery can call the provider again
Inbox commits before provider callRecovery can suppress an effect that never happened
Kafka progress commits firstNormal recovery can skip an effect that never happened

The provider must participate through its own contract:

  • stable operation key;
  • atomic key + effect + stored result;
  • semantic fingerprint conflict behavior;
  • result lookup after timeout;
  • retention long enough for every permitted delayed retry, DLQ redrive, failover, and restore;
  • current authorization for the original or a separately approved new action.

That is provider idempotency, not Kafka EOS. Kafka can atomically commit output records and consumed offsets in the same cluster. The producer transaction API does not enlist an arbitrary HTTP server.

Retention has at least four independent clocks

“Dedup TTL is longer than topic retention” is not enough. Track:

  1. source topic history, including compaction and transactional visibility;
  2. coordinator-stored group offsets;
  3. source outbox/CDC recovery history;
  4. sink identity and stored-result history.

Those clocks can start at different events and restore to different points. Kafka's topic configuration and broker/group-offset configuration describe separate retention systems. A group checkpoint can disappear while source records remain. A DLQ or archive can outlive the source topic. A restored sink can forget an operation key while Kafka still retains the record.

Compaction adds another boundary. It can produce a current-state rebuild while removing older operations. It is not business-effect deduplication and does not reconstruct the historical sequence of effects.

Kafka transactions protect Kafka participants only

The protocol article on producer idempotence and transactions owns PID, epoch, sequence, coordinator decisions, markers, and LSO. For application design, use this narrower table:

PathProtected unitRemaining obligation
Raw consumer + producer transactionKafka output records + source next offsetsCurrent group metadata, committed-only input, abort/rewind, terminal uncertainty
Kafka Streams exactly_once_v2Kafka-managed input progress, output, and state/changelogExternal processor effects stay external
Connect source EOSSupporting source records + accurate source offsetsConnector/source replay contract, distributed workers, ACLs, rollout
Connect sinkConnector- and sink-specificread_committed alone does not make sink writes atomic
MirrorMaker EOS modeOne documented replication legNo cross-cluster application transaction or automatic failover authority

The Streams configuration, Connect guide, and cross-cluster mirroring guide set different prerequisites. Do not compress them into one platform-wide claim.

What could go wrong

  • The outbox event ID changes on relay retry. Duplicate publications no longer carry the same identity.
  • The inbox protects event ID while two events request one operation. The database mutation repeats correctly per event but incorrectly per operation.
  • A key match skips a fingerprint check. A conflicting request silently receives another operation's stored result.
  • The provider retains keys for 24 hours while a DLQ retains work for 30 days. A late redrive can apply another effect.
  • A sink reads aborted input. Transactional output cannot repair a decision made from an input record that should have been hidden.
  • A local transaction is described as end-to-end atomicity. Kafka progress, provider truth, and caller knowledge still cross separate commits.

Choose the smallest transaction that can actually contain the effect. Then name every state outside it.


Sources and references

Share this post

HNPost to Hacker News

Follow future work

Follow public article updates through RSS. Intentionally unlisted posts stay out of the feed.

Working through a similar reliability boundary?

The Async Reliability Review turns one messaging or background-job flow into an evidence map, recovery plan, and owned next actions.

Keep reading