studyDDIAChapter 10 · Consistency and Consensus

A Lamport Timestamp Orders Two Concurrent Events; a Vector Clock Refuses

Run a Lamport clock and a vector clock over the same ten-event log. Two events come out L(e9) = 3 < 4 = L(e4) — begging the reading “e9 happened before e4” — but they were concurrent: no message path connects them, and the vectors [0,0,3] and [2,2,0] are incomparable. Then drop Lamport’s max-on-receive rule and even its one guarantee breaks: a message sent at timestamp 2 is received at timestamp 1, so the effect looks earlier than its cause.


Concept

A Lamport timestamp is one integer per event (plus a node id to break ties). It buys a total order, and that order is safe in exactly one direction: if a happened-before b, then L(a) < L(b). The reverse is a trap. Run a Lamport clock and a vector clock over the same event log and you find a pair of events e9 and e4 with L(e9) = 3 < 4 = L(e4) — which begs the reader to conclude “e9 happened before e4” — that were in fact concurrent: no message path connects them, neither causally precedes the other. The vector clock stamps them [0,0,3] and [2,2,0], neither dominates the other, and reports concurrent. Then watch the mechanism: strip Lamport’s max-on-receive rule and even the one-way guarantee collapses — a message received at e3 gets timestamp 1 though it was sent at e2 with timestamp 2, so the effect now looks earlier than its cause.

This exercise is the counterpart to Ch. 6’s version-vectors, which uses per-process counters to detect conflicting sibling writes. There the angle was conflict detection; here it is the total-order illusion — a single integer that reads like causality but isn’t — and the max-on-receive mechanism that a Lamport clock rests on.

Provenance

Designing Data-Intensive Applications, 2nd ed. (Kleppmann & Riccomini, 2025), Chapter 10 — Consistency and Consensus, §"Lamport timestamps / hybrid logical clocks versus vector clocks."

Lamport timestamps give a total order consistent with causality — a → b implies L(a) < L(b) — but from a Lamport timestamp alone you cannot tell whether two events are concurrent or causally ordered; only a vector clock (one entry per node) distinguishes the two.

Here you build both over one log and watch exactly that gap open.

Prerequisites

The surprise turns on a one-directional implication. Each line notes where the idea shows up in the run.

  1. Happens-before and concurrencya → b ("a happened-before b") holds if a is earlier on the same process, or a is a send and b its receive, or by transitivity. Two events are concurrent when neither a → b nor b → a. It shows up as e9 and e4: no chain of process-steps or messages links them. Concurrency is about the absence of a causal path, not wall-clock simultaneity. (carries the result)
  2. Lamport clock — total order, one-directional guarantee — one integer per event; on receive, L = max(local, message) + 1. It totally orders all events and guarantees a → b ⇒ L(a) < L(b). It shows up as the Lamport column. The one-way part is the whole trap. (carries the result)
  3. Vector clock — partial order, detects concurrency — one counter per process; V(a) ≤ V(b) componentwise iff a → b, so incomparable vectors mean concurrent. It shows up as the vector [P1,P2,P3] column and the dominates check.
  4. Max-on-receive — the receive rule takes the componentwise/scalar MAX with the incoming timestamp before incrementing. It shows up in the mechanism demo: drop it and a receive can be stamped below its send.
  5. The one-way implicationa → b ⇒ L(a) < L(b) is true; its converse L(a) < L(b) ⇒ a → b is false. It shows up as L(e9) < L(e4) on concurrent events. (carries the result)
