A Kotlin multiplatform implementation of a hybrid logical clock
Technically speaking this is an implementation of a HULC [Hybrid Unique Logical Clock]
It combines:
- A system timestamp (Epoch millis)
- A counter (Lamport Clock)
- A unique identifier (First 16 chars of a uuid v4)
To produce a unique monotonic timestamp which looks like the following when serialised
000943920000000:0000f:abcda554fcb2613b
As with all logical clocks, the clock "ticks"/"tocks" with events, by this I mean operations to advance the clock surround the exchange of information between nodes in the system.
Hybrid Logical Clocks overcome issues that traditional system clock based timestamps suffer from in disributed environments, notably:
- Duplicate timestamps, its entirely possible two nodes generate an event in the same second/millisecond
- Drift, its easy for nodes system clocks to get out of sync, even in environments with protocols like NTP
At the heart of a HLC is a counter / logical clock
The most famous logical clock is a Lamport Clock, Lamport clocks determine the causality of events amongst nodes in a distributed system by observing:
- Events generated on a single node
- The exchange (sending and receiving) of events between nodes
For example in the diagram below, we know that event 6 happened after event 1 despite the fact they occurred on different nodes.
sequenceDiagram
autonumber
participant A as Node_A
participant B as Node_B
participant C as Node_C
A->>B:
B->>B:
B->>B:
B->>B:
B->>C:
C->>C:
The algorithm behind lamport clocks is quite simple
- A node increments its counter before each local event (e.g., message sending event);
- When a node sends a message, it includes its counter value with the message after executing step 1;
- On receiving a message, the counter of the recipient is updated, if necessary, to the greater of its current counter and the timestamp in the received message. The counter is then incremented by 1 before the message is considered received.
Whilst HLCs observe the same events as Lamport Clocks to determine causality, they use a composite of elements to create their timestamps.
000943920000000:0000f:abcda554fcb2613b
This provides all the causality assurances of logical clocks, with the human readability of system clocks and the uniqueness of uuids.
On each node that is a writer (i.e. can create events) create a clock.
// You should only ever have one source of truth for your local hlc, so it's advisable to store it in a singleton.
var local = HybridLogicalClock()
As the clock contains a uuid portion of its composite, you can consider the clock a datastore for the nodes identifier.
To access the identifier simply call
val nodeId = local.node
The clock must survive process death and continue where it left after reboot, you can easily serialise the clock to disk with the help of the encode/decode functions.
val saveMeToDisk = HybridLogicalClock.encodeToString(local)
Decoding can produce errors and thus a Result monad is returned
val result = HybridLogicalClock.decodeFromString(encoded)
When generating local events increment the clock, the result is your new clock and also the timestamp for that local event
HybridLogicalClock.localTick(local).let { updated ->
local = updated
event.timestamp = updated
}
When receiving events from external nodes update the clock
val remote = event.hlc
local = HybridLogicalClock.remoteTock(local, remote)
HybridLogicalClocks implement the Kotlin Comparable interface, and thus you can simply
val winner = hlc1 > hlc2