315 lines
9.8 KiB
C#
315 lines
9.8 KiB
C#
// <copyright file="HybridLogicalClockTests.cs" company="StellaOps">
|
|
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
|
// </copyright>
|
|
|
|
using FluentAssertions;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Time.Testing;
|
|
|
|
namespace StellaOps.HybridLogicalClock.Tests;
|
|
|
|
/// <summary>
|
|
/// Unit tests for <see cref="HybridLogicalClock"/>.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class HybridLogicalClockTests
|
|
{
|
|
private const string TestNodeId = "test-node-1";
|
|
private static readonly ILogger<HybridLogicalClock> NullLogger = NullLogger<HybridLogicalClock>.Instance;
|
|
|
|
[Fact]
|
|
public void Tick_Monotonic_SuccessiveTicksAlwaysIncrease()
|
|
{
|
|
// Arrange
|
|
var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
|
var stateStore = new InMemoryHlcStateStore();
|
|
var clock = new HybridLogicalClock(timeProvider, TestNodeId, stateStore, NullLogger);
|
|
|
|
// Act
|
|
var timestamps = Enumerable.Range(0, 100)
|
|
.Select(_ => clock.Tick())
|
|
.ToList();
|
|
|
|
// Assert
|
|
for (var i = 1; i < timestamps.Count; i++)
|
|
{
|
|
timestamps[i].Should().BeGreaterThan(timestamps[i - 1],
|
|
$"Timestamp {i} should be greater than timestamp {i - 1}");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Tick_SamePhysicalTime_IncrementsCounter()
|
|
{
|
|
// Arrange
|
|
var fixedTime = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
|
var timeProvider = new FakeTimeProvider(fixedTime);
|
|
var stateStore = new InMemoryHlcStateStore();
|
|
var clock = new HybridLogicalClock(timeProvider, TestNodeId, stateStore, NullLogger);
|
|
|
|
// Act
|
|
var first = clock.Tick();
|
|
var second = clock.Tick();
|
|
var third = clock.Tick();
|
|
|
|
// Assert
|
|
first.LogicalCounter.Should().Be(0);
|
|
second.LogicalCounter.Should().Be(1);
|
|
third.LogicalCounter.Should().Be(2);
|
|
|
|
// All should have same physical time
|
|
first.PhysicalTime.Should().Be(second.PhysicalTime);
|
|
second.PhysicalTime.Should().Be(third.PhysicalTime);
|
|
}
|
|
|
|
[Fact]
|
|
public void Tick_NewPhysicalTime_ResetsCounter()
|
|
{
|
|
// Arrange
|
|
var startTime = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
|
var timeProvider = new FakeTimeProvider(startTime);
|
|
var stateStore = new InMemoryHlcStateStore();
|
|
var clock = new HybridLogicalClock(timeProvider, TestNodeId, stateStore, NullLogger);
|
|
|
|
// Act - generate some ticks
|
|
clock.Tick();
|
|
clock.Tick();
|
|
var beforeAdvance = clock.Tick();
|
|
|
|
// Advance time
|
|
timeProvider.Advance(TimeSpan.FromMilliseconds(1));
|
|
var afterAdvance = clock.Tick();
|
|
|
|
// Assert
|
|
beforeAdvance.LogicalCounter.Should().Be(2);
|
|
afterAdvance.LogicalCounter.Should().Be(0);
|
|
afterAdvance.PhysicalTime.Should().BeGreaterThan(beforeAdvance.PhysicalTime);
|
|
}
|
|
|
|
[Fact]
|
|
public void Tick_NodeId_IsCorrectlySet()
|
|
{
|
|
// Arrange
|
|
var timeProvider = new FakeTimeProvider();
|
|
var stateStore = new InMemoryHlcStateStore();
|
|
var clock = new HybridLogicalClock(timeProvider, "my-custom-node", stateStore, NullLogger);
|
|
|
|
// Act
|
|
var timestamp = clock.Tick();
|
|
|
|
// Assert
|
|
timestamp.NodeId.Should().Be("my-custom-node");
|
|
clock.NodeId.Should().Be("my-custom-node");
|
|
}
|
|
|
|
[Fact]
|
|
public void Receive_RemoteTimestampAhead_MergesCorrectly()
|
|
{
|
|
// Arrange
|
|
var localTime = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
|
var timeProvider = new FakeTimeProvider(localTime);
|
|
var stateStore = new InMemoryHlcStateStore();
|
|
var clock = new HybridLogicalClock(timeProvider, TestNodeId, stateStore, NullLogger);
|
|
|
|
// Local tick first
|
|
var localTick = clock.Tick();
|
|
|
|
// Remote timestamp is 100ms ahead
|
|
var remote = new HlcTimestamp
|
|
{
|
|
PhysicalTime = localTime.AddMilliseconds(100).ToUnixTimeMilliseconds(),
|
|
NodeId = "remote-node",
|
|
LogicalCounter = 5
|
|
};
|
|
|
|
// Act
|
|
var result = clock.Receive(remote);
|
|
|
|
// Assert
|
|
result.PhysicalTime.Should().Be(remote.PhysicalTime);
|
|
result.LogicalCounter.Should().Be(6); // remote counter + 1
|
|
result.NodeId.Should().Be(TestNodeId);
|
|
}
|
|
|
|
[Fact]
|
|
public void Receive_LocalTimestampAhead_MergesCorrectly()
|
|
{
|
|
// Arrange
|
|
var localTime = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
|
var timeProvider = new FakeTimeProvider(localTime);
|
|
var stateStore = new InMemoryHlcStateStore();
|
|
var clock = new HybridLogicalClock(timeProvider, TestNodeId, stateStore, NullLogger);
|
|
|
|
// Generate several local ticks to advance counter
|
|
clock.Tick();
|
|
clock.Tick();
|
|
var localState = clock.Tick();
|
|
|
|
// Remote timestamp is behind
|
|
var remote = new HlcTimestamp
|
|
{
|
|
PhysicalTime = localTime.AddMilliseconds(-100).ToUnixTimeMilliseconds(),
|
|
NodeId = "remote-node",
|
|
LogicalCounter = 0
|
|
};
|
|
|
|
// Act
|
|
var result = clock.Receive(remote);
|
|
|
|
// Assert
|
|
result.PhysicalTime.Should().Be(localState.PhysicalTime);
|
|
result.LogicalCounter.Should().Be(localState.LogicalCounter + 1);
|
|
}
|
|
|
|
[Fact]
|
|
public void Receive_SamePhysicalTime_MergesCounters()
|
|
{
|
|
// Arrange
|
|
var localTime = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
|
var timeProvider = new FakeTimeProvider(localTime);
|
|
var stateStore = new InMemoryHlcStateStore();
|
|
var clock = new HybridLogicalClock(timeProvider, TestNodeId, stateStore, NullLogger);
|
|
|
|
// Local tick
|
|
clock.Tick();
|
|
clock.Tick();
|
|
var localState = clock.Current; // counter = 1
|
|
|
|
// Remote timestamp with same physical time but higher counter
|
|
var remote = new HlcTimestamp
|
|
{
|
|
PhysicalTime = localTime.ToUnixTimeMilliseconds(),
|
|
NodeId = "remote-node",
|
|
LogicalCounter = 10
|
|
};
|
|
|
|
// Act
|
|
var result = clock.Receive(remote);
|
|
|
|
// Assert
|
|
result.PhysicalTime.Should().Be(localTime.ToUnixTimeMilliseconds());
|
|
result.LogicalCounter.Should().Be(11); // max(local, remote) + 1
|
|
}
|
|
|
|
[Fact]
|
|
public void Receive_ClockSkewExceeded_ThrowsException()
|
|
{
|
|
// Arrange
|
|
var localTime = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
|
var timeProvider = new FakeTimeProvider(localTime);
|
|
var stateStore = new InMemoryHlcStateStore();
|
|
var maxSkew = TimeSpan.FromMinutes(1);
|
|
var clock = new HybridLogicalClock(timeProvider, TestNodeId, stateStore, NullLogger, maxSkew);
|
|
|
|
// Remote timestamp is 2 minutes ahead (exceeds 1 minute tolerance)
|
|
var remote = new HlcTimestamp
|
|
{
|
|
PhysicalTime = localTime.AddMinutes(2).ToUnixTimeMilliseconds(),
|
|
NodeId = "remote-node",
|
|
LogicalCounter = 0
|
|
};
|
|
|
|
// Act
|
|
var act = () => clock.Receive(remote);
|
|
|
|
// Assert
|
|
act.Should().Throw<HlcClockSkewException>()
|
|
.Where(e => e.MaxAllowedSkew == maxSkew)
|
|
.Where(e => e.ActualSkew > maxSkew);
|
|
}
|
|
|
|
[Fact]
|
|
public void Current_ReturnsLatestState()
|
|
{
|
|
// Arrange
|
|
var timeProvider = new FakeTimeProvider();
|
|
var stateStore = new InMemoryHlcStateStore();
|
|
var clock = new HybridLogicalClock(timeProvider, TestNodeId, stateStore, NullLogger);
|
|
|
|
// Act
|
|
var tick1 = clock.Tick();
|
|
var current1 = clock.Current;
|
|
|
|
var tick2 = clock.Tick();
|
|
var current2 = clock.Current;
|
|
|
|
// Assert
|
|
current1.Should().Be(tick1);
|
|
current2.Should().Be(tick2);
|
|
}
|
|
|
|
[Fact]
|
|
public void Tick_PersistsStateToStore()
|
|
{
|
|
// Arrange
|
|
var timeProvider = new FakeTimeProvider();
|
|
var stateStore = new InMemoryHlcStateStore();
|
|
var clock = new HybridLogicalClock(timeProvider, TestNodeId, stateStore, NullLogger);
|
|
|
|
// Act
|
|
clock.Tick();
|
|
|
|
// Assert - state should be persisted after tick
|
|
stateStore.GetAllStates().Count.Should().Be(1);
|
|
}
|
|
|
|
[Fact]
|
|
public void Constructor_NullTimeProvider_ThrowsArgumentNullException()
|
|
{
|
|
// Arrange & Act
|
|
var act = () => new HybridLogicalClock(null!, TestNodeId, new InMemoryHlcStateStore(), NullLogger);
|
|
|
|
// Assert
|
|
act.Should().Throw<ArgumentNullException>()
|
|
.WithParameterName("timeProvider");
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(null)]
|
|
[InlineData("")]
|
|
[InlineData(" ")]
|
|
public void Constructor_InvalidNodeId_ThrowsArgumentException(string? nodeId)
|
|
{
|
|
// Arrange & Act
|
|
var act = () => new HybridLogicalClock(
|
|
new FakeTimeProvider(),
|
|
nodeId!,
|
|
new InMemoryHlcStateStore(),
|
|
NullLogger);
|
|
|
|
// Assert
|
|
act.Should().Throw<ArgumentException>();
|
|
}
|
|
|
|
[Fact]
|
|
public void Constructor_NullStateStore_ThrowsArgumentNullException()
|
|
{
|
|
// Arrange & Act
|
|
var act = () => new HybridLogicalClock(
|
|
new FakeTimeProvider(),
|
|
TestNodeId,
|
|
null!,
|
|
NullLogger);
|
|
|
|
// Assert
|
|
act.Should().Throw<ArgumentNullException>()
|
|
.WithParameterName("stateStore");
|
|
}
|
|
|
|
[Fact]
|
|
public void Constructor_NullLogger_ThrowsArgumentNullException()
|
|
{
|
|
// Arrange & Act
|
|
var act = () => new HybridLogicalClock(
|
|
new FakeTimeProvider(),
|
|
TestNodeId,
|
|
new InMemoryHlcStateStore(),
|
|
null!);
|
|
|
|
// Assert
|
|
act.Should().Throw<ArgumentNullException>()
|
|
.WithParameterName("logger");
|
|
}
|
|
}
|