ADR 0026 Fenced effects check the lease in the same transaction - agbossjs/agboss GitHub Wiki
ADR-0026 — Fenced effects check the lease in the same transaction
- Status: Accepted (#500)
- Issue: #500 (part of #479; PostgreSQL in #482)
Context
#471 gave every step a fencing token (executionContext.fence). An application that writes business
rows still had to build the same thing each time: a high-water-mark table per lease scope, an
idempotency table, and their subtle order — a stale token refused before the key is read, so a
zombie never receives the new holder's result. agbossjs/distributed-agent-recovery carried a
LeaseFencingService and fencing_tokens DDL only for this.
A high-water mark alone has a race: a zombie that wakes before the new holder wrote finds the mark still at its own token and writes. Only the lease itself knows that another instance took it.
Decision
- A repository contract, generic over the store's transaction:
FencedEffectRepository<TTransaction>withsaveResultIfAbsent(key, work), the key fromfencedEffectKeyOf(executionContext, name). The business rows of the write go in the same transaction (InMemoryFencedEffectTransaction, SQLite'sDatabaseSync, a PostgreSQL client), so the application keeps no fencing or idempotency table. - The lease is checked in the same transaction, before the mark: the lease row of the key's scope,
in the same database, must be the key's (its epoch, not released, not expired). A zombie is refused
even when it reaches the write first. The price: the fenced-effect store and the lease store must be
of one database — each implementation declares its lease store (
static leaseRepository), and a listed mismatch fails assembly ([application.fenced-effect-lease-store]). - Then the mark, then the key, then the write, as #471's reference SQL; a replay advances the mark and returns the stored result, the write not run.
- A durable singleflight: concurrent calls on one key of one instance share the first's promise; across instances the transaction serializes them.
- SQLite runs the work synchronously, with a guard: its transactions are one synchronous group
of statements on one connection shared by every store, and they do not nest. An asynchronous
work rolls back with
FencedEffectAsyncWorkError. PostgreSQL (#482) may wait: one client per transaction. - Limits are settings, not code:
@Application({ fencedEffects: { maxResultSize, retention } }), 64 KB and 24 h by default; the recovery sweep removes rows past the retention, never when a tree ends. - One published contract suite (
fencedEffectContract) that the in-memory, SQLite and PostgreSQL stores run.
Consequences
- One call replaces a service, a repository and their DDL in every application; flow-patterns' credit desk charges with it and keeps no key of its own.
- No I/O inside a SQLite effect: a step calls its API before or after the effect, never in it.
- A fenced effect is exactly-once only for rows of the store's database. HTTP APIs, email and
webhooks still need their own
Idempotency-Keyandfence.confirmNow()(#471). - A fourth store means implementing the four steps in its own transaction and passing the suite.