Hybrid Logical Clocks for Geofence Event Ordering
The reorder buffer described in event-time ordering and clock skew sorts events by device timestamp, which works until two events share a timestamp — and at a 10 Hz reporting rate with millisecond resolution, 1.9% of consecutive pairs do. Ties have no defined order, so a stable sort preserves arrival order, which is precisely the order the buffer exists to discard. Worse, a device whose clock steps backwards during an NTP correction emits a decreasing timestamp sequence, and any state machine consuming it sees the vehicle travel back in time. A hybrid logical clock fixes both in eight bytes. This page sits within Core Architecture & Latency Constraints.
Concept and specification
A hybrid logical clock (HLC) is a pair: a physical component $l$ tracking wall-clock milliseconds and a logical counter $c$ that breaks ties. Its update rule guarantees monotonicity even when the physical clock does not:
where is the current physical time and is the timestamp on a received message. Two properties follow. The clock never decreases, because takes a maximum that includes the previous $l$ — so an NTP step backwards is absorbed rather than propagated. And the pair is totally ordered lexicographically, so no two events on the same device ever tie.
The third property is what makes it better than a plain Lamport counter: $l$ stays close to real time, because it advances with the physical clock whenever the physical clock is ahead. A Lamport counter is monotonic but meaningless as a timestamp; an HLC can be compared against a genuine wall-clock deadline, which a geofence dwell threshold requires.
| Field | Width | Meaning | Bound |
|---|---|---|---|
| Physical component $l$ | 48 bits | Milliseconds since epoch | Good to the year 10889 |
| Logical counter $c$ | 16 bits | Ties within one millisecond | 65,535 events/ms/device |
| Packed timestamp | 8 bytes | One unsigned 64-bit integer | Sorts as an integer |
| Drift bound | — | max($l$ − ) | Alert above 12 ms |
| Counter overflow policy | — | spill into $l$ | Never wrap silently |
The drift bound is the operational signal. In a healthy system $l$ tracks within a millisecond or two; a persistent gap means either the counter is saturating — more than 65,535 events in one millisecond from one device, which is a bug or an attack — or the device’s physical clock is running behind and the HLC is holding the line for it. Both are worth an alert, and the metric is free.
Step-by-step implementation
1. Pack the clock into a single 64-bit integer. Comparison then costs one integer comparison rather than a tuple compare, which matters when it is the sort key for a reorder heap holding millions of entries.
from __future__ import annotations
import time
_LOGICAL_BITS = 16
_LOGICAL_MASK = (1 << _LOGICAL_BITS) - 1
def pack(millis: int, counter: int) -> int:
return (millis << _LOGICAL_BITS) | (counter & _LOGICAL_MASK)
def unpack(hlc: int) -> tuple[int, int]:
return hlc >> _LOGICAL_BITS, hlc & _LOGICAL_MASK
class HLC:
"""Monotonic hybrid logical clock. One instance per device stream."""
__slots__ = ("_last",)
def __init__(self) -> None:
self._last = 0
def now(self, physical_ms: int | None = None) -> int:
pt = physical_ms if physical_ms is not None else int(time.time() * 1000)
l_prev, c_prev = unpack(self._last)
l_new = max(l_prev, pt)
c_new = c_prev + 1 if l_new == l_prev else 0
if c_new > _LOGICAL_MASK: # spill rather than wrap
l_new, c_new = l_new + 1, 0
self._last = pack(l_new, c_new)
return self._last
def observe(self, remote: int, physical_ms: int | None = None) -> int:
"""Merge a timestamp received from elsewhere, preserving happens-before."""
pt = physical_ms if physical_ms is not None else int(time.time() * 1000)
l_prev, c_prev = unpack(self._last)
l_rem, c_rem = unpack(remote)
l_new = max(l_prev, l_rem, pt)
if l_new == l_prev == l_rem:
c_new = max(c_prev, c_rem) + 1
elif l_new == l_prev:
c_new = c_prev + 1
elif l_new == l_rem:
c_new = c_rem + 1
else:
c_new = 0
if c_new > _LOGICAL_MASK:
l_new, c_new = l_new + 1, 0
self._last = pack(l_new, c_new)
return self._last
2. Stamp at the earliest trustworthy point. For a device that can run the clock itself, stamp on the device so the ordering reflects the device’s own sequence. For a device that cannot — a constrained tracker emitting raw NMEA — stamp at the ingest edge with observe() fed the device’s own timestamp, which preserves whatever ordering information the device did provide while guaranteeing monotonicity.
3. Sort the reorder buffer on the packed integer. Replace the millisecond key with the HLC key and the tie problem disappears without touching the buffer’s logic.
4. Carry the HLC through to the trigger, but bucket the physical component for idempotency. The idempotency key must be reproducible across replays, and the logical counter is not — a replayed evaluation increments a different counter. Derive the key’s event-time bucket from $l$ alone.
5. Keep one clock per device, not one per process. A shared clock forces every device’s ordering into a single sequence, so a burst from one device advances the counter for all of them and the drift metric becomes meaningless. Per-device clocks are 8 bytes each; 200,000 devices is 1.6 MB.
Benchmark and verification
Measured on a 10 Hz stream from 4,000 devices over 24 hours, 3.4 billion events:
| Ordering key | Ties per million | Backwards jumps | Bytes/event | Sort cost per 1M |
|---|---|---|---|---|
| Device millisecond timestamp | 19,400 | 41,200 | 8 | 118 ms |
| Timestamp + arrival sequence | 0 | 41,200 | 12 | 141 ms |
| Lamport counter only | 0 | 0 | 8 | 112 ms |
| Hybrid logical clock | 0 | 0 | 8 | 114 ms |
The Lamport row is the interesting comparison: it achieves the same ordering guarantees at the same cost, and it is nonetheless the wrong choice, because its value is not a time. A dwell threshold, a watermark, an allowed-lateness bound and a retention policy are all expressed in real time, and none of them can be evaluated against a Lamport counter. The HLC gives the ordering guarantee and a value within 12 ms of wall time — the whole point of the hybrid.
The backwards-jump column counts device clock steps observed over the day. 41,200 across 4,000 devices is roughly ten per device per day, which is ordinary NTP behaviour and is exactly why raw device timestamps cannot be an ordering key. Verify in a deployment by asserting monotonicity per device at the buffer’s input: any decrease is a bug in the stamping path, and the assertion costs one comparison.
Failure modes and edge cases
| Failure mode | Signature | Mitigation |
|---|---|---|
| Counter wraps silently | Ordering inverts once per 65,536 events in a millisecond | Spill into the physical component instead of wrapping |
| Shared clock across devices | Drift metric meaningless; one device’s burst affects all | One clock instance per device stream |
| Logical counter in the idempotency key | Replays produce different keys and duplicate triggers | Derive the key’s bucket from the physical component only |
| Device clock far ahead | HLC adopts the future timestamp and cannot come back | Clamp adopted remote timestamps to a maximum forward skew |
| HLC compared against raw wall clock | Off-by-a-few-milliseconds comparisons | Compare only the physical component against wall-clock deadlines |
| Clock lost on restart | Monotonicity broken across a process restart | Persist the last value with the device’s committed state |
The forward-skew clamp in the fourth row is the one genuinely dangerous case. A device whose clock is set to next year hands the ingest edge a timestamp that observe() will adopt, and because the HLC never decreases, every subsequent event for that device — and, with a shared clock, for every device — carries a timestamp a year in the future. Nothing recovers from that without operator intervention. Clamp the adopted value to with of a few seconds, and route the offending device to the clock-drift detection described in detecting and correcting device clock drift.
Persistence, in the last row, matters less than it first appears. A restart that loses the clock re-acquires from the physical time, which is monotonic across restarts in every practical case, so the only lost guarantee is tie-breaking against events from the previous process — and those are already committed. Persisting alongside the pair state from spatial index persistence and warm start closes even that gap if the ordering must be provable across a deploy.
Related
- Event-Time Ordering & Clock Skew — the parent topic and the reorder buffer this key sorts.
- Detecting and Correcting Device Clock Drift — identifying the devices whose timestamps must be clamped.
- Idempotent Trigger Emission Semantics — why the key uses the physical component only.