Skip to content

feat(commands): retry-safe commands with proven dedup over SQLite - #502

Merged
paymog merged 2 commits into
mainfrom
feat/commands-375
Oct 19, 2026
Merged

paymog merged 2 commits into
mainfrom
feat/commands-375

Conversation

@paymog

@paymog paymog commented Oct 19, 2026

Copy link
Copy Markdown
Owner

Summary

A webhook, job, or provisioning handler can now take a delivery twice and apply it once: commands.handle commits the domain state, a record of the outcome, and the outbox effects in one SQLite transaction, and a retry of the same id replays the recorded outcome. Reusing an id with a different payload is a conflict, and both paths leave the state untouched.

What is proved, and what is trusted

LAWS.bend holds for every domain (any state, payload, result, and effect types, any eq, any decide) over the pure step that handle runs inside the transaction:

law claim
duplicate_replays same payload: nothing written, no effect, reply is the recorded outcome
conflict_rejected different payload: nothing written, no effect, Conflict
accept_recorded the record's outcome is exactly the reply's, beside the new state and effects
refuse_keeps_state a refusal keeps the state, emits nothing, and is recorded
retry_replays with a reflexive eq, a retry after the first commit replays it at any later time

SQLite's atomicity, BEGIN IMMEDIATE isolation, and the SQL wiring are trust assumptions, tested by check.bend rather than proved. Payload identity is full-text equality, not a hash.

Design decisions

  • The lock is taken before the lookup. BEGIN IMMEDIATE means two deliveries of one id can never both see "no record". The check proves this with writer A paused mid-transaction: writer B waits, fails with SQLITE_BUSY, commits nothing, and its retry replays A's result.
  • Refusals are recorded. A retried overdraft gets the same refusal even after the balance changes.
  • Effects go to an outbox in the same transaction. Delivery is at least once (outbox.next, then outbox.ack). This is not exactly-once external execution, and the README says so.
  • Retention is explicit. prune(st, before) forgets old records. After a prune, a retry of a forgotten id applies again. Deduplication is never promised past the window.
  • State is one text row. decide is String -> String -> Decision, so the consumer owns serialization. ledger.bend (the chosen consumer: a payment ledger fed by webhook events) uses json for payloads and state, and a collections OMap for balances.

Checks

scripts/check.sh commands passes: the type check, PROOF.bend (no LAWS. def relies on foreign code), and check.bend, which covers duplicate replay, conflicts, refusals, restart, a child process that dies before COMMIT, the in-flight duplicate above, two processes that deliver the same 400 events at once (balance +400, not +800; 404 outbox rows), outbox order and ack, and prune. The same check also passes as a native binary.

Bench (commands/bench, in-memory, N = 10,000, checksums agree across all five variants): Bend runs 107k new commands/s and 238k duplicates/s, against C at 261k and 1.49M. With a file database, the disk dominates new commands and Bend is within 1.1x of C.

Fixes #375

handle runs one command per BEGIN IMMEDIATE transaction: it finds the
record for the id, runs a pure decide on a new id, and commits state,
record, and outbox effects together. Duplicates replay the recorded
outcome; an id reused with another payload is a conflict. Laws over the
pure step cover replay, conflict, recorded results, refusals, and a
retry after the first commit. ledger.bend is a webhook-fed consumer.

Closes #375
…mands-375

# Conflicts:
#	CHANGELOG.md
#	README.md
@paymog
paymog merged commit 71c587e into main Oct 19, 2026
4 checks passed
@paymog
paymog deleted the feat/commands-375 branch October 19, 2026 01:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Commands: prove retry-safe deduplication and durable state updates

1 participant