A write can commit and the caller still gets nothing back. From where the caller sits that looks like a failure. At the destination the work is done, so a second attempt that treats the first as failed ends up being a second mutation.
While qualifying ImportFlow’s gateway design we wanted to know what a caller is left with in that state, so we forced it on purpose and looked at what the database and the gateway were holding afterwards.
A 504 after the commit
In an earlier hosted gateway qualification, a test-only fault returned HTTP 504 after the destination operation had already committed. The fault was injected on purpose, and a separate audit agent reproduced it on a disposable hosted Supabase project.
The caller got HTTP 504. The test reported synthetic_response_lost_after_commit. The durable status showed one inserted row, and an independent SQL count found exactly one row in the table.
So the caller was holding a 504 while the table held the data. If that client reads the 504 as “nothing was written” and sends the batch again, the second write lands on top of a finished one.
Losing the COMMIT completion
A gateway timeout loses a response above the database. Underneath it there’s a harder case, where PostgreSQL commits and the driver never hears about it. A later qualification round exercised that on PostgreSQL 17.6 by intercepting the actual COMMIT completion before the driver received it.
caller sends batch
-> destination commits
-> COMMIT completion intercepted
-> caller can't confirm the outcome
-> fresh runtime recovers the original receipt
-> replay performs no mutation
The batch was a controlled upsert of three rows: one insert, one update and one row that didn’t change. Afterwards we looked at the destination revisions, the operation evidence and the effects of the transactional triggers.
The original caller couldn’t confirm the outcome and reported reconciliation uncertainty. The transaction had committed. A fresh runtime then recovered the original durable receipt, with the original identity and time still on it. Replaying the batch performed no further mutation, and the revisions, the operation evidence and the trigger effects were all unchanged.
Concurrent deliveries
We also delivered the same logical batch identity concurrently. In the hosted evidence the deliveries converged on one durable row, and one hosted campaign with 50 duplicate deliveries gave every caller the retained outcome while the destination held a single row. In the later round we ran 2, 10 and 50 concurrent callers and saw a real PostgreSQL lock waiter during the runs. We didn’t use these runs for throughput numbers. The question was only whether the same logical batch could end up applied twice, and in the campaigns we ran it converged on one retained result.
Same identity, changed content
We reused a batch identity with different content as well. One version won and the other was rejected as a payload mismatch, and the retained receipt and result stayed as they were.
What this changed for us
The tests made us pull two things apart that retry code often merges. A transport attempt is an event on a wire, and it can fail, repeat or vanish after the destination has already acted. The logical write is what the user actually asked for. It needs a stable identity that survives retries, plus a durable record of its result.
With both, a caller that lost the acknowledgement has something to ask, which is what happened to this logical write. In the path we tested, the answer was already sitting at the destination as a durable receipt and retained destination state.
Top comments (2)
Losing the COMMIT completion is the honest version of a case most retry logic pretends does not exist: the caller is not holding a failure, it is holding an unknown, and treating unknown as failed is what manufactures the duplicate. Recovering the original receipt — with its original identity and time — is the right center for the design, because it lets a fresh runtime answer 'what happened' from evidence instead of re-executing and hoping. The replay performing no mutation is the proof that matters; a dedupe claim is only real when revisions, triggers, and side effects all stay unchanged on the second delivery, as yours did.
Exactly. That's why I cared more about what stayed unchanged on replay than the row count itself. If the table still has one row but a trigger fired twice or a revision bumped, the retry wasn't actually harmless.