Zum Inhalt springen
Deutsch

Single-Writer-Lease

Replicated Event Sourcing tauscht Single-Writer-Konsistenz gegen Verfügbarkeit — mehrere Replicas können gleichzeitig schreiben, und der Conflict Resolver merged.

Für manche Workloads sollten Konflikte überhaupt nicht passieren — sie repräsentieren Bugs oder Domain-Verletzungen. Aber die Multi-Region-Verfügbarkeit zu verlieren wäre ein Rückschritt.

Das Single-Writer-Lease ist der Mittelweg:

Zu jedem Moment hält GENAU EINE Replica das Lease.
Der Lease-Halter schreibt Events normal.
Andere Replicas lesen, aber schreiben nicht (bis sie das Lease erwerben).
Wenn der Lease-Halter ausfällt, erwirbt eine andere Replica es.

Verwandelt Replicated ES effektiv in ein failover-fähiges Single-Writer-System mit den Recovery-Semantiken von Replicated-ES darunter.

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` hat als Default die Cluster-Adresse dieses Nodes; hier
// festgenagelt, damit die Id eine Adressänderung überlebt und zum
// Lease-Owner unten passt.
override get replicaId(): string { return process.env.REPLICA_ID!; }
// Lease per lease()-Override aktivieren — der Default gibt null zurück (kein 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);
}
// Der Conflict Resolver wird genauso überschrieben (siehe unten):
protected override resolver(): ConflictResolver<Event> {
return new LastWriterWinsResolver<Event>();
}
}

Der Actor:

  1. Versucht beim preStart, das Lease zu erwerben.
  2. Bei Erfolg → wird der Writer.
  3. Bei Misserfolg → startet im Read-only-Modus.
  4. Bei onLeaseLost(reason) → fällt zurück in Read-only; eine andere Replica erwirbt schließlich.

Wenn du Active-Active-Failover willst, aber Single-Writer- Konsistenz:

  • Finanztransaktionen — Saldoänderungen müssen serialisieren.
  • Stock / Inventar — gleichzeitiges Dekrementieren könnte überschießen.
  • Workflow-Zustandsmaschinen — Übergänge können nicht gleichzeitig sein.

Ohne das Lease bräuchtest du einen Resolver, der gleichzeitige Abhebungen handhabt — möglich, aber fehleranfällig. Mit dem Lease entstehen Konflikte einfach nicht.

override async onCommand(state: State, command: Command): Promise<void> {
if (!this.isLeaseHolder) {
// Ich bin nicht der Writer — ablehnen oder weiterleiten
command.replyTo.tell({ kind: 'not-writer' });
return;
}
// Ich bin der Writer — normal fortfahren.
// (persist() wirft für Nicht-Holder ebenfalls als Absicherung.)
this.persist(event, () => {});
}

Die Replica:

  • Spielt das Journal trotzdem ab (sieht die Events des Writers).
  • Erhält State (Read-Side-Queries funktionieren).
  • Meldet state an Reader.

Aber lehnt Writes ab — Aufrufer sehen “diese Replica ist nicht der Writer; frag woanders.”

Für einen Client, der Writes transparent routet, ist das hart. Das übliche Muster ist ein Proxy-Actor, der den Lease-Besitz beobachtet + Writes an den aktuellen Writer routet.

Replicas C, DReplica BLease-BackendWriter AReplicas C, DReplica BLease-BackendWriter AA hält das LeaseLease wird verfügbarneuer Writer — beginnt zu schreibenStabilstürzt ab — oder Lease-TTL läuft abacquireacquireErfolg — atomarFehlererholt sich, prüft Leasegehalten von B — A läuft Read-only

Failover-Fenster: TTL des Leases (typischerweise 15-30 s). Kürzere TTL = schnelleres Failover, aber mehr Renewal-Traffic.

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

Der Resolver ist weiterhin Pflicht. Warum?

  • Während des Failover-Fensters könnten der alte + der neue Writer kurz schreiben — der alte, bevor er bemerkt, dass sein Lease weg ist, der neue nach dem Erwerb. Der Resolver handhabt diese seltenen nebenläufigen Events.
  • Netzwerk-Partition zwischen dem Lease-Backend und einer Replica — die Replica denkt, sie hält das Lease + schreibt, während eine andere Replica es tatsächlich erworben hat. Der Resolver gleicht ab, wenn die Partition heilt.

Das Lease reduziert die Konflikt-Frequenz auf nahe null, eliminiert sie aber nicht. Habe immer einen Resolver.

Genauso wie Cluster-Singleton-Leases — siehe Coordination.

  • InMemoryLease — Tests.
  • KubernetesLease — Produktion auf K8s.
  • Benutzerdefiniert — implementiere Lease gegen dein Koordinations-Backend (etcd, Consul).

Das Lease hinzuzufügen:

  • Lease-Acquire — ein Netzwerk-Call zum Lease-Backend (K8s-Lease-Patch, etc.). Sub-Sekunde.
  • Renewal — jedes ttl / 3 (~10 s typisch). Billig.
  • Konflikt-Frequenz fällt auf nahe null — Resolver läuft selten.

Das Lease selbst verlangsamt normale Writes nicht — sie laufen lokal ohne Netzwerk-Round-Trip pro Call. Das Write-Gate ist der gecachte isLeaseHolder-Getter (lokal, Sub-Mikrosekunde).

Plain Replicated ES:

  • Mehrere Writer pro Replica.
  • Conflict-Resolver läuft bei jedem nebenläufigen Write.
  • Keine Koordination erforderlich; toleriert Partitions.
  • “Eventually consistent.”

Mit dem Lease:

  • Ein Writer zur Zeit (cluster-weit).
  • Konflikte sind selten (nur während Failover / Partition).
  • Koordination über das Lease-Backend.
  • “Stark konsistent außer während Failover.”

Wähle nach deinen Konsistenz- vs. Verfügbarkeitsanforderungen.