In a program running on a single machine, the question "what happened first?" always has an answer: you just look at the clock or at the order of the instructions. In a distributed system, that question becomes surprisingly hard. Each node has its own clock, those clocks never agree exactly, and messages take a variable amount of time to arrive. When Anna, in Barcelona, and Mark, in Valencia, press "buy" on the last unit of aged cheese from Montblanc Dairy, deciding who was first is not a matter of looking at two timestamps.
This lesson explains why there is no global clock and how far physical clocks can be synchronised (NTP, PTP). Above all, it presents the solution Leslie Lamport proposed in 1978, which is still fundamental today: replacing physical time with a logical order based on causality. We will implement Lamport clocks and vector clocks step by step in Python, see how they detect concurrent events, and finish with an overview of hybrid clocks and TrueTime, which combine the best of both worlds.
Contents
- Why there is no global clock
- Physical clocks: drift and synchronisation (NTP and PTP)
- The problem of ordering events: Anna, Mark and the last cheese
- The "happens-before" relation
- Lamport logical clocks
- Vector clocks
- Hybrid clocks and TrueTime: an overview
- Logical clocks in practice
- Common mistakes and tips
- Exercises
- Conclusion
- Why there is no global clock
A "global clock" would be a shared, exact instant in time that every node could consult. It does not exist, for three reasons that compound one another:
- Each node has its own hardware clock, a quartz oscillator whose frequency depends on temperature, age and manufacturing. Two identical clocks, set at the same moment, inevitably drift apart.
- Reading another node's clock takes time, and that time is variable and unknown. If you ask "what time is it?" and the answer takes 30 ms to arrive, is the time you were given from 15 ms ago? From 5? From 25? You do not know.
- Nodes pause without knowing it. A process can stop for tens or hundreds of milliseconds because of a garbage collection, a virtual machine interruption or CPU overload. When it resumes, it believes that "now" is the instant at which it stopped.
The practical consequence: two timestamps taken on different machines cannot be compared with a precision better than the synchronisation error between them. If that error is 10 ms and the timestamps differ by 2 ms, the real order is undetermined.
- Physical clocks: drift and synchronisation (NTP and PTP)
Drift
Drift is the rate at which a clock deviates from real time. A typical server quartz crystal has a drift of about 50 parts per million (ppm): it deviates by 50 microseconds every second, that is, about 4.3 seconds a day. It does not sound like much, but for a system processing hundreds of orders per second, 4 seconds is an eternity. That is why clocks are periodically synchronised with a reference source.
NTP (Network Time Protocol)
NTP is the protocol almost every operating system uses to set its clock over the Internet. It works as a hierarchy of "strata": stratum 0 servers are atomic clocks or GPS receivers; stratum 1 servers synchronise directly with them; stratum 2 servers with stratum 1, and so on. An NTP client sends a request, notes when it sent it and when it received the response, and uses the server's timestamps to estimate the offset, assuming that the outbound trip takes as long as the return trip. That assumption is the main source of error.
| Environment | Typical NTP accuracy |
|---|---|
| Public Internet | 5 to 100 ms |
| Well-configured local network | 0.5 to 5 ms |
| With a local GPS server | 0.1 to 1 ms |
Furthermore, NTP adjusts the clock in two ways: by slewing, slightly speeding up or slowing down the clock until the offset is corrected, or by stepping, if the offset is large. A step backwards means that the clock may show the same instant twice, or that a duration measurement (end - start) may come out negative. For this reason operating systems offer a monotonic clock (time.monotonic() in Python), which never goes backwards and is used for measuring durations, as opposed to the wall clock (time.time()), which tells you the date and time but can jump.
PTP (Precision Time Protocol)
PTP (IEEE 1588) achieves accuracies of microseconds or better because the timestamps are applied in hardware by the network cards and switches themselves, removing the variability of software. It requires compatible hardware along the whole path, so it is used in data centres, high-frequency finance and telecommunications, not on the open Internet.
Even with PTP, clocks have an error. And that error, however small, is enough for two "almost simultaneous" events to be impossible to order with certainty. We need a different idea.
- The problem of ordering events: Anna, Mark and the last cheese
Suppose Kilometre Zero has replicated the inventory service in two cities to reduce latency: one replica in Barcelona (inv-bcn) and another in Valencia (inv-vlc). There is one unit of aged cheese from Montblanc Dairy left. Anna buys from Barcelona and Mark from Valencia, almost at the same time.
| Event | Node | Time according to the local clock |
|---|---|---|
| Anna presses "buy" | inv-bcn |
10:00:00.120 |
| Mark presses "buy" | inv-vlc |
10:00:00.118 |
If we trust the clocks, Mark was 2 ms earlier. But the clock on inv-vlc is synchronised via NTP with an estimated error of ±15 ms. So Mark's "true" time lies between 10:00:00.103 and 10:00:00.133: it may have been before or after Anna. Physical clocks cannot decide.
And now the key question: does "who was first" in physical time really matter? What the system needs is for both replicas to make the same decision (one of the two purchases wins, the other gets "out of stock") and for that decision to respect the cause-and-effect relationships we do know about. For example, if Anna checked the stock, saw "1 unit" and then bought, her purchase happened after her check, and that much is a fact.
This is Lamport's idea: give up on physical time and keep only what we can know for certain, causality.
- The "happens-before" relation
Lamport defined the happens-before relation (written a → b, "a happened before b") from just three rules:
- Same process. If
aandbare events in the same process andaoccurs beforebin its execution, thena → b. - Send and receive. If
ais the sending of a message andbis the receipt of that same message, thena → b. - Transitivity. If
a → bandb → c, thena → c.
And one fundamental definition: if neither a → b nor b → a, then a and b are concurrent (a ∥ b). Concurrent does not mean "at the same time": it means that neither could have influenced the other, because there is no chain of messages connecting them. That is why, as far as the system is concerned, their order is irrelevant: any order is equally valid, as long as all nodes choose the same one.
sequenceDiagram
participant A as inv-bcn (Anna)
participant D as Montblanc Dairy
participant M as inv-vlc (Mark)
D->>A: a1: stock = 1 (message)
D->>M: m1: stock = 1 (message)
Note over A: a2: Anna checks stock
Note over M: m2: Mark checks stock
Note over A: a3: Anna buys
Note over M: m3: Mark buys
A->>M: a4: "I have sold the unit"
Note over M: m4: receives notice from Barcelona
In this diagram: a2 → a3 (same process), a3 → a4 → m4 (send and receive, plus transitivity). But a3 and m3 (the two purchases) are concurrent: there is no chain of messages between them. The system cannot know which was "really" first, and it does not need to: what it needs is a deterministic rule to break the tie.
- Lamport logical clocks
A Lamport logical clock is one integer counter per process that assigns each event a stamp L(e) such that the clock condition holds: if a → b, then L(a) < L(b). The algorithm has three rules, mirroring the three happens-before rules:
- Before each local event, the process increments its counter:
L = L + 1. - When sending a message, the process increments its counter and attaches the value to the message.
- When receiving a message with stamp
Lm, the process setsL = max(L, Lm) + 1.
Rule 3 is the key: it guarantees that the receipt always has a higher stamp than the send, and that from that moment on the receiver "knows" that at least that logical time exists.
Implementation in Python
from dataclasses import dataclass, field
@dataclass
class Message:
source: str
content: str
stamp: int # the sender's Lamport clock at the time of sending
@dataclass
class LamportProcess:
"""A node with its Lamport logical clock and an event log."""
name: str
clock: int = 0
log: list = field(default_factory=list)
def _record(self, description: str) -> None:
self.log.append((self.clock, self.name, description))
print(f" [{self.name} L={self.clock:>2}] {description}")
def local_event(self, description: str) -> None:
# Rule 1: increment before the event
self.clock += 1
self._record(description)
def send(self, content: str) -> Message:
# Rule 2: increment and attach the stamp to the message
self.clock += 1
self._record(f"sends '{content}'")
return Message(self.name, content, self.clock)
def receive(self, msg: Message) -> None:
# Rule 3: move the clock forward if the sender was ahead, then advance by one
self.clock = max(self.clock, msg.stamp) + 1
self._record(f"receives '{msg.content}' from {msg.source} (stamp {msg.stamp})")
if __name__ == "__main__":
bcn = LamportProcess("inv-bcn")
vlc = LamportProcess("inv-vlc")
dairy = LamportProcess("dairy")
print("Montblanc Dairy publishes the stock:")
m_bcn = dairy.send("aged cheese stock = 1")
m_vlc = dairy.send("aged cheese stock = 1")
bcn.receive(m_bcn)
vlc.receive(m_vlc)
print("\nAnna (Barcelona) and Mark (Valencia) act concurrently:")
bcn.local_event("Anna checks stock: sees 1 unit")
bcn.local_event("Anna buys the unit")
vlc.local_event("Mark checks stock: sees 1 unit")
vlc.local_event("Mark buys the unit")
print("\nBarcelona notifies Valencia of the sale:")
notice = bcn.send("unit of aged cheese sold")
vlc.receive(notice)
vlc.local_event("detects conflict with Mark's purchase")
print("\nTotal order of events (stamp, process name):")
everything = sorted(bcn.log + vlc.log + dairy.log)
for stamp, name, desc in everything:
print(f" {stamp:>2} {name:<9} {desc}")The output:
Montblanc Dairy publishes the stock: [dairy L= 1] sends 'aged cheese stock = 1' [dairy L= 2] sends 'aged cheese stock = 1' [inv-bcn L= 2] receives 'aged cheese stock = 1' from dairy (stamp 1) [inv-vlc L= 3] receives 'aged cheese stock = 1' from dairy (stamp 2) Anna (Barcelona) and Mark (Valencia) act concurrently: [inv-bcn L= 3] Anna checks stock: sees 1 unit [inv-bcn L= 4] Anna buys the unit [inv-vlc L= 4] Mark checks stock: sees 1 unit [inv-vlc L= 5] Mark buys the unit Barcelona notifies Valencia of the sale: [inv-bcn L= 5] sends 'unit of aged cheese sold' [inv-vlc L= 6] receives 'unit of aged cheese sold' from inv-bcn (stamp 5) [inv-vlc L= 7] detects conflict with Mark's purchase Total order of events (stamp, process name): 1 dairy sends 'aged cheese stock = 1' 2 dairy sends 'aged cheese stock = 1' 2 inv-bcn receives 'aged cheese stock = 1' from dairy (stamp 1) 3 inv-bcn Anna checks stock: sees 1 unit 3 inv-vlc receives 'aged cheese stock = 1' from dairy (stamp 2) 4 inv-bcn Anna buys the unit 4 inv-vlc Mark checks stock: sees 1 unit 5 inv-bcn sends 'unit of aged cheese sold' 5 inv-vlc Mark buys the unit 6 inv-vlc receives 'unit of aged cheese sold' from inv-bcn (stamp 5) 7 inv-vlc detects conflict with Mark's purchase
The same scenario, drawn as a sequence diagram with the Lamport stamp of each event:
sequenceDiagram
participant D as dairy
participant A as inv-bcn
participant M as inv-vlc
Note over D: L=1 sends stock=1
D->>A: stamp 1
Note over D: L=2 sends stock=1
D->>M: stamp 2
Note over A: L=2 receives (max(0,1)+1)
Note over M: L=3 receives (max(0,2)+1)
Note over A: L=3 Anna checks
Note over A: L=4 Anna buys
Note over M: L=4 Mark checks
Note over M: L=5 Mark buys
Note over A: L=5 sends "sold"
A->>M: stamp 5
Note over M: L=6 receives (max(5,5)+1)
Note over M: L=7 detects conflict
Important observations:
- The clock condition holds. Every receipt has a higher stamp than its send (1 → 2, 2 → 3, 5 → 6), and within each process the stamps increase. Every causal chain has increasing stamps.
- There are ties. "Anna buys" (
inv-bcn, 4) and "Mark checks" (inv-vlc, 4) have the same stamp. Lamport resolves ties with an arbitrary but deterministic criterion: if the stamps are equal, order by the name (or identifier) of the process. That is whatsorted(...)does with the(stamp, name, description)tuples. This way, every node that applies the same rule will obtain the same total order. - The total order is not "the real order", and it does not matter. In the final order, "Anna buys" (4) comes before "Mark buys" (5). Was that the case in physical time? We do not know, and we cannot know. But it is an order consistent with all the known causality and it is the same for everyone, which is exactly what we needed to decide who gets the cheese.
- The limitation of Lamport clocks. If
a → b, thenL(a) < L(b). But the converse is false:L(a) < L(b)does not implya → b. "Anna buys" (4) has a lower stamp than "Mark buys" (5), yet they are concurrent events: there is no causal relationship between them. By looking at Lamport stamps alone we cannot tell "happened before" from "concurrent". For that we need vector clocks.
- Vector clocks
A vector clock replaces the single integer with a vector holding one counter per process. If there are three processes, each event carries a stamp such as {inv-bcn: 3, inv-vlc: 1, dairy: 2}, which reads as "this event knows about everything up to Barcelona's event 3, Valencia's event 1 and the dairy's event 2". The rules:
- Before each local event, process
iincrements its own component:V[i] = V[i] + 1. - When sending, it increments its component and attaches the whole vector to the message.
- When receiving a message with vector
Vm, it setsV[k] = max(V[k], Vm[k])for every componentk, and then increments its own component.
And the comparison, which is what we gain:
V(a) ≤ V(b)if all the components ofV(a)are less than or equal to those ofV(b).a → bif and only ifV(a) ≤ V(b)andV(a) ≠ V(b).a ∥ b(concurrent) if neitherV(a) ≤ V(b)norV(b) ≤ V(a): that is, each has some component greater than the other's.
Now the converse does hold: by comparing vectors we can know for certain whether two events are causally related or concurrent.
Implementation in Python
from dataclasses import dataclass, field
@dataclass(frozen=True)
class VectorStamp:
"""An immutable vector of counters, with the comparison operations."""
values: tuple # a tuple of integers, one per process, in a fixed order
processes: tuple # the process names, in the same order
def __le__(self, other: "VectorStamp") -> bool:
return all(a <= b for a, b in zip(self.values, other.values))
def happened_before(self, other: "VectorStamp") -> bool:
return self <= other and self.values != other.values
def concurrent(self, other: "VectorStamp") -> bool:
return not (self <= other) and not (other <= self)
def __str__(self) -> str:
return "{" + ", ".join(f"{p}:{v}" for p, v in zip(self.processes, self.values)) + "}"
@dataclass
class VMessage:
source: str
content: str
stamp: VectorStamp
class VectorProcess:
"""A node with a vector clock. Every process must know the full list."""
def __init__(self, name: str, processes: list[str]):
self.name = name
self.processes = tuple(processes)
self.index = processes.index(name) # my position in the vector
self.vector = [0] * len(processes)
self.events: dict[str, VectorStamp] = {} # label -> stamp of the event
def _current_stamp(self) -> VectorStamp:
return VectorStamp(tuple(self.vector), self.processes)
def _record(self, label: str, description: str) -> VectorStamp:
stamp = self._current_stamp()
self.events[label] = stamp
print(f" [{self.name} {stamp}] {label}: {description}")
return stamp
def local_event(self, label: str, description: str) -> None:
self.vector[self.index] += 1 # rule 1
self._record(label, description)
def send(self, label: str, content: str) -> VMessage:
self.vector[self.index] += 1 # rule 2
stamp = self._record(label, f"sends '{content}'")
return VMessage(self.name, content, stamp)
def receive(self, label: str, msg: VMessage) -> None:
# rule 3: component-wise maximum, then advance our own component
self.vector = [max(mine, theirs) for mine, theirs in zip(self.vector, msg.stamp.values)]
self.vector[self.index] += 1
self._record(label, f"receives '{msg.content}' from {msg.source}")
if __name__ == "__main__":
names = ["inv-bcn", "inv-vlc", "dairy"]
bcn = VectorProcess("inv-bcn", names)
vlc = VectorProcess("inv-vlc", names)
dairy = VectorProcess("dairy", names)
print("Montblanc Dairy publishes the stock:")
m1 = dairy.send("d1", "aged cheese stock = 1")
m2 = dairy.send("d2", "aged cheese stock = 1")
bcn.receive("a1", m1)
vlc.receive("m1", m2)
print("\nAnna and Mark act concurrently:")
bcn.local_event("a2", "Anna checks stock: sees 1 unit")
bcn.local_event("a3", "Anna buys the unit")
vlc.local_event("m2", "Mark checks stock: sees 1 unit")
vlc.local_event("m3", "Mark buys the unit")
print("\nBarcelona notifies Valencia:")
notice = bcn.send("a4", "unit of aged cheese sold")
vlc.receive("m4", notice)
print("\nCausal relationships:")
events = {**bcn.events, **vlc.events, **dairy.events}
for x, y in [("a2", "a3"), ("a3", "m4"), ("a3", "m3"), ("m3", "a3"), ("d1", "m3")]:
vx, vy = events[x], events[y]
if vx.happened_before(vy):
relation = f"{x} -> {y} (happened before)"
elif vy.happened_before(vx):
relation = f"{y} -> {x} (happened before)"
else:
relation = f"{x} || {y} (CONCURRENT)"
print(f" {x}={vx} {y}={vy} => {relation}")The output:
Montblanc Dairy publishes the stock:
[dairy {inv-bcn:0, inv-vlc:0, dairy:1}] d1: sends 'aged cheese stock = 1'
[dairy {inv-bcn:0, inv-vlc:0, dairy:2}] d2: sends 'aged cheese stock = 1'
[inv-bcn {inv-bcn:1, inv-vlc:0, dairy:1}] a1: receives 'aged cheese stock = 1' from dairy
[inv-vlc {inv-bcn:0, inv-vlc:1, dairy:2}] m1: receives 'aged cheese stock = 1' from dairy
Anna and Mark act concurrently:
[inv-bcn {inv-bcn:2, inv-vlc:0, dairy:1}] a2: Anna checks stock: sees 1 unit
[inv-bcn {inv-bcn:3, inv-vlc:0, dairy:1}] a3: Anna buys the unit
[inv-vlc {inv-bcn:0, inv-vlc:2, dairy:2}] m2: Mark checks stock: sees 1 unit
[inv-vlc {inv-bcn:0, inv-vlc:3, dairy:2}] m3: Mark buys the unit
Barcelona notifies Valencia:
[inv-bcn {inv-bcn:4, inv-vlc:0, dairy:1}] a4: sends 'unit of aged cheese sold'
[inv-vlc {inv-bcn:4, inv-vlc:4, dairy:2}] m4: receives 'unit of aged cheese sold' from inv-bcn
Causal relationships:
a2={inv-bcn:2, inv-vlc:0, dairy:1} a3={inv-bcn:3, inv-vlc:0, dairy:1} => a2 -> a3 (happened before)
a3={inv-bcn:3, inv-vlc:0, dairy:1} m4={inv-bcn:4, inv-vlc:4, dairy:2} => a3 -> m4 (happened before)
a3={inv-bcn:3, inv-vlc:0, dairy:1} m3={inv-bcn:0, inv-vlc:3, dairy:2} => a3 || m3 (CONCURRENT)
m3={inv-bcn:0, inv-vlc:3, dairy:2} a3={inv-bcn:3, inv-vlc:0, dairy:1} => m3 || a3 (CONCURRENT)
d1={inv-bcn:0, inv-vlc:0, dairy:1} m3={inv-bcn:0, inv-vlc:3, dairy:2} => d1 -> m3 (happened before)Let's look at what we have gained:
- We detect concurrency.
a3(Anna buys) hasinv-bcn:3, greater than inm3, butm3(Mark buys) hasinv-vlc:3, greater than ina3. Each one "knows something" the other does not: they are concurrent, and the system can detect it mechanically. With Lamport clocks, this was impossible. - We confirm causality.
a3 → m4: when Valencia receives the notice from Barcelona, its vector absorbs Barcelona's (inv-bcn:4), and from then on everything that happens in Valencia "knows" about Anna's purchase. The vector ofm4dominates that ofa3in every component. - A subtle detail:
d1 → m3. The dairy sentd1to Barcelona, not to Valencia, so how comem3"knows" aboutd1? Becaused2happened afterd1at the dairy (same component: 2 > 1), andm1receivedd2. Transitivity propagates by itself through the vector. - The price. Each stamp takes up as many integers as there are processes. With 3 processes that is trivial; with 1,000 replicas or millions of clients, it is not. In practice, systems use vectors containing only the nodes that write (not the clients), or compacted variants (dotted version vectors), or they accept Lamport's loss of information when there is no need to detect concurrency.
- Hybrid clocks and TrueTime: an overview
Logical clocks solve causal ordering, but they lose something valuable: the link with real time. A Lamport stamp of 4732 says nothing about whether the event happened this morning or last year, and many uses (auditing, expiry, "give me the orders from the last hour") need physical time. Two families of solutions combine both worlds:
- Hybrid logical clocks (HLC, 2014). An HLC stamp has two parts: the physical time (according to NTP) and a logical counter. It behaves like a Lamport clock (it respects causality: if
a → b, thenHLC(a) < HLC(b)), but its physical part always stays close to real time (with an error bounded by NTP's). The logical counter only comes into play to break ties between events that physical time cannot order. They are used by distributed databases such as CockroachDB and MongoDB. - TrueTime (Google Spanner, 2012). Instead of giving one instant, the TrueTime API returns an interval
[earliest, latest]that is guaranteed to contain the real instant, thanks to atomic clocks and GPS in every data centre, which keep the interval down to a few milliseconds. Spanner assigns each transaction a timestamp and, before committing it, waits until the uncertainty interval has completely passed (commit wait). This guarantees that if transaction A finished before transaction B started in real time, then A's timestamp is lower than B's, anywhere in the world. It is an expensive solution (specialised hardware) but a conceptually elegant one: it turns clock uncertainty into an explicit, bounded wait.
| Mechanism | Orders by causality | Detects concurrency | Link with real time | Stamp size | Requirements |
|---|---|---|---|---|---|
| Physical clock (NTP) | No (with errors) | No | Yes (±ms) | 1 integer | None |
| Lamport | Yes | No | None | 1 integer | None |
| Vector | Yes | Yes | None | N integers | Knowing the N processes |
| HLC | Yes | No | Yes (±NTP error) | 2 integers | NTP |
| TrueTime | Yes (with a wait) | Implicit | Yes (guaranteed interval) | Interval | Atomic clocks/GPS |
- Logical clocks in practice
Where do these mechanisms show up in real systems, and at Kilometre Zero?
- Ordering messages and events. When the
deliveryservice receives positions from couriers over 4G, they may arrive out of order. If each position carries a per-courier counter (a single-process Lamport clock),deliverycan discard old positions that arrive late. Messaging systems (lesson 02-04) offer ordering guarantees based on the same idea of sequence numbers. - Conflict detection in replication. When two replicas of
inventory(or two copies of Anna's basket, one on her phone and one on the server) are modified concurrently, version vectors make it possible to tell a genuine conflict (two concurrent writes) from a simple update (one write that happened after the other). The system can then resolve the conflict (with a business rule, by asking the user, or by keeping both versions). This is the mechanism popularised by Amazon Dynamo, which we will study in lesson 03-04. - Consistent snapshots. For
analyticsto calculate "the total stock at 12:00" over data spread across many replicas without stopping the system, it needs to know which events to include. Logical clocks make it possible to define consistent cuts (the Chandy-Lamport algorithm) that never include an effect without its cause. - Transaction timestamps. Distributed databases assign timestamps (HLC, TrueTime) to transactions to decide which version of a piece of data each read sees. This will come up in Module 3 and in lesson 04-04.
The practical rule that follows from all this: never use physical timestamps taken on different machines to decide the order of operations that affect data consistency. Use them for what they are good for (dates for humans, expiry, metrics) and use counters or vectors for ordering.
Common Mistakes and Tips
- Using
time.time()to measure durations. The wall clock can jump backwards because of an NTP adjustment and give negative or absurd durations. To measure how long something takes, always usetime.monotonic(). - Ordering events from different nodes by their physical timestamp. This is the clock-based last-writer-wins mistake: with out-of-sync clocks, an older write can "beat" a more recent one and silently lose data. If you use that strategy, you need to be aware that it can lose writes.
- Believing that Lamport clocks detect concurrency.
L(a) < L(b)tells you nothing about the causal relationship betweenaandb. If you need to know whether two events are concurrent, you need vectors. - Forgetting the deterministic tie-break. A Lamport total order requires a rule for ties (usually the process identifier). Without it, two nodes may order two events with the same stamp differently.
- Vectors that grow out of control. If every client that writes adds a component to the vector, the vector grows without bound. You have to decide who "counts" as a process (normally the replicas, not the clients) and prune old components.
- Tip: in every message or record that crosses the network, always include two things: a physical timestamp (for humans and for metrics) and a sequence number or vector (for ordering). They cost a few bytes and save hours of debugging.
Exercises
Exercise 1: Tracing Lamport clocks by hand
Three processes, orders, payments and analytics, start with their clocks at 0. The following events occur, in this order of execution:
orderslocal event "creates Lucy's order".orderssends "charge €14.50" topayments.analyticslocal event "starts report".paymentsreceives the message fromorders.paymentslocal event "charge accepted".paymentssends "charge OK" toordersand also toanalytics(two consecutive sends).analyticsreceives "charge OK".ordersreceives "charge OK".
Work out the Lamport stamp of each event and name two events that are concurrent even though their stamps are different.
Exercise 2: Detecting conflicts in the basket
Anna modifies her basket from her phone (process mobile) and from the browser (process web), and both sync with the server (process server). Using the VectorProcess class, simulate:
serversends the initial basket tomobileand toweb(two sends).mobilereceives it, and adds "fresh cheese" (local event).webreceives it, and adds "red wine" (local event).mobilesends its basket to theserver; theserverreceives it.websends its basket to theserver; theserverreceives it.
Use concurrent() to check whether the two modifications are concurrent and explain what the server should do. Then change the order so that web receives the basket from the server after the server has merged in the one from mobile, and check that the web modification now happens after the mobile one.
Exercise 3: A Lamport clock for courier positions
The delivery service receives positions from a courier over a network that reorders them. Write a CourierTracker class with a receive_position(sequence, lat, lon) method that only updates the current position if sequence is greater than the last one applied, and that counts how many positions it has discarded as stale. Simulate the arrival of the sequences [1, 2, 4, 3, 5, 7, 6, 8] and show the final position and the number of discards. What information is lost with this strategy, and when would that be acceptable?
Solutions
Solution 1:
| Step | Process | Event | Calculation | Stamp |
|---|---|---|---|---|
| 1 | orders | creates order | 0 + 1 | 1 |
| 2 | orders | sends "charge" | 1 + 1 | 2 |
| 3 | analytics | starts report | 0 + 1 | 1 |
| 4 | payments | receives "charge" (stamp 2) | max(0, 2) + 1 | 3 |
| 5 | payments | charge accepted | 3 + 1 | 4 |
| 6a | payments | sends "charge OK" to orders | 4 + 1 | 5 |
| 6b | payments | sends "charge OK" to analytics | 5 + 1 | 6 |
| 7 | analytics | receives "charge OK" (stamp 6) | max(1, 6) + 1 | 7 |
| 8 | orders | receives "charge OK" (stamp 5) | max(2, 5) + 1 | 6 |
Concurrent events with different stamps: "starts report" (analytics, 1) and "charge accepted" (payments, 4). There is no chain of messages between them (analytics had not received anything yet), so they are concurrent, even though 1 < 4. Another pair: "creates order" (1) and "starts report" (1), with equal stamps and also concurrent. Notice that "starts report" (1) and "receives charge OK" on orders (6) are concurrent as well: the 6 on orders descends from payments, not from analytics.
Solution 2:
names = ["server", "mobile", "web"]
server = VectorProcess("server", names)
mobile = VectorProcess("mobile", names)
web = VectorProcess("web", names)
c1 = server.send("s1", "initial basket")
c2 = server.send("s2", "initial basket")
mobile.receive("mb1", c1)
mobile.local_event("mb2", "adds fresh cheese")
web.receive("w1", c2)
web.local_event("w2", "adds red wine")
server.receive("s3", mobile.send("mb3", "basket with cheese"))
server.receive("s4", web.send("w3", "basket with wine"))
mb2, w2 = mobile.events["mb2"], web.events["w2"]
print(mb2, w2, "concurrent:", mb2.concurrent(w2))The result is {server:1, mobile:2, web:0} versus {server:2, mobile:0, web:2}: concurrent. Neither modification knew about the other. The server must not simply keep whichever arrived last (it would lose the cheese or the wine): it must merge the two (a basket with cheese and wine) or, if the merge is not obvious (for example, both changed the quantity of the same product), ask Anna.
In the sequential variant (the server first merges in the basket from mobile and then sends the updated basket to web, which then adds the wine), the stamp of w2 will be something like {server:3, mobile:3, web:2}, which dominates mb2: mb2.happened_before(w2) is True. There is no conflict: the web modification already knew about the mobile one, and the server can simply apply it.
Solution 3:
class CourierTracker:
def __init__(self, name: str):
self.name = name
self.last_sequence = 0
self.position = None
self.discarded = 0
def receive_position(self, sequence: int, lat: float, lon: float) -> None:
if sequence <= self.last_sequence:
self.discarded += 1
print(f" discarded seq {sequence} (already applied {self.last_sequence})")
return
self.last_sequence = sequence
self.position = (lat, lon)
print(f" applied seq {sequence}: {self.position}")
tracker = CourierTracker("van-3")
arrivals = [1, 2, 4, 3, 5, 7, 6, 8]
for seq in arrivals:
tracker.receive_position(seq, round(41.38 + seq * 0.001, 3), round(2.17 + seq * 0.001, 3))
print(f"Final position: {tracker.position}, discarded: {tracker.discarded}")The final position is the one from sequence 8, and 2 positions are discarded (3 and 6). What is lost is the complete route: if analytics wanted to reconstruct the exact route, two points would be missing. The strategy is acceptable when only the current position matters (showing the courier on the map), which is the case for real-time tracking; if the history is needed, all positions would have to be stored and sorted by sequence afterwards, instead of being discarded.
Conclusion
There is no global clock: each node has its own, with its own drift, and synchronising them (NTP with millisecond accuracy, PTP with microsecond accuracy) reduces the error but never eliminates it. That is why the order of two events that occurred on different machines cannot be decided by comparing their physical timestamps, as the case of Anna and Mark buying the last cheese shows.
Lamport's way out was to change the question: instead of "what occurred earlier in time?", ask "what could have caused what?". The happens-before relation captures exactly that causality, Lamport clocks turn it into a total order that all nodes share (at the cost of not distinguishing concurrency), and vector clocks add the ability to detect when two events are concurrent, which is the basis of conflict detection in replication. Hybrid clocks and TrueTime reconcile causality with real time for the systems that need both.
This brings the conceptual foundations of the module to a close. The next lesson, From Monolith to Distributed Platform: the Kilometre Zero Case, gathers everything we have seen (partial failures, models, advantages and costs, fallacies, time) and applies it to a concrete design: the target architecture we will build over the rest of the course.
Distributed Architectures Course
Module 1: Introduction to Distributed Systems
- Basic Concepts of Distributed Systems
- Distributed System Models
- Advantages and Challenges of Distributed Systems
- The Fallacies of Distributed Computing
- Time, Clocks and Event Ordering
- From Monolith to Distributed Platform: the Kilometre Zero Case
Module 2: Communication in Distributed Systems
- Communication Protocols
- RPC and RMI
- gRPC and Data Serialization
- Messaging and Message Queues
- Asynchronous Communication Patterns
Module 3: Consistency and Replication
- Consistency Models
- The CAP Theorem and PACELC
- Consensus Algorithms
- Data Replication
- Distributed Transactions and Sagas
Module 4: Distributed Storage
- Data Partitioning and Consistent Hashing
- Distributed File Systems
- Object Storage
- Distributed Databases
- Distributed Caches
Module 5: Distributed Computing
- Distributed Computing Models
- MapReduce and Hadoop
- Spark and In-Memory Computing
- Stream Processing
- Job Scheduling and Data Pipelines
Module 6: Security in Distributed Systems
- Authentication and Authorization
- Encryption and Data Protection
- Identity Management
- Service-to-Service Security: mTLS and Secrets Management
- API Gateways, Rate Limiting and Auditing
Module 7: Monitoring and Maintenance
- Monitoring Distributed Systems
- Centralized Logs and Distributed Tracing
- Failure Management and Recovery
- Resilience Patterns: Timeouts, Retries and Circuit Breakers
- Automation and Orchestration
- Testing Distributed Systems and Chaos Engineering
