studyDDIAChapter 10 · Consistency and Consensus

A Backward NTP Step Reorders Wall-Clock Timestamps; an HLC Stays Monotonic

One node runs a causal chain of writes while NTP steps the clock back 100 ms mid-run. Plain physical timestamps go non-monotonic — event 5 commits at 940 ms though its cause committed at 1030 ms — so sorting by timestamp puts effects before their cause. A Hybrid Logical Clock's max-and-increment rule keeps the same chain strictly monotonic: (1030,c0) < (1030,c1) < (1030,c2) < (1030,c3).


Concept

One node performs a causal chain of writes — event k reads x=k-1 and writes x=k, so each event provably happens-after the last. Timestamp the chain two ways: plain physical (wall-clock) time, and a Hybrid Logical Clock (HLC). Partway through, NTP decides the clock is fast and steps it back 100 ms. The plain physical timestamps then go non-monotonic: event 5 commits at 940 ms though its own cause, event 4, committed at 1030 ms — a later event with a smaller timestamp. Sorting the events by physical time places e5, e6, e7 before e4, the write they read — causality inverted — and a snapshot read in the gap sees the effects but not the committed cause. The HLC applies one rule per event — pt = max(pt_last, phys); counter += 1 if pt unchanged else 0 — and its timestamps stay strictly monotonic ((1030,c0) < (1030,c1) < …), preserving the causal order while staying within the jump's bound of physical time. "Our clocks are NTP-synced, so timestamps are ordered" is wrong the instant the clock steps back; the HLC's max-and-increment rule buys the ordering back with no special hardware.

Provenance

Designing Data-Intensive Applications, 2nd ed. (Kleppmann & Riccomini, 2025), Chapter 10 — Consistency and Consensus, §"Hybrid logical clocks":

A hybrid logical clock combines a physical timestamp with a logical counter, giving it the best of both worlds: it stays close to the actual time of day, while also preserving the causal ordering of events like a Lamport clock.

The book presents the HLC (used by CockroachDB) as the clock that keeps physical time meaningful and never lets it regress. Here you watch a real backward step break the plain wall-clock ordering, then watch the HLC absorb the same step into its counter and keep the causal order intact.

Prerequisites

The whole result turns on one line: pt = max(seen, physical); bump the counter on a tie or regression. These place why; one line each, with a free resource, and the one that carries the result flagged.

  1. Physical vs logical clocks — a physical clock reports civil time and is corrected by an external authority (NTP); a logical clock (Lamport) is a counter that only encodes happened-before, with no tie to real time. An HLC fuses the two. It shows up as the (pt, counter) pair: pt is physical, counter is logical.
  2. NTP can step a clock backward — when a clock is too far off to slew away gently, NTP steps it, instantly, and the step can go back. That backward step is the whole event. It shows up as clock.step(-100) mid-chain.
  3. Monotonicity & causal order — an ordering source is only valid if a later causal event never gets a smaller timestamp; plain physical time loses this property the moment it is stepped back. It shows up as e5 (940) < e4 (1030) inverting the sort.
  4. The HLC update rule (carries the result) — on every event pt = max(pt_last, phys); if pt did not advance (a tie, or physical time regressed) counter += 1, else counter = 0; receive first takes the max with the remote HLC. pt never decreases and the counter breaks ties, so the (pt, counter) pair is strictly monotonic. It shows up as e5 becoming (1030, c1) instead of going back to 940.
