Lease API
Ce contenu n’est pas encore disponible dans votre langue.
The Lease interface is the framework’s distributed-lock
abstraction. Implementations differ in backing store (in-memory,
K8s, etcd); the contract is identical.
interface Lease { acquire(): Promise<boolean>; acquireWithToken?(): Promise<{ readonly token: string } | null>; release(): Promise<void>; checkAlive(): boolean; onLost(handler: (reason: string) => void): () => void;}Three methods that do work + one that registers a callback.
acquireWithToken is an optional fifth method (marked ?) —
backends implement it only when they support fencing tokens.
acquire(): Promise<boolean>
Section titled “acquire(): Promise<boolean>”const got = await lease.acquire();if (got) { // We hold the lease — proceed with leader-only work} else { // Someone else has it — back off and retry later}The semantics:
- Resolves
trueif the lease was successfully acquired. - Resolves
falseif another holder owns the lease. - Rejects on transient errors (network, backend unavailable).
Implementations typically retry internally up to acquireRetries
times before resolving false. A false result means “another
holder definitively has it”; a rejection means “I don’t know.”
acquire() is idempotent when this caller already holds the
lease — calling acquire() twice in a row by the same owner
returns true both times.
acquireWithToken?(): Promise<{ readonly token: string } | null>
Section titled “acquireWithToken?(): Promise<{ readonly token: string } | null>”An optional variant of acquire() that also returns a
backend-issued fencing token on success:
if (typeof lease.acquireWithToken === 'function') { const result = await lease.acquireWithToken(); if (result) { // Acquired — `result.token` is the fencing token }} else { const got = await lease.acquire(); // no fencing support — fall back // ...}- Resolves
{ token }on success — the semantic equivalent ofacquire()returningtrue, plus an opaque token. - Resolves
nullon contention — equivalent toacquire()returningfalse.
A fencing token lets an external observer order two acquires
against the same lease. It’s backed by the coordination service’s
own optimistic-concurrency primitive — Kubernetes resourceVersion,
Redis SETNX with a counter, etcd revision. Tokens are opaque
strings, compared exact-string.
Because the method is optional, consumers feature-detect it and
fall back to plain acquire() when it’s absent — the
LeaseMajority split-brain strategy
does exactly this. Both InMemoryLease and KubernetesLease
implement it.
release(): Promise<void>
Section titled “release(): Promise<void>”await lease.release();Voluntarily drop ownership. Calling without holding the lease is a no-op — no error. Resolves once the backend has confirmed the release.
It rejects if the backend could not be told. A release()
that fails leaves the record claimed on the server while this
process has already dropped it locally — the holder no longer
knows whether it owns the lease, and that is worth knowing.
Backends must propagate the failure instead of swallowing it;
callers using release() purely as cleanup wrap it:
await lease.release().catch(() => { /* best-effort cleanup */ });Everything the framework does internally is already wrapped that
way. The one caller that acts on the rejection is
LeaseMajority, which enters
fail-safe and stops claiming majority until the partition heals.
The framework calls release():
- When the singleton manager stops being leader (graceful hand-off to another node).
- When the actor system shuts down via coordinated shutdown.
For the non-graceful case — process crash — the backend’s
TTL handles cleanup automatically; no release is sent.
Note the flip side of the no-op: release() does nothing while
an acquire() is still in flight, because ownership only begins
when that acquire resolves. Undoing an acquire you stopped
waiting for means waiting for it to report back first.
checkAlive(): boolean
Section titled “checkAlive(): boolean”if (lease.checkAlive()) { // We still own the lease — proceed}A synchronous, local check. No network roundtrip. Returns the holder’s most-recent knowledge of “do I still own this?”
Used by the framework to gate ownership-dependent work — e.g.,
before issuing a shard allocation, the coordinator calls
checkAlive() and aborts if it returns false.
Implementations track ownership locally; the backend’s renewal
loop updates the local flag. This means checkAlive() reflects
up to one missed renewal of staleness — a sub-second window
where the lease might actually be gone but checkAlive() still
returns true.
For absolute certainty, use onLost(...) and react to the
notification rather than polling.
onLost(handler): () => void
Section titled “onLost(handler): () => void”const unsubscribe = lease.onLost((reason) => { console.log(`lease lost: ${reason}`); // Stop leader-only work immediately});
// Later: unsubscribe();Register a callback fired when ownership is lost unexpectedly:
- The backend reported the lease was taken over by another holder.
- The TTL expired without successful renewal (e.g., network partition).
- The backend itself reported a state inconsistency.
onLost fires once per loss. After it fires, checkAlive()
returns false and acquire() is needed before regaining
ownership.
The handler should drop ownership state immediately — stop work, release locks, signal interested actors. Don’t await expensive operations; the lease is gone and any other holder may already be acting.
Returns an unsubscribe function — call to remove the handler when you no longer need it.
How the framework uses each method
Section titled “How the framework uses each method”For a singleton with a lease:
Same pattern for sharding coordinator:
lease.acquirebefore processing allocation requests.lease.checkAlivebefore issuing each allocation.onLost→ reject pending allocations, stop coordinator.
Writing a custom backend
Section titled “Writing a custom backend”import type { Lease, LeaseOptionsType } from 'actor-ts/coordination';
class EtcdLease implements Lease { private alive = false; private onLostHandlers = new Set<(reason: string) => void>(); private renewTimer: NodeJS.Timeout | null = null;
constructor(private readonly settings: LeaseOptionsType & { /* etcd-specific */ }) {}
async acquire(): Promise<boolean> { // Try to atomically CAS the etcd key from empty to this owner. // Start a renewal timer on success. // ... }
async release(): Promise<void> { // Stop the renewal timer. // CAS the etcd key from this owner to empty. // ... }
checkAlive(): boolean { return this.alive; }
onLost(handler: (reason: string) => void): () => void { this.onLostHandlers.add(handler); return () => this.onLostHandlers.delete(handler); }
private fireOnLost(reason: string): void { this.alive = false; for (const h of this.onLostHandlers) { try { h(reason); } catch { /* swallow */ } } }}Three things any backend needs to get right:
- Atomicity on
acquire— two concurrentacquire()calls from different owners must produce one winner. The backend’s own consistency model has to provide this (CAS, paxos, raft-backed). - Periodic renewal — keep the lease alive in the backend.
Configurable interval, typically
ttl / 3. onLostaccuracy — fire when ownership truly transitions away, including the TTL-expiry case.
Test the implementation against:
- Two concurrent acquires from different owners.
- Network partition with both sides trying to renew.
- Holder crash + new acquire after TTL.
- Holder process pause (e.g., GC stall) longer than TTL.
Where to next
Section titled “Where to next”- Coordination overview — the bigger picture.
- InMemoryLease — the dev/test reference implementation.
- KubernetesLease — the production K8s backend.
- Singleton with lease — the main consumer.
The Lease API reference covers
the full contract.
