Hybrid Logical Clock
A Hybrid Logical Clock (HLC) combines a physical wall-clock timestamp with a logical counter to achieve causality tracking that stays close to real time. Based on the Kulkarni et al. 2014 paper, HLC provides O(1) causality tracking with configurable drift enforcement — making it ideal for high-throughput distributed systems where wall-clock proximity matters.
Key Types
| Type | Description |
|---|---|
HlcTimestamp | Immutable (wallTime, counter, nodeId) tuple with total ordering |
HlcGuidFactory | Generates UUIDv7 Guid values with HLC semantics; also exposes/witnesses HlcTimestamp state |
HlcCoordinator | Manages BeforeSend / BeforeReceive coordination for a node |
HlcMessageHeader | Wire format for propagating an HlcTimestamp across service boundaries |
HlcStatistics | Counters for send/receive operations and drift observations |
HlcClusterRegistry | Manages a set of HlcGuidFactory nodes for simple cluster simulations |
Timestamp Structure
An HlcTimestamp is a (wallTime, counter, nodeId) tuple that defines a total order:
- Compare
wallTime(higher = later) - If equal, compare
counter(higher = later) - If equal, compare
nodeId(higher = later)
This total order means every timestamp is unambiguously before or after every other timestamp.
Encoding Formats
64-bit packed (ToPackedInt64 / FromPackedInt64)
An optimized single-long encoding suitable for compact storage:
- 48 bits for wall time (milliseconds)
- 12 bits for logical counter
- 4 bits for node ID (node ID is truncated — only the lowest 4 bits are preserved)
Use this when you need compact storage and don't need full node ID fidelity.
80-bit canonical (WriteTo / ReadFrom)
A full-fidelity 10-byte big-endian encoding:
- 48 bits for wall time
- 16 bits for counter
- 16 bits for node ID
Use this for wire formats and persistent storage where full fidelity is required.
BeforeSend / BeforeReceive
The HlcCoordinator implements the two-operation protocol for causality propagation:
var tp = new SimulatedTimeProvider();
using var aFactory = new HlcGuidFactory(tp, nodeId: 1);
using var bFactory = new HlcGuidFactory(tp, nodeId: 2);
var a = new HlcCoordinator(aFactory);
var b = new HlcCoordinator(bFactory);
// A sends a message
var t1 = a.BeforeSend();
var header = new HlcMessageHeader(t1, correlationId: Guid.NewGuid());
var headerValue = header.ToString(); // e.g. "1700000000000.0000@1;d7c3... (N format)"
// B receives the message — witnesses A's timestamp and advances its own
var parsed = HlcMessageHeader.Parse(headerValue);
b.BeforeReceive(parsed.Timestamp);
// B sends a reply — guaranteed to be causally after the received timestamp
var t2 = b.BeforeSend();
Console.WriteLine(t1 < t2); // trueBeforeReceive witnesses the full remote HlcTimestamp including its node ID for tie-breaking.
HLC GUIDs (UUIDv7 encoding)
HlcGuidFactory also generates UUIDv7 Guid values that embed the HLC wall time and counter, plus a 14-bit node ID field. Node IDs must be in the range 0..HlcGuidFactory.MaxNodeId (0..16383) because the UUID variant consumes the top two bits of the UUID bytes that carry the node field.
using var factory = new HlcGuidFactory(TimeProvider.System, nodeId: 42);
var (guid, hlc) = factory.NewGuidWithHlc();
Console.WriteLine(guid);
Console.WriteLine(hlc); // "walltime.counter@node"You can reconstruct an HlcTimestamp from a GUID produced by HlcGuidFactory:
var decoded = guid.ToHlcTimestamp();
Console.WriteLine(decoded?.WallTimeMs);
Console.WriteLine(decoded?.Counter);
Console.WriteLine(decoded?.NodeId); // node id is stored in 14 bits in the UUIDHlcTimestamp itself can still represent a full ushort node ID in its canonical 80-bit WriteTo / ReadFrom format. The 14-bit limit applies specifically to node IDs used with HlcGuidFactory, where the node ID must be recoverable from an RFC-compatible UUIDv7 value.
Drift Configuration
HlcOptions controls how the coordinator handles clock drift:
var tp = TimeProvider.System;
// Strict: throw HlcDriftException when drift exceeds the bound
using var strict = new HlcGuidFactory(tp, nodeId: 1, options: new HlcOptions
{
MaxDriftMs = 1_000,
ThrowOnExcessiveDrift = true
});
// High-throughput: silently allow drift beyond the bound (maintains monotonicity)
using var highThroughput = new HlcGuidFactory(tp, nodeId: 1, options: new HlcOptions
{
MaxDriftMs = 60_000,
ThrowOnExcessiveDrift = false
});Clockworks also provides a few ready-made presets:
HlcOptions.StrictHlcOptions.DefaultHlcOptions.HighThroughput
Trade-offs
| Property | HLC |
|---|---|
| Space complexity | ✅ O(1) — single timestamp regardless of cluster size |
| Time complexity | ✅ O(1) — send and receive are constant time |
| Wall-clock proximity | ✅ Bounded drift from physical time |
| Causality tracking | ✅ Preserved via happens-before ordering |
| Concurrency detection | ❌ Cannot detect concurrent events |
| Physical clock dependency | ❌ Requires reasonably synchronized clocks |
See HLC vs Vector Clocks for a full comparison and decision guide.
Running the Demos
# Propagating HLC across service boundaries (header format)
dotnet run --project demo/Clockworks.Demo -- hlc-messaging
# BeforeSend/BeforeReceive workflow with coordinator statistics
dotnet run --project demo/Clockworks.Demo -- hlc-coordinator