Where to learn the prerequisites Physical vs logical clocks and the HLC rule (#1, #4): DDIA §"Hybrid logical clocks" and §"Sequence number ordering"; Kulkarni et al., "Logical Physical Clocks and Consistent Snapshots in Globally Distributed Databases" (2014) — the paper that defines the HLC and proves its bounds. NTP stepping and monotonicity (#2, #3): the ntpd docs on the step/slew threshold; the Ch. 9 exercise monotonic-vs-wallclock for what a backward step does to a duration, which this exercise's ordering failure is the sibling of. If only one is new, make it #4 — max-and-increment is the entire reason the HLC stays monotonic while tracking physical time.
Environment these numbers came from macOS 26.5.2 (Darwin 25.5.0), arm64; Python 3.15.0a8, standard library only; no Docker; the physical clock and its backward step are modeled by a mock, so the transcript is fully deterministic.

Mental model

An HLC is physical time that is never allowed to go backward. It carries a logical counter alongside the physical reading, and that counter does two jobs: it breaks ties when two events read the same physical millisecond, and it absorbs a backward jump — when physical time tries to regress, the HLC holds pt where it was and bumps the counter instead. So the HLC still tracks real time (its pt is a real millisecond value, within a bound of the wall clock) yet stays monotonic, because the pair (pt, counter) — compared lexicographically — can only ever increase.

Both stamp the identical causal chain; only the second keeps the order honest across the step. The whole thing is one script, code/hybrid_logical_clock.py.

Setup

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

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


Step 1 · the physical timestamps of the causal chain

Predict. Seven causal writes, physical clock starting at 1000 ms and normally advancing +10 ms per event — but NTP steps the clock back 100 ms between event 4 and event 5. What physical timestamp does event 5 get, and how does it compare to event 4's?

What physical timestamp does event 5 get?
e1 x:=1 physical_ts = 1000 ms e2 x:=2 physical_ts = 1010 ms e3 x:=3 physical_ts = 1020 ms e4 x:=4 physical_ts = 1030 ms e5 x:=5 physical_ts = 940 ms e6 x:=6 physical_ts = 950 ms e7 x:=7 physical_ts = 960 ms The NTP step lands between e4 and e5: e4 committed at physical_ts = 1030 ms e5 committed at physical_ts = 940 ms (clock was stepped BACK 100 ms) e5 happens-AFTER e4 (it read x=4, wrote x=5), yet 940 < 1030: a LATER event carries a SMALLER physical timestamp -- NON-MONOTONIC.

940 < 1030 — a later event with a smaller timestamp. The clock ran forward to 1030, NTP stepped it back to ~930, and event 5 read 940. Event 5 causally happens-after event 4, but its physical timestamp is earlier. The timestamps are no longer a valid order.

Step 2 · the failure: order the events by physical timestamp

Predict. A timestamp-ordered log (or a snapshot) sorts these events by their physical timestamp. What order comes out — and where do e5, e6, e7 land relative to e4, the write they read?

What order comes out of sorting by physical timestamp?
sorted by physical_ts (what a timestamp-ordered log / snapshot would do): physical_ts = 940 ms e5 x:=5 physical_ts = 950 ms e6 x:=6 physical_ts = 960 ms e7 x:=7 physical_ts = 1000 ms e1 x:=1 physical_ts = 1010 ms e2 x:=2 physical_ts = 1020 ms e3 x:=3 physical_ts = 1030 ms e4 x:=4 causal order actually performed : e1 -> e2 -> e3 -> e4 -> e5 -> e6 -> e7 order by physical timestamp : e5 -> e6 -> e7 -> e1 -> e2 -> e3 -> e4 e5, e6, e7 sort BEFORE e4 -- their cause. Sorting by physical time places the effect ahead of the write it read: causality INVERTED. a snapshot read at physical_ts T = 1025 ms (writes with ts <= T are visible): visible: e1, e2, e3, e5, e6, e7 -> e5, e6, e7 are visible but e4 (which they causally depend on) is NOT. The snapshot shows x=7 while the write that set x=4 is missing: a state that never existed. A committed earlier-in-causal-order write is lost.

The sort puts effects before their cause. e5, e6, e7 — each of which read a value written by e4 — sort ahead of e4. Worse, a snapshot read at T = 1025 sees e5, e6, e7 (their ts ≤ 1025) but not e4 (ts 1030 > T): it exposes x=7 while the write that produced the chain's middle is invisible. Nothing errored; the ordering is just wrong.

Step 3 · the same chain, timestamped by the HLC

Predict. Same seven events, same backward step, but each stamped by the HLC rule pt = max(pt_last, phys); counter += 1 if pt unchanged else 0. What HLC does event 5 get — does it go back to 940, or something else? Are the HLC timestamps strictly increasing?

What HLC does event 5 get?
e1 x:=1 physical_ts = 1000 ms hlc = (1000, c0) e2 x:=2 physical_ts = 1010 ms hlc = (1010, c0) e3 x:=3 physical_ts = 1020 ms hlc = (1020, c0) e4 x:=4 physical_ts = 1030 ms hlc = (1030, c0) e5 x:=5 physical_ts = 940 ms hlc = (1030, c1) e6 x:=6 physical_ts = 950 ms hlc = (1030, c2) e7 x:=7 physical_ts = 960 ms hlc = (1030, c3) Across the jump the HLC does NOT go back: e4 hlc = (1030, c0) e5 hlc = (1030, c1) (pt held at 1030, counter bumped 0 -> 1) physical time tried to regress, so pt stayed put and the counter absorbed the step. e5, e6, e7 keep climbing on the counter: (1030,c1) < (1030,c2) < (1030,c3). HLC timestamps strictly increasing (lexicographic (pt, counter))? True Ordering the events by HLC reproduces the exact causal order e1..e7.

Event 5 is (1030, c1), not 940. When physical time tried to regress, max held pt at 1030 and the tie-branch bumped the counter to 1. The next two events keep climbing on the counter — (1030,c1) < (1030,c2) < (1030,c3) — so the HLC timestamps are strictly increasing and sorting by them reproduces the true causal order e1..e7.

Step 4 · receive(): causality carried across a message

Predict. A peer node B, whose clock is ahead, sends a message stamped hlc = (1200, c4). Our node (whose HLC is (1030, c3), physical clock 960 ms) applies receive(). What does our HLC become — and is it strictly greater than the message's own stamp?

What does our HLC become after receive()?
our physical clock now = 960 ms message arrives from B stamped hlc = (1200, c4) (B is ahead) our HLC before receive = (1030, c3) our HLC after receive = (1200, c5) it jumped up to B's physical part (1200) and set counter = 5: strictly greater than the message's own stamp, so the receipt is ordered AFTER the send even though our local physical clock never read that high.

Our HLC jumps to (1200, c5). receive takes max(pt_last, remote_pt, phys) = 1200, and because that came from the remote, sets counter = remote_counter + 1 = 5. The receipt is now strictly greater than the send's (1200, c4), so the message's causality is preserved even though our own physical clock never read as high as 1200. This is how the HLC carries happened-before across a wire, exactly like a Lamport clock.

Step 5 · bounded skew: the HLC still tracks physical time

Predict. After absorbing a 100 ms backward step, how far can the HLC's pt run ahead of the true wall clock? Bounded, or does it drift without limit?

How far ahead of the wall clock does the HLC run?
Right after the backward step, before the remote message: e7 physical_ts = 960 ms e7 hlc.pt = 1030 skew = hlc.pt - physical_ts = 70 ms The HLC ran at most 70 ms ahead of the wall clock -- exactly the part of the 100 ms backward step not yet re-covered by real time. The counter, not an unbounded drift, carried the ordering; the skew is bounded by the jump.

70 ms — bounded by the jump. At e7 the wall clock reads 960 ms but the HLC's pt is still 1030 ms: 70 ms ahead, the part of the 100 ms step that real time has not yet caught back up to. The counter, not an ever-growing physical offset, carried the ordering across the gap, so the HLC never drifts unboundedly from physical time.


What you should see

Why

From first principles, plain physical time has an external authority — NTP — that is allowed to move it backward. That is not a bug in NTP; it is its job: when the local clock is too far from UTC to slew away gently, it steps it, possibly back. But an ordering source must be monotonic along causality — a later event may never receive a smaller value — and a quantity someone else can decrement has forfeited that property. So the moment the step lands, two events on either side of it are stamped from different calibrations of the same dial; comparing them is meaningless, and when the step is backward and larger than the real elapsed time, the later event's timestamp comes out smaller. That is exactly e5 = 940 < e4 = 1030, and it is why sorting inverts causality and the snapshot loses a committed write.

The HLC fixes this by clamping: on every event it sets pt = max(pt_last, phys). The max is the whole trick — it makes pt a running maximum of every physical reading ever seen, and a running maximum can never decrease. When physical time moves forward, pt follows it and the counter resets to 0 (a fresh millisecond, no tie to break). When physical time regresses (or two events share a millisecond), pt stays put — max ignores the smaller value — and the counter += 1. So a backward step doesn't invert anything; it just spends counter increments. Because (pt, counter) is compared lexicographically and pt is monotonic with the counter breaking every tie, the pair is strictly increasing by construction — a total order that never regresses. And since pt is still a real max of physical readings, it stays close to the wall clock: at most the un-recovered part of the last backward step ahead of it (70 ms here). receive extends the same max to remote timestamps, so happened-before crosses messages too — the HLC is a Lamport clock whose "counter" is anchored to real time.

The boundary — the HLC matters exactly when the clock jumps or ties If NTP only ever slews the clock forward and it never steps back (and no two events share a physical instant), plain physical timestamps stay ordered and the counter sits at 0 forever — the HLC reduces to the wall clock and buys you nothing. Its value shows up precisely at backward steps and ties. And note what it does not give you: the HLC bounds — it does not eliminate — reliance on rough physical sync. pt staying "close" to real time depends on the clocks being roughly synced; a wildly wrong clock still produces a large-but-monotonic pt. The HLC guarantees a monotonic order, not an accurate instant.

Go deeper

Sources: DDIA 2e, Ch. 10, §"Hybrid logical clocks", §"Sequence number ordering" · Kulkarni et al., "Logical Physical Clocks and Consistent Snapshots in Globally Distributed Databases" (2014).