This guide is for scripts, jobs and applications that change a database and must cope with failure: a lock held by another writer, a killed process, a disk that failed mid-commit. It shows how to make every retry safe, and how to tell from the exit status whether to retry at all. Why the outcomes are what they are is explained in transactions.
Give every change a key that names it – the job and the unit of work it does. If history already holds a revision made under that key, the change is not made again, and that revision is reported:
$ reldir --idempotency-key rename-ada-1 sql "UPDATE users SET name = 'Ada Lovelace' WHERE id = 'ada'"
action | path | planned
-------+----------------+--------
write | users/ada.json | false
(1 row)
committed: 1 file(s), revision #2 ([..])
$ reldir --idempotency-key rename-ada-1 sql "UPDATE users SET name = 'Ada Lovelace' WHERE id = 'ada'"
already made: revision #2 ([..]) carries this idempotency key; nothing was changed again
The replay exits 0, and in machine output its data.replayed is true.
Every command that changes the database takes a key: SQL, the row commands,
import, restore, migrate, schema new, schema pin, schema widen,
infer --write, doctor --fix and snapshot restore. A retried migration is
not planned again, so one that dropped a column is reported, not refused
because the column is gone.
The key alone identifies the change. Any change under a key history already holds is reported as made, whatever it says, so use a new key for every distinct change, and the same key only to retry it:
$ reldir --idempotency-key rename-ada-1 sql "DELETE FROM posts WHERE id = 'p2'"
already made: revision #2 ([..]) carries this idempotency key; nothing was changed again
From a library, pass the key with the change:
| Library | |
|---|---|
| Python | with db.transaction(idempotency_key="transfer-42"): |
| Node.js | db.execute(sql, params, { idempotencyKey: 'withdraw-17' }), or the same option to db.transaction |
| Rust | db.execute_with(sql, params, Change { idempotency_key: Some("transfer-42"), ..Default::default() }) |
A replay reports the earlier revision with replayed: true.
| Exit | The change | Do |
|---|---|---|
0 |
was made, or was made before under its key | nothing |
3 |
was not made: another writer, an outside edit or an expired transaction got in the way | retry it |
12 |
was made and every reader sees it; finishing it on disk failed | do not retry; the next writing command, or reldir recover, finishes it |
13 |
may or may not have been made | run reldir recover, then retry with the same key |
5, 6 |
was not made: an interrupted change must be finished, or metadata cannot be trusted | follow How to recover |
| any other | was refused, and nothing was written | do not retry: the same change gets the same answer; fix the change or the data |
The codes behind exit 3, each of which leaves nothing written:
| Code | A retry |
|---|---|
LOCK_CONTENDED |
waits for the lock again; to wait longer each time, pass --wait SECONDS |
CONCURRENT_MODIFICATION |
plans the change again against the files as they now are |
PLAN_CHANGED |
plans the change again; review it first if it was reviewed before |
TRANSACTION_EXPIRED |
starts the transaction over |
PATH_INTERFERENCE |
meets the same path: look at the path the error names first |
In a shell script:
key="nightly-import-$(date +%F)"
for attempt in 1 2 3 4 5; do
reldir --idempotency-key "$key" sql -f import.sql
status=$?
case $status in
0|12) exit 0 ;; # made; 12 is finished by the next writer
3) sleep "$attempt" ;; # nothing written: try again
13) reldir recover || exit $? ;; # then retry under the same key
*) exit $status ;; # refused: retrying cannot help
esac
done
exit 3
In Python and Node.js, the error carries the same code and exit; see each
library’s errors section.
When a person or a program reviews a change before it is made, commit it only
if the database is still in the state that was reviewed. A dry run reports
that state as data.state:
$ reldir --format jsonl --dry-run sql "DELETE FROM posts WHERE id = 'p2'"
{"type":"change","action":"delete","path":"posts/p2.json","planned":true}
{"type":"result",[..],"data":{"files":1,"dry_run":true,"replayed":false,"state":"[..]"}}
Pass it back with --expect-state:
state=$(reldir --format json --dry-run sql "$change" | jq -r .data.state)
# ... review the plan ...
reldir --expect-state "$state" sql "$change"
If anything changed in between – a row, a schema, a setting – the change is refused and nothing is written:
$ reldir --expect-state 86e1d1baa8ea14071e7c9b46928d40f8 sql "DELETE FROM posts WHERE id = 'p2'"
error[PLAN_CHANGED]: the database changed since this change was planned, so the plan no longer says what committing would do; nothing was written
= help: plan the change again, and review what it would do now
Plan it again, and review the new plan before committing it. The MCP server holds every change an agent makes to this rule: it plans first, and commits only the plan that was reviewed.