Docs/Ledger APIv0.0.1-preview
Idempotency
Durable operation identity — how TulaDB makes retries, redeliveries, and crash-recovery loops safe by construction.
Every failure mode in a distributed payments stack eventually reduces to the same question: did my write land? TulaDB’s answer is to make the question harmless. Send the operation again. If it landed, you get the original result back; if it didn’t, it lands now. Either way, exactly one financial effect exists.
Operation identity
Every write names itself with four fields:
(source_id, ledger_id, currency, txn_id)
txn_id is caller-chosen — up to 36 printable ASCII characters, compared exactly (no trimming, no case folding; whitespace and control characters are rejected). Use the identifier your system already owns: an order id, a payment reference, a saga step.
From this tuple the engine derives the transfer’s permanent identity with a length-prefixed canonical encoding and SHA3-256:
transfer_id = SHA3-256("{len}:{source}|{len}:{ledger}|{len}:{currency}|{len}:{txn_id}")
Two reference forms exist, chosen per deployment: a 35-character Base62 reference over the digest’s first 208 bits, which fits ISO 20022 message fields (the default), or the full 256-bit digest as 64 hex characters. The choice (use_iso20022_ref_size) is part of what identity means for that data: set it before the first write and never change it. This release does not refuse a change on a populated store, so holding it fixed is on you.
The duplicate contract
Submitting a tuple the engine has seen returns the original result — same phase, same balances-at-the-time, same transfer_id — with status 1 (OK_DUPLICATE):
r1 = c.transfer(t) # status 0 — applied
r2 = c.transfer(t) # status 1 — replayed; r2 describes r1's outcome
Three properties make this strong enough to build on:
- It’s durable, and it lasts as long as history. The duplicate index is persisted with the ledger and survives restart, crash recovery, leader failover, and replica replay. There is no clock-based window: an identity is forgotten only when the ledger’s history retention purges the transfer it names — and with the default, nothing is ever purged.
- It covers every write — transfers, reserves, commits, rollbacks, tiered operations, and explicit rejections. A rejected identity replays its rejection rather than being re-decided against fresh state.
- The original wins — silently. The tuple does not include the amount. If a request arrives with a known tuple but a different amount, the engine replays the original result and returns
OK_DUPLICATE— it does not apply the new amount, and it does not return a mismatch error. That’s the right behaviour for a true retry, but it means the engine cannot tell a retry from a bug that reused atxn_idwith new contents. The rule this imposes on your code: atxn_idmust be bound to the exact operation content, once, at the moment it’s created — derive it from the business event (order id, payment id) so reusing it with different contents is structurally impossible. If you need to verify what an identity actually holds, read it back:get_transaction_by_keyreturns the stored original.
What this makes safe
| Failure | Without durable identity | With TulaDB |
|---|---|---|
| Client timeout, then retry | Maybe double-posted | Replays the original result |
| At-least-once queue redelivery | Dedup table in app code, hopefully | OK_DUPLICATE |
| Worker crashes mid-batch, batch rerun | Manual reconciliation | Landed entries replay, rest apply |
| Two services race the same operation | Row locks and prayer | Exactly one applies; the other gets its result |
| Indeterminate write during failover | Unknown state | Resubmit; the answer is definitive |
The operational rule is one sentence: always retry with the same tuple. Generating a fresh txn_id for a retry is the one way to defeat the mechanism — that’s a new operation, and it will apply again.
How long duplicate protection lasts
Exactly as long as the transfer’s history. There is no separate idempotency window to configure — the old clock-based setting (idem_index_ttl_ns) was removed, and a config that still names it refuses to start.
- Default (
history_retention_hours = 0): forever. Nothing is purged, so every identity is remembered and the duplicate guarantee never ends. - With history retention on: the cluster commits a replicated purge floor, and an identity retires only when that floor passes the transfer it names. A retry of a retired identity is then admitted as new — identically on every node, because the decision is a position in the replicated log, not a node’s clock. A lookup of history below the floor answers
ERR_HISTORY_PURGED(43). The floor never passes an open hold, so a pending reservation keeps its identity until it is settled or expires.
So a short duplicate window is a short history window: choose retention by the longest path a retry can take — stalled queues, disaster recovery, a replayed message archive — not by p99 latency. Retention also needs, by default, an export pipeline to acknowledge history before it is purged — and that pipeline can only record its position through an export lease, which is off until you set export_lease_ttl_ms > 0 (default 0). With leases off, the engine warns at boot that nothing will be purged. See Limits and sizing and Configuration.
Sizing note
The idempotency index grows with the number of distinct committed operations still inside retention — with the default retention, without bound. Where it lives is the dedup backend’s job: the default resolves to the run tier for a new data directory on the standard single-lane WAL, which keeps a bounded resident set in memory over sorted runs on disk, so the index grows on disk rather than in RAM. Long-running high-volume deployments should monitor tuladb_idempotency_live_entries and plan disk for it. See Limits and sizing.