Skip to content

Idempotency & locks

Two coordination primitives. Idempotency makes a retried operation a no-op that returns the first result; a distributed lock gives mutual exclusion across processes. Both are small, TTL-bounded specs.

Idempotency

ctx.idempotency(spec) returns an IdempotencyPort. In practice the engine applies it as the outermost wrap on a keyed operation — you rarely call the port directly; see Idempotency and the recipe.

IdempotencySpec:

Field Type Default Meaning
name str \| StrEnum required store namespace
ttl timedelta 24h how long a completed result is remembered
encrypt_result bool False seal the cached result at rest (AAD binds tenant, principal and op:key; needs a keyring)

The port lifecycle the wrap drives:

Method Notes
begin(...) claim (operation, key, payload-hash) for the calling principal; returns the stored result if already complete
commit(...) store the encoded result on success
fail(...) release the claim so a retry can re-run

Distributed lock

ctx.dlock.query(spec) / ctx.dlock.command(spec) give cross-process mutual exclusion.

DistributedLockSpec:

Field Type Default Meaning
name str \| StrEnum required lock namespace
ttl timedelta 30s lease lifetime — a held lock auto-expires after this
requires_fencing_token bool False fail closed at wiring unless the backend issues fencing tokens (AcquiredLock.token)
requires_fence_advance bool False fail closed at wiring unless the backend issues fencing tokens and implements FenceAdvancing
Port Method Notes
command acquire(key, owner) returns an AcquiredLock (with an optional fencing token), or None if already held; refused with dlock.fence_exhausted once tokens reach 2⁵³ − 1
command release(key, owner) release if owned; returns whether it was
command reset(key, owner) renew the lease — reset the TTL of a held lock (the fencing token doesn't change); returns whether it was held
command advance_fence(key, *, past) FenceAdvancing only: move the key's fencing counter to at least past (0 to 2⁵³ − 1), so the next token is above it; never lowers it; returns the counter
query is_locked(key) presence check
query get_owner(key) the current owner, or None
query get_ttl(key) remaining lease time, or None

For a long-held critical section, use the DistributedLockScope kit with an extend_interval to renew the lease rather than picking a large ttl.

A lease is released on its own schedule, not at a commit. For a lock a transaction holds until it ends, use the transaction-scoped lock instead: Serializing an operation.

A fenced write claims its token first (fence < :token), then accepts an equal fence (fence <= :token); a lower token is always refused, and zero rows on any fenced write ends the term. The rule, and when to call advance_fence, are in Fencing after data loss.

Implemented by

Contract Backend Integration
Idempotency Redis, Postgres Redis · Postgres
Distributed lock Redis Redis

A mock implements both for tests.