Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions Directory.Build.props
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>

<Deterministic>true</Deterministic>

<!-- SourceLink / packing best practices (safe for non-pack projects too) -->
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<EmbedUntrackedSources>true</EmbedUntrackedSources>

<!-- Recommended when producing packages in CI; safe locally too -->
<ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
</PropertyGroup>
</Project>
3 changes: 0 additions & 3 deletions demo/Clockworks.Demo/Clockworks.Demo.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,6 @@

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>

<ItemGroup>
Expand Down
1 change: 0 additions & 1 deletion property-tests/Clockworks.PropertyTests.fsproj
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>false</IsPackable>
<RootNamespace>Clockworks.PropertyTests</RootNamespace>
</PropertyGroup>
Expand Down
127 changes: 127 additions & 0 deletions property-tests/HlcCoordinatorProperties.fs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
module Clockworks.PropertyTests.HlcCoordinatorProperties

open System
open FsCheck.FSharp
open FsCheck
open FsCheck.Xunit
open Clockworks
open Clockworks.Distributed
Expand Down Expand Up @@ -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).
[<Property>]
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.
[<Property>]
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.
[<Property>]
let ``Receive then send yields timestamp after remote`` (deltaMs: uint16) =
Expand Down Expand Up @@ -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.
[<Property>]
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<HlcStep> =
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.
[<Property(Arbitrary = [| typeof<HlcStepArb> |], 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
14 changes: 2 additions & 12 deletions src/Clockworks.csproj
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>

<GenerateDocumentationFile>true</GenerateDocumentationFile>
Expand All @@ -17,7 +13,7 @@
<PackageId>Clockworks</PackageId>
<AssemblyName>Clockworks</AssemblyName>
<RootNamespace>Clockworks</RootNamespace>
<Version>1.1.1</Version>
<Version>1.2.0</Version>
<Authors>Dexter Ajoku</Authors>
<Company>CloudyBox</Company>
<Description>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.</Description>
Expand All @@ -30,13 +26,7 @@

<PackageReadmeFile>README.md</PackageReadmeFile>

<!-- SourceLink / packing best practices -->
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<EmbedUntrackedSources>true</EmbedUntrackedSources>
<Deterministic>true</Deterministic>

<!-- Recommended when producing packages in CI; safe locally too -->
<ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
<!-- SourceLink package reference is in this project; common SourceLink properties are set in Directory.Build.props -->
</PropertyGroup>

<ItemGroup>
Expand Down
8 changes: 3 additions & 5 deletions src/Distributed/HlcCoordinator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
49 changes: 48 additions & 1 deletion src/HlcGuidFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,54 @@ public void NewGuids(Span<Guid> destination)
/// <inheritdoc/>
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);
}
}
}

/// <inheritdoc/>
Expand Down
4 changes: 0 additions & 4 deletions tests/Clockworks.Tests.csproj
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>false</IsPackable>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<RootNamespace>Clockworks.Tests</RootNamespace>
</PropertyGroup>

Expand Down
25 changes: 25 additions & 0 deletions tests/HlcCausalityTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}