Skip to content

Conflict resolver

When two replicas of the same entity write events concurrently (detected via vector clocks), the framework needs to know how to merge them. That’s the conflict resolver — a function you supply.

import type { ConflictResolver } from 'actor-ts';
type State = { value: number };
type Event = { kind: 'set'; value: number };
const resolver: ConflictResolver<State, Event> = {
resolve(state, conflicts) {
// conflicts is an array of events that are concurrent with state
// each carries its own vector clock + replica id
const values = conflicts.map(c => (c.event as Event).value);
return { value: Math.max(...values) };
},
};
interface ConflictResolver<State, Event> {
resolve(
state: State, // current local state
conflicts: Array<{
event: Event;
replicaId: ReplicaId;
vectorClock: VectorClock;
}>,
): State;
}

resolve receives:

  • state — the local replica’s view before reconciling.
  • conflicts — array of concurrent events with their metadata (which replica wrote them, vector clock at write time).

Returns the merged state. Must be pure + deterministic — same input always produces the same output; no side effects.

Every replica runs the same resolver on the same conflicting events, possibly in different orders + at different times. For convergence, the resolver must produce the same state on every replica.

Non-determinism breaks convergence:

// ✗ NOT deterministic — Date.now() differs per call
{ value: state.value + Date.now() }
// ✗ NOT deterministic — depends on which replica runs
{ winner: thisReplicaId } // (different on each replica)
// ✓ Deterministic — same answer everywhere
{ value: Math.max(...values) }
// ✓ Deterministic if event.ts is consistent
{ value: conflicts.sort((a, b) => a.event.ts - b.event.ts).pop().event.value }
const resolver: ConflictResolver<State, Event> = {
resolve(state, conflicts) {
const winner = conflicts.reduce((acc, c) =>
c.event.ts > acc.event.ts ? c : acc
);
return applyEvent(state, winner.event);
},
};

Each event carries a ts (wall-clock millis); latest timestamp wins. Simple, often “good enough.” Caveat: depends on synchronized clocks — wide clock skew breaks intuition.

const resolver = {
resolve(state, conflicts) {
return { value: Math.max(state.value, ...conflicts.map(c => c.event.value)) };
},
};

Take the largest (or smallest) value. Right for counters that should not regress: stock levels, score boards, completion counts.

const resolver: ConflictResolver<UserProfile, ProfileEvent> = {
resolve(state, conflicts) {
// Each field uses LWW independently
const fieldsWithLatest = new Map<keyof UserProfile, { value: any; ts: number }>();
for (const c of conflicts) {
const ev = c.event;
if (ev.kind === 'set-name' && (!fieldsWithLatest.has('name') || ev.ts > fieldsWithLatest.get('name')!.ts))
fieldsWithLatest.set('name', { value: ev.name, ts: ev.ts });
if (ev.kind === 'set-email' && (!fieldsWithLatest.has('email') || ev.ts > fieldsWithLatest.get('email')!.ts))
fieldsWithLatest.set('email', { value: ev.email, ts: ev.ts });
}
let merged = state;
for (const [field, { value }] of fieldsWithLatest) {
merged = { ...merged, [field]: value };
}
return merged;
},
};

Mimics CRDT-style per-field LWW. Each field’s last writer wins independently. Good for documents with mostly-independent fields.

const resolver = {
resolve(state, conflicts) {
// Shopping cart: union of items, max of quantities
const items = new Map<string, number>(state.items.entries());
for (const c of conflicts) {
if (c.event.kind === 'add-item') {
const current = items.get(c.event.sku) ?? 0;
items.set(c.event.sku, Math.max(current, c.event.quantity));
}
}
return { items };
},
};

Custom business logic. When the data has natural merge semantics (sets, max counters), encode them here.

Two ways to apply conflicts:

resolve returns the merged state directly. Conflicts are combined; the result is one state. Subsequent events apply normally via onEvent.

const resolver: ConflictResolver<State, Event> = {
resolve(state, conflicts) {
// Apply events in a deterministic order, then return result
const sorted = [...conflicts].sort((a, b) =>
a.event.ts - b.event.ts || a.replicaId.localeCompare(b.replicaId)
);
let s = state;
for (const c of sorted) s = applyEvent(s, c.event);
return s;
},
};

Sort events deterministically; replay through onEvent. Often the cleanest pattern — your existing onEvent does the work; the resolver just imposes an order.

Sometimes concurrent events shouldn’t merge — they conflict semantically:

// Concurrent: "close account" + "deposit"
// → resolver has to choose which "wins"

For these, the right answer is often single-writer with a lease — see Single-writer lease. Forces sequential writes; no conflicts to resolve.

Use replicated ES when conflicts are expected to merge naturally; use single-writer lease when they shouldn’t happen.

resolve runs on every concurrent-write detection — bounded by the rate of cross-replica events arriving. For typical replicated-ES workloads, this is rare (one or two events per key per day).

Heavy resolvers are fine. Sort 50 conflicting events, run a quadratic algorithm — still cheap compared to the journal I/O.