Zum Inhalt springen
Deutsch

Conflict Resolver

Wenn zwei Replicas derselben Entity Events gleichzeitig schreiben (erkannt via Vector Clocks), muss das Framework wissen, welches Event gewinnt oder wie das Paar gemergt wird. Das ist der Conflict Resolver — eine Strategie, die du durch Überschreiben von resolver() am Actor bereitstellst.

import { ReplicatedEventSourcedActor, LastWriterWinsResolver } from 'actor-ts';
import type { ConflictResolver } from 'actor-ts';
class Counter extends ReplicatedEventSourcedActor<Command, Event, State> {
// Der Default ist Last-Writer-Wins; zum Ändern überschreiben:
protected override resolver(): ConflictResolver<Event> {
return new LastWriterWinsResolver<Event>();
}
// ...
}

Der Resolver versöhnt ein nebenläufiges Paar — er gibt genau ein Event zurück (einen Gewinner oder eine synthetisierte Zusammenführung der beiden):

interface ConflictResolver<E> {
resolve(a: ConflictCandidate<E>, b: ConflictCandidate<E>): E;
}
type ConflictCandidate<E> = {
event: E; // die User-Domain-Event-Payload
timestamp: number; // Wall-Clock der ursprünglichen Replica
replica: ReplicaId; // ID der ursprünglichen Replica
vc: VectorClock; // Vector Clock zum Persist-Zeitpunkt
};

Beachte den einzelnen Typparameter (das Event, nicht den State). Sind mehr als zwei Events nebenläufig, faltet das Framework sie paarweise durch resolve, sodass die ganze Menge auf ein Gewinner-Event reduziert wird — das dann über dein normales onEvent angewandt wird.

resolve muss sein:

  • Deterministisch — dasselbe (a, b)-Paar liefert auf jeder Replica dasselbe Ergebnis; kein Date.now(), kein Math.random(), keine externen Reads.
  • Kommutativresolve(a, b) muss gleich resolve(b, a) sein. Events kommen auf verschiedenen Replicas in unterschiedlicher Reihenfolge an, daher lässt ein nicht-kommutativer Resolver Replicas divergieren.

Entscheide den Gewinner aus den Daten der Kandidaten selbst (timestamp, replica, Payload) — nie aus der Ankunftsreihenfolge oder „nimm a”.

protected override resolver(): ConflictResolver<Event> {
return new LastWriterWinsResolver<Event>();
}

Höherer timestamp gewinnt; bei Gleichstand gewinnt die höhere (lexikografische) replica-ID, sodass jede Replica konvergiert. Einfach und oft „gut genug”. Vorbehalt: setzt grob vergleichbare Wall-Clocks über Replicas voraus — derselbe Trade-off wie LWWRegister.

Kapsle ein kommutatives Merge der beiden Event-Payloads — nutze es, wenn du Domänenwissen hast, das LWW nicht erfasst (z. B. addieren sich zwei nebenläufige Einzahlungen einfach):

import { CustomMergeResolver } from 'actor-ts';
protected override resolver(): ConflictResolver<Event> {
return new CustomMergeResolver<Event>((a, b) => ({
kind: 'deposited',
amount: a.amount + b.amount, // additives Merge
}));
}

CustomMergeResolver sortiert die beiden Kandidaten vor dem Aufruf deines Merge nach der Replica-ID, sodass es eine deterministische Argumentreihenfolge erhält, selbst wenn deine Funktion nicht perfekt symmetrisch ist — der Vertrag verlangt aber weiterhin Kommutativität.

Für alles andere implementiere ConflictResolver<Event> direkt. resolve gibt ein einzelnes Event zurück; der Actor wendet es über onEvent an.

class HighestWins implements ConflictResolver<Event> {
resolve(a: ConflictCandidate<Event>, b: ConflictCandidate<Event>): Event {
if (a.event.value !== b.event.value) {
return a.event.value > b.event.value ? a.event : b.event;
}
return a.replica > b.replica ? a.event : b.event; // deterministischer Tie-Break
}
}

Richtig für Werte, die nicht zurückgehen dürfen: Lagerbestände, High-Scores. Löse exakte Gleichstände über replica, damit das Ergebnis auf jeder Replica gleich ist.

Deterministische Reihenfolge nach (timestamp, replica)

Abschnitt betitelt „Deterministische Reihenfolge nach (timestamp, replica)“
class OrderedWins implements ConflictResolver<Event> {
resolve(a: ConflictCandidate<Event>, b: ConflictCandidate<Event>): Event {
if (a.timestamp !== b.timestamp) return a.timestamp > b.timestamp ? a.event : b.event;
return a.replica > b.replica ? a.event : b.event;
}
}

Genau das macht LastWriterWinsResolver — hier als Vorlage für dein eigenes Tie-Breaking gezeigt.

Manchmal sollten nebenläufige Events nicht mergen — sie widersprechen sich semantisch (z. B. „Konto schließen” nebenläufig zu „Einzahlung”). Dafür ist Single-Writer mit Lease besser — siehe Single-Writer-Lease — das erzwingt sequenzielle Writes, sodass keine Konflikte entstehen.

Nutze Replicated ES, wenn nebenläufige Events natürlich mergen; nutze das Lease, wenn sie gar nicht auftreten sollen.

resolve läuft nur bei Erkennung eines nebenläufigen Writes — begrenzt durch die Rate replica-übergreifender Events, typischerweise selten. Selbst ein aufwändigeres Merge ist billig neben dem Journal-I/O.