Aller au contenu
Français

Single-writer lease

Ce contenu n’est pas encore disponible dans votre langue.

Replicated event sourcing trades single-writer consistency for availability — multiple replicas can write concurrently, and the conflict resolver merges.

For some workloads, conflicts shouldn’t happen at all — they represent bugs or domain violations. But losing the multi-region availability would be a step back.

The single-writer lease is the middle ground:

At any moment, exactly ONE replica holds the lease.
The lease-holder writes events normally.
Other replicas read but don't write (until they acquire the lease).
If the lease-holder fails, another replica acquires it.

Effectively turns replicated ES into a failover-capable single-writer system with replicated-ES’s recovery semantics underneath.

import { ReplicatedEventSourcedActor, KubernetesLease, KubernetesLeaseOptions, LastWriterWinsResolver } from 'actor-ts';
import type { Lease, ConflictResolver } from 'actor-ts';
class Account extends ReplicatedEventSourcedActor<Command, Event, State> {
readonly persistenceId = `account-${this.userId}`;
// `replicaId` defaults to this node's cluster address; pinned here so the
// id survives a re-address, matching the lease owner below.
override get replicaId(): string { return process.env.REPLICA_ID!; }
// Opt in to a lease by overriding lease() — the default returns null (no lease):
protected override lease(): Lease {
const kubernetesLeaseOptions = KubernetesLeaseOptions.create()
.withName(`account-${this.userId}-writer`)
.withOwner(process.env.REPLICA_ID!)
.withTtlMs(30_000)
.withNamespace('default');
return new KubernetesLease(kubernetesLeaseOptions);
}
// The conflict resolver is overridden the same way (see below):
protected override resolver(): ConflictResolver<Event> {
return new LastWriterWinsResolver<Event>();
}
}

The actor:

  1. On preStart, attempts to acquire the lease.
  2. On success → becomes the writer.
  3. On failure → starts in read-only mode.
  4. On onLeaseLost(reason) → drops back to read-only; another replica eventually acquires.

When you want active-active failover but single-writer consistency:

  • Financial transactions — balance changes must serialize.
  • Stock / inventory — concurrent decrement could overshoot.
  • Workflow state machines — transitions can’t be concurrent.

Without the lease, you’d need a resolver that handles concurrent withdrawals — possible but error-prone. With the lease, conflicts simply don’t arise.

override async onCommand(state: State, command: Command): Promise<void> {
if (!this.isLeaseHolder) {
// I'm not the writer — reject or forward
command.replyTo.tell({ kind: 'not-writer' });
return;
}
// I am the writer — proceed normally.
// (persist() also throws for a non-holder as a backstop.)
this.persist(event, () => {});
}

The replica still:

  • Replays the journal (sees the writer’s events).
  • Maintains state (read-side queries work).
  • Reports state to readers.

But rejects writes — callers see “this replica isn’t the writer; ask elsewhere.”

For a client transparently routing writes, this is harsh. The common pattern is a proxy actor that watches lease ownership + routes writes to the current writer.

Replicas C, DReplica BLease backendWriter AReplicas C, DReplica BLease backendWriter AA holds leaselease becomes availablenew writer — starts writingStablecrashes — or lease TTL expiresacquireacquiresuccess — atomicfailrecovers, checks leaseheld by B — A runs read-only

Failover window: TTL of the lease (typically 15-30 s). Shorter TTL = faster failover but more renewal traffic.

class Account extends ReplicatedEventSourcedActor<...> {
protected override lease(): Lease { /* ... */ }
protected override resolver(): ConflictResolver<Event> { /* ← still required */ }
}

The resolver is still mandatory. Why?

  • During failover window, both the old + new writer might briefly write — the old one before it notices its lease is gone, the new one after acquiring. Resolver handles those rare concurrent events.
  • Network partition between the lease backend and a replica — the replica thinks it has the lease + writes, while another replica has actually acquired it. Resolver reconciles when partition heals.

The lease reduces conflict frequency to near-zero but doesn’t eliminate. Always have a resolver.

Same as cluster-singleton leases — see Coordination.

  • InMemoryLease — tests.
  • KubernetesLease — production on K8s.
  • Custom — implement Lease against your coordination backend (etcd, Consul).

Adding the lease:

  • Lease acquire — one network call to the lease backend (K8s Lease patch, etc.). Sub-second.
  • Renewal — every ttl / 3 (~10 s typically). Cheap.
  • Conflict frequency drops to near-zero — resolver runs rarely.

The lease itself doesn’t slow normal writes — they proceed locally without a network round-trip per call. The write gate is the cached isLeaseHolder getter (local, sub-microsecond).

Plain replicated ES:

  • Multiple writers per replica.
  • Conflict-resolver runs on every concurrent write.
  • No coordination required; tolerates partitions.
  • “Eventually consistent.”

With the lease:

  • One writer at a time (cluster-wide).
  • Conflicts are rare (only during failover / partition).
  • Coordination via the lease backend.
  • “Strongly consistent except during failover.”

Pick by your consistency vs availability requirements.