From cf58077398671e1cd385a61553c3023051355d40 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 17 Jul 2026 20:58:02 -0400 Subject: [PATCH] feat(localdb): hybrid logical clock (UTC-ms<<16 | counter) --- .../Hlc/HybridLogicalClock.cs | 33 +++++++ .../HybridLogicalClockTests.cs | 90 +++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 ZB.MOM.WW.LocalDb/src/ZB.MOM.WW.LocalDb/Hlc/HybridLogicalClock.cs create mode 100644 ZB.MOM.WW.LocalDb/tests/ZB.MOM.WW.LocalDb.Tests/HybridLogicalClockTests.cs diff --git a/ZB.MOM.WW.LocalDb/src/ZB.MOM.WW.LocalDb/Hlc/HybridLogicalClock.cs b/ZB.MOM.WW.LocalDb/src/ZB.MOM.WW.LocalDb/Hlc/HybridLogicalClock.cs new file mode 100644 index 0000000..3d80201 --- /dev/null +++ b/ZB.MOM.WW.LocalDb/src/ZB.MOM.WW.LocalDb/Hlc/HybridLogicalClock.cs @@ -0,0 +1,33 @@ +namespace ZB.MOM.WW.LocalDb.Hlc; + +/// 64-bit HLC: (UTC unix-ms << 16) | 16-bit logical counter. Thread-safe. +public sealed class HybridLogicalClock +{ + private readonly Func _utcNowMs; + private readonly Lock _lock = new(); + private long _last; + + public HybridLogicalClock(long initialValue = 0, Func? utcNowMs = null) + { + _utcNowMs = utcNowMs ?? (() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); + _last = initialValue; + } + + public long Next() + { + lock (_lock) + { + var candidate = _utcNowMs() << 16; + _last = candidate > _last ? candidate : _last + 1; // +1 rolls counter; overflow naturally carries into physical bits + return _last; + } + } + + /// Merge a peer's HLC so our next stamp orders after everything we've seen. + public void Observe(long remote) { lock (_lock) { if (remote > _last) _last = remote; } } + + public long Current { get { lock (_lock) return _last; } } + + public static long PhysicalMs(long hlc) => hlc >> 16; + public static int Counter(long hlc) => (int)(hlc & 0xFFFF); +} diff --git a/ZB.MOM.WW.LocalDb/tests/ZB.MOM.WW.LocalDb.Tests/HybridLogicalClockTests.cs b/ZB.MOM.WW.LocalDb/tests/ZB.MOM.WW.LocalDb.Tests/HybridLogicalClockTests.cs new file mode 100644 index 0000000..d16a6e3 --- /dev/null +++ b/ZB.MOM.WW.LocalDb/tests/ZB.MOM.WW.LocalDb.Tests/HybridLogicalClockTests.cs @@ -0,0 +1,90 @@ +using ZB.MOM.WW.LocalDb.Hlc; + +namespace ZB.MOM.WW.LocalDb.Tests; + +public sealed class HybridLogicalClockTests +{ + [Fact] + public void Next_IsStrictlyMonotonic() + { + var clock = new HybridLogicalClock(); + var prev = clock.Next(); + for (var i = 0; i < 1000; i++) + { + var next = clock.Next(); + Assert.True(next > prev, $"call {i}: {next} !> {prev}"); + prev = next; + } + } + + [Fact] + public void Next_UsesUtcPhysicalMs() + { + const long fixedMs = 1_700_000_000_123; + var clock = new HybridLogicalClock(utcNowMs: () => fixedMs); + + Assert.Equal(fixedMs, HybridLogicalClock.PhysicalMs(clock.Next())); + } + + [Fact] + public void Next_SameMs_IncrementsCounter() + { + const long fixedMs = 1_700_000_000_000; + var clock = new HybridLogicalClock(utcNowMs: () => fixedMs); + + var first = clock.Next(); + var second = clock.Next(); + var third = clock.Next(); + + Assert.Equal(0, HybridLogicalClock.Counter(first)); + Assert.Equal(1, HybridLogicalClock.Counter(second)); + Assert.Equal(2, HybridLogicalClock.Counter(third)); + } + + [Fact] + public void Observe_RemoteAhead_AdvancesPastRemote() + { + // Frozen wall clock well behind the remote stamp. + var clock = new HybridLogicalClock(utcNowMs: () => 1_000); + var remote = 5_000_000_000L << 16; + + clock.Observe(remote); + + Assert.True(clock.Next() > remote); + } + + [Fact] + public void Observe_RemoteBehind_NoRegression() + { + var clock = new HybridLogicalClock(utcNowMs: () => 1_700_000_000_000); + var current = clock.Next(); + + clock.Observe(current - 1_000_000); // a stamp from the past + + Assert.Equal(current, clock.Current); + } + + [Fact] + public void Resume_FromPersistedValue_NeverRegresses() + { + // Persisted value is far ahead of what the (injected) wall clock yields. + var persisted = (2_000_000_000_000L << 16) | 0x00FF; + var clock = new HybridLogicalClock(initialValue: persisted, utcNowMs: () => 1_000); + + Assert.True(clock.Next() > persisted); + } + + [Fact] + public void CounterOverflow_RollsPhysicalForward() + { + const long ms = 1_700_000_000_000; + // Counter already maxed out under a frozen clock at the same physical ms. + var maxed = (ms << 16) | 0xFFFF; + var clock = new HybridLogicalClock(initialValue: maxed, utcNowMs: () => ms); + + var next = clock.Next(); + + Assert.Equal(ms + 1, HybridLogicalClock.PhysicalMs(next)); + Assert.Equal(0, HybridLogicalClock.Counter(next)); + } +}