Skip to content

Vector clocks

A vector clock is a logical timestamp distributed across replicas. Each replica maintains a per-replica counter; every persisted event includes the current vector clock at write time.

Given two vector clocks a and b:

RelationWhen
a < b (“a happens-before b”)Every component a[i] ≤ b[i] and at least one strictly less.
a == bEvery component equal.
a concurrent with bNeither < nor >. Some components have a > b, others a < b.

The third case — concurrent — is what the replicated-ES machinery uses to detect “two replicas wrote independently and neither saw the other’s write.”

type VectorClockData = Readonly<Record<ReplicaId, number>>;

A plain object. Replicas not present are treated as 0 — useful for compactness on the wire (skip components that are zero).

The framework’s VectorClock class wraps this:

import { VectorClock } from 'actor-ts';
let vc = VectorClock.empty();
vc = vc.increment('replica-a'); // → { 'replica-a': 1 }
vc = vc.increment('replica-a'); // → { 'replica-a': 2 }
vc = vc.merge(VectorClock.fromData({ 'replica-b': 3 }));
// → { 'replica-a': 2, 'replica-b': 3 }
MethodPurpose
empty()Zero clock.
fromData(record)Build from a plain object.
get(replicaId)Read one component.
increment(replicaId)Bump a replica’s component — called when persisting.
merge(other)Per-replica max.
compare(other)Returns '<' / '>' / '==' / 'concurrent'.
toData()Plain-object form for serialization.
Replica A writes event_A1 at vc { A: 1 } ← A's counter bumps
Replica B (no prior knowledge of A's event):
writes event_B1 at vc { B: 1 } ← B's counter bumps
Both events end up in the shared journal.
Later, replica A reads B's event:
vc { B: 1 } compared to A's current { A: 1 }:
→ concurrent (A > B for A's component, B > A for B's component)
→ conflict resolver runs to merge state from both branches

The framework:

  1. On each persist, increments this replica’s component.
  2. Embeds the full vector clock in the persisted event.
  3. On reading another replica’s event, compares the embedded vector clock to the local state’s vector clock.
  4. If concurrent → invoke the conflict resolver.
  5. If < (causally prior) → already applied or strictly older; ignore (or fold into state normally).
  6. If > (causally later) → apply to state.
t1: Replica A persists event_A1 at vc { A: 1 }
state_A = applyEvent(initial, event_A1)
state_A's vc = { A: 1 }
t2: Replica B persists event_B1 at vc { B: 1 } ← concurrent
state_B = applyEvent(initial, event_B1)
state_B's vc = { B: 1 }
t3: Replica A reads event_B1.
Comparing event_B1's vc { B: 1 } to state_A's vc { A: 1 }:
→ concurrent
Invoke resolver:
merged_state = resolver.resolve(state_A, [event_A1, event_B1])
state_A = merged_state
state_A's vc = { A: 1, B: 1 } (the merge)
t4: Replica A persists event_A2 at vc { A: 2, B: 1 }
state_A = applyEvent(merged_state, event_A2)
t5: Replica B eventually sees the entire chain — its own,
A's pre-merge events, plus A's post-merge events.
Resolves any concurrent branches the same way.

Eventually, both replicas converge — given enough cross-replica visibility and a deterministic resolver.

Vector clocks do not give a total order. Two events at { A: 5 } and { B: 5 } are concurrent — neither “happened first.” The order in which they appear in the journal is incidental.

This is the whole point — replicas write independently; clocks track causality, not wall-clock time.

For a total order (rare), pair with a single-writer lease (see Single-writer lease).

Vector clocks serialize as plain JSON objects:

{
"_vc": { "A": 5, "B": 3, "C": 1 }
}

Zero-value components are typically omitted for compactness; the framework treats missing keys as 0.

Over time, retired replicas (decommissioned regions, removed nodes) leave entries in vector clocks:

vc { A: 100, B: 50, C: 25, RETIRED-D: 7 }

The retired replica’s component is irrelevant but takes up space. For long-running deployments with replica churn, the framework supports vector-clock pruning at snapshot time — when a replica is gone for long enough, its component can be safely dropped.

This is an advanced topic; the defaults are fine for most deployments.

You usually don’t. The framework manages them internally; your conflict resolver receives events with their vector clocks already compared — you just decide how to merge the state given the conflicting events.

For diagnostics:

override async onCommand(state, cmd) {
this.log.debug(`current vc: ${JSON.stringify(this.vectorClock.toData())}`);
await this.persist(event, () => {});
}

this.vectorClock is read-only access to the actor’s current clock. Useful for logging / debugging concurrent-write scenarios.