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:
- source business intent;
- Kafka publication occurrence;
- consumer progress;
- sink identity/result;
- 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.
| Order | Crash window | Result |
|---|---|---|
| Commit DB, then publish | Process dies after DB commit | Business state changes but no durable notification intent exists |
| Publish, then commit DB | DB transaction later fails | Kafka can contain an event for business state that never committed |
| Commit business row + outbox row together | Relay fails later | Intent 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:
- compare the fingerprint that defines the intended operation;
- reuse the stored result only when the intent matches;
- reject or reconcile a mismatch;
- 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?
Current outcome · step 0 of 6
No source transaction, Kafka publication, consumer progress, or business effect exists yet.
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.
| Step | Actor | Action | Observer result |
|---|---|---|---|
| 10 ms | source database | Commit business change and outbox intent | The source transaction atomically commits the business change and outbox intent. |
| 210 ms | relay | Publish outbox event to Kafka | Kafka publication occurrence 1 is acknowledged in the teaching trace. |
| 320 ms | consumer | Deliver the Kafka event to attempt A | Consumer delivery 1 carries event event-a, operation operation-a, and source offset 10. |
| 430 ms | consumer A | Attempt A stages operation identity | Attempt attempt-a stages inbox identity operation-a; neither the claim nor DB mutation is committed yet. |
| 531 ms | consumer B | Attempt B races for the same identity | A concurrent attempt already stages the unique identity. Only one local transaction can win. |
| 640 ms | source database | Attempt A commits identity and DB mutation | The inbox identity, local DB mutation, and stored result commit in one local transaction. |
Evidence and limits
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.
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 progressNo ordering closes every crash window:
| Crash point | What can repeat or disappear |
|---|---|
| Before provider call | Safe to retry only if the claim can roll back or expire correctly |
| Provider succeeds, response lost | Caller effect state is UNKNOWN |
| Provider succeeds, process dies before inbox commit | Redelivery can call the provider again |
| Inbox commits before provider call | Recovery can suppress an effect that never happened |
| Kafka progress commits first | Normal 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:
- source topic history, including compaction and transactional visibility;
- coordinator-stored group offsets;
- source outbox/CDC recovery history;
- 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:
| Path | Protected unit | Remaining obligation |
|---|---|---|
| Raw consumer + producer transaction | Kafka output records + source next offsets | Current group metadata, committed-only input, abort/rewind, terminal uncertainty |
Kafka Streams exactly_once_v2 | Kafka-managed input progress, output, and state/changelog | External processor effects stay external |
| Connect source EOS | Supporting source records + accurate source offsets | Connector/source replay contract, distributed workers, ACLs, rollout |
| Connect sink | Connector- and sink-specific | read_committed alone does not make sink writes atomic |
| MirrorMaker EOS mode | One documented replication leg | No 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
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.


