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);
+ }
}