diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..7a29796 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,17 @@ + + + net10.0 + preview + enable + enable + + true + + + true + true + + + true + + diff --git a/demo/Clockworks.Demo/Clockworks.Demo.csproj b/demo/Clockworks.Demo/Clockworks.Demo.csproj index 1c5aa8d..19f838b 100644 --- a/demo/Clockworks.Demo/Clockworks.Demo.csproj +++ b/demo/Clockworks.Demo/Clockworks.Demo.csproj @@ -2,9 +2,6 @@ Exe - net10.0 - enable - enable diff --git a/demo/Clockworks.IntegrationDemo/Clockworks.IntegrationDemo.csproj b/demo/Clockworks.IntegrationDemo/Clockworks.IntegrationDemo.csproj index cfdf376..e344554 100644 --- a/demo/Clockworks.IntegrationDemo/Clockworks.IntegrationDemo.csproj +++ b/demo/Clockworks.IntegrationDemo/Clockworks.IntegrationDemo.csproj @@ -1,9 +1,6 @@ - net10.0 - enable - enable diff --git a/property-tests/Clockworks.PropertyTests.fsproj b/property-tests/Clockworks.PropertyTests.fsproj index 6a0a0b3..21fadef 100644 --- a/property-tests/Clockworks.PropertyTests.fsproj +++ b/property-tests/Clockworks.PropertyTests.fsproj @@ -1,7 +1,6 @@ - net10.0 false Clockworks.PropertyTests diff --git a/property-tests/HlcCoordinatorProperties.fs b/property-tests/HlcCoordinatorProperties.fs index 821c56c..9b2d996 100644 --- a/property-tests/HlcCoordinatorProperties.fs +++ b/property-tests/HlcCoordinatorProperties.fs @@ -1,6 +1,8 @@ module Clockworks.PropertyTests.HlcCoordinatorProperties open System +open FsCheck.FSharp +open FsCheck open FsCheck.Xunit open Clockworks open Clockworks.Distributed @@ -62,6 +64,56 @@ let ``BeforeReceive adopts remote wall time when remote is ahead`` (deltaMs: uin let after = coordinator.CurrentTimestamp after.WallTimeMs = remote.WallTimeMs && after.Counter = 1us +/// Property: When remote is ahead within the same wall-time, we should adopt the higher counter (and exceed it). +[] +let ``BeforeReceive adopts remote counter when remote is ahead at same wall time`` (remoteCounter: uint16) = + let startMs = 1_700_000_000_000L + let timeProvider = SimulatedTimeProvider.FromUnixMs(startMs) + use factory = new HlcGuidFactory(timeProvider, nodeId = 1us) + let coordinator = new HlcCoordinator(factory) + + // Ensure we have a stable local wall time. + let _ = coordinator.BeforeSend() + let local = coordinator.CurrentTimestamp + + let safeRemoteCounter = remoteCounter &&& 0x0FFFus + + // Make the remote strictly greater than local, still at same wall time. + let remote = + if safeRemoteCounter > local.Counter then + HlcTimestamp(local.WallTimeMs, counter = safeRemoteCounter, nodeId = 2us) + else + HlcTimestamp(local.WallTimeMs, counter = local.Counter + 1us, nodeId = 2us) + + coordinator.BeforeReceive(remote) + let after = coordinator.CurrentTimestamp + + after.WallTimeMs = remote.WallTimeMs + && after.Counter = remote.Counter + 1us + && after > remote + +/// Property: When remote is ahead only by nodeId (same wall time and counter), witnessing should still advance. +[] +let ``BeforeReceive uses nodeId as tie-breaker`` () = + let startMs = 1_700_000_000_000L + let timeProvider = SimulatedTimeProvider.FromUnixMs(startMs) + use factory = new HlcGuidFactory(timeProvider, nodeId = 1us) + let coordinator = new HlcCoordinator(factory) + + // Put local at a known (wall,counter). + let _ = coordinator.BeforeSend() + let local = coordinator.CurrentTimestamp + + // Same wall/counter but higher node id => remote > local by timestamp ordering. + let remote = HlcTimestamp(local.WallTimeMs, counter = local.Counter, nodeId = local.NodeId + 1us) + + coordinator.BeforeReceive(remote) + let after = coordinator.CurrentTimestamp + + after.WallTimeMs = remote.WallTimeMs + && after.Counter = remote.Counter + 1us + && after > remote + /// Property: Receive followed by send yields a timestamp after the remote. [] let ``Receive then send yields timestamp after remote`` (deltaMs: uint16) = @@ -98,3 +150,78 @@ let ``BeforeSend resets counter when physical time jumps ahead`` (jumpMs: uint16 after.WallTimeMs = newPhysical.ToUnixTimeMilliseconds() && after.Counter = 0us && after > before + +/// Property: When the remote timestamp is behind the local timestamp, local wall time should not change. +[] +let ``BeforeReceive with remote behind does not change wall time`` (behindMs: uint16) = + let startMs = 1_700_000_000_000L + let timeProvider = SimulatedTimeProvider.FromUnixMs(startMs) + use factory = new HlcGuidFactory(timeProvider, nodeId = 1us) + let coordinator = new HlcCoordinator(factory) + + let _ = coordinator.BeforeSend() + let _ = coordinator.BeforeSend() + let before = coordinator.CurrentTimestamp + + let delta = int64 (behindMs % 1000us) + 1L + let remote = HlcTimestamp(before.WallTimeMs - delta, counter = 0us, nodeId = 2us) + + coordinator.BeforeReceive(remote) + let after = coordinator.CurrentTimestamp + + after.WallTimeMs = before.WallTimeMs + && after > before + +type HlcStep = + | Send + | ReceiveDeltaMs of int + | PhysicalAdvanceMs of int + | PhysicalSetDeltaMs of int + +type HlcStepArb = + static member HlcStep() : Arbitrary = + let gen = + Gen.frequency + [ 5, Gen.constant Send + 4, Gen.choose (-2000, 2000) |> Gen.map ReceiveDeltaMs + 2, Gen.choose (0, 500) |> Gen.map PhysicalAdvanceMs + 2, Gen.choose (-2000, 2000) |> Gen.map PhysicalSetDeltaMs ] + Arb.fromGen gen + +/// Property: Mixed sequences of send/receive/time-changes never break monotonicity of successive sends, +/// and each receive advances local timestamp. +[ |], MaxTest = 100)>] +let ``Mixed send/receive/time steps preserve invariants`` (steps: HlcStep list) = + let startMs = 1_700_000_000_000L + let tp = SimulatedTimeProvider.FromUnixMs(startMs) + use factory = new HlcGuidFactory(tp, nodeId = 1us, options = HlcOptions.HighThroughput) + let coord = new HlcCoordinator(factory) + + let mutable lastSend : HlcTimestamp option = None + let mutable ok = true + + for step in (steps |> List.truncate 100) do + match step with + | Send -> + let t = coord.BeforeSend() + match lastSend with + | None -> lastSend <- Some t + | Some prev -> + ok <- ok && (prev < t) + lastSend <- Some t + + | ReceiveDeltaMs delta -> + let before = coord.CurrentTimestamp + let remote = HlcTimestamp(before.WallTimeMs + int64 delta, counter = 0us, nodeId = 2us) + coord.BeforeReceive(remote) + let after = coord.CurrentTimestamp + ok <- ok && (after > before) + + | PhysicalAdvanceMs ms -> + tp.Advance(TimeSpan.FromMilliseconds(float ms)) + + | PhysicalSetDeltaMs delta -> + let now = tp.GetUtcNow().ToUnixTimeMilliseconds() + tp.SetUnixMs(now + int64 delta) + + ok diff --git a/src/Clockworks.csproj b/src/Clockworks.csproj index 556f31c..4b12342 100644 --- a/src/Clockworks.csproj +++ b/src/Clockworks.csproj @@ -1,10 +1,6 @@ - net10.0 - preview - enable - enable true true @@ -17,7 +13,7 @@ Clockworks Clockworks Clockworks - 1.1.1 + 1.2.0 Dexter Ajoku CloudyBox Clockworks is a .NET library for deterministic, fully controllable time in distributed-system simulations and tests. It provides a simulated TimeProvider with deterministic timer scheduling, TimeProvider-driven timeouts, UUIDv7 generation, and Hybrid Logical Clock (HLC) utilities with lightweight instrumentation. @@ -30,13 +26,7 @@ README.md - - true - true - true - - - true + diff --git a/src/Distributed/HlcCoordinator.cs b/src/Distributed/HlcCoordinator.cs index 48f9065..abd2fc3 100644 --- a/src/Distributed/HlcCoordinator.cs +++ b/src/Distributed/HlcCoordinator.cs @@ -162,11 +162,9 @@ internal void RecordReceive(HlcTimestamp before, HlcTimestamp after, HlcTimestam { Interlocked.Increment(ref _receiveCount); - // Clock advances due to remote when: - // 1. The remote timestamp was ahead of our local time before witnessing, AND - // 2. The remote timestamp was adopted (became our new wall time after witnessing) - // This indicates the local clock moved forward by adopting the remote timestamp. - if (remote.WallTimeMs > before.WallTimeMs && after.WallTimeMs == remote.WallTimeMs) + // Clock advances due to remote when the remote timestamp is ahead of the local timestamp + // prior to witnessing and the post-witness wall time matches the remote wall time. + if (remote > before && after.WallTimeMs == remote.WallTimeMs) { Interlocked.Increment(ref _clockAdvances); } diff --git a/src/HlcGuidFactory.cs b/src/HlcGuidFactory.cs index 8fe5bd5..d1b1520 100644 --- a/src/HlcGuidFactory.cs +++ b/src/HlcGuidFactory.cs @@ -185,7 +185,54 @@ public void NewGuids(Span destination) /// public void Witness(HlcTimestamp remoteTimestamp) { - Witness(remoteTimestamp.WallTimeMs); + lock (_lock) + { + var physicalTimeMs = _timeProvider.GetUtcNow().ToUnixTimeMilliseconds(); + + // Physical time participates in max selection but has no counter/node information. + // We treat it as (physicalTimeMs, 0, 0) for ordering purposes. + var physical = new HlcTimestamp(physicalTimeMs, counter: 0, nodeId: 0); + var local = new HlcTimestamp(_logicalTimeMs, _counter, _nodeId); + + var max = local; + if (remoteTimestamp > max) max = remoteTimestamp; + if (physical > max) max = physical; + + if (max.WallTimeMs == local.WallTimeMs && max.Counter == local.Counter && max.NodeId == local.NodeId) + { + // Local time is already the max (or tied with max) - just increment counter. + _counter++; + if (_counter > MaxCounterValue) + { + _logicalTimeMs++; + _counter = 0; + } + } + else if (max.WallTimeMs == remoteTimestamp.WallTimeMs && max.Counter == remoteTimestamp.Counter && max.NodeId == remoteTimestamp.NodeId) + { + // Remote timestamp is the max - adopt its wall time and advance counter beyond it. + _logicalTimeMs = remoteTimestamp.WallTimeMs; + _counter = (ushort)(remoteTimestamp.Counter + 1); + if (_counter > MaxCounterValue) + { + _logicalTimeMs++; + _counter = 0; + } + } + else + { + // Physical time is the max - sync to it. + _logicalTimeMs = physicalTimeMs; + _counter = 0; + } + + // Check drift bounds + var drift = _logicalTimeMs - physicalTimeMs; + if (drift > _options.MaxDriftMs && _options.ThrowOnExcessiveDrift) + { + throw new HlcDriftException(drift, _options.MaxDriftMs); + } + } } /// diff --git a/tests/Clockworks.Tests.csproj b/tests/Clockworks.Tests.csproj index 1f3a18b..15e7a03 100644 --- a/tests/Clockworks.Tests.csproj +++ b/tests/Clockworks.Tests.csproj @@ -1,11 +1,7 @@ - net10.0 false - enable - enable - preview Clockworks.Tests diff --git a/tests/HlcCausalityTests.cs b/tests/HlcCausalityTests.cs index 4983e9d..109a23a 100644 --- a/tests/HlcCausalityTests.cs +++ b/tests/HlcCausalityTests.cs @@ -55,4 +55,29 @@ public void BidirectionalMessageExchange_CurrentTimestampIsNonDecreasing() Assert.True(a1 <= a2); Assert.True(b1 <= b2); } + + [Fact] + public void Receive_RemoteSameWallTimeHigherCounter_AdoptsAndExceedsRemoteCounter() + { + var tp = SimulatedTimeProvider.FromUnixMs(1_700_000_000_000); + + using var a = new HlcGuidFactory(tp, nodeId: 1); + using var b = new HlcGuidFactory(tp, nodeId: 2); + + var coordA = new HlcCoordinator(a); + var coordB = new HlcCoordinator(b); + + // Establish local time on B. + var b0 = coordB.BeforeSend(); + + // Remote timestamp has same wall time but higher counter. + var remote = new HlcTimestamp(b0.WallTimeMs, counter: (ushort)(b0.Counter + 5), nodeId: 1); + + coordB.BeforeReceive(remote); + var after = coordB.CurrentTimestamp; + + Assert.True(after > remote); + Assert.Equal(remote.WallTimeMs, after.WallTimeMs); + Assert.Equal((ushort)(remote.Counter + 1), after.Counter); + } }