Skip to content

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

TypeDescription
HlcTimestampImmutable (wallTime, counter, nodeId) tuple with total ordering
HlcGuidFactoryGenerates UUIDv7 Guid values with HLC semantics; also exposes/witnesses HlcTimestamp state
HlcCoordinatorManages BeforeSend / BeforeReceive coordination for a node
HlcMessageHeaderWire format for propagating an HlcTimestamp across service boundaries
HlcStatisticsCounters for send/receive operations and drift observations
HlcClusterRegistryManages a set of HlcGuidFactory nodes for simple cluster simulations

Timestamp Structure

An HlcTimestamp is a (wallTime, counter, nodeId) tuple that defines a total order:

  1. Compare wallTime (higher = later)
  2. If equal, compare counter (higher = later)
  3. 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:

csharp
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); // true

BeforeReceive 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.

csharp
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:

csharp
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 UUID

HlcTimestamp 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:

csharp
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.Strict
  • HlcOptions.Default
  • HlcOptions.HighThroughput

Trade-offs

PropertyHLC
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

bash
# 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

Released under the MIT License.