Leases
One owner per task, with a fencing token that never goes backwards.
Quick example
@rivus/agent-kit-collab/lease exports createLeaseManager, LeaseRepository, sqliteLeaseRepository, fileLeaseRepository and memoryLeaseRepository. It is an Effect entry and needs a platform. It needs effect at the peer version from Installation.
A lease lets one process at a time own a task, and refuses a stale owner's writes. The package knows no particular Agent.
import { NodePlatformLive } from "@rivus/agent-kit/node/effect";
import { createLeaseManager, sqliteLeaseRepository } from "@rivus/agent-kit-collab/lease";
import { Effect, Layer } from "effect";
const LeaseLive = sqliteLeaseRepository({ path: "/var/lib/my-app/leases.db" }).pipe(
Layer.provideMerge(NodePlatformLive)
);
const program = Effect.scoped(
Effect.gen(function* () {
const leases = yield* createLeaseManager({ ttlMs: 60_000, heartbeatMs: 15_000 });
const lease = yield* leases.acquire("task:42"); // fails with LeaseHeld while another live holder renews it
// Writes carry the fencing token; when the lease is lost, the fenced work is interrupted.
yield* lease.runFenced((token) => saveResult(token));
// Stop other work on loss by racing it against lease.lost.
yield* longRunningWork.pipe(Effect.raceFirst(lease.lost));
})
);
await Effect.runPromiseExit(program.pipe(Effect.provide(LeaseLive)));Holders and fencing
- A lease has one holder. Its generation, the fencing token, grows on every acquisition and never goes back. A release keeps the record as a tombstone. Losing the lease and getting it again gives a new generation, so the old handle's token is refused.
- The holder renews every
heartbeatMsin the Scope that acquired it.heartbeatMs × 2must not exceedttlMs. Closing the Scope stops the heartbeat and releases the lease. - A holder on the same host whose process is gone (or whose pid now belongs to another process) is taken over at once. A holder that is alive but stopped renewing is taken over after the TTL, measured by the observer's own monotonic clock from the moment it first saw the current record.
runFencedholds the repository's per-key fence and re-reads the record before the work starts. Waiting for the fence stops when the lease is lost. The fence is released when the work's fiber ends, so a successor's fenced work starts after that. An interrupted fiber ends at once, but a Promise it started keeps running. Put a write that cannot be cancelled inEffect.uninterruptible(the fence then waits for it to settle), or pass it the AbortSignal thatEffect.tryPromiseprovides and let it settle only once the write has stopped.- Guard plus re-read is as strong as a check by the protected resource itself only when every writer goes through the same repository on the same machine. A resource that can compare atomically should keep the highest token it has seen and reject a smaller generation.
sqliteLeaseRepositorycompares revisions insideBEGIN IMMEDIATEtransactions.fileLeaseRepositoryis the fallback without SQLite: one JSON file per key, written under a per-key lock file.memoryLeaseRepositoryserves one process.- The repository port follows the kit's repository shape.
load(key)is the storedLeaseSnapshotorundefined, andsave(key, snapshot, expectedRevision)writes only over that revision and fails with aRevisionConflictwhen another writer moved the record first.
Repositories and failures
Expected failures are typed values with a _tag: LeaseHeld, LeaseLost, LeaseConfigInvalid, LeaseRepositoryFailure and the repository's RevisionConflict. The kit runs no Effect itself. Run the program at your application's assembly root.
Holders are judged by host name, boot id, pid and start time. See Installation for win32, NFS, containers and a renamed host.