docs consolidation and others
This commit is contained in:
@@ -0,0 +1,452 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// HlcTimestampJsonConverterTests.cs
|
||||
// Sprint: SPRINT_20260105_002_001_LB_hlc_core_library
|
||||
// Task: HLC-008 - Write unit tests for HLC
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.HybridLogicalClock.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for HlcTimestamp JSON converters.
|
||||
/// </summary>
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
public class HlcTimestampJsonConverterTests
|
||||
{
|
||||
private const long BasePhysicalTime = 1704067200000L;
|
||||
|
||||
#region HlcTimestampJsonConverter Tests
|
||||
|
||||
[Fact]
|
||||
public void StringConverter_Serialize_WritesAsString()
|
||||
{
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
Converters = { new HlcTimestampJsonConverter() }
|
||||
};
|
||||
|
||||
var timestamp = new HlcTimestamp
|
||||
{
|
||||
PhysicalTime = BasePhysicalTime,
|
||||
NodeId = "test-node",
|
||||
LogicalCounter = 42
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(timestamp, options);
|
||||
|
||||
Assert.Equal("\"1704067200000-test-node-000042\"", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StringConverter_Deserialize_ReadsFromString()
|
||||
{
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
Converters = { new HlcTimestampJsonConverter() }
|
||||
};
|
||||
|
||||
var json = "\"1704067200000-test-node-000042\"";
|
||||
var result = JsonSerializer.Deserialize<HlcTimestamp>(json, options);
|
||||
|
||||
Assert.Equal(BasePhysicalTime, result.PhysicalTime);
|
||||
Assert.Equal("test-node", result.NodeId);
|
||||
Assert.Equal(42, result.LogicalCounter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StringConverter_RoundTrip_PreservesValues()
|
||||
{
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
Converters = { new HlcTimestampJsonConverter() }
|
||||
};
|
||||
|
||||
var original = new HlcTimestamp
|
||||
{
|
||||
PhysicalTime = BasePhysicalTime,
|
||||
NodeId = "scheduler-east-1",
|
||||
LogicalCounter = 999999
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(original, options);
|
||||
var deserialized = JsonSerializer.Deserialize<HlcTimestamp>(json, options);
|
||||
|
||||
Assert.Equal(original.PhysicalTime, deserialized.PhysicalTime);
|
||||
Assert.Equal(original.NodeId, deserialized.NodeId);
|
||||
Assert.Equal(original.LogicalCounter, deserialized.LogicalCounter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StringConverter_Deserialize_NullToken_ThrowsJsonException()
|
||||
{
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
Converters = { new HlcTimestampJsonConverter() }
|
||||
};
|
||||
|
||||
Assert.Throws<JsonException>(() =>
|
||||
JsonSerializer.Deserialize<HlcTimestamp>("null", options));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StringConverter_Deserialize_NumberToken_ThrowsJsonException()
|
||||
{
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
Converters = { new HlcTimestampJsonConverter() }
|
||||
};
|
||||
|
||||
Assert.Throws<JsonException>(() =>
|
||||
JsonSerializer.Deserialize<HlcTimestamp>("12345", options));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StringConverter_Deserialize_EmptyString_ThrowsJsonException()
|
||||
{
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
Converters = { new HlcTimestampJsonConverter() }
|
||||
};
|
||||
|
||||
Assert.Throws<JsonException>(() =>
|
||||
JsonSerializer.Deserialize<HlcTimestamp>("\"\"", options));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StringConverter_Deserialize_InvalidFormat_ThrowsJsonException()
|
||||
{
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
Converters = { new HlcTimestampJsonConverter() }
|
||||
};
|
||||
|
||||
var ex = Assert.Throws<JsonException>(() =>
|
||||
JsonSerializer.Deserialize<HlcTimestamp>("\"invalid-format\"", options));
|
||||
|
||||
Assert.Contains("Invalid HlcTimestamp format", ex.Message);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region NullableHlcTimestampJsonConverter Tests
|
||||
|
||||
[Fact]
|
||||
public void NullableConverter_Serialize_NullValue_WritesNull()
|
||||
{
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
Converters = { new NullableHlcTimestampJsonConverter() }
|
||||
};
|
||||
|
||||
HlcTimestamp? value = null;
|
||||
var json = JsonSerializer.Serialize(value, options);
|
||||
|
||||
Assert.Equal("null", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullableConverter_Serialize_NonNullValue_WritesString()
|
||||
{
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
Converters = { new NullableHlcTimestampJsonConverter() }
|
||||
};
|
||||
|
||||
HlcTimestamp? value = new HlcTimestamp
|
||||
{
|
||||
PhysicalTime = BasePhysicalTime,
|
||||
NodeId = "test-node",
|
||||
LogicalCounter = 42
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(value, options);
|
||||
|
||||
Assert.Equal("\"1704067200000-test-node-000042\"", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullableConverter_Deserialize_NullToken_ReturnsNull()
|
||||
{
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
Converters = { new NullableHlcTimestampJsonConverter() }
|
||||
};
|
||||
|
||||
var result = JsonSerializer.Deserialize<HlcTimestamp?>("null", options);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullableConverter_Deserialize_ValidString_ReturnsValue()
|
||||
{
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
Converters = { new NullableHlcTimestampJsonConverter() }
|
||||
};
|
||||
|
||||
var result = JsonSerializer.Deserialize<HlcTimestamp?>(
|
||||
"\"1704067200000-test-node-000042\"", options);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(BasePhysicalTime, result.Value.PhysicalTime);
|
||||
Assert.Equal("test-node", result.Value.NodeId);
|
||||
Assert.Equal(42, result.Value.LogicalCounter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullableConverter_RoundTrip_NullValue()
|
||||
{
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
Converters = { new NullableHlcTimestampJsonConverter() }
|
||||
};
|
||||
|
||||
HlcTimestamp? original = null;
|
||||
var json = JsonSerializer.Serialize(original, options);
|
||||
var result = JsonSerializer.Deserialize<HlcTimestamp?>(json, options);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullableConverter_RoundTrip_NonNullValue()
|
||||
{
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
Converters = { new NullableHlcTimestampJsonConverter() }
|
||||
};
|
||||
|
||||
HlcTimestamp? original = new HlcTimestamp
|
||||
{
|
||||
PhysicalTime = BasePhysicalTime,
|
||||
NodeId = "node",
|
||||
LogicalCounter = 100
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(original, options);
|
||||
var result = JsonSerializer.Deserialize<HlcTimestamp?>(json, options);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(original.Value.PhysicalTime, result.Value.PhysicalTime);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region HlcTimestampObjectJsonConverter Tests
|
||||
|
||||
[Fact]
|
||||
public void ObjectConverter_Serialize_WritesObject()
|
||||
{
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
Converters = { new HlcTimestampObjectJsonConverter() }
|
||||
};
|
||||
|
||||
var timestamp = new HlcTimestamp
|
||||
{
|
||||
PhysicalTime = BasePhysicalTime,
|
||||
NodeId = "test-node",
|
||||
LogicalCounter = 42
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(timestamp, options);
|
||||
|
||||
var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
Assert.Equal(BasePhysicalTime, root.GetProperty("physicalTime").GetInt64());
|
||||
Assert.Equal("test-node", root.GetProperty("nodeId").GetString());
|
||||
Assert.Equal(42, root.GetProperty("logicalCounter").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObjectConverter_Deserialize_ReadsObject()
|
||||
{
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
Converters = { new HlcTimestampObjectJsonConverter() }
|
||||
};
|
||||
|
||||
var json = """{"physicalTime":1704067200000,"nodeId":"test-node","logicalCounter":42}""";
|
||||
var result = JsonSerializer.Deserialize<HlcTimestamp>(json, options);
|
||||
|
||||
Assert.Equal(BasePhysicalTime, result.PhysicalTime);
|
||||
Assert.Equal("test-node", result.NodeId);
|
||||
Assert.Equal(42, result.LogicalCounter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObjectConverter_Deserialize_PascalCaseProperties_Works()
|
||||
{
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
Converters = { new HlcTimestampObjectJsonConverter() }
|
||||
};
|
||||
|
||||
var json = """{"PhysicalTime":1704067200000,"NodeId":"test-node","LogicalCounter":42}""";
|
||||
var result = JsonSerializer.Deserialize<HlcTimestamp>(json, options);
|
||||
|
||||
Assert.Equal(BasePhysicalTime, result.PhysicalTime);
|
||||
Assert.Equal("test-node", result.NodeId);
|
||||
Assert.Equal(42, result.LogicalCounter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObjectConverter_Deserialize_MissingPhysicalTime_ThrowsJsonException()
|
||||
{
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
Converters = { new HlcTimestampObjectJsonConverter() }
|
||||
};
|
||||
|
||||
var json = """{"nodeId":"test-node","logicalCounter":42}""";
|
||||
|
||||
var ex = Assert.Throws<JsonException>(() =>
|
||||
JsonSerializer.Deserialize<HlcTimestamp>(json, options));
|
||||
|
||||
Assert.Contains("physicalTime", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObjectConverter_Deserialize_MissingNodeId_ThrowsJsonException()
|
||||
{
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
Converters = { new HlcTimestampObjectJsonConverter() }
|
||||
};
|
||||
|
||||
var json = """{"physicalTime":1704067200000,"logicalCounter":42}""";
|
||||
|
||||
var ex = Assert.Throws<JsonException>(() =>
|
||||
JsonSerializer.Deserialize<HlcTimestamp>(json, options));
|
||||
|
||||
Assert.Contains("nodeId", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObjectConverter_Deserialize_MissingLogicalCounter_ThrowsJsonException()
|
||||
{
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
Converters = { new HlcTimestampObjectJsonConverter() }
|
||||
};
|
||||
|
||||
var json = """{"physicalTime":1704067200000,"nodeId":"test-node"}""";
|
||||
|
||||
var ex = Assert.Throws<JsonException>(() =>
|
||||
JsonSerializer.Deserialize<HlcTimestamp>(json, options));
|
||||
|
||||
Assert.Contains("logicalCounter", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObjectConverter_Deserialize_ExtraProperties_Ignored()
|
||||
{
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
Converters = { new HlcTimestampObjectJsonConverter() }
|
||||
};
|
||||
|
||||
var json = """{"physicalTime":1704067200000,"nodeId":"test-node","logicalCounter":42,"extra":"ignored"}""";
|
||||
var result = JsonSerializer.Deserialize<HlcTimestamp>(json, options);
|
||||
|
||||
Assert.Equal(BasePhysicalTime, result.PhysicalTime);
|
||||
Assert.Equal("test-node", result.NodeId);
|
||||
Assert.Equal(42, result.LogicalCounter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObjectConverter_Deserialize_StringToken_ThrowsJsonException()
|
||||
{
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
Converters = { new HlcTimestampObjectJsonConverter() }
|
||||
};
|
||||
|
||||
Assert.Throws<JsonException>(() =>
|
||||
JsonSerializer.Deserialize<HlcTimestamp>("\"not-an-object\"", options));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObjectConverter_RoundTrip_PreservesValues()
|
||||
{
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
Converters = { new HlcTimestampObjectJsonConverter() }
|
||||
};
|
||||
|
||||
var original = new HlcTimestamp
|
||||
{
|
||||
PhysicalTime = BasePhysicalTime,
|
||||
NodeId = "scheduler-east-1",
|
||||
LogicalCounter = 999999
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(original, options);
|
||||
var deserialized = JsonSerializer.Deserialize<HlcTimestamp>(json, options);
|
||||
|
||||
Assert.Equal(original.PhysicalTime, deserialized.PhysicalTime);
|
||||
Assert.Equal(original.NodeId, deserialized.NodeId);
|
||||
Assert.Equal(original.LogicalCounter, deserialized.LogicalCounter);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Object with HlcTimestamp Property Tests
|
||||
|
||||
[Fact]
|
||||
public void StringConverter_InObject_SerializesCorrectly()
|
||||
{
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
Converters = { new HlcTimestampJsonConverter() }
|
||||
};
|
||||
|
||||
var obj = new TestRecordWithTimestamp
|
||||
{
|
||||
Id = "test-123",
|
||||
Timestamp = new HlcTimestamp
|
||||
{
|
||||
PhysicalTime = BasePhysicalTime,
|
||||
NodeId = "node",
|
||||
LogicalCounter = 0
|
||||
}
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(obj, options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
Assert.Equal("test-123", doc.RootElement.GetProperty("Id").GetString());
|
||||
Assert.Equal("1704067200000-node-000000", doc.RootElement.GetProperty("Timestamp").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StringConverter_InObject_DeserializesCorrectly()
|
||||
{
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
Converters = { new HlcTimestampJsonConverter() }
|
||||
};
|
||||
|
||||
var json = """{"Id":"test-123","Timestamp":"1704067200000-node-000042"}""";
|
||||
var result = JsonSerializer.Deserialize<TestRecordWithTimestamp>(json, options);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("test-123", result.Id);
|
||||
Assert.Equal(BasePhysicalTime, result.Timestamp.PhysicalTime);
|
||||
Assert.Equal("node", result.Timestamp.NodeId);
|
||||
Assert.Equal(42, result.Timestamp.LogicalCounter);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private sealed record TestRecordWithTimestamp
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required HlcTimestamp Timestamp { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,551 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// HlcTimestampTests.cs
|
||||
// Sprint: SPRINT_20260105_002_001_LB_hlc_core_library
|
||||
// Task: HLC-008 - Write unit tests for HLC
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.HybridLogicalClock.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for HlcTimestamp record struct.
|
||||
/// </summary>
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
public class HlcTimestampTests
|
||||
{
|
||||
private const string TestNodeId = "test-node-1";
|
||||
private const long BasePhysicalTime = 1704067200000L; // 2024-01-01T00:00:00Z
|
||||
|
||||
#region ToSortableString Tests
|
||||
|
||||
[Fact]
|
||||
public void ToSortableString_FormatsCorrectly()
|
||||
{
|
||||
var timestamp = new HlcTimestamp
|
||||
{
|
||||
PhysicalTime = BasePhysicalTime,
|
||||
NodeId = TestNodeId,
|
||||
LogicalCounter = 42
|
||||
};
|
||||
|
||||
var result = timestamp.ToSortableString();
|
||||
|
||||
Assert.Equal("1704067200000-test-node-1-000042", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToSortableString_ZeroPadsPhysicalTime()
|
||||
{
|
||||
var timestamp = new HlcTimestamp
|
||||
{
|
||||
PhysicalTime = 123L,
|
||||
NodeId = TestNodeId,
|
||||
LogicalCounter = 0
|
||||
};
|
||||
|
||||
var result = timestamp.ToSortableString();
|
||||
|
||||
Assert.StartsWith("0000000000123-", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToSortableString_ZeroPadsCounter()
|
||||
{
|
||||
var timestamp = new HlcTimestamp
|
||||
{
|
||||
PhysicalTime = BasePhysicalTime,
|
||||
NodeId = TestNodeId,
|
||||
LogicalCounter = 1
|
||||
};
|
||||
|
||||
var result = timestamp.ToSortableString();
|
||||
|
||||
Assert.EndsWith("-000001", result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Parse Tests
|
||||
|
||||
[Fact]
|
||||
public void Parse_ValidString_ReturnsTimestamp()
|
||||
{
|
||||
var result = HlcTimestamp.Parse("1704067200000-test-node-1-000042");
|
||||
|
||||
Assert.Equal(BasePhysicalTime, result.PhysicalTime);
|
||||
Assert.Equal("test-node-1", result.NodeId);
|
||||
Assert.Equal(42, result.LogicalCounter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_RoundTrip_PreservesValues()
|
||||
{
|
||||
var original = new HlcTimestamp
|
||||
{
|
||||
PhysicalTime = BasePhysicalTime,
|
||||
NodeId = "scheduler-east-1",
|
||||
LogicalCounter = 999999
|
||||
};
|
||||
|
||||
var serialized = original.ToSortableString();
|
||||
var parsed = HlcTimestamp.Parse(serialized);
|
||||
|
||||
Assert.Equal(original.PhysicalTime, parsed.PhysicalTime);
|
||||
Assert.Equal(original.NodeId, parsed.NodeId);
|
||||
Assert.Equal(original.LogicalCounter, parsed.LogicalCounter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_NullString_ThrowsArgumentException()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => HlcTimestamp.Parse(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_EmptyString_ThrowsArgumentException()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => HlcTimestamp.Parse(""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_WhitespaceString_ThrowsArgumentException()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => HlcTimestamp.Parse(" "));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_InvalidFormat_ThrowsFormatException()
|
||||
{
|
||||
Assert.Throws<FormatException>(() => HlcTimestamp.Parse("invalid-format"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_MissingCounter_ThrowsFormatException()
|
||||
{
|
||||
Assert.Throws<FormatException>(() => HlcTimestamp.Parse("1704067200000-node"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_ShortPhysicalTime_ThrowsFormatException()
|
||||
{
|
||||
Assert.Throws<FormatException>(() => HlcTimestamp.Parse("170406720000-node-000042"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_ShortCounter_ThrowsFormatException()
|
||||
{
|
||||
Assert.Throws<FormatException>(() => HlcTimestamp.Parse("1704067200000-node-00042"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TryParse Tests
|
||||
|
||||
[Fact]
|
||||
public void TryParse_ValidString_ReturnsTrue()
|
||||
{
|
||||
var success = HlcTimestamp.TryParse("1704067200000-test-node-1-000042", out var result);
|
||||
|
||||
Assert.True(success);
|
||||
Assert.Equal(BasePhysicalTime, result.PhysicalTime);
|
||||
Assert.Equal("test-node-1", result.NodeId);
|
||||
Assert.Equal(42, result.LogicalCounter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_InvalidString_ReturnsFalse()
|
||||
{
|
||||
var success = HlcTimestamp.TryParse("invalid", out var result);
|
||||
|
||||
Assert.False(success);
|
||||
Assert.Equal(default, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_Null_ReturnsFalse()
|
||||
{
|
||||
var success = HlcTimestamp.TryParse(null, out var result);
|
||||
|
||||
Assert.False(success);
|
||||
Assert.Equal(default, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_Empty_ReturnsFalse()
|
||||
{
|
||||
var success = HlcTimestamp.TryParse("", out var result);
|
||||
|
||||
Assert.False(success);
|
||||
Assert.Equal(default, result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CompareTo Tests
|
||||
|
||||
[Fact]
|
||||
public void CompareTo_EarlierPhysicalTime_ReturnsNegative()
|
||||
{
|
||||
var earlier = new HlcTimestamp
|
||||
{
|
||||
PhysicalTime = BasePhysicalTime,
|
||||
NodeId = TestNodeId,
|
||||
LogicalCounter = 0
|
||||
};
|
||||
var later = new HlcTimestamp
|
||||
{
|
||||
PhysicalTime = BasePhysicalTime + 1000,
|
||||
NodeId = TestNodeId,
|
||||
LogicalCounter = 0
|
||||
};
|
||||
|
||||
Assert.True(earlier.CompareTo(later) < 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompareTo_LaterPhysicalTime_ReturnsPositive()
|
||||
{
|
||||
var earlier = new HlcTimestamp
|
||||
{
|
||||
PhysicalTime = BasePhysicalTime,
|
||||
NodeId = TestNodeId,
|
||||
LogicalCounter = 0
|
||||
};
|
||||
var later = new HlcTimestamp
|
||||
{
|
||||
PhysicalTime = BasePhysicalTime + 1000,
|
||||
NodeId = TestNodeId,
|
||||
LogicalCounter = 0
|
||||
};
|
||||
|
||||
Assert.True(later.CompareTo(earlier) > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompareTo_SamePhysicalTime_LowerCounter_ReturnsNegative()
|
||||
{
|
||||
var lower = new HlcTimestamp
|
||||
{
|
||||
PhysicalTime = BasePhysicalTime,
|
||||
NodeId = TestNodeId,
|
||||
LogicalCounter = 5
|
||||
};
|
||||
var higher = new HlcTimestamp
|
||||
{
|
||||
PhysicalTime = BasePhysicalTime,
|
||||
NodeId = TestNodeId,
|
||||
LogicalCounter = 10
|
||||
};
|
||||
|
||||
Assert.True(lower.CompareTo(higher) < 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompareTo_SamePhysicalTime_HigherCounter_ReturnsPositive()
|
||||
{
|
||||
var lower = new HlcTimestamp
|
||||
{
|
||||
PhysicalTime = BasePhysicalTime,
|
||||
NodeId = TestNodeId,
|
||||
LogicalCounter = 5
|
||||
};
|
||||
var higher = new HlcTimestamp
|
||||
{
|
||||
PhysicalTime = BasePhysicalTime,
|
||||
NodeId = TestNodeId,
|
||||
LogicalCounter = 10
|
||||
};
|
||||
|
||||
Assert.True(higher.CompareTo(lower) > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompareTo_SamePhysicalTimeAndCounter_SortsLexicographicallyByNodeId()
|
||||
{
|
||||
var nodeA = new HlcTimestamp
|
||||
{
|
||||
PhysicalTime = BasePhysicalTime,
|
||||
NodeId = "node-a",
|
||||
LogicalCounter = 0
|
||||
};
|
||||
var nodeB = new HlcTimestamp
|
||||
{
|
||||
PhysicalTime = BasePhysicalTime,
|
||||
NodeId = "node-b",
|
||||
LogicalCounter = 0
|
||||
};
|
||||
|
||||
Assert.True(nodeA.CompareTo(nodeB) < 0);
|
||||
Assert.True(nodeB.CompareTo(nodeA) > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompareTo_IdenticalTimestamps_ReturnsZero()
|
||||
{
|
||||
var ts1 = new HlcTimestamp
|
||||
{
|
||||
PhysicalTime = BasePhysicalTime,
|
||||
NodeId = TestNodeId,
|
||||
LogicalCounter = 42
|
||||
};
|
||||
var ts2 = new HlcTimestamp
|
||||
{
|
||||
PhysicalTime = BasePhysicalTime,
|
||||
NodeId = TestNodeId,
|
||||
LogicalCounter = 42
|
||||
};
|
||||
|
||||
Assert.Equal(0, ts1.CompareTo(ts2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompareTo_TotalOrdering_IsTransitive()
|
||||
{
|
||||
var a = new HlcTimestamp { PhysicalTime = 100, NodeId = "node", LogicalCounter = 0 };
|
||||
var b = new HlcTimestamp { PhysicalTime = 100, NodeId = "node", LogicalCounter = 1 };
|
||||
var c = new HlcTimestamp { PhysicalTime = 100, NodeId = "node", LogicalCounter = 2 };
|
||||
|
||||
// If a < b and b < c then a < c
|
||||
Assert.True(a < b);
|
||||
Assert.True(b < c);
|
||||
Assert.True(a < c);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Operator Tests
|
||||
|
||||
[Fact]
|
||||
public void LessThanOperator_Works()
|
||||
{
|
||||
var earlier = new HlcTimestamp { PhysicalTime = 100, NodeId = "n", LogicalCounter = 0 };
|
||||
var later = new HlcTimestamp { PhysicalTime = 200, NodeId = "n", LogicalCounter = 0 };
|
||||
|
||||
Assert.True(earlier < later);
|
||||
Assert.False(later < earlier);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GreaterThanOperator_Works()
|
||||
{
|
||||
var earlier = new HlcTimestamp { PhysicalTime = 100, NodeId = "n", LogicalCounter = 0 };
|
||||
var later = new HlcTimestamp { PhysicalTime = 200, NodeId = "n", LogicalCounter = 0 };
|
||||
|
||||
Assert.True(later > earlier);
|
||||
Assert.False(earlier > later);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LessThanOrEqualOperator_Works()
|
||||
{
|
||||
var ts1 = new HlcTimestamp { PhysicalTime = 100, NodeId = "n", LogicalCounter = 0 };
|
||||
var ts2 = new HlcTimestamp { PhysicalTime = 100, NodeId = "n", LogicalCounter = 0 };
|
||||
var ts3 = new HlcTimestamp { PhysicalTime = 200, NodeId = "n", LogicalCounter = 0 };
|
||||
|
||||
Assert.True(ts1 <= ts2);
|
||||
Assert.True(ts1 <= ts3);
|
||||
Assert.False(ts3 <= ts1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GreaterThanOrEqualOperator_Works()
|
||||
{
|
||||
var ts1 = new HlcTimestamp { PhysicalTime = 100, NodeId = "n", LogicalCounter = 0 };
|
||||
var ts2 = new HlcTimestamp { PhysicalTime = 100, NodeId = "n", LogicalCounter = 0 };
|
||||
var ts3 = new HlcTimestamp { PhysicalTime = 200, NodeId = "n", LogicalCounter = 0 };
|
||||
|
||||
Assert.True(ts1 >= ts2);
|
||||
Assert.True(ts3 >= ts1);
|
||||
Assert.False(ts1 >= ts3);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IsBefore/IsAfter/IsConcurrent Tests
|
||||
|
||||
[Fact]
|
||||
public void IsBefore_EarlierTimestamp_ReturnsTrue()
|
||||
{
|
||||
var earlier = new HlcTimestamp { PhysicalTime = 100, NodeId = "n", LogicalCounter = 0 };
|
||||
var later = new HlcTimestamp { PhysicalTime = 200, NodeId = "n", LogicalCounter = 0 };
|
||||
|
||||
Assert.True(earlier.IsBefore(later));
|
||||
Assert.False(later.IsBefore(earlier));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsAfter_LaterTimestamp_ReturnsTrue()
|
||||
{
|
||||
var earlier = new HlcTimestamp { PhysicalTime = 100, NodeId = "n", LogicalCounter = 0 };
|
||||
var later = new HlcTimestamp { PhysicalTime = 200, NodeId = "n", LogicalCounter = 0 };
|
||||
|
||||
Assert.True(later.IsAfter(earlier));
|
||||
Assert.False(earlier.IsAfter(later));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsConcurrent_SameTimeAndCounterDifferentNode_ReturnsTrue()
|
||||
{
|
||||
var nodeA = new HlcTimestamp { PhysicalTime = 100, NodeId = "node-a", LogicalCounter = 5 };
|
||||
var nodeB = new HlcTimestamp { PhysicalTime = 100, NodeId = "node-b", LogicalCounter = 5 };
|
||||
|
||||
Assert.True(nodeA.IsConcurrent(nodeB));
|
||||
Assert.True(nodeB.IsConcurrent(nodeA));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsConcurrent_SameNode_ReturnsFalse()
|
||||
{
|
||||
var ts1 = new HlcTimestamp { PhysicalTime = 100, NodeId = "node", LogicalCounter = 5 };
|
||||
var ts2 = new HlcTimestamp { PhysicalTime = 100, NodeId = "node", LogicalCounter = 5 };
|
||||
|
||||
Assert.False(ts1.IsConcurrent(ts2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsConcurrent_DifferentCounter_ReturnsFalse()
|
||||
{
|
||||
var ts1 = new HlcTimestamp { PhysicalTime = 100, NodeId = "node-a", LogicalCounter = 5 };
|
||||
var ts2 = new HlcTimestamp { PhysicalTime = 100, NodeId = "node-b", LogicalCounter = 6 };
|
||||
|
||||
Assert.False(ts1.IsConcurrent(ts2));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Increment/WithPhysicalTime Tests
|
||||
|
||||
[Fact]
|
||||
public void Increment_IncreasesCounter()
|
||||
{
|
||||
var original = new HlcTimestamp { PhysicalTime = 100, NodeId = "n", LogicalCounter = 5 };
|
||||
var incremented = original.Increment();
|
||||
|
||||
Assert.Equal(6, incremented.LogicalCounter);
|
||||
Assert.Equal(original.PhysicalTime, incremented.PhysicalTime);
|
||||
Assert.Equal(original.NodeId, incremented.NodeId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithPhysicalTime_UpdatesTimeAndResetsCounter()
|
||||
{
|
||||
var original = new HlcTimestamp { PhysicalTime = 100, NodeId = "n", LogicalCounter = 42 };
|
||||
var updated = original.WithPhysicalTime(200);
|
||||
|
||||
Assert.Equal(200, updated.PhysicalTime);
|
||||
Assert.Equal(0, updated.LogicalCounter);
|
||||
Assert.Equal(original.NodeId, updated.NodeId);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Now Tests
|
||||
|
||||
[Fact]
|
||||
public void Now_CreatesTimestampWithZeroCounter()
|
||||
{
|
||||
var fakeTime = new FakeTimeProvider();
|
||||
fakeTime.SetUtcNow(new DateTimeOffset(2024, 6, 15, 12, 0, 0, TimeSpan.Zero));
|
||||
|
||||
var timestamp = HlcTimestamp.Now("test-node", fakeTime);
|
||||
|
||||
Assert.Equal("test-node", timestamp.NodeId);
|
||||
Assert.Equal(0, timestamp.LogicalCounter);
|
||||
Assert.Equal(fakeTime.GetUtcNow().ToUnixTimeMilliseconds(), timestamp.PhysicalTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Now_NullNodeId_ThrowsArgumentException()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => HlcTimestamp.Now(null!, TimeProvider.System));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Now_NullTimeProvider_ThrowsArgumentNullException()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => HlcTimestamp.Now("node", null!));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ToDateTimeOffset Tests
|
||||
|
||||
[Fact]
|
||||
public void ToDateTimeOffset_ConvertsCorrectly()
|
||||
{
|
||||
var expected = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
var timestamp = new HlcTimestamp
|
||||
{
|
||||
PhysicalTime = expected.ToUnixTimeMilliseconds(),
|
||||
NodeId = "n",
|
||||
LogicalCounter = 0
|
||||
};
|
||||
|
||||
var result = timestamp.ToDateTimeOffset();
|
||||
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ToString Tests
|
||||
|
||||
[Fact]
|
||||
public void ToString_ReturnsSortableString()
|
||||
{
|
||||
var timestamp = new HlcTimestamp
|
||||
{
|
||||
PhysicalTime = BasePhysicalTime,
|
||||
NodeId = TestNodeId,
|
||||
LogicalCounter = 42
|
||||
};
|
||||
|
||||
Assert.Equal(timestamp.ToSortableString(), timestamp.ToString());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Lexicographic Sorting Tests
|
||||
|
||||
[Fact]
|
||||
public void ToSortableString_LexicographicOrder_MatchesLogicalOrder()
|
||||
{
|
||||
var timestamps = new[]
|
||||
{
|
||||
new HlcTimestamp { PhysicalTime = 100, NodeId = "node", LogicalCounter = 0 },
|
||||
new HlcTimestamp { PhysicalTime = 100, NodeId = "node", LogicalCounter = 1 },
|
||||
new HlcTimestamp { PhysicalTime = 101, NodeId = "node", LogicalCounter = 0 },
|
||||
new HlcTimestamp { PhysicalTime = 200, NodeId = "node", LogicalCounter = 0 }
|
||||
};
|
||||
|
||||
// Sort by string representation
|
||||
var sortedByString = timestamps.OrderBy(t => t.ToSortableString()).ToList();
|
||||
|
||||
// Sort by logical comparison
|
||||
var sortedByLogical = timestamps.OrderBy(t => t).ToList();
|
||||
|
||||
// Both orderings should match
|
||||
for (var i = 0; i < timestamps.Length; i++)
|
||||
{
|
||||
Assert.Equal(sortedByLogical[i], sortedByString[i]);
|
||||
}
|
||||
}
|
||||
|
||||
#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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// HybridLogicalClockBenchmarks.cs
|
||||
// Sprint: SPRINT_20260105_002_001_LB_hlc_core_library
|
||||
// Task: HLC-010 - Write benchmarks: tick throughput, memory allocation
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// Disable xUnit analyzer warning for async methods - cancellation token not relevant for these tests
|
||||
#pragma warning disable xUnit1051
|
||||
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.HybridLogicalClock.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Performance benchmarks for HLC operations.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// These tests measure tick throughput and memory allocation patterns.
|
||||
/// They are tagged as Performance for CI filtering.
|
||||
/// </remarks>
|
||||
[Trait("Category", TestCategories.Performance)]
|
||||
public class HybridLogicalClockBenchmarks
|
||||
{
|
||||
#region Tick Throughput Benchmarks
|
||||
|
||||
[Fact]
|
||||
public void Tick_Throughput_SingleThread()
|
||||
{
|
||||
const int iterations = 100_000;
|
||||
|
||||
var timeProvider = new FakeTimeProvider();
|
||||
var stateStore = new InMemoryHlcStateStore();
|
||||
var clock = CreateClock(timeProvider, stateStore);
|
||||
|
||||
// Warmup
|
||||
for (var i = 0; i < 1000; i++)
|
||||
{
|
||||
clock.Tick();
|
||||
}
|
||||
|
||||
// Measure
|
||||
var sw = Stopwatch.StartNew();
|
||||
for (var i = 0; i < iterations; i++)
|
||||
{
|
||||
clock.Tick();
|
||||
}
|
||||
sw.Stop();
|
||||
|
||||
var ticksPerSecond = iterations / sw.Elapsed.TotalSeconds;
|
||||
|
||||
// Assert minimum performance threshold (at least 100K ticks/sec)
|
||||
Assert.True(ticksPerSecond > 100_000,
|
||||
$"Expected at least 100K ticks/sec but got {ticksPerSecond:N0}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tick_Throughput_MultiThread()
|
||||
{
|
||||
const int threads = 4;
|
||||
const int iterationsPerThread = 25_000;
|
||||
const int totalIterations = threads * iterationsPerThread;
|
||||
|
||||
var timeProvider = new FakeTimeProvider();
|
||||
var stateStore = new InMemoryHlcStateStore();
|
||||
var clock = CreateClock(timeProvider, stateStore);
|
||||
|
||||
// Warmup
|
||||
for (var i = 0; i < 1000; i++)
|
||||
{
|
||||
clock.Tick();
|
||||
}
|
||||
|
||||
// Measure
|
||||
var sw = Stopwatch.StartNew();
|
||||
var tasks = new List<Task>();
|
||||
|
||||
for (var t = 0; t < threads; t++)
|
||||
{
|
||||
tasks.Add(Task.Run(() =>
|
||||
{
|
||||
for (var i = 0; i < iterationsPerThread; i++)
|
||||
{
|
||||
clock.Tick();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
sw.Stop();
|
||||
|
||||
var ticksPerSecond = totalIterations / sw.Elapsed.TotalSeconds;
|
||||
|
||||
// Assert minimum performance threshold (at least 50K ticks/sec under contention)
|
||||
Assert.True(ticksPerSecond > 50_000,
|
||||
$"Expected at least 50K ticks/sec but got {ticksPerSecond:N0}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_Throughput_WithTimeAdvance()
|
||||
{
|
||||
const int iterations = 50_000;
|
||||
|
||||
var timeProvider = new FakeTimeProvider();
|
||||
var stateStore = new InMemoryHlcStateStore();
|
||||
var clock = CreateClock(timeProvider, stateStore);
|
||||
|
||||
// Warmup
|
||||
for (var i = 0; i < 1000; i++)
|
||||
{
|
||||
clock.Tick();
|
||||
if (i % 100 == 0) timeProvider.Advance(TimeSpan.FromMilliseconds(1));
|
||||
}
|
||||
|
||||
// Measure - simulate realistic scenario with occasional time advances
|
||||
var sw = Stopwatch.StartNew();
|
||||
for (var i = 0; i < iterations; i++)
|
||||
{
|
||||
clock.Tick();
|
||||
if (i % 100 == 0)
|
||||
{
|
||||
timeProvider.Advance(TimeSpan.FromMilliseconds(1));
|
||||
}
|
||||
}
|
||||
sw.Stop();
|
||||
|
||||
var ticksPerSecond = iterations / sw.Elapsed.TotalSeconds;
|
||||
|
||||
// Should still maintain good throughput
|
||||
Assert.True(ticksPerSecond > 50_000,
|
||||
$"Expected at least 50K ticks/sec but got {ticksPerSecond:N0}");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Receive Throughput Benchmarks
|
||||
|
||||
[Fact]
|
||||
public void Receive_Throughput_SingleThread()
|
||||
{
|
||||
const int iterations = 50_000;
|
||||
|
||||
var timeProvider = new FakeTimeProvider();
|
||||
var stateStore = new InMemoryHlcStateStore();
|
||||
var clock = CreateClock(timeProvider, stateStore);
|
||||
|
||||
// Pre-generate remote timestamps
|
||||
var remoteTimestamps = new HlcTimestamp[iterations];
|
||||
for (var i = 0; i < iterations; i++)
|
||||
{
|
||||
remoteTimestamps[i] = new HlcTimestamp
|
||||
{
|
||||
PhysicalTime = timeProvider.GetUtcNow().ToUnixTimeMilliseconds() + (i % 100),
|
||||
NodeId = "remote-node",
|
||||
LogicalCounter = i % 1000
|
||||
};
|
||||
}
|
||||
|
||||
// Warmup
|
||||
for (var i = 0; i < 1000; i++)
|
||||
{
|
||||
clock.Receive(remoteTimestamps[i]);
|
||||
}
|
||||
|
||||
// Measure
|
||||
var sw = Stopwatch.StartNew();
|
||||
for (var i = 0; i < iterations; i++)
|
||||
{
|
||||
clock.Receive(remoteTimestamps[i]);
|
||||
}
|
||||
sw.Stop();
|
||||
|
||||
var receivesPerSecond = iterations / sw.Elapsed.TotalSeconds;
|
||||
|
||||
// Assert minimum performance threshold
|
||||
Assert.True(receivesPerSecond > 50_000,
|
||||
$"Expected at least 50K receives/sec but got {receivesPerSecond:N0}");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Parse/Serialize Throughput Benchmarks
|
||||
|
||||
[Fact]
|
||||
public void Parse_Throughput()
|
||||
{
|
||||
const int iterations = 100_000;
|
||||
const string testString = "1704067200000-scheduler-east-1-000042";
|
||||
|
||||
// Warmup
|
||||
for (var i = 0; i < 1000; i++)
|
||||
{
|
||||
HlcTimestamp.Parse(testString);
|
||||
}
|
||||
|
||||
// Measure
|
||||
var sw = Stopwatch.StartNew();
|
||||
for (var i = 0; i < iterations; i++)
|
||||
{
|
||||
HlcTimestamp.Parse(testString);
|
||||
}
|
||||
sw.Stop();
|
||||
|
||||
var parsesPerSecond = iterations / sw.Elapsed.TotalSeconds;
|
||||
|
||||
// Assert minimum performance threshold
|
||||
Assert.True(parsesPerSecond > 500_000,
|
||||
$"Expected at least 500K parses/sec but got {parsesPerSecond:N0}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToSortableString_Throughput()
|
||||
{
|
||||
const int iterations = 100_000;
|
||||
var timestamp = new HlcTimestamp
|
||||
{
|
||||
PhysicalTime = 1704067200000L,
|
||||
NodeId = "scheduler-east-1",
|
||||
LogicalCounter = 42
|
||||
};
|
||||
|
||||
// Warmup
|
||||
for (var i = 0; i < 1000; i++)
|
||||
{
|
||||
_ = timestamp.ToSortableString();
|
||||
}
|
||||
|
||||
// Measure
|
||||
var sw = Stopwatch.StartNew();
|
||||
for (var i = 0; i < iterations; i++)
|
||||
{
|
||||
_ = timestamp.ToSortableString();
|
||||
}
|
||||
sw.Stop();
|
||||
|
||||
var serializesPerSecond = iterations / sw.Elapsed.TotalSeconds;
|
||||
|
||||
// Assert minimum performance threshold
|
||||
Assert.True(serializesPerSecond > 500_000,
|
||||
$"Expected at least 500K serializes/sec but got {serializesPerSecond:N0}");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Comparison Throughput Benchmarks
|
||||
|
||||
[Fact]
|
||||
public void CompareTo_Throughput()
|
||||
{
|
||||
const int iterations = 1_000_000;
|
||||
|
||||
var ts1 = new HlcTimestamp { PhysicalTime = 1000, NodeId = "node-1", LogicalCounter = 5 };
|
||||
var ts2 = new HlcTimestamp { PhysicalTime = 1000, NodeId = "node-2", LogicalCounter = 10 };
|
||||
|
||||
// Warmup
|
||||
for (var i = 0; i < 1000; i++)
|
||||
{
|
||||
_ = ts1.CompareTo(ts2);
|
||||
}
|
||||
|
||||
// Measure
|
||||
var sw = Stopwatch.StartNew();
|
||||
for (var i = 0; i < iterations; i++)
|
||||
{
|
||||
_ = ts1.CompareTo(ts2);
|
||||
}
|
||||
sw.Stop();
|
||||
|
||||
var comparesPerSecond = iterations / sw.Elapsed.TotalSeconds;
|
||||
|
||||
// Assert minimum performance threshold (comparisons should be very fast)
|
||||
Assert.True(comparesPerSecond > 10_000_000,
|
||||
$"Expected at least 10M compares/sec but got {comparesPerSecond:N0}");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Memory Allocation Tests
|
||||
|
||||
[Fact]
|
||||
public void Tick_MemoryAllocation_Minimal()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider();
|
||||
var stateStore = new InMemoryHlcStateStore();
|
||||
var clock = CreateClock(timeProvider, stateStore);
|
||||
|
||||
// Force GC and get baseline
|
||||
GC.Collect();
|
||||
GC.WaitForPendingFinalizers();
|
||||
GC.Collect();
|
||||
|
||||
var beforeMemory = GC.GetTotalMemory(true);
|
||||
|
||||
// Generate many ticks
|
||||
const int iterations = 10_000;
|
||||
var timestamps = new HlcTimestamp[iterations];
|
||||
for (var i = 0; i < iterations; i++)
|
||||
{
|
||||
timestamps[i] = clock.Tick();
|
||||
}
|
||||
|
||||
// Measure memory increase
|
||||
var afterMemory = GC.GetTotalMemory(false);
|
||||
var memoryIncrease = afterMemory - beforeMemory;
|
||||
var bytesPerTick = (double)memoryIncrease / iterations;
|
||||
|
||||
// HlcTimestamp is a struct (24 bytes: long + int + string reference)
|
||||
// Array storage is expected, but tick operation itself should be minimal
|
||||
var expectedMaxPerTick = 100; // Allow up to 100 bytes per tick including array storage
|
||||
Assert.True(bytesPerTick < expectedMaxPerTick,
|
||||
$"Memory per tick ({bytesPerTick:N0} bytes) exceeds threshold ({expectedMaxPerTick} bytes)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HlcTimestamp_IsValueType()
|
||||
{
|
||||
// Verify HlcTimestamp is a struct (value type) for performance
|
||||
Assert.True(typeof(HlcTimestamp).IsValueType,
|
||||
"HlcTimestamp should be a value type (struct) for performance");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HlcTimestamp_Size_Reasonable()
|
||||
{
|
||||
// HlcTimestamp contains: long (8 bytes) + int (4 bytes) + string reference (8 bytes on 64-bit)
|
||||
var timestamps = new HlcTimestamp[1000];
|
||||
for (var i = 0; i < 1000; i++)
|
||||
{
|
||||
timestamps[i] = new HlcTimestamp
|
||||
{
|
||||
PhysicalTime = i,
|
||||
NodeId = "node",
|
||||
LogicalCounter = i
|
||||
};
|
||||
}
|
||||
|
||||
// Verify the array was created successfully
|
||||
Assert.Equal(1000, timestamps.Length);
|
||||
Assert.Equal(999, timestamps[999].PhysicalTime);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region State Store Throughput
|
||||
|
||||
[Fact]
|
||||
public async Task InMemoryStateStore_Save_Throughput()
|
||||
{
|
||||
const int iterations = 50_000;
|
||||
var stateStore = new InMemoryHlcStateStore();
|
||||
|
||||
// Warmup
|
||||
for (var i = 0; i < 1000; i++)
|
||||
{
|
||||
await stateStore.SaveAsync(new HlcTimestamp
|
||||
{
|
||||
PhysicalTime = i,
|
||||
NodeId = "test-node",
|
||||
LogicalCounter = i
|
||||
});
|
||||
}
|
||||
|
||||
stateStore.Clear();
|
||||
|
||||
// Measure
|
||||
var sw = Stopwatch.StartNew();
|
||||
for (var i = 0; i < iterations; i++)
|
||||
{
|
||||
await stateStore.SaveAsync(new HlcTimestamp
|
||||
{
|
||||
PhysicalTime = i,
|
||||
NodeId = "test-node",
|
||||
LogicalCounter = i
|
||||
});
|
||||
}
|
||||
sw.Stop();
|
||||
|
||||
var savesPerSecond = iterations / sw.Elapsed.TotalSeconds;
|
||||
|
||||
Assert.True(savesPerSecond > 100_000,
|
||||
$"Expected at least 100K saves/sec but got {savesPerSecond:N0}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InMemoryStateStore_Load_Throughput()
|
||||
{
|
||||
const int iterations = 100_000;
|
||||
var stateStore = new InMemoryHlcStateStore();
|
||||
|
||||
// Pre-populate
|
||||
await stateStore.SaveAsync(new HlcTimestamp
|
||||
{
|
||||
PhysicalTime = 1000,
|
||||
NodeId = "test-node",
|
||||
LogicalCounter = 0
|
||||
});
|
||||
|
||||
// Warmup
|
||||
for (var i = 0; i < 1000; i++)
|
||||
{
|
||||
await stateStore.LoadAsync("test-node");
|
||||
}
|
||||
|
||||
// Measure
|
||||
var sw = Stopwatch.StartNew();
|
||||
for (var i = 0; i < iterations; i++)
|
||||
{
|
||||
await stateStore.LoadAsync("test-node");
|
||||
}
|
||||
sw.Stop();
|
||||
|
||||
var loadsPerSecond = iterations / sw.Elapsed.TotalSeconds;
|
||||
|
||||
Assert.True(loadsPerSecond > 500_000,
|
||||
$"Expected at least 500K loads/sec but got {loadsPerSecond:N0}");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static HybridLogicalClock CreateClock(
|
||||
TimeProvider timeProvider,
|
||||
IHlcStateStore stateStore)
|
||||
{
|
||||
return new HybridLogicalClock(
|
||||
timeProvider,
|
||||
"test-node",
|
||||
stateStore,
|
||||
NullLogger<HybridLogicalClock>.Instance);
|
||||
}
|
||||
|
||||
#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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// HybridLogicalClockIntegrationTests.cs
|
||||
// Sprint: SPRINT_20260105_002_001_LB_hlc_core_library
|
||||
// Task: HLC-009 - Write integration tests: concurrent ticks, node restart recovery
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// Disable xUnit analyzer warning for async methods - cancellation token not relevant for these tests
|
||||
#pragma warning disable xUnit1051
|
||||
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.HybridLogicalClock.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for HLC concurrent and multi-node scenarios.
|
||||
/// </summary>
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
public class HybridLogicalClockIntegrationTests
|
||||
{
|
||||
private const string TestNodeId = "test-node";
|
||||
|
||||
#region Concurrent Ticks Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ConcurrentTicks_AllUnique_SingleClock()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider();
|
||||
var stateStore = new InMemoryHlcStateStore();
|
||||
var clock = CreateClock(timeProvider, stateStore);
|
||||
|
||||
var timestamps = new System.Collections.Concurrent.ConcurrentBag<HlcTimestamp>();
|
||||
var tasks = new List<Task>();
|
||||
|
||||
// Generate 1000 concurrent ticks from multiple threads
|
||||
for (var i = 0; i < 100; i++)
|
||||
{
|
||||
tasks.Add(Task.Run(() =>
|
||||
{
|
||||
for (var j = 0; j < 10; j++)
|
||||
{
|
||||
timestamps.Add(clock.Tick());
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
// Verify all timestamps are unique
|
||||
var uniqueStrings = timestamps.Select(t => t.ToSortableString()).ToHashSet();
|
||||
Assert.Equal(1000, uniqueStrings.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConcurrentTicks_AllMonotonic_WithinThread()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider();
|
||||
var stateStore = new InMemoryHlcStateStore();
|
||||
var clock = CreateClock(timeProvider, stateStore);
|
||||
|
||||
var tasksCompleted = 0;
|
||||
var tasks = new List<Task>();
|
||||
|
||||
// Run multiple threads, each generating sequential ticks
|
||||
for (var threadId = 0; threadId < 10; threadId++)
|
||||
{
|
||||
tasks.Add(Task.Run(() =>
|
||||
{
|
||||
var localTimestamps = new List<HlcTimestamp>();
|
||||
|
||||
for (var i = 0; i < 100; i++)
|
||||
{
|
||||
localTimestamps.Add(clock.Tick());
|
||||
}
|
||||
|
||||
// Verify monotonicity within this thread's sequence
|
||||
for (var i = 1; i < localTimestamps.Count; i++)
|
||||
{
|
||||
Assert.True(localTimestamps[i] > localTimestamps[i - 1],
|
||||
$"Monotonicity violated at index {i}");
|
||||
}
|
||||
|
||||
Interlocked.Increment(ref tasksCompleted);
|
||||
}));
|
||||
}
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
Assert.Equal(10, tasksCompleted);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Node Restart Recovery Tests
|
||||
|
||||
[Fact]
|
||||
public async Task NodeRestart_ResumesFromPersisted_InMemory()
|
||||
{
|
||||
// Shared state store simulates persistent storage
|
||||
var stateStore = new InMemoryHlcStateStore();
|
||||
|
||||
// First instance - generate some ticks
|
||||
var timeProvider1 = new FakeTimeProvider();
|
||||
timeProvider1.SetUtcNow(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
|
||||
var clock1 = CreateClock(timeProvider1, stateStore);
|
||||
var lastTickBeforeRestart = clock1.Tick();
|
||||
|
||||
// Simulate multiple ticks
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
clock1.Tick();
|
||||
}
|
||||
|
||||
var finalTickBeforeRestart = clock1.Current;
|
||||
|
||||
// Give async persistence time to complete
|
||||
await Task.Delay(50);
|
||||
|
||||
// "Restart" - create new clock instance with same state store
|
||||
var timeProvider2 = new FakeTimeProvider();
|
||||
timeProvider2.SetUtcNow(new DateTimeOffset(2024, 1, 1, 0, 0, 1, TimeSpan.Zero)); // 1 second later
|
||||
|
||||
var clock2 = CreateClock(timeProvider2, stateStore);
|
||||
var recovered = await clock2.InitializeFromStateAsync();
|
||||
|
||||
Assert.True(recovered, "Should have recovered from persisted state");
|
||||
|
||||
// First tick after restart should be greater than last tick before restart
|
||||
var firstTickAfterRestart = clock2.Tick();
|
||||
Assert.True(firstTickAfterRestart > finalTickBeforeRestart,
|
||||
$"First tick after restart ({firstTickAfterRestart}) should be > last tick before restart ({finalTickBeforeRestart})");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NodeRestart_SamePhysicalTime_IncrementCounter()
|
||||
{
|
||||
var stateStore = new InMemoryHlcStateStore();
|
||||
var fixedTime = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
// First instance
|
||||
var timeProvider1 = new FakeTimeProvider();
|
||||
timeProvider1.SetUtcNow(fixedTime);
|
||||
|
||||
var clock1 = CreateClock(timeProvider1, stateStore);
|
||||
var tick1 = clock1.Tick(); // Counter = 0
|
||||
|
||||
await Task.Delay(50); // Allow persistence
|
||||
|
||||
// "Restart" with same physical time
|
||||
var timeProvider2 = new FakeTimeProvider();
|
||||
timeProvider2.SetUtcNow(fixedTime);
|
||||
|
||||
var clock2 = CreateClock(timeProvider2, stateStore);
|
||||
await clock2.InitializeFromStateAsync();
|
||||
|
||||
// First tick should have incremented counter since physical time is same
|
||||
var tick2 = clock2.Tick();
|
||||
|
||||
Assert.Equal(tick1.PhysicalTime, tick2.PhysicalTime);
|
||||
Assert.True(tick2.LogicalCounter > tick1.LogicalCounter,
|
||||
$"Counter should be greater: {tick2.LogicalCounter} > {tick1.LogicalCounter}");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Multi-Node Causal Ordering Tests
|
||||
|
||||
[Fact]
|
||||
public void MultiNode_CausalOrdering_RequestResponse()
|
||||
{
|
||||
var baseTime = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
var timeProvider1 = new FakeTimeProvider();
|
||||
var timeProvider2 = new FakeTimeProvider();
|
||||
timeProvider1.SetUtcNow(baseTime);
|
||||
timeProvider2.SetUtcNow(baseTime);
|
||||
|
||||
var clock1 = CreateClock(timeProvider1, new InMemoryHlcStateStore(), nodeId: "node-1");
|
||||
var clock2 = CreateClock(timeProvider2, new InMemoryHlcStateStore(), nodeId: "node-2");
|
||||
|
||||
// Node 1 sends request
|
||||
var requestTs = clock1.Tick();
|
||||
|
||||
// Node 2 receives request
|
||||
var node2AfterReceive = clock2.Receive(requestTs);
|
||||
|
||||
// Node 2 sends response
|
||||
var responseTs = clock2.Tick();
|
||||
|
||||
// Node 1 receives response
|
||||
var node1AfterReceive = clock1.Receive(responseTs);
|
||||
|
||||
// Verify causal ordering
|
||||
Assert.True(requestTs < node2AfterReceive, "Request < Node2 after receive");
|
||||
Assert.True(node2AfterReceive < responseTs, "Node2 after receive < Response");
|
||||
Assert.True(responseTs < node1AfterReceive, "Response < Node1 after receive");
|
||||
|
||||
// The entire chain should be causally ordered
|
||||
Assert.True(requestTs < node1AfterReceive, "Request < final");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultiNode_CausalOrdering_BroadcastGather()
|
||||
{
|
||||
var baseTime = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
// Create 5 node clocks
|
||||
var clocks = new List<HybridLogicalClock>();
|
||||
var timeProviders = new List<FakeTimeProvider>();
|
||||
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
var tp = new FakeTimeProvider();
|
||||
tp.SetUtcNow(baseTime);
|
||||
timeProviders.Add(tp);
|
||||
clocks.Add(CreateClock(tp, new InMemoryHlcStateStore(), nodeId: $"node-{i}"));
|
||||
}
|
||||
|
||||
// Node 0 broadcasts to all others
|
||||
var broadcast = clocks[0].Tick();
|
||||
var receivedTimestamps = new List<HlcTimestamp>();
|
||||
|
||||
for (var i = 1; i < 5; i++)
|
||||
{
|
||||
receivedTimestamps.Add(clocks[i].Receive(broadcast));
|
||||
}
|
||||
|
||||
// All received timestamps should be > broadcast
|
||||
foreach (var received in receivedTimestamps)
|
||||
{
|
||||
Assert.True(received > broadcast);
|
||||
}
|
||||
|
||||
// Each node responds
|
||||
var responses = new List<HlcTimestamp>();
|
||||
for (var i = 1; i < 5; i++)
|
||||
{
|
||||
responses.Add(clocks[i].Tick());
|
||||
}
|
||||
|
||||
// Node 0 gathers all responses
|
||||
var maxResponse = responses[0];
|
||||
foreach (var response in responses)
|
||||
{
|
||||
var gathered = clocks[0].Receive(response);
|
||||
if (gathered > maxResponse) maxResponse = gathered;
|
||||
}
|
||||
|
||||
var final = clocks[0].Tick();
|
||||
|
||||
// Final timestamp should be > broadcast and > all responses
|
||||
Assert.True(final > broadcast);
|
||||
foreach (var response in responses)
|
||||
{
|
||||
Assert.True(final > response);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultiNode_ClockSkew_DetectedAndRejected()
|
||||
{
|
||||
var baseTime = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
var maxSkew = TimeSpan.FromSeconds(30);
|
||||
|
||||
var tp1 = new FakeTimeProvider();
|
||||
var tp2 = new FakeTimeProvider();
|
||||
tp1.SetUtcNow(baseTime);
|
||||
tp2.SetUtcNow(baseTime.AddMinutes(2)); // 2 minutes ahead
|
||||
|
||||
var clock1 = CreateClock(tp1, new InMemoryHlcStateStore(), maxSkew, "node-1");
|
||||
var clock2 = CreateClock(tp2, new InMemoryHlcStateStore(), maxSkew, "node-2");
|
||||
|
||||
var ts2 = clock2.Tick();
|
||||
|
||||
// Clock 1 should reject timestamp from clock 2 due to excessive skew
|
||||
var exception = Assert.Throws<HlcClockSkewException>(() => clock1.Receive(ts2));
|
||||
|
||||
Assert.True(exception.ActualSkew > maxSkew);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultiNode_ConcurrentEvents_StillTotallyOrdered()
|
||||
{
|
||||
var baseTime = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
var tp1 = new FakeTimeProvider();
|
||||
var tp2 = new FakeTimeProvider();
|
||||
tp1.SetUtcNow(baseTime);
|
||||
tp2.SetUtcNow(baseTime);
|
||||
|
||||
var clock1 = CreateClock(tp1, new InMemoryHlcStateStore(), nodeId: "node-1");
|
||||
var clock2 = CreateClock(tp2, new InMemoryHlcStateStore(), nodeId: "node-2");
|
||||
|
||||
// Generate concurrent events (no communication)
|
||||
var events = new List<HlcTimestamp>();
|
||||
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
events.Add(clock1.Tick());
|
||||
events.Add(clock2.Tick());
|
||||
}
|
||||
|
||||
// Even concurrent events can be totally ordered
|
||||
var sorted = events.OrderBy(e => e).ToList();
|
||||
|
||||
// No two elements should be equal (total ordering)
|
||||
for (var i = 1; i < sorted.Count; i++)
|
||||
{
|
||||
Assert.NotEqual(sorted[i], sorted[i - 1]);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region State Store Concurrency Tests
|
||||
|
||||
[Fact]
|
||||
public async Task InMemoryStateStore_ConcurrentSaves_NoLoss()
|
||||
{
|
||||
var stateStore = new InMemoryHlcStateStore();
|
||||
var tasks = new List<Task>();
|
||||
|
||||
// Multiple concurrent saves with increasing timestamps
|
||||
for (var i = 0; i < 100; i++)
|
||||
{
|
||||
var timestamp = new HlcTimestamp
|
||||
{
|
||||
PhysicalTime = 1000 + i,
|
||||
NodeId = TestNodeId,
|
||||
LogicalCounter = i
|
||||
};
|
||||
|
||||
tasks.Add(stateStore.SaveAsync(timestamp));
|
||||
}
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
// The final state should be the highest timestamp
|
||||
var stored = await stateStore.LoadAsync(TestNodeId);
|
||||
Assert.NotNull(stored);
|
||||
Assert.True(stored.Value.PhysicalTime >= 1099, "Should have the highest physical time");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InMemoryStateStore_ConcurrentSaves_MaintainsMonotonicity()
|
||||
{
|
||||
var stateStore = new InMemoryHlcStateStore();
|
||||
|
||||
// Save a high timestamp first
|
||||
var highTs = new HlcTimestamp { PhysicalTime = 10000, NodeId = TestNodeId, LogicalCounter = 0 };
|
||||
await stateStore.SaveAsync(highTs);
|
||||
|
||||
var tasks = new List<Task>();
|
||||
|
||||
// Concurrent saves with lower timestamps
|
||||
for (var i = 0; i < 50; i++)
|
||||
{
|
||||
var lowTs = new HlcTimestamp
|
||||
{
|
||||
PhysicalTime = 5000 + i,
|
||||
NodeId = TestNodeId,
|
||||
LogicalCounter = i
|
||||
};
|
||||
tasks.Add(stateStore.SaveAsync(lowTs));
|
||||
}
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
// High timestamp should still be preserved
|
||||
var stored = await stateStore.LoadAsync(TestNodeId);
|
||||
Assert.NotNull(stored);
|
||||
Assert.Equal(highTs, stored.Value);
|
||||
}
|
||||
|
||||
#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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// InMemoryHlcStateStoreTests.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 StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.HybridLogicalClock.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for InMemoryHlcStateStore.
|
||||
/// </summary>
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
public class InMemoryHlcStateStoreTests
|
||||
{
|
||||
#region LoadAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_NoState_ReturnsNull()
|
||||
{
|
||||
var store = new InMemoryHlcStateStore();
|
||||
|
||||
var result = await store.LoadAsync("node-1");
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_ExistingState_ReturnsTimestamp()
|
||||
{
|
||||
var store = new InMemoryHlcStateStore();
|
||||
var timestamp = new HlcTimestamp
|
||||
{
|
||||
PhysicalTime = 1000,
|
||||
NodeId = "node-1",
|
||||
LogicalCounter = 5
|
||||
};
|
||||
await store.SaveAsync(timestamp);
|
||||
|
||||
var result = await store.LoadAsync("node-1");
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(timestamp.PhysicalTime, result.Value.PhysicalTime);
|
||||
Assert.Equal(timestamp.NodeId, result.Value.NodeId);
|
||||
Assert.Equal(timestamp.LogicalCounter, result.Value.LogicalCounter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_DifferentNode_ReturnsNull()
|
||||
{
|
||||
var store = new InMemoryHlcStateStore();
|
||||
var timestamp = new HlcTimestamp
|
||||
{
|
||||
PhysicalTime = 1000,
|
||||
NodeId = "node-1",
|
||||
LogicalCounter = 5
|
||||
};
|
||||
await store.SaveAsync(timestamp);
|
||||
|
||||
var result = await store.LoadAsync("node-2");
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_NullNodeId_ThrowsArgumentException()
|
||||
{
|
||||
var store = new InMemoryHlcStateStore();
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => store.LoadAsync(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_EmptyNodeId_ThrowsArgumentException()
|
||||
{
|
||||
var store = new InMemoryHlcStateStore();
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => store.LoadAsync(""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_CancellationRequested_ThrowsOperationCanceledException()
|
||||
{
|
||||
var store = new InMemoryHlcStateStore();
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
await Assert.ThrowsAsync<OperationCanceledException>(() =>
|
||||
store.LoadAsync("node-1", cts.Token));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SaveAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task SaveAsync_NewTimestamp_StoresIt()
|
||||
{
|
||||
var store = new InMemoryHlcStateStore();
|
||||
var timestamp = new HlcTimestamp
|
||||
{
|
||||
PhysicalTime = 1000,
|
||||
NodeId = "node-1",
|
||||
LogicalCounter = 5
|
||||
};
|
||||
|
||||
await store.SaveAsync(timestamp);
|
||||
|
||||
var result = await store.LoadAsync("node-1");
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(timestamp, result.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveAsync_GreaterTimestamp_UpdatesStore()
|
||||
{
|
||||
var store = new InMemoryHlcStateStore();
|
||||
|
||||
var ts1 = new HlcTimestamp { PhysicalTime = 1000, NodeId = "node-1", LogicalCounter = 5 };
|
||||
var ts2 = new HlcTimestamp { PhysicalTime = 2000, NodeId = "node-1", LogicalCounter = 0 };
|
||||
|
||||
await store.SaveAsync(ts1);
|
||||
await store.SaveAsync(ts2);
|
||||
|
||||
var result = await store.LoadAsync("node-1");
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(ts2, result.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveAsync_SmallerTimestamp_DoesNotUpdateStore()
|
||||
{
|
||||
var store = new InMemoryHlcStateStore();
|
||||
|
||||
var ts1 = new HlcTimestamp { PhysicalTime = 2000, NodeId = "node-1", LogicalCounter = 0 };
|
||||
var ts2 = new HlcTimestamp { PhysicalTime = 1000, NodeId = "node-1", LogicalCounter = 99 };
|
||||
|
||||
await store.SaveAsync(ts1);
|
||||
await store.SaveAsync(ts2);
|
||||
|
||||
var result = await store.LoadAsync("node-1");
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(ts1, result.Value); // Original is kept
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveAsync_MultipleNodes_StoresSeparately()
|
||||
{
|
||||
var store = new InMemoryHlcStateStore();
|
||||
|
||||
var ts1 = new HlcTimestamp { PhysicalTime = 1000, NodeId = "node-1", LogicalCounter = 0 };
|
||||
var ts2 = new HlcTimestamp { PhysicalTime = 2000, NodeId = "node-2", LogicalCounter = 0 };
|
||||
|
||||
await store.SaveAsync(ts1);
|
||||
await store.SaveAsync(ts2);
|
||||
|
||||
var result1 = await store.LoadAsync("node-1");
|
||||
var result2 = await store.LoadAsync("node-2");
|
||||
|
||||
Assert.NotNull(result1);
|
||||
Assert.NotNull(result2);
|
||||
Assert.Equal(ts1, result1.Value);
|
||||
Assert.Equal(ts2, result2.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveAsync_CancellationRequested_ThrowsOperationCanceledException()
|
||||
{
|
||||
var store = new InMemoryHlcStateStore();
|
||||
var timestamp = new HlcTimestamp { PhysicalTime = 1000, NodeId = "node-1", LogicalCounter = 0 };
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
await Assert.ThrowsAsync<OperationCanceledException>(() =>
|
||||
store.SaveAsync(timestamp, cts.Token));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetAllStates Tests
|
||||
|
||||
[Fact]
|
||||
public void GetAllStates_EmptyStore_ReturnsEmptyDictionary()
|
||||
{
|
||||
var store = new InMemoryHlcStateStore();
|
||||
|
||||
var result = store.GetAllStates();
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAllStates_WithStates_ReturnsAllStates()
|
||||
{
|
||||
var store = new InMemoryHlcStateStore();
|
||||
|
||||
var ts1 = new HlcTimestamp { PhysicalTime = 1000, NodeId = "node-1", LogicalCounter = 0 };
|
||||
var ts2 = new HlcTimestamp { PhysicalTime = 2000, NodeId = "node-2", LogicalCounter = 0 };
|
||||
|
||||
await store.SaveAsync(ts1);
|
||||
await store.SaveAsync(ts2);
|
||||
|
||||
var result = store.GetAllStates();
|
||||
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.True(result.ContainsKey("node-1"));
|
||||
Assert.True(result.ContainsKey("node-2"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAllStates_ReturnsDefensiveCopy()
|
||||
{
|
||||
var store = new InMemoryHlcStateStore();
|
||||
var ts = new HlcTimestamp { PhysicalTime = 1000, NodeId = "node-1", LogicalCounter = 0 };
|
||||
await store.SaveAsync(ts);
|
||||
|
||||
var states1 = store.GetAllStates();
|
||||
var states2 = store.GetAllStates();
|
||||
|
||||
// Should be different dictionary instances
|
||||
Assert.NotSame(states1, states2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Clear Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Clear_RemovesAllStates()
|
||||
{
|
||||
var store = new InMemoryHlcStateStore();
|
||||
|
||||
await store.SaveAsync(new HlcTimestamp { PhysicalTime = 1000, NodeId = "node-1", LogicalCounter = 0 });
|
||||
await store.SaveAsync(new HlcTimestamp { PhysicalTime = 2000, NodeId = "node-2", LogicalCounter = 0 });
|
||||
|
||||
store.Clear();
|
||||
|
||||
var result = store.GetAllStates();
|
||||
Assert.Empty(result);
|
||||
|
||||
var loaded = await store.LoadAsync("node-1");
|
||||
Assert.Null(loaded);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Monotonicity Tests
|
||||
|
||||
[Fact]
|
||||
public async Task SaveAsync_MaintainsMonotonicity_SamePhysicalTimeHigherCounter()
|
||||
{
|
||||
var store = new InMemoryHlcStateStore();
|
||||
|
||||
var ts1 = new HlcTimestamp { PhysicalTime = 1000, NodeId = "node-1", LogicalCounter = 5 };
|
||||
var ts2 = new HlcTimestamp { PhysicalTime = 1000, NodeId = "node-1", LogicalCounter = 10 };
|
||||
|
||||
await store.SaveAsync(ts1);
|
||||
await store.SaveAsync(ts2);
|
||||
|
||||
var result = await store.LoadAsync("node-1");
|
||||
Assert.Equal(10, result!.Value.LogicalCounter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveAsync_MaintainsMonotonicity_SamePhysicalTimeLowerCounter()
|
||||
{
|
||||
var store = new InMemoryHlcStateStore();
|
||||
|
||||
var ts1 = new HlcTimestamp { PhysicalTime = 1000, NodeId = "node-1", LogicalCounter = 10 };
|
||||
var ts2 = new HlcTimestamp { PhysicalTime = 1000, NodeId = "node-1", LogicalCounter = 5 };
|
||||
|
||||
await store.SaveAsync(ts1);
|
||||
await store.SaveAsync(ts2);
|
||||
|
||||
var result = await store.LoadAsync("node-1");
|
||||
Assert.Equal(10, result!.Value.LogicalCounter); // Original kept
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<LangVersion>preview</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" />
|
||||
<PackageReference Include="xunit.v3.assert" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" PrivateAssets="all" />
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="Moq" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.HybridLogicalClock/StellaOps.HybridLogicalClock.csproj" />
|
||||
<ProjectReference Include="../../StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user