Where to learn the prerequisites Happens-before, Lamport clocks, max-on-receive (#1, #2, #4): Lamport, Time, Clocks, and the Ordering of Events in a Distributed System (1978) — the source of the clock condition and the receive rule. Vector clocks (#3): Fidge (1988) and Mattern (1988); DDIA §"Detecting Concurrent Writes" and the companion version-vectors exercise, which applies the same partial order to sibling detection. The one-way implication (#5): DDIA §"Lamport timestamps" — a Lamport timestamp is consistent with causality but loses it. If only one is new, make it #2 + #5 together: Lamport is one-directional. The clock guarantees ordered-if-causal, never causal-if-ordered.
Environment these numbers came from Deterministic — the event log is fixed, so every timestamp reproduces exactly. Captured on macOS 26.5.2 (Darwin 25.5.0), arm64, Python 3.15.0a8, standard library only — no Docker. No real concurrency or network: three processes and their messages are one fixed list, replayed in a single causal order, so the stamps are identical every run.

Mental model

Both clocks watch the same ten events; only the stamp differs.

The event log (three processes, two messages):

P1:  e1 local --- e2 send(m1) ---------------------------------.
                       \                                        |
P2:                     e3 recv(m1) - e4 local - e5 local - e6 send(m2)
                                                                 \
P3:  e7 local - e8 local - e9 local ----------------------- e10 recv(m2)

There is no message path from P3’s local run (e7, e8, e9) to P2’s local run (e4, e5) — those events are genuinely concurrent. Everything is one script, code/lamport_vs_vector_clocks.py.

Setup

cd study/ddia/ch10
python3 code/lamport_vs_vector_clocks.py

Do not read the output yet — make each prediction first.


Step 1 · stamp every event with both clocks

Predict. Walk the log with both rules. Lamport: local/send do L += 1; recv does L = max(local, message) + 1. Vector: bump your own component, and on receive take the componentwise max first. P3 does three local events (e7, e8, e9) before receiving m2. What Lamport timestamp does e9 get? What vector?

What stamps do e9 and e4 get?
========================================================================== ONE event log, two clocks: Lamport timestamp vs vector clock ========================================================================== P1: e1 local, e2 send(m1) P2: e3 recv(m1), e4 local, e5 local, e6 send(m2) P3: e7 local, e8 local, e9 local, e10 recv(m2) (no message path from P3's e7/e8/e9 to P2's e4/e5) event proc kind Lamport vector [P1,P2,P3] ----- ---- --------- ------- ----------------- e1 P1 local 1 [1,0,0] e2 P1 send(m1) 2 [2,0,0] e3 P2 recv(m1) 3 [2,1,0] e4 P2 local 4 [2,2,0] e5 P2 local 5 [2,3,0] e6 P2 send(m2) 6 [2,4,0] e7 P3 local 1 [0,0,1] e8 P3 local 2 [0,0,2] e9 P3 local 3 [0,0,3] e10 P3 recv(m2) 7 [2,4,4]

e9 is P3’s third local event, so it climbs P3’s own counter to Lamport 3 and vector [0,0,3] — a stamp that knows nothing of P1 or P2. e4 sits on P2 at Lamport 4, vector [2,2,0] — it has already absorbed m1 from P1.

Step 2 · the pair Lamport puts in a false order

Predict. Compare e9 (Lamport 3) and e4 (Lamport 4). Lamport says 3 < 4. Is e9 actually happened-before e4? Check the vectors: does either [0,0,3] or [2,2,0] dominate the other componentwise?

Does L(e9) < L(e4) mean e9 happened before e4?
-------------------------------------------------------------------------- THE TRAP: a concurrent pair that Lamport puts in a total order -------------------------------------------------------------------------- e9: [0,0,3] Lamport = 3 (P3 local) e4: [2,2,0] Lamport = 4 (P2 local) Lamport says L(e9) = 3 < 4 = L(e4) -> tempting (WRONG) reading: 'e9 happened before e4'. Vector clock: [0,0,3] vs [2,2,0] e9 <= e4? no (e9[P3]=3 > 0=e4[P3]) e4 <= e9? no (e4[P1]=2 > 0=e9[P1]) neither dominates -> vectors report: CONCURRENT Verdict: 'e9 before e4' is FALSE. They are concurrent. Lamport's single integer cannot tell 'earlier' from 'concurrent'; the vector can.

L(e9) < L(e4) is real, but it does not mean e9 → e4. e9 knows P3 did 3 steps ([_,_,3]) and nothing of P2; e4 knows P2 did 2 steps and saw P1 ([2,2,_]) and nothing of P3. Each vector has a component the other lacks → neither happened-before the other → concurrent. Lamport’s 3 < 4 is a tie-break, not a causal fact.

Step 3 · the causal pair where both clocks agree

Predict. Now take e2 (send m1) and e3 (receive m1). A send always happens-before its receive, so this pair is causal. Will Lamport order them the same way, and will the vector for e3 dominate the vector for e2?

On a genuinely causal pair, do both clocks agree?
-------------------------------------------------------------------------- THE CONTRAST: a causal pair (m1 send -> recv) where BOTH agree -------------------------------------------------------------------------- e2: [2,0,0] Lamport = 2 (send m1) e3: [2,1,0] Lamport = 3 (recv m1) Lamport: L(e2) = 2 < 3 = L(e3) -> ordered e2 before e3 Vector: [2,1,0] dominates [2,0,0] -> e2 -> e3 (causal) Here the total order is RIGHT: e2 really did happen-before e3. (Lamport is safe one way: a -> b => L(a) < L(b). The trap above is the converse, which does not hold.)

When the pair really is causal, both agree — L(e2) < L(e3) and [2,1,0] dominates [2,0,0]. That is the one-way guarantee working: a → b ⇒ L(a) < L(b). The trap in Step 2 was the converse, L(a) < L(b) ⇒ a → b, which is false.

Step 4 · drop max-on-receive and even the one-way rule breaks

Predict. Recompute Lamport with the receive rule changed to a plain L += 1 — the receiver ignores the message’s timestamp. Message m1 is sent at e2 (Lamport 2) and delivered to e3, which is P2’s first event. Without max-on-receive, what timestamp does e3 get — and is L(e2) < L(e3) still true?

Without max-on-receive, is the receive still after its send?
-------------------------------------------------------------------------- THE MECHANISM: why max-on-receive is the minimum for even the one-way rule -------------------------------------------------------------------------- Message m1: sent at e2 (on P1), received at e3 (on P2). Since the send causes the receive, e2 -> e3, so the one-way guarantee REQUIRES L(e2) < L(e3). BROKEN rule (recv just does L += 1, ignoring the message): L(e2) = 2 L(e3) = 1 -> 2 >= 1 VIOLATION: receive <= send P2's first event e3 is stamped 1, but the message it delivered was sent at Lamport 2. The effect looks EARLIER than its cause -- the one thing Lamport promises is broken. FIXED rule (recv does L = max(L, msg) + 1): L(e2) = 2 L(e3) = 3 -> 2 < 3 ok: receive > send, guarantee restored max-on-receive drags the receiver's counter past the message's timestamp, so no receive can ever undercut its send.

Without max-on-receive, P2’s counter knows only its own history — one step — so e3 gets 1, though the message it delivered was sent at 2. Now L(e2) = 2 > 1 = L(e3): the receive is stamped below its send, so the effect reads as earlier than its cause. max(local, message) + 1 is the minimum fix — it forces the receiver past the message’s timestamp — and e3 becomes 3 > 2.


What you should see

Why

Start from the guarantee Lamport actually gives. Every event advances a counter, and a receive takes max(local, message) + 1, so any event on the causal path from a to b — same-process successors, and receives of a’s messages — is forced strictly higher than a. By transitivity a → b ⇒ L(a) < L(b). That is real and it is all Lamport promises.

The converse fails because the guarantee is a projection that throws away information. A single integer is a high-water mark: it records the length of the longest causal chain leading to this event, and nothing about which processes contributed to that chain. e9’s counter reached 3 purely from P3’s own local steps; e4’s reached 4 from P2’s steps plus the m1 it absorbed from P1. Two different chains, two different lengths — and 3 < 4 compares the lengths, not the causality. When two events lie on unrelated chains (no message ever flowed between their branches), Lamport still hands back two numbers, and one is smaller. The order is consistent with causality (it never contradicts a real a → b) but not faithful to it (it invents an order where none exists).

A vector clock keeps exactly the discarded piece: one component per process, so the stamp says how much of each process’s history this event has seen. a → b holds iff b has seen at least as much of every process as a — i.e. V(a) ≤ V(b) in all components. Concurrency is then visible directly: if a has seen more of some process than b, and b more of another, then a causal path cannot exist in either direction (a path would have carried the counts along), so the vectors are incomparable and the events are concurrent. That is why the vector can detect the absence of a causal path and the integer cannot — the integer summed the per-process knowledge into one number and lost the ability to ask "did b know everything a knew?"

The mechanism demo isolates why max-on-receive is not optional. The only way a send can influence the receiver’s counter is for the receive to read the message’s timestamp; a bare L += 1 leaves the receiver advancing on its own local history alone. If that history is shorter than the sender’s — and P2’s first event has history length 0 — the receive lands below the send, and the lone guarantee a → b ⇒ L(a) < L(b) is gone. max(local, message) is the minimum coupling: it pulls the receiver up to at least the message’s height before the +1, so no receive can ever undercut its send.

The boundary — when the integer is enough You need vectors only when you must detect concurrency or conflicts. If all you need is some total order everyone agrees on — a tie-break key, a deterministic sort, total-order broadcast where every node must deliver messages in the same sequence — a Lamport timestamp (integer + node id) is exactly right and far cheaper than an O(nodes) vector. The catch it hides is precisely the one this exercise exposes: that agreed order will silently linearize events that were concurrent, so never read L(a) < L(b) back as "a caused b." Reach for a vector clock the moment the question is "were these concurrent?" — sibling detection, conflict resolution, causal consistency — and for a plain Lamport clock when the question is only "give me one order we all share."

Go deeper

Sources: DDIA 2e, Ch. 10, §"Lamport timestamps / hybrid logical clocks versus vector clocks" · Lamport, "Time, Clocks, and the Ordering of Events in a Distributed System" (1978) · Fidge (1988), Mattern (1988) on vector clocks.