546 lines
16 KiB
C#
546 lines
16 KiB
C#
// -----------------------------------------------------------------------------
|
|
// HybridLogicalClockTests.cs
|
|
// Sprint: SPRINT_20260105_002_001_LB_hlc_core_library
|
|
// Task: HLC-008 - Write unit tests for HLC
|
|
// -----------------------------------------------------------------------------
|
|
|
|
// Disable xUnit analyzer warning for async methods - cancellation token not relevant for these unit tests
|
|
#pragma warning disable xUnit1051
|
|
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using StellaOps.TestKit;
|
|
using Xunit;
|
|
|
|
namespace StellaOps.HybridLogicalClock.Tests;
|
|
|
|
/// <summary>
|
|
/// Unit tests for HybridLogicalClock class.
|
|
/// </summary>
|
|
[Trait("Category", TestCategories.Unit)]
|
|
public class HybridLogicalClockTests
|
|
{
|
|
private const string TestNodeId = "test-node";
|
|
|
|
#region Tick Monotonicity Tests
|
|
|
|
[Fact]
|
|
public void Tick_Monotonic_SuccessiveTicks_AlwaysIncrease()
|
|
{
|
|
var timeProvider = new FakeTimeProvider();
|
|
var stateStore = new InMemoryHlcStateStore();
|
|
var clock = CreateClock(timeProvider, stateStore);
|
|
|
|
var timestamps = new List<HlcTimestamp>();
|
|
|
|
// Generate multiple ticks at the same physical time
|
|
for (var i = 0; i < 100; i++)
|
|
{
|
|
timestamps.Add(clock.Tick());
|
|
}
|
|
|
|
// Verify each subsequent timestamp is greater
|
|
for (var i = 1; i < timestamps.Count; i++)
|
|
{
|
|
Assert.True(timestamps[i] > timestamps[i - 1],
|
|
$"Timestamp {i} should be greater than timestamp {i - 1}");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Tick_Monotonic_EvenWithBackwardPhysicalTime()
|
|
{
|
|
var timeProvider = new FakeTimeProvider();
|
|
var stateStore = new InMemoryHlcStateStore();
|
|
var clock = CreateClock(timeProvider, stateStore);
|
|
|
|
// Get first timestamp
|
|
var ts1 = clock.Tick();
|
|
|
|
// Move time backward (simulating clock adjustment)
|
|
timeProvider.Advance(TimeSpan.FromSeconds(-5));
|
|
|
|
// Get second timestamp - should still be greater
|
|
var ts2 = clock.Tick();
|
|
|
|
Assert.True(ts2 > ts1, "HLC should maintain monotonicity even with backward physical time");
|
|
}
|
|
|
|
[Fact]
|
|
public void Tick_SamePhysicalTime_IncrementCounter()
|
|
{
|
|
var timeProvider = new FakeTimeProvider();
|
|
var stateStore = new InMemoryHlcStateStore();
|
|
var clock = CreateClock(timeProvider, stateStore);
|
|
|
|
var ts1 = clock.Tick();
|
|
var ts2 = clock.Tick();
|
|
|
|
Assert.Equal(ts1.PhysicalTime, ts2.PhysicalTime);
|
|
Assert.Equal(ts1.LogicalCounter + 1, ts2.LogicalCounter);
|
|
}
|
|
|
|
[Fact]
|
|
public void Tick_NewPhysicalTime_ResetCounter()
|
|
{
|
|
var timeProvider = new FakeTimeProvider();
|
|
var stateStore = new InMemoryHlcStateStore();
|
|
var clock = CreateClock(timeProvider, stateStore);
|
|
|
|
// First tick
|
|
var ts1 = clock.Tick();
|
|
clock.Tick(); // Counter = 1
|
|
clock.Tick(); // Counter = 2
|
|
|
|
// Advance physical time
|
|
timeProvider.Advance(TimeSpan.FromMilliseconds(1));
|
|
|
|
// Next tick should reset counter
|
|
var ts2 = clock.Tick();
|
|
|
|
Assert.True(ts2.PhysicalTime > ts1.PhysicalTime);
|
|
Assert.Equal(0, ts2.LogicalCounter);
|
|
}
|
|
|
|
[Fact]
|
|
public void Tick_HighFrequency_AllUnique()
|
|
{
|
|
var timeProvider = new FakeTimeProvider();
|
|
var stateStore = new InMemoryHlcStateStore();
|
|
var clock = CreateClock(timeProvider, stateStore);
|
|
|
|
var timestamps = new HashSet<string>();
|
|
|
|
for (var i = 0; i < 10000; i++)
|
|
{
|
|
var ts = clock.Tick();
|
|
var str = ts.ToSortableString();
|
|
Assert.True(timestamps.Add(str), $"Duplicate timestamp detected: {str}");
|
|
}
|
|
|
|
Assert.Equal(10000, timestamps.Count);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Receive Tests
|
|
|
|
[Fact]
|
|
public void Receive_MergesCorrectly_WhenRemoteIsAhead()
|
|
{
|
|
var timeProvider = new FakeTimeProvider();
|
|
timeProvider.SetUtcNow(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
|
|
|
var stateStore = new InMemoryHlcStateStore();
|
|
var clock = CreateClock(timeProvider, stateStore);
|
|
|
|
// Create a remote timestamp in the future (but within skew threshold)
|
|
var remote = new HlcTimestamp
|
|
{
|
|
PhysicalTime = timeProvider.GetUtcNow().ToUnixTimeMilliseconds() + 10000, // 10 seconds ahead
|
|
NodeId = "remote-node",
|
|
LogicalCounter = 5
|
|
};
|
|
|
|
var result = clock.Receive(remote);
|
|
|
|
// Result should be at remote's physical time with incremented counter
|
|
Assert.Equal(remote.PhysicalTime, result.PhysicalTime);
|
|
Assert.Equal(remote.LogicalCounter + 1, result.LogicalCounter);
|
|
Assert.Equal(TestNodeId, result.NodeId);
|
|
}
|
|
|
|
[Fact]
|
|
public void Receive_MergesCorrectly_WhenLocalIsAhead()
|
|
{
|
|
var timeProvider = new FakeTimeProvider();
|
|
timeProvider.SetUtcNow(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
|
|
|
var stateStore = new InMemoryHlcStateStore();
|
|
var clock = CreateClock(timeProvider, stateStore);
|
|
|
|
// Tick to advance local clock
|
|
var localTs = clock.Tick();
|
|
|
|
// Create a remote timestamp in the past
|
|
var remote = new HlcTimestamp
|
|
{
|
|
PhysicalTime = localTs.PhysicalTime - 5000, // 5 seconds behind
|
|
NodeId = "remote-node",
|
|
LogicalCounter = 10
|
|
};
|
|
|
|
var result = clock.Receive(remote);
|
|
|
|
// Result should maintain local physical time and increment local counter
|
|
Assert.Equal(localTs.PhysicalTime, result.PhysicalTime);
|
|
Assert.True(result.LogicalCounter > localTs.LogicalCounter);
|
|
}
|
|
|
|
[Fact]
|
|
public void Receive_MergesCorrectly_WhenTimesEqual()
|
|
{
|
|
var timeProvider = new FakeTimeProvider();
|
|
timeProvider.SetUtcNow(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
|
|
|
var stateStore = new InMemoryHlcStateStore();
|
|
var clock = CreateClock(timeProvider, stateStore);
|
|
|
|
// Tick to establish local state
|
|
var localTs = clock.Tick();
|
|
|
|
// Create remote with same physical time but higher counter
|
|
var remote = new HlcTimestamp
|
|
{
|
|
PhysicalTime = localTs.PhysicalTime,
|
|
NodeId = "remote-node",
|
|
LogicalCounter = 100
|
|
};
|
|
|
|
var result = clock.Receive(remote);
|
|
|
|
// Result should have same physical time, counter = max(local, remote) + 1
|
|
Assert.Equal(localTs.PhysicalTime, result.PhysicalTime);
|
|
Assert.Equal(101, result.LogicalCounter); // max(1, 100) + 1
|
|
}
|
|
|
|
[Fact]
|
|
public void Receive_AfterReceive_MaintainsMonotonicity()
|
|
{
|
|
var timeProvider = new FakeTimeProvider();
|
|
var stateStore = new InMemoryHlcStateStore();
|
|
var clock = CreateClock(timeProvider, stateStore);
|
|
|
|
var ts1 = clock.Tick();
|
|
|
|
var remote = new HlcTimestamp
|
|
{
|
|
PhysicalTime = ts1.PhysicalTime + 1000,
|
|
NodeId = "remote",
|
|
LogicalCounter = 5
|
|
};
|
|
|
|
var ts2 = clock.Receive(remote);
|
|
var ts3 = clock.Tick();
|
|
|
|
Assert.True(ts2 > ts1);
|
|
Assert.True(ts3 > ts2);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Clock Skew Tests
|
|
|
|
[Fact]
|
|
public void Receive_ClockSkewExceeded_ThrowsHlcClockSkewException()
|
|
{
|
|
var timeProvider = new FakeTimeProvider();
|
|
timeProvider.SetUtcNow(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
|
|
|
var maxSkew = TimeSpan.FromSeconds(30);
|
|
var stateStore = new InMemoryHlcStateStore();
|
|
var clock = CreateClock(timeProvider, stateStore, maxSkew);
|
|
|
|
// Create remote timestamp with excessive skew
|
|
var remote = new HlcTimestamp
|
|
{
|
|
PhysicalTime = timeProvider.GetUtcNow().ToUnixTimeMilliseconds() + 60_000, // 60 seconds ahead
|
|
NodeId = "remote-node",
|
|
LogicalCounter = 0
|
|
};
|
|
|
|
var exception = Assert.Throws<HlcClockSkewException>(() => clock.Receive(remote));
|
|
|
|
Assert.True(exception.ActualSkew > maxSkew);
|
|
Assert.Equal(maxSkew, exception.MaxAllowedSkew);
|
|
}
|
|
|
|
[Fact]
|
|
public void Receive_WithinSkewThreshold_Succeeds()
|
|
{
|
|
var timeProvider = new FakeTimeProvider();
|
|
timeProvider.SetUtcNow(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
|
|
|
var maxSkew = TimeSpan.FromMinutes(1);
|
|
var stateStore = new InMemoryHlcStateStore();
|
|
var clock = CreateClock(timeProvider, stateStore, maxSkew);
|
|
|
|
// Create remote timestamp just within threshold
|
|
var remote = new HlcTimestamp
|
|
{
|
|
PhysicalTime = timeProvider.GetUtcNow().ToUnixTimeMilliseconds() + 55_000, // 55 seconds
|
|
NodeId = "remote-node",
|
|
LogicalCounter = 0
|
|
};
|
|
|
|
var result = clock.Receive(remote);
|
|
|
|
Assert.Equal(TestNodeId, result.NodeId);
|
|
}
|
|
|
|
[Fact]
|
|
public void Receive_NegativeSkew_StillChecked()
|
|
{
|
|
var timeProvider = new FakeTimeProvider();
|
|
timeProvider.SetUtcNow(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
|
|
|
var maxSkew = TimeSpan.FromSeconds(30);
|
|
var stateStore = new InMemoryHlcStateStore();
|
|
var clock = CreateClock(timeProvider, stateStore, maxSkew);
|
|
|
|
// Create remote timestamp far in the past
|
|
var remote = new HlcTimestamp
|
|
{
|
|
PhysicalTime = timeProvider.GetUtcNow().ToUnixTimeMilliseconds() - 60_000, // 60 seconds behind
|
|
NodeId = "remote-node",
|
|
LogicalCounter = 0
|
|
};
|
|
|
|
Assert.Throws<HlcClockSkewException>(() => clock.Receive(remote));
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Current Property Tests
|
|
|
|
[Fact]
|
|
public void Current_ReturnsCurrentState()
|
|
{
|
|
var timeProvider = new FakeTimeProvider();
|
|
var stateStore = new InMemoryHlcStateStore();
|
|
var clock = CreateClock(timeProvider, stateStore);
|
|
|
|
var ts = clock.Tick();
|
|
var current = clock.Current;
|
|
|
|
Assert.Equal(ts.PhysicalTime, current.PhysicalTime);
|
|
Assert.Equal(ts.LogicalCounter, current.LogicalCounter);
|
|
Assert.Equal(ts.NodeId, current.NodeId);
|
|
}
|
|
|
|
[Fact]
|
|
public void NodeId_ReturnsConfiguredNodeId()
|
|
{
|
|
var timeProvider = new FakeTimeProvider();
|
|
var stateStore = new InMemoryHlcStateStore();
|
|
var clock = CreateClock(timeProvider, stateStore);
|
|
|
|
Assert.Equal(TestNodeId, clock.NodeId);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region State Initialization Tests
|
|
|
|
[Fact]
|
|
public async Task InitializeFromStateAsync_WithNoPersistedState_ReturnsFalse()
|
|
{
|
|
var timeProvider = new FakeTimeProvider();
|
|
var stateStore = new InMemoryHlcStateStore();
|
|
var clock = CreateClock(timeProvider, stateStore);
|
|
|
|
var result = await clock.InitializeFromStateAsync();
|
|
|
|
Assert.False(result);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task InitializeFromStateAsync_WithPersistedState_ReturnsTrue()
|
|
{
|
|
var timeProvider = new FakeTimeProvider();
|
|
var stateStore = new InMemoryHlcStateStore();
|
|
|
|
// Pre-persist some state
|
|
var persistedState = new HlcTimestamp
|
|
{
|
|
PhysicalTime = 1000,
|
|
NodeId = TestNodeId,
|
|
LogicalCounter = 50
|
|
};
|
|
await stateStore.SaveAsync(persistedState);
|
|
|
|
var clock = CreateClock(timeProvider, stateStore);
|
|
var result = await clock.InitializeFromStateAsync();
|
|
|
|
Assert.True(result);
|
|
|
|
// Next tick should be greater than persisted state
|
|
var ts = clock.Tick();
|
|
Assert.True(ts > persistedState);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task InitializeFromStateAsync_ResumesFromPersistedState()
|
|
{
|
|
var timeProvider = new FakeTimeProvider();
|
|
timeProvider.SetUtcNow(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
|
|
|
var stateStore = new InMemoryHlcStateStore();
|
|
|
|
// Pre-persist state at current physical time
|
|
var persistedState = new HlcTimestamp
|
|
{
|
|
PhysicalTime = timeProvider.GetUtcNow().ToUnixTimeMilliseconds(),
|
|
NodeId = TestNodeId,
|
|
LogicalCounter = 50
|
|
};
|
|
await stateStore.SaveAsync(persistedState);
|
|
|
|
var clock = CreateClock(timeProvider, stateStore);
|
|
await clock.InitializeFromStateAsync();
|
|
|
|
// Since physical time matches, counter should be incremented
|
|
var current = clock.Current;
|
|
Assert.Equal(persistedState.PhysicalTime, current.PhysicalTime);
|
|
Assert.True(current.LogicalCounter > persistedState.LogicalCounter);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region State Persistence Tests
|
|
|
|
[Fact]
|
|
public async Task Tick_PersistsState()
|
|
{
|
|
var timeProvider = new FakeTimeProvider();
|
|
var stateStore = new InMemoryHlcStateStore();
|
|
var clock = CreateClock(timeProvider, stateStore);
|
|
|
|
var ts = clock.Tick();
|
|
|
|
// Give async persistence time to complete
|
|
await Task.Delay(10);
|
|
|
|
var persisted = await stateStore.LoadAsync(TestNodeId);
|
|
Assert.NotNull(persisted);
|
|
Assert.Equal(ts.PhysicalTime, persisted.Value.PhysicalTime);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Constructor Validation Tests
|
|
|
|
[Fact]
|
|
public void Constructor_NullTimeProvider_ThrowsArgumentNullException()
|
|
{
|
|
Assert.Throws<ArgumentNullException>(() =>
|
|
new HybridLogicalClock(
|
|
null!,
|
|
TestNodeId,
|
|
new InMemoryHlcStateStore(),
|
|
NullLogger<HybridLogicalClock>.Instance));
|
|
}
|
|
|
|
[Fact]
|
|
public void Constructor_NullNodeId_ThrowsArgumentNullException()
|
|
{
|
|
Assert.Throws<ArgumentNullException>(() =>
|
|
new HybridLogicalClock(
|
|
TimeProvider.System,
|
|
null!,
|
|
new InMemoryHlcStateStore(),
|
|
NullLogger<HybridLogicalClock>.Instance));
|
|
}
|
|
|
|
[Fact]
|
|
public void Constructor_EmptyNodeId_ThrowsArgumentException()
|
|
{
|
|
Assert.Throws<ArgumentException>(() =>
|
|
new HybridLogicalClock(
|
|
TimeProvider.System,
|
|
"",
|
|
new InMemoryHlcStateStore(),
|
|
NullLogger<HybridLogicalClock>.Instance));
|
|
}
|
|
|
|
[Fact]
|
|
public void Constructor_NullStateStore_ThrowsArgumentNullException()
|
|
{
|
|
Assert.Throws<ArgumentNullException>(() =>
|
|
new HybridLogicalClock(
|
|
TimeProvider.System,
|
|
TestNodeId,
|
|
null!,
|
|
NullLogger<HybridLogicalClock>.Instance));
|
|
}
|
|
|
|
[Fact]
|
|
public void Constructor_NullLogger_ThrowsArgumentNullException()
|
|
{
|
|
Assert.Throws<ArgumentNullException>(() =>
|
|
new HybridLogicalClock(
|
|
TimeProvider.System,
|
|
TestNodeId,
|
|
new InMemoryHlcStateStore(),
|
|
null!));
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Causal Ordering Tests
|
|
|
|
[Fact]
|
|
public void Tick_Receive_Tick_MaintainsCausalOrder()
|
|
{
|
|
// Simulates message exchange between two nodes
|
|
var timeProvider1 = new FakeTimeProvider();
|
|
var timeProvider2 = new FakeTimeProvider();
|
|
var startTime = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
|
timeProvider1.SetUtcNow(startTime);
|
|
timeProvider2.SetUtcNow(startTime);
|
|
|
|
var clock1 = CreateClock(timeProvider1, new InMemoryHlcStateStore(), nodeId: "node-1");
|
|
var clock2 = CreateClock(timeProvider2, new InMemoryHlcStateStore(), nodeId: "node-2");
|
|
|
|
// Node 1 sends event
|
|
var send1 = clock1.Tick();
|
|
|
|
// Node 2 receives event
|
|
var recv2 = clock2.Receive(send1);
|
|
|
|
// Node 2 sends reply
|
|
var send2 = clock2.Tick();
|
|
|
|
// Node 1 receives reply
|
|
var recv1 = clock1.Receive(send2);
|
|
|
|
// Causal order: send1 < recv2 < send2 < recv1
|
|
Assert.True(send1 < recv2);
|
|
Assert.True(recv2 < send2);
|
|
Assert.True(send2 < recv1);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Helper Methods
|
|
|
|
private static HybridLogicalClock CreateClock(
|
|
TimeProvider timeProvider,
|
|
IHlcStateStore stateStore,
|
|
TimeSpan? maxSkew = null,
|
|
string nodeId = TestNodeId)
|
|
{
|
|
return new HybridLogicalClock(
|
|
timeProvider,
|
|
nodeId,
|
|
stateStore,
|
|
NullLogger<HybridLogicalClock>.Instance,
|
|
maxSkew);
|
|
}
|
|
|
|
#endregion
|
|
|
|
/// <summary>
|
|
/// Fake TimeProvider for deterministic testing.
|
|
/// </summary>
|
|
private sealed class FakeTimeProvider : TimeProvider
|
|
{
|
|
private DateTimeOffset _now = DateTimeOffset.UtcNow;
|
|
|
|
public override DateTimeOffset GetUtcNow() => _now;
|
|
|
|
public void SetUtcNow(DateTimeOffset value) => _now = value;
|
|
|
|
public void Advance(TimeSpan duration) => _now = _now.Add(duration);
|
|
}
|
|
}
|