docs consolidation and others
This commit is contained in:
@@ -0,0 +1,545 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// 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_ThrowsArgumentException()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user