Blog/Vector Clocks and Lamport Timestamps: Ordering Events in Distributed Systems
distributed-systemslogical-clocksconsistencyconcurrency

Vector Clocks and Lamport Timestamps: Ordering Events in Distributed Systems

May 30, 2024·13 min read·by Bishwambhar Sen
A space-time process diagram showing event lines across three independent nodes and vector clock state modifications.

Concept

In centralized systems, ordering events is trivial: we use the system CPU’s physical clock to assign a timestamp to each operation. In distributed systems, however, physical clocks are unreliable.

Due to clock drift (caused by temperature changes, hardware age, and quartz crystal variations) and network jitter affecting synchronization protocols like NTP, different servers in a cluster will disagree on the current time. If Server A writes a record at $10:00:00.005$ according to its clock, and Server B overwrites it at $10:00:00.001$ according to its slightly slow clock, a naive "Last-Write-Wins" policy will discard the newer write.

To solve this without expensive atomic clocks (like Google's TrueTime), Leslie Lamport published his seminal 1978 paper, "Time, Clocks, and the Ordering of Events in a Distributed System." He established that physical time is not necessary to determine order; instead, we can use logical clocks to track causality based on the happened-before relation ($\to$):

  1. If events $a$ and $b$ occur within the same process, and $a$ occurs before $b$, then $a \to b$.
  2. If $a$ is the sending of a message by one process, and $b$ is the receipt of that same message by another process, then $a \to b$.
  3. If $a \to b$ and $b \to c$, then $a \to c$ (transitivity).

If two events have no causal link (neither can influence the other), they are considered concurrent ($a \parallel b$).

Lamport Timestamps

A Lamport logical clock is a simple monotonically increasing integer counter maintained by each process. The algorithm operates as follows:

  • Each process initializes its clock $L = 0$.
  • Before executing a local event, a process increments its clock: $$L = L + 1$$
  • When sending a message, the process attaches its current clock $L$ to the payload.
  • Upon receiving a message with timestamp $L_{\text{msg}}$, the receiver updates its local clock: $$L = \max(L, L_{\text{msg}}) + 1$$

While Lamport Timestamps create a total ordering of events, they cannot detect concurrency. If $L(a) < L(b)$, it does not imply that $a \to b$; the events could be concurrent.

Vector Clocks

To capture true causality and detect concurrency, Colin Fidge and Friedemann Mattern independently developed Vector Clocks in 1988.

In a system of $N$ processes, each process maintains a vector $V$ of size $N$, where $V[i]$ represents process $i$'s current knowledge of the logical clock of process $j$.

  • Each process $i$ initializes its vector $V_i = [0, 0, \dots, 0]$.
  • Before a local event, process $i$ increments its own element: $$V_i[i] = V_i[i] + 1$$
  • When sending a message, process $i$ attaches its vector $V_i$.
  • Upon receiving a message from process $j$ containing vector $V_{\text{msg}}$, process $i$ merges the vectors element-wise: $$V_i[k] = \max(V_i[k], V_{\text{msg}}[k]) \quad \forall k \in [1, N]$$ It then increments its own entry: $$V_i[i] = V_i[i] + 1$$
 Connor's Space-Time Diagram:
    Process 0  o---(V0=[1,0,0])-------\ (msg)
                                      \
    Process 1  o--(V1=[0,1,0])---------\---> o (merge: V1=[1,2,0]) ---> o (local: V1=[1,3,0])
                                            /
    Process 2  o-------(V2=[0,0,1])--------/ (msg)

By comparing two vectors, we can determine their exact relationship:

  • $V_A \le V_B \iff V_A[k] \le V_B[k]$ for all $k$.
  • $V_A < V_B \iff V_A \le V_B$ and at least one element $V_A[k] < V_B[k]$. This indicates event $A$ causally preceded event $B$.
  • If neither $V_A \le V_B$ nor $V_B \le V_A$, the events are concurrent ($A \parallel B$). This signals a conflict that the application must resolve.

Constraints

Architects implementing logical clocks must deal with significant physical and architectural constraints:

Metadata Space Complexity ($O(N)$ Growth)

The size of a vector clock is proportional to the number of active nodes ($N$) in the system. In dynamic environments where instances auto-scale, or in collaborative client-side applications (like offline mobile apps syncing with a database), $N$ can grow into thousands or millions.

This causes the metadata payload attached to every database write to dwarf the actual application data.

Vector Pruning and Conflict False Positives

To prevent infinite vector growth, databases like Riak prune vector clocks using size thresholds and age bounds. When a vector exceeds a threshold, the oldest entries (or those corresponding to inactive nodes) are removed.

However, pruning destroys causal history. If a pruned vector is compared against an unpruned one, the database may incorrectly classify a causal relation as a conflict, generating unnecessary siblings (duplicate entries) that the client must resolve.

Distributed Conflict Resolution Cost

Logical clocks only detect conflicts; they cannot resolve them. When $A \parallel B$ is observed, the database must write both versions (called siblings) to disk. Reclaiming space requires the client to fetch both siblings, merge them, and write back a new value that "supersedes" both vector clocks.

This read-modify-merge-write cycle increases network and CPU overhead.

Lack of Physical Time Correlation

Logical clocks cannot answer questions like "Did this happen within the last 5 minutes?" or "Order these events by physical date." To address this constraint, modern distributed databases use Hybrid Logical Clocks (HLC), which combine physical wall-clock time with logical counters, ensuring logical ordering while remaining close to real-world time.


Trade-offs

The choice of ordering mechanism determines how a system balances consistency, storage overhead, and performance:

Mechanism Storage Cost Network Overhead Conflict Detection Resolution Mechanism
Physical (Wall) Clock Minimal ($8\text{ bytes}$) Low None (Silent overwrites) Last-Write-Wins (LWW) (Prone to data loss)
Lamport Timestamp Minimal ($8\text{ bytes}$) Low None (Only establishes total order) Requires deterministic tie-breaking (e.g., node ID)
Vector Clock High ($O(N)$ integers) High (Sent with every message) Precise (Flags concurrent writes) Application-level merge or conflict resolution
Hybrid Logical Clock Moderate ($12\text{ bytes}$) Low Partial (Tracks causality within drift bounds) LWW or application merge

Selecting the Right Logical Clock

  • Use Physical Clocks / LWW if you are building non-critical systems (e.g., clickstream tracking, view counters) where losing occasional updates due to clock drift is acceptable.
  • Use Vector Clocks if you have a strict "no data loss" requirement for concurrent updates (e.g., cooperative document editing, distributed shopping carts) and can bound the number of actors ($N < 100$).
  • Use Hybrid Logical Clocks (HLC) if you need distributed transaction support and want to query data based on physical time windows while preserving causal sequence (e.g., CockroachDB, YugabyteDB).

Code

Below is a complete C# implementation of a Vector Clock. It demonstrates how to initialize vectors, increment logical clocks for local processes, merge incoming vectors during message receipt, and compare vectors to determine causality or concurrency.

using System;
using System.Collections.Generic;
using System.Linq;

namespace LogicalClocks
{
    public enum CausalityRelation
    {
        Ancestor,   // V1 < V2 (V1 causally preceded V2)
        Descendant, // V1 > V2 (V1 causally follows V2)
        Equal,      // V1 == V2 (Same logical state)
        Concurrent  // V1 || V2 (Concurrent updates, conflict detected)
    }

    public class VectorClock : IEquatable<VectorClock>
    {
        // Internal representation of the vector mapping Node ID -> Logical Clock value
        private readonly Dictionary<string, long> _vector = new(StringComparer.Ordinal);

        public VectorClock() { }

        public VectorClock(Dictionary<string, long> initialVector)
        {
            foreach (var kvp in initialVector)
            {
                _vector[kvp.Key] = kvp.Value;
            }
        }

        public IReadOnlyDictionary<string, long> Value => _vector;

        /// <summary>
        /// Increment the logical clock for this specific node.
        /// </summary>
        public void Increment(string nodeId)
        {
            if (string.IsNullOrWhiteSpace(nodeId))
                throw new ArgumentException("Node ID cannot be null or empty.", nameof(nodeId));

            _vector[nodeId] = _vector.GetValueOrDefault(nodeId, 0L) + 1;
        }

        /// <summary>
        /// Merge an incoming vector clock into this local clock.
        /// Corresponds to: V_local[k] = max(V_local[k], V_incoming[k]) for all k
        /// </summary>
        public void Merge(VectorClock incoming)
        {
            if (incoming == null) throw new ArgumentNullException(nameof(incoming));

            foreach (var kvp in incoming.Value)
            {
                var localVal = _vector.GetValueOrDefault(kvp.Key, 0L);
                _vector[kvp.Key] = Math.Max(localVal, kvp.Value);
            }
        }

        /// <summary>
        /// Compare this vector clock with another to evaluate logical causality.
        /// </summary>
        public CausalityRelation Compare(VectorClock other)
        {
            if (other == null) throw new ArgumentNullException(nameof(other));

            bool greater = false;
            bool lesser = false;

            // Gather all unique keys present in both vectors
            var allKeys = _vector.Keys.Union(other.Value.Keys);

            foreach (var key in allKeys)
            {
                var v1 = _vector.GetValueOrDefault(key, 0L);
                var v2 = other.Value.GetValueOrDefault(key, 0L);

                if (v1 > v2) greater = true;
                if (v1 < v2) lesser = true;

                // If elements skew in opposite directions, it is immediately concurrent
                if (greater && lesser)
                {
                    return CausalityRelation.Concurrent;
                }
            }

            if (greater && !lesser) return CausalityRelation.Descendant;
            if (!greater && lesser) return CausalityRelation.Ancestor;
            return CausalityRelation.Equal;
        }

        public VectorClock Clone()
        {
            return new VectorClock(_vector);
        }

        public bool Equals(VectorClock? other)
        {
            if (other == null) return false;
            return Compare(other) == CausalityRelation.Equal;
        }

        public override bool Equals(object? obj) => Equals(obj as VectorClock);

        public override int GetHashCode()
        {
            unchecked
            {
                int hash = 17;
                foreach (var kvp in _vector.OrderBy(x => x.Key))
                {
                    hash = hash * 23 + kvp.Key.GetHashCode();
                    hash = hash * 23 + kvp.Value.GetHashCode();
                }
                return hash;
            }
        }

        public override string ToString()
        {
            var pairs = _vector.Select(kv => $"\"{kv.Key}\": {kv.Value}");
            return $"{{ {string.Join(", ", pairs)} }}";
        }
    }
}