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>(); } // ...}Das Interface
Abschnitt betitelt „Das Interface“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.
Zwei harte Verträge
Abschnitt betitelt „Zwei harte Verträge“resolve muss sein:
- Deterministisch — dasselbe
(a, b)-Paar liefert auf jeder Replica dasselbe Ergebnis; keinDate.now(), keinMath.random(), keine externen Reads. - Kommutativ —
resolve(a, b)muss gleichresolve(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”.
Eingebaute Resolver
Abschnitt betitelt „Eingebaute Resolver“LastWriterWinsResolver (Default)
Abschnitt betitelt „LastWriterWinsResolver (Default)“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.
CustomMergeResolver
Abschnitt betitelt „CustomMergeResolver“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.
Eigene Strategien
Abschnitt betitelt „Eigene Strategien“Für alles andere implementiere ConflictResolver<Event> direkt.
resolve gibt ein einzelnes Event zurück; der Actor wendet es
über onEvent an.
Gewinner wählen (max / min)
Abschnitt betitelt „Gewinner wählen (max / min)“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.
Wann ein Merge die falsche Abstraktion ist
Abschnitt betitelt „Wann ein Merge die falsche Abstraktion ist“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.
Performance
Abschnitt betitelt „Performance“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.
Wie geht es weiter
Abschnitt betitelt „Wie geht es weiter“- Replicated Event Sourcing im Überblick — das große Ganze.
- Vector Clocks — wie Konflikte erkannt werden.
- Single-Writer-Lease — Konflikte verhindern vs. auflösen.
- Snapshotting — Snapshots, die den konvergierten State des Resolvers enthalten.
