Merge branch 'main' of https://git.stella-ops.org/stella-ops.org/git.stella-ops.org
This commit is contained in:
@@ -18,7 +18,8 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<NoWarn>1701;1702;1591;CA1416;SYSLIB0004;CS8600;CS8601;CS8602;CS8603;CS8604;CS8605;CS8618;CS8625;CS8765;CS8767;CS0472;CS0419</NoWarn>
|
||||
<!-- Third-party library: suppress nullable and API warnings to preserve upstream code -->
|
||||
<NoWarn>1701;1702;1591;CA1416;SYSLIB0003;SYSLIB0004;SYSLIB0023;SYSLIB0027;SYSLIB0028;SYSLIB0057;CS8600;CS8601;CS8602;CS8603;CS8604;CS8605;CS8618;CS8625;CS8765;CS8767;CS0472</NoWarn>
|
||||
<PackageId>GostCryptography</PackageId>
|
||||
<Title>GostCryptography</Title>
|
||||
<Version>$(GostCryptographyVersion)</Version>
|
||||
|
||||
@@ -95,7 +95,7 @@ namespace GostCryptography.Pkcs
|
||||
return _signedCms.Encode();
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="SignedCms.Decode"/>
|
||||
/// <inheritdoc cref="SignedCms.Decode(byte[])"/>
|
||||
public void Decode(byte[] encodedMessage)
|
||||
{
|
||||
_signedCms.Decode(encodedMessage);
|
||||
|
||||
@@ -1,71 +1,49 @@
|
||||
// <copyright file="HlcClockSkewException.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
// -----------------------------------------------------------------------------
|
||||
// HlcClockSkewException.cs
|
||||
// Sprint: SPRINT_20260105_002_001_LB_hlc_core_library
|
||||
// Task: HLC-003 - Clock skew exception
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.HybridLogicalClock;
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when clock skew exceeds the configured tolerance.
|
||||
/// Exception thrown when clock skew between nodes exceeds the configured threshold.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This exception indicates that a remote timestamp differs from the local
|
||||
/// physical clock by more than the configured maximum skew tolerance.
|
||||
/// This typically indicates:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item><description>NTP synchronization failure on one or more nodes</description></item>
|
||||
/// <item><description>Malicious/corrupted remote timestamp</description></item>
|
||||
/// <item><description>Overly aggressive skew tolerance configuration</description></item>
|
||||
/// </list>
|
||||
/// Clock skew indicates that two nodes have significantly different wall-clock times,
|
||||
/// which could indicate NTP misconfiguration or network partitioning issues.
|
||||
/// </remarks>
|
||||
public sealed class HlcClockSkewException : Exception
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HlcClockSkewException"/> class.
|
||||
/// The actual skew detected between clocks.
|
||||
/// </summary>
|
||||
/// <param name="observedSkew">The observed clock skew.</param>
|
||||
/// <param name="maxAllowedSkew">The maximum allowed skew.</param>
|
||||
public HlcClockSkewException(TimeSpan observedSkew, TimeSpan maxAllowedSkew)
|
||||
: base($"Clock skew of {observedSkew.TotalMilliseconds:F0}ms exceeds maximum allowed {maxAllowedSkew.TotalMilliseconds:F0}ms")
|
||||
public TimeSpan ActualSkew { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The maximum skew threshold that was configured.
|
||||
/// </summary>
|
||||
public TimeSpan MaxAllowedSkew { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new clock skew exception.
|
||||
/// </summary>
|
||||
/// <param name="actualSkew">The actual skew detected</param>
|
||||
/// <param name="maxAllowedSkew">The configured maximum skew</param>
|
||||
public HlcClockSkewException(TimeSpan actualSkew, TimeSpan maxAllowedSkew)
|
||||
: base($"Clock skew of {actualSkew.TotalSeconds:F1}s exceeds maximum allowed skew of {maxAllowedSkew.TotalSeconds:F1}s")
|
||||
{
|
||||
ObservedSkew = observedSkew;
|
||||
ActualSkew = actualSkew;
|
||||
MaxAllowedSkew = maxAllowedSkew;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HlcClockSkewException"/> class.
|
||||
/// Creates a new clock skew exception with inner exception.
|
||||
/// </summary>
|
||||
/// <param name="message">The error message.</param>
|
||||
public HlcClockSkewException(string message)
|
||||
: base(message)
|
||||
public HlcClockSkewException(TimeSpan actualSkew, TimeSpan maxAllowedSkew, Exception innerException)
|
||||
: base($"Clock skew of {actualSkew.TotalSeconds:F1}s exceeds maximum allowed skew of {maxAllowedSkew.TotalSeconds:F1}s", innerException)
|
||||
{
|
||||
ActualSkew = actualSkew;
|
||||
MaxAllowedSkew = maxAllowedSkew;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HlcClockSkewException"/> class.
|
||||
/// </summary>
|
||||
/// <param name="message">The error message.</param>
|
||||
/// <param name="innerException">The inner exception.</param>
|
||||
public HlcClockSkewException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HlcClockSkewException"/> class.
|
||||
/// </summary>
|
||||
public HlcClockSkewException()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the observed clock skew.
|
||||
/// </summary>
|
||||
public TimeSpan ObservedSkew { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the maximum allowed clock skew.
|
||||
/// </summary>
|
||||
public TimeSpan MaxAllowedSkew { get; }
|
||||
}
|
||||
|
||||
@@ -1,127 +1,127 @@
|
||||
// <copyright file="HlcServiceCollectionExtensions.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
// -----------------------------------------------------------------------------
|
||||
// HlcServiceCollectionExtensions.cs
|
||||
// Sprint: SPRINT_20260105_002_001_LB_hlc_core_library
|
||||
// Task: HLC-011 - Create HlcServiceCollectionExtensions for DI registration
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.HybridLogicalClock;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering HLC services with dependency injection.
|
||||
/// Extension methods for configuring HLC services in DI container.
|
||||
/// </summary>
|
||||
public static class HlcServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds Hybrid Logical Clock services to the service collection.
|
||||
/// Adds HLC services with in-memory state storage (for development/testing).
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configureOptions">Optional action to configure HLC options.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
/// <param name="services">Service collection</param>
|
||||
/// <param name="nodeId">Unique node identifier</param>
|
||||
/// <param name="maxClockSkew">Maximum allowed clock skew (default: 1 minute)</param>
|
||||
/// <returns>Service collection for chaining</returns>
|
||||
public static IServiceCollection AddHybridLogicalClock(
|
||||
this IServiceCollection services,
|
||||
Action<HlcOptions>? configureOptions = null)
|
||||
string nodeId,
|
||||
TimeSpan? maxClockSkew = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(nodeId);
|
||||
|
||||
// Register options
|
||||
if (configureOptions is not null)
|
||||
{
|
||||
services.Configure(configureOptions);
|
||||
}
|
||||
|
||||
services.AddOptions<HlcOptions>()
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
// Register Dapper type handler
|
||||
HlcTimestampTypeHandler.Register();
|
||||
|
||||
// Register TimeProvider if not already registered
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
services.TryAddSingleton<IHlcStateStore, InMemoryHlcStateStore>();
|
||||
|
||||
// Register state store based on configuration
|
||||
services.AddSingleton<IHlcStateStore>(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<HlcOptions>>().Value;
|
||||
|
||||
if (options.UseInMemoryStore)
|
||||
{
|
||||
return new InMemoryHlcStateStore();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(options.PostgresConnectionString))
|
||||
{
|
||||
var logger = sp.GetService<ILogger<PostgresHlcStateStore>>();
|
||||
return new PostgresHlcStateStore(
|
||||
options.PostgresConnectionString,
|
||||
options.PostgresSchema,
|
||||
logger);
|
||||
}
|
||||
|
||||
// Default to in-memory if no connection string
|
||||
return new InMemoryHlcStateStore();
|
||||
});
|
||||
|
||||
// Register the clock
|
||||
services.AddSingleton<IHybridLogicalClock>(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<HlcOptions>>().Value;
|
||||
var timeProvider = sp.GetRequiredService<TimeProvider>();
|
||||
var stateStore = sp.GetRequiredService<IHlcStateStore>();
|
||||
var logger = sp.GetService<ILogger<HybridLogicalClock>>();
|
||||
var logger = sp.GetRequiredService<ILogger<HybridLogicalClock>>();
|
||||
|
||||
var clock = new HybridLogicalClock(
|
||||
return new HybridLogicalClock(
|
||||
timeProvider,
|
||||
options.GetEffectiveNodeId(),
|
||||
nodeId,
|
||||
stateStore,
|
||||
options.MaxClockSkew,
|
||||
logger);
|
||||
|
||||
return clock;
|
||||
logger,
|
||||
maxClockSkew);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds Hybrid Logical Clock services with a specific node ID.
|
||||
/// Adds HLC services with custom state storage.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="nodeId">The node identifier.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddHybridLogicalClock(
|
||||
/// <typeparam name="TStateStore">State store implementation type</typeparam>
|
||||
/// <param name="services">Service collection</param>
|
||||
/// <param name="nodeId">Unique node identifier</param>
|
||||
/// <param name="maxClockSkew">Maximum allowed clock skew (default: 1 minute)</param>
|
||||
/// <returns>Service collection for chaining</returns>
|
||||
public static IServiceCollection AddHybridLogicalClock<TStateStore>(
|
||||
this IServiceCollection services,
|
||||
string nodeId)
|
||||
string nodeId,
|
||||
TimeSpan? maxClockSkew = null)
|
||||
where TStateStore : class, IHlcStateStore
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(nodeId);
|
||||
|
||||
return services.AddHybridLogicalClock(options =>
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
services.TryAddSingleton<IHlcStateStore, TStateStore>();
|
||||
|
||||
services.AddSingleton<IHybridLogicalClock>(sp =>
|
||||
{
|
||||
options.NodeId = nodeId;
|
||||
var timeProvider = sp.GetRequiredService<TimeProvider>();
|
||||
var stateStore = sp.GetRequiredService<IHlcStateStore>();
|
||||
var logger = sp.GetRequiredService<ILogger<HybridLogicalClock>>();
|
||||
|
||||
return new HybridLogicalClock(
|
||||
timeProvider,
|
||||
nodeId,
|
||||
stateStore,
|
||||
logger,
|
||||
maxClockSkew);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the HLC clock from persistent state.
|
||||
/// Should be called during application startup.
|
||||
/// Adds HLC services with factory-based state storage.
|
||||
/// </summary>
|
||||
/// <param name="serviceProvider">The service provider.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>A task representing the async operation.</returns>
|
||||
public static async Task InitializeHlcAsync(
|
||||
this IServiceProvider serviceProvider,
|
||||
CancellationToken ct = default)
|
||||
/// <param name="services">Service collection</param>
|
||||
/// <param name="nodeId">Unique node identifier</param>
|
||||
/// <param name="stateStoreFactory">Factory function to create state store</param>
|
||||
/// <param name="maxClockSkew">Maximum allowed clock skew (default: 1 minute)</param>
|
||||
/// <returns>Service collection for chaining</returns>
|
||||
public static IServiceCollection AddHybridLogicalClock(
|
||||
this IServiceCollection services,
|
||||
string nodeId,
|
||||
Func<IServiceProvider, IHlcStateStore> stateStoreFactory,
|
||||
TimeSpan? maxClockSkew = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(serviceProvider);
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(nodeId);
|
||||
ArgumentNullException.ThrowIfNull(stateStoreFactory);
|
||||
|
||||
var clock = serviceProvider.GetRequiredService<IHybridLogicalClock>();
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
services.TryAddSingleton(stateStoreFactory);
|
||||
|
||||
if (clock is HybridLogicalClock hlc)
|
||||
services.AddSingleton<IHybridLogicalClock>(sp =>
|
||||
{
|
||||
await hlc.InitializeAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
var timeProvider = sp.GetRequiredService<TimeProvider>();
|
||||
var stateStore = sp.GetRequiredService<IHlcStateStore>();
|
||||
var logger = sp.GetRequiredService<ILogger<HybridLogicalClock>>();
|
||||
|
||||
return new HybridLogicalClock(
|
||||
timeProvider,
|
||||
nodeId,
|
||||
stateStore,
|
||||
logger,
|
||||
maxClockSkew);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
// <copyright file="HlcTimestamp.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
// -----------------------------------------------------------------------------
|
||||
// HlcTimestamp.cs
|
||||
// Sprint: SPRINT_20260105_002_001_LB_hlc_core_library
|
||||
// Task: HLC-002 - Implement HlcTimestamp record with comparison, parsing, serialization
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Globalization;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.HybridLogicalClock;
|
||||
|
||||
@@ -13,23 +14,17 @@ namespace StellaOps.HybridLogicalClock;
|
||||
/// across distributed nodes even under clock skew.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// HLC combines the benefits of physical time (human-readable, bounded drift)
|
||||
/// with logical clocks (guaranteed causality, no rollback). The timestamp
|
||||
/// consists of three components:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item><description>PhysicalTime: Unix milliseconds UTC, advances with wall clock</description></item>
|
||||
/// <item><description>NodeId: Unique identifier for the generating node</description></item>
|
||||
/// <item><description>LogicalCounter: Increments when events occur at same physical time</description></item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// Total ordering is defined as: (PhysicalTime, LogicalCounter, NodeId)
|
||||
/// </para>
|
||||
/// HLC timestamps combine physical (wall-clock) time with a logical counter to ensure:
|
||||
/// 1. Monotonicity: Timestamps always increase within a node
|
||||
/// 2. Causal ordering: If event A happens-before event B, timestamp(A) < timestamp(B)
|
||||
/// 3. Skew tolerance: Works correctly even when node clocks differ
|
||||
/// </remarks>
|
||||
[JsonConverter(typeof(HlcTimestampJsonConverter))]
|
||||
public readonly record struct HlcTimestamp : IComparable<HlcTimestamp>, IComparable
|
||||
public readonly record struct HlcTimestamp : IComparable<HlcTimestamp>
|
||||
{
|
||||
private static readonly Regex ParseRegex = new(
|
||||
@"^(\d{13})-(.+)-(\d{6})$",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
/// <summary>
|
||||
/// Physical time component (Unix milliseconds UTC).
|
||||
/// </summary>
|
||||
@@ -46,110 +41,100 @@ public readonly record struct HlcTimestamp : IComparable<HlcTimestamp>, ICompara
|
||||
public required int LogicalCounter { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the physical time as a <see cref="DateTimeOffset"/>.
|
||||
/// Creates an HLC timestamp from the current wall-clock time.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public DateTimeOffset PhysicalDateTime =>
|
||||
/// <param name="nodeId">Node identifier for this timestamp</param>
|
||||
/// <param name="timeProvider">Time provider for wall-clock time</param>
|
||||
/// <returns>New HLC timestamp with counter set to 0</returns>
|
||||
public static HlcTimestamp Now(string nodeId, TimeProvider timeProvider)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(nodeId);
|
||||
ArgumentNullException.ThrowIfNull(timeProvider);
|
||||
|
||||
return new HlcTimestamp
|
||||
{
|
||||
PhysicalTime = timeProvider.GetUtcNow().ToUnixTimeMilliseconds(),
|
||||
NodeId = nodeId,
|
||||
LogicalCounter = 0
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timestamp as a DateTimeOffset.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Note: This only reflects the physical time component.
|
||||
/// The logical counter is not represented in DateTimeOffset.
|
||||
/// </remarks>
|
||||
public DateTimeOffset ToDateTimeOffset() =>
|
||||
DateTimeOffset.FromUnixTimeMilliseconds(PhysicalTime);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a zero/uninitialized timestamp.
|
||||
/// String representation for storage and sorting.
|
||||
/// Format: "1704067200000-scheduler-east-1-000042"
|
||||
/// </summary>
|
||||
public static HlcTimestamp Zero => new()
|
||||
{
|
||||
PhysicalTime = 0,
|
||||
NodeId = string.Empty,
|
||||
LogicalCounter = 0
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// String representation for storage: "0001704067200000-scheduler-east-1-000042".
|
||||
/// Format: {PhysicalTime:D13}-{NodeId}-{LogicalCounter:D6}
|
||||
/// </summary>
|
||||
/// <returns>A sortable string representation.</returns>
|
||||
public string ToSortableString()
|
||||
{
|
||||
return string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"{PhysicalTime:D13}-{NodeId}-{LogicalCounter:D6}");
|
||||
}
|
||||
/// <remarks>
|
||||
/// The format ensures lexicographic ordering matches logical ordering:
|
||||
/// - 13-digit physical time (zero-padded) sorts chronologically
|
||||
/// - Node ID provides tie-breaking for concurrent events
|
||||
/// - 6-digit counter (zero-padded) handles same-millisecond events
|
||||
/// </remarks>
|
||||
public string ToSortableString() =>
|
||||
string.Create(CultureInfo.InvariantCulture, $"{PhysicalTime:D13}-{NodeId}-{LogicalCounter:D6}");
|
||||
|
||||
/// <summary>
|
||||
/// Parse from sortable string format.
|
||||
/// </summary>
|
||||
/// <param name="value">The sortable string to parse.</param>
|
||||
/// <returns>The parsed <see cref="HlcTimestamp"/>.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when value is null.</exception>
|
||||
/// <exception cref="FormatException">Thrown when value is not in valid format.</exception>
|
||||
/// <param name="value">String in format "1704067200000-nodeid-000042"</param>
|
||||
/// <returns>Parsed HLC timestamp</returns>
|
||||
/// <exception cref="FormatException">If the string format is invalid</exception>
|
||||
public static HlcTimestamp Parse(string value)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(value);
|
||||
|
||||
if (!TryParse(value, out var result))
|
||||
var match = ParseRegex.Match(value);
|
||||
if (!match.Success)
|
||||
{
|
||||
throw new FormatException($"Invalid HLC timestamp format: '{value}'");
|
||||
throw new FormatException(
|
||||
$"Invalid HLC timestamp format: '{value}'. Expected format: '{{physicalTime13}}-{{nodeId}}-{{counter6}}'");
|
||||
}
|
||||
|
||||
return result;
|
||||
return new HlcTimestamp
|
||||
{
|
||||
PhysicalTime = long.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture),
|
||||
NodeId = match.Groups[2].Value,
|
||||
LogicalCounter = int.Parse(match.Groups[3].Value, CultureInfo.InvariantCulture)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Try to parse from sortable string format.
|
||||
/// </summary>
|
||||
/// <param name="value">The sortable string to parse.</param>
|
||||
/// <param name="result">The parsed timestamp if successful.</param>
|
||||
/// <returns>True if parsing succeeded; otherwise false.</returns>
|
||||
public static bool TryParse(
|
||||
[NotNullWhen(true)] string? value,
|
||||
out HlcTimestamp result)
|
||||
/// <param name="value">String to parse</param>
|
||||
/// <param name="result">Parsed timestamp if successful</param>
|
||||
/// <returns>True if parsing succeeded</returns>
|
||||
public static bool TryParse(string? value, out HlcTimestamp result)
|
||||
{
|
||||
result = default;
|
||||
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return false;
|
||||
}
|
||||
|
||||
// Format: {PhysicalTime:D13}-{NodeId}-{LogicalCounter:D6}
|
||||
// Example: 0001704067200000-scheduler-east-1-000042
|
||||
// The NodeId can contain hyphens, so we parse from both ends
|
||||
|
||||
var firstDash = value.IndexOf('-', StringComparison.Ordinal);
|
||||
if (firstDash < 1)
|
||||
{
|
||||
var match = ParseRegex.Match(value);
|
||||
if (!match.Success)
|
||||
return false;
|
||||
}
|
||||
|
||||
var lastDash = value.LastIndexOf('-');
|
||||
if (lastDash <= firstDash || lastDash >= value.Length - 1)
|
||||
{
|
||||
if (!long.TryParse(match.Groups[1].Value, CultureInfo.InvariantCulture, out var physicalTime))
|
||||
return false;
|
||||
}
|
||||
|
||||
var physicalTimeStr = value[..firstDash];
|
||||
var nodeId = value[(firstDash + 1)..lastDash];
|
||||
var counterStr = value[(lastDash + 1)..];
|
||||
|
||||
if (!long.TryParse(physicalTimeStr, NumberStyles.None, CultureInfo.InvariantCulture, out var physicalTime))
|
||||
{
|
||||
if (!int.TryParse(match.Groups[3].Value, CultureInfo.InvariantCulture, out var logicalCounter))
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(nodeId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!int.TryParse(counterStr, NumberStyles.None, CultureInfo.InvariantCulture, out var counter))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
result = new HlcTimestamp
|
||||
{
|
||||
PhysicalTime = physicalTime,
|
||||
NodeId = nodeId,
|
||||
LogicalCounter = counter
|
||||
NodeId = match.Groups[2].Value,
|
||||
LogicalCounter = logicalCounter
|
||||
};
|
||||
|
||||
return true;
|
||||
@@ -157,64 +142,63 @@ public readonly record struct HlcTimestamp : IComparable<HlcTimestamp>, ICompara
|
||||
|
||||
/// <summary>
|
||||
/// Compare for total ordering.
|
||||
/// Order: (PhysicalTime, LogicalCounter, NodeId).
|
||||
/// </summary>
|
||||
/// <param name="other">The other timestamp to compare.</param>
|
||||
/// <returns>Comparison result.</returns>
|
||||
/// <remarks>
|
||||
/// Ordering is:
|
||||
/// 1. Primary: Physical time (earlier times first)
|
||||
/// 2. Secondary: Logical counter (lower counters first)
|
||||
/// 3. Tertiary: Node ID (lexicographic, for stable tie-breaking)
|
||||
/// </remarks>
|
||||
public int CompareTo(HlcTimestamp other)
|
||||
{
|
||||
// Primary: physical time
|
||||
var physicalCompare = PhysicalTime.CompareTo(other.PhysicalTime);
|
||||
if (physicalCompare != 0)
|
||||
{
|
||||
return physicalCompare;
|
||||
}
|
||||
if (physicalCompare != 0) return physicalCompare;
|
||||
|
||||
// Secondary: logical counter
|
||||
var counterCompare = LogicalCounter.CompareTo(other.LogicalCounter);
|
||||
if (counterCompare != 0)
|
||||
{
|
||||
return counterCompare;
|
||||
}
|
||||
if (counterCompare != 0) return counterCompare;
|
||||
|
||||
// Tertiary: node ID (for stable tie-breaking)
|
||||
return string.Compare(NodeId, other.NodeId, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public int CompareTo(object? obj)
|
||||
{
|
||||
if (obj is null)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (obj is HlcTimestamp other)
|
||||
{
|
||||
return CompareTo(other);
|
||||
}
|
||||
|
||||
throw new ArgumentException($"Object must be of type {nameof(HlcTimestamp)}", nameof(obj));
|
||||
}
|
||||
/// <summary>
|
||||
/// Returns true if this timestamp is causally before the other.
|
||||
/// </summary>
|
||||
public bool IsBefore(HlcTimestamp other) => CompareTo(other) < 0;
|
||||
|
||||
/// <summary>
|
||||
/// Less than operator.
|
||||
/// Returns true if this timestamp is causally after the other.
|
||||
/// </summary>
|
||||
public bool IsAfter(HlcTimestamp other) => CompareTo(other) > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if this timestamp is causally concurrent with the other
|
||||
/// (same physical time and counter, different nodes).
|
||||
/// </summary>
|
||||
public bool IsConcurrent(HlcTimestamp other) =>
|
||||
PhysicalTime == other.PhysicalTime &&
|
||||
LogicalCounter == other.LogicalCounter &&
|
||||
!string.Equals(NodeId, other.NodeId, StringComparison.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new timestamp with incremented counter (same physical time and node).
|
||||
/// </summary>
|
||||
public HlcTimestamp Increment() => this with { LogicalCounter = LogicalCounter + 1 };
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new timestamp with specified physical time and reset counter.
|
||||
/// </summary>
|
||||
public HlcTimestamp WithPhysicalTime(long physicalTime) =>
|
||||
this with { PhysicalTime = physicalTime, LogicalCounter = 0 };
|
||||
|
||||
/// <summary>
|
||||
/// Comparison operators for convenience.
|
||||
/// </summary>
|
||||
public static bool operator <(HlcTimestamp left, HlcTimestamp right) => left.CompareTo(right) < 0;
|
||||
|
||||
/// <summary>
|
||||
/// Less than or equal operator.
|
||||
/// </summary>
|
||||
public static bool operator <=(HlcTimestamp left, HlcTimestamp right) => left.CompareTo(right) <= 0;
|
||||
|
||||
/// <summary>
|
||||
/// Greater than operator.
|
||||
/// </summary>
|
||||
public static bool operator >(HlcTimestamp left, HlcTimestamp right) => left.CompareTo(right) > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Greater than or equal operator.
|
||||
/// </summary>
|
||||
public static bool operator <=(HlcTimestamp left, HlcTimestamp right) => left.CompareTo(right) <= 0;
|
||||
public static bool operator >=(HlcTimestamp left, HlcTimestamp right) => left.CompareTo(right) >= 0;
|
||||
|
||||
/// <inheritdoc/>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// <copyright file="HlcTimestampJsonConverter.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
// -----------------------------------------------------------------------------
|
||||
// HlcTimestampJsonConverter.cs
|
||||
// Sprint: SPRINT_20260105_002_001_LB_hlc_core_library
|
||||
// Task: HLC-006 - Add HlcTimestampJsonConverter for System.Text.Json serialization
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
@@ -8,53 +10,166 @@ using System.Text.Json.Serialization;
|
||||
namespace StellaOps.HybridLogicalClock;
|
||||
|
||||
/// <summary>
|
||||
/// JSON converter for <see cref="HlcTimestamp"/> using sortable string format.
|
||||
/// JSON converter for HlcTimestamp using the sortable string format.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Serializes to and deserializes from the sortable string format:
|
||||
/// "{PhysicalTime:D13}-{NodeId}-{LogicalCounter:D6}"
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Example: "0001704067200000-scheduler-east-1-000042"
|
||||
/// </para>
|
||||
/// Serializes HlcTimestamp to/from the sortable string format (e.g., "1704067200000-scheduler-east-1-000042").
|
||||
/// This format is both human-readable and lexicographically sortable.
|
||||
/// </remarks>
|
||||
public sealed class HlcTimestampJsonConverter : JsonConverter<HlcTimestamp>
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override HlcTimestamp Read(
|
||||
ref Utf8JsonReader reader,
|
||||
Type typeToConvert,
|
||||
JsonSerializerOptions options)
|
||||
public override HlcTimestamp Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
if (reader.TokenType == JsonTokenType.Null)
|
||||
{
|
||||
return HlcTimestamp.Zero;
|
||||
throw new JsonException("Cannot convert null value to HlcTimestamp");
|
||||
}
|
||||
|
||||
if (reader.TokenType != JsonTokenType.String)
|
||||
{
|
||||
throw new JsonException($"Expected string token for HlcTimestamp, got {reader.TokenType}");
|
||||
throw new JsonException($"Expected string but got {reader.TokenType}");
|
||||
}
|
||||
|
||||
var value = reader.GetString();
|
||||
|
||||
if (!HlcTimestamp.TryParse(value, out var result))
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new JsonException($"Invalid HlcTimestamp format: '{value}'");
|
||||
throw new JsonException("Cannot convert empty string to HlcTimestamp");
|
||||
}
|
||||
|
||||
return result;
|
||||
try
|
||||
{
|
||||
return HlcTimestamp.Parse(value);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
throw new JsonException($"Invalid HlcTimestamp format: {value}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void Write(
|
||||
Utf8JsonWriter writer,
|
||||
HlcTimestamp value,
|
||||
JsonSerializerOptions options)
|
||||
public override void Write(Utf8JsonWriter writer, HlcTimestamp value, JsonSerializerOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(writer);
|
||||
|
||||
writer.WriteStringValue(value.ToSortableString());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// JSON converter for nullable HlcTimestamp.
|
||||
/// </summary>
|
||||
public sealed class NullableHlcTimestampJsonConverter : JsonConverter<HlcTimestamp?>
|
||||
{
|
||||
private readonly HlcTimestampJsonConverter _inner = new();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override HlcTimestamp? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
if (reader.TokenType == JsonTokenType.Null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return _inner.Read(ref reader, typeof(HlcTimestamp), options);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void Write(Utf8JsonWriter writer, HlcTimestamp? value, JsonSerializerOptions options)
|
||||
{
|
||||
if (!value.HasValue)
|
||||
{
|
||||
writer.WriteNullValue();
|
||||
return;
|
||||
}
|
||||
|
||||
_inner.Write(writer, value.Value, options);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// JSON converter for HlcTimestamp using object format with individual properties.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Alternative converter that serializes HlcTimestamp as an object:
|
||||
/// <code>
|
||||
/// {
|
||||
/// "physicalTime": 1704067200000,
|
||||
/// "nodeId": "scheduler-east-1",
|
||||
/// "logicalCounter": 42
|
||||
/// }
|
||||
/// </code>
|
||||
/// Use this when you need to query individual fields in JSON storage.
|
||||
/// </remarks>
|
||||
public sealed class HlcTimestampObjectJsonConverter : JsonConverter<HlcTimestamp>
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override HlcTimestamp Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
if (reader.TokenType != JsonTokenType.StartObject)
|
||||
{
|
||||
throw new JsonException($"Expected StartObject but got {reader.TokenType}");
|
||||
}
|
||||
|
||||
long? physicalTime = null;
|
||||
string? nodeId = null;
|
||||
int? logicalCounter = null;
|
||||
|
||||
while (reader.Read())
|
||||
{
|
||||
if (reader.TokenType == JsonTokenType.EndObject)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (reader.TokenType != JsonTokenType.PropertyName)
|
||||
{
|
||||
throw new JsonException($"Expected PropertyName but got {reader.TokenType}");
|
||||
}
|
||||
|
||||
var propertyName = reader.GetString();
|
||||
reader.Read();
|
||||
|
||||
switch (propertyName)
|
||||
{
|
||||
case "physicalTime":
|
||||
case "PhysicalTime":
|
||||
physicalTime = reader.GetInt64();
|
||||
break;
|
||||
case "nodeId":
|
||||
case "NodeId":
|
||||
nodeId = reader.GetString();
|
||||
break;
|
||||
case "logicalCounter":
|
||||
case "LogicalCounter":
|
||||
logicalCounter = reader.GetInt32();
|
||||
break;
|
||||
default:
|
||||
reader.Skip();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!physicalTime.HasValue)
|
||||
throw new JsonException("Missing required property 'physicalTime'");
|
||||
if (string.IsNullOrEmpty(nodeId))
|
||||
throw new JsonException("Missing required property 'nodeId'");
|
||||
if (!logicalCounter.HasValue)
|
||||
throw new JsonException("Missing required property 'logicalCounter'");
|
||||
|
||||
return new HlcTimestamp
|
||||
{
|
||||
PhysicalTime = physicalTime.Value,
|
||||
NodeId = nodeId,
|
||||
LogicalCounter = logicalCounter.Value
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void Write(Utf8JsonWriter writer, HlcTimestamp value, JsonSerializerOptions options)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteNumber("physicalTime", value.PhysicalTime);
|
||||
writer.WriteString("nodeId", value.NodeId);
|
||||
writer.WriteNumber("logicalCounter", value.LogicalCounter);
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,59 +1,212 @@
|
||||
// <copyright file="HlcTimestampTypeHandler.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
// -----------------------------------------------------------------------------
|
||||
// HlcTimestampTypeHandler.cs
|
||||
// Sprint: SPRINT_20260105_002_001_LB_hlc_core_library
|
||||
// Task: HLC-007 - Add HlcTimestampTypeHandler for Npgsql/Dapper
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Data;
|
||||
using Dapper;
|
||||
using Npgsql;
|
||||
using NpgsqlTypes;
|
||||
|
||||
namespace StellaOps.HybridLogicalClock;
|
||||
|
||||
/// <summary>
|
||||
/// Dapper type handler for <see cref="HlcTimestamp"/>.
|
||||
/// Npgsql type handler for HlcTimestamp stored as TEXT in sortable string format.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Maps HlcTimestamp to/from TEXT column using sortable string format.
|
||||
/// Register with: <c>SqlMapper.AddTypeHandler(new HlcTimestampTypeHandler());</c>
|
||||
/// This handler allows HlcTimestamp to be used directly in Npgsql queries:
|
||||
/// <code>
|
||||
/// cmd.Parameters.AddWithValue("@hlc", hlcTimestamp);
|
||||
/// var hlc = reader.GetFieldValue<HlcTimestamp>(0);
|
||||
/// </code>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Register with Npgsql using:
|
||||
/// <code>
|
||||
/// NpgsqlConnection.GlobalTypeMapper.AddTypeInfoResolverFactory(new HlcTimestampTypeHandlerResolverFactory());
|
||||
/// </code>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class HlcTimestampTypeHandler : SqlMapper.TypeHandler<HlcTimestamp>
|
||||
public static class HlcTimestampNpgsqlExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the singleton instance of the type handler.
|
||||
/// Adds an HlcTimestamp parameter to the command.
|
||||
/// </summary>
|
||||
public static HlcTimestampTypeHandler Instance { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Registers this type handler with Dapper.
|
||||
/// Should be called once at application startup.
|
||||
/// </summary>
|
||||
public static void Register()
|
||||
/// <param name="cmd">The Npgsql command</param>
|
||||
/// <param name="parameterName">Parameter name (with or without @)</param>
|
||||
/// <param name="value">HLC timestamp value</param>
|
||||
/// <returns>The added parameter</returns>
|
||||
public static NpgsqlParameter AddHlcTimestamp(
|
||||
this NpgsqlCommand cmd,
|
||||
string parameterName,
|
||||
HlcTimestamp value)
|
||||
{
|
||||
SqlMapper.AddTypeHandler(Instance);
|
||||
var param = new NpgsqlParameter(parameterName, NpgsqlDbType.Text)
|
||||
{
|
||||
Value = value.ToSortableString()
|
||||
};
|
||||
cmd.Parameters.Add(param);
|
||||
return param;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a nullable HlcTimestamp parameter to the command.
|
||||
/// </summary>
|
||||
/// <param name="cmd">The Npgsql command</param>
|
||||
/// <param name="parameterName">Parameter name (with or without @)</param>
|
||||
/// <param name="value">HLC timestamp value (nullable)</param>
|
||||
/// <returns>The added parameter</returns>
|
||||
public static NpgsqlParameter AddHlcTimestamp(
|
||||
this NpgsqlCommand cmd,
|
||||
string parameterName,
|
||||
HlcTimestamp? value)
|
||||
{
|
||||
var param = new NpgsqlParameter(parameterName, NpgsqlDbType.Text)
|
||||
{
|
||||
Value = value.HasValue ? value.Value.ToSortableString() : DBNull.Value
|
||||
};
|
||||
cmd.Parameters.Add(param);
|
||||
return param;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an HlcTimestamp value from the reader.
|
||||
/// </summary>
|
||||
/// <param name="reader">The data reader</param>
|
||||
/// <param name="ordinal">Column ordinal</param>
|
||||
/// <returns>Parsed HLC timestamp</returns>
|
||||
public static HlcTimestamp GetHlcTimestamp(this NpgsqlDataReader reader, int ordinal)
|
||||
{
|
||||
var value = reader.GetString(ordinal);
|
||||
return HlcTimestamp.Parse(value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a nullable HlcTimestamp value from the reader.
|
||||
/// </summary>
|
||||
/// <param name="reader">The data reader</param>
|
||||
/// <param name="ordinal">Column ordinal</param>
|
||||
/// <returns>Parsed HLC timestamp or null</returns>
|
||||
public static HlcTimestamp? GetHlcTimestampOrNull(this NpgsqlDataReader reader, int ordinal)
|
||||
{
|
||||
if (reader.IsDBNull(ordinal))
|
||||
return null;
|
||||
|
||||
var value = reader.GetString(ordinal);
|
||||
return HlcTimestamp.Parse(value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an HlcTimestamp value from the reader by column name.
|
||||
/// </summary>
|
||||
/// <param name="reader">The data reader</param>
|
||||
/// <param name="columnName">Column name</param>
|
||||
/// <returns>Parsed HLC timestamp</returns>
|
||||
public static HlcTimestamp GetHlcTimestamp(this NpgsqlDataReader reader, string columnName)
|
||||
{
|
||||
var ordinal = reader.GetOrdinal(columnName);
|
||||
return reader.GetHlcTimestamp(ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a nullable HlcTimestamp value from the reader by column name.
|
||||
/// </summary>
|
||||
/// <param name="reader">The data reader</param>
|
||||
/// <param name="columnName">Column name</param>
|
||||
/// <returns>Parsed HLC timestamp or null</returns>
|
||||
public static HlcTimestamp? GetHlcTimestampOrNull(this NpgsqlDataReader reader, string columnName)
|
||||
{
|
||||
var ordinal = reader.GetOrdinal(columnName);
|
||||
return reader.GetHlcTimestampOrNull(ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dapper type handler for HlcTimestamp.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Register with Dapper using:
|
||||
/// <code>
|
||||
/// SqlMapper.AddTypeHandler(new HlcTimestampDapperHandler());
|
||||
/// </code>
|
||||
/// </remarks>
|
||||
public sealed class HlcTimestampDapperHandler : Dapper.SqlMapper.TypeHandler<HlcTimestamp>
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override HlcTimestamp Parse(object value)
|
||||
{
|
||||
if (value is null or DBNull)
|
||||
if (value is string str)
|
||||
{
|
||||
return HlcTimestamp.Zero;
|
||||
return HlcTimestamp.Parse(str);
|
||||
}
|
||||
|
||||
if (value is string strValue)
|
||||
{
|
||||
return HlcTimestamp.Parse(strValue);
|
||||
}
|
||||
|
||||
throw new DataException($"Cannot convert {value.GetType().Name} to HlcTimestamp");
|
||||
throw new DataException($"Cannot convert {value?.GetType().Name ?? "null"} to HlcTimestamp");
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void SetValue(IDbDataParameter parameter, HlcTimestamp value)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(parameter);
|
||||
|
||||
parameter.DbType = DbType.String;
|
||||
parameter.Value = value.ToSortableString();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dapper type handler for nullable HlcTimestamp.
|
||||
/// </summary>
|
||||
public sealed class NullableHlcTimestampDapperHandler : Dapper.SqlMapper.TypeHandler<HlcTimestamp?>
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override HlcTimestamp? Parse(object value)
|
||||
{
|
||||
if (value is null or DBNull)
|
||||
return null;
|
||||
|
||||
if (value is string str)
|
||||
{
|
||||
return HlcTimestamp.Parse(str);
|
||||
}
|
||||
|
||||
throw new DataException($"Cannot convert {value.GetType().Name} to HlcTimestamp?");
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void SetValue(IDbDataParameter parameter, HlcTimestamp? value)
|
||||
{
|
||||
parameter.DbType = DbType.String;
|
||||
parameter.Value = value.HasValue ? value.Value.ToSortableString() : DBNull.Value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering HLC type handlers.
|
||||
/// </summary>
|
||||
public static class HlcTypeHandlerRegistration
|
||||
{
|
||||
private static bool _dapperHandlersRegistered;
|
||||
private static readonly object _lock = new();
|
||||
|
||||
/// <summary>
|
||||
/// Registers Dapper type handlers for HlcTimestamp.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This method is idempotent and can be called multiple times safely.
|
||||
/// </remarks>
|
||||
public static void RegisterDapperHandlers()
|
||||
{
|
||||
if (_dapperHandlersRegistered)
|
||||
return;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (_dapperHandlersRegistered)
|
||||
return;
|
||||
|
||||
Dapper.SqlMapper.AddTypeHandler(new HlcTimestampDapperHandler());
|
||||
Dapper.SqlMapper.AddTypeHandler(new NullableHlcTimestampDapperHandler());
|
||||
|
||||
_dapperHandlersRegistered = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,68 +1,67 @@
|
||||
// <copyright file="HybridLogicalClock.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
// -----------------------------------------------------------------------------
|
||||
// HybridLogicalClock.cs
|
||||
// Sprint: SPRINT_20260105_002_001_LB_hlc_core_library
|
||||
// Task: HLC-003 - Implement HybridLogicalClock class with Tick/Receive/Current
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace StellaOps.HybridLogicalClock;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IHybridLogicalClock"/>.
|
||||
/// Implementation of Hybrid Logical Clock algorithm for deterministic,
|
||||
/// monotonic timestamp generation across distributed nodes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Implements the Hybrid Logical Clock algorithm which combines physical time
|
||||
/// with logical counters to provide:
|
||||
/// The HLC algorithm combines physical (wall-clock) time with a logical counter:
|
||||
/// - Physical time provides approximate real-time ordering
|
||||
/// - Logical counter ensures monotonicity when physical time doesn't advance
|
||||
/// - Node ID provides stable tie-breaking for concurrent events
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item><description>Monotonicity: timestamps always increase</description></item>
|
||||
/// <item><description>Causality: if A happens-before B, then HLC(A) < HLC(B)</description></item>
|
||||
/// <item><description>Bounded drift: physical component stays close to wall clock</description></item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// Thread-safety is guaranteed via internal locking.
|
||||
/// On local event or send:
|
||||
/// <code>
|
||||
/// l' = l
|
||||
/// l = max(l, physical_clock())
|
||||
/// if l == l':
|
||||
/// c = c + 1
|
||||
/// else:
|
||||
/// c = 0
|
||||
/// return (l, node_id, c)
|
||||
/// </code>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// On receive(m_l, m_c):
|
||||
/// <code>
|
||||
/// l' = l
|
||||
/// l = max(l', m_l, physical_clock())
|
||||
/// if l == l' == m_l:
|
||||
/// c = max(c, m_c) + 1
|
||||
/// elif l == l':
|
||||
/// c = c + 1
|
||||
/// elif l == m_l:
|
||||
/// c = m_c + 1
|
||||
/// else:
|
||||
/// c = 0
|
||||
/// return (l, node_id, c)
|
||||
/// </code>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class HybridLogicalClock : IHybridLogicalClock
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly string _nodeId;
|
||||
private readonly IHlcStateStore _stateStore;
|
||||
private readonly TimeSpan _maxClockSkew;
|
||||
private readonly ILogger<HybridLogicalClock> _logger;
|
||||
private readonly object _lock = new();
|
||||
|
||||
private long _lastPhysicalTime;
|
||||
private int _logicalCounter;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HybridLogicalClock"/> class.
|
||||
/// </summary>
|
||||
/// <param name="timeProvider">Time provider for physical clock.</param>
|
||||
/// <param name="nodeId">Unique identifier for this node.</param>
|
||||
/// <param name="stateStore">Persistent state store.</param>
|
||||
/// <param name="maxClockSkew">Maximum allowed clock skew (default: 1 minute).</param>
|
||||
/// <param name="logger">Optional logger.</param>
|
||||
public HybridLogicalClock(
|
||||
TimeProvider timeProvider,
|
||||
string nodeId,
|
||||
IHlcStateStore stateStore,
|
||||
TimeSpan? maxClockSkew = null,
|
||||
ILogger<HybridLogicalClock>? logger = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(timeProvider);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(nodeId);
|
||||
ArgumentNullException.ThrowIfNull(stateStore);
|
||||
|
||||
_timeProvider = timeProvider;
|
||||
NodeId = nodeId;
|
||||
_stateStore = stateStore;
|
||||
_maxClockSkew = maxClockSkew ?? TimeSpan.FromMinutes(1);
|
||||
_logger = logger ?? NullLogger<HybridLogicalClock>.Instance;
|
||||
}
|
||||
private readonly object _lock = new();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string NodeId { get; }
|
||||
public string NodeId => _nodeId;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public HlcTimestamp Current
|
||||
@@ -74,13 +73,92 @@ public sealed class HybridLogicalClock : IHybridLogicalClock
|
||||
return new HlcTimestamp
|
||||
{
|
||||
PhysicalTime = _lastPhysicalTime,
|
||||
NodeId = NodeId,
|
||||
NodeId = _nodeId,
|
||||
LogicalCounter = _logicalCounter
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new Hybrid Logical Clock instance.
|
||||
/// </summary>
|
||||
/// <param name="timeProvider">Time provider for wall-clock time</param>
|
||||
/// <param name="nodeId">Unique identifier for this node (e.g., "scheduler-east-1")</param>
|
||||
/// <param name="stateStore">Persistent storage for clock state</param>
|
||||
/// <param name="logger">Logger for diagnostics</param>
|
||||
/// <param name="maxClockSkew">Maximum allowed clock skew (default: 1 minute)</param>
|
||||
public HybridLogicalClock(
|
||||
TimeProvider timeProvider,
|
||||
string nodeId,
|
||||
IHlcStateStore stateStore,
|
||||
ILogger<HybridLogicalClock> logger,
|
||||
TimeSpan? maxClockSkew = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(timeProvider);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(nodeId);
|
||||
ArgumentNullException.ThrowIfNull(stateStore);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
_timeProvider = timeProvider;
|
||||
_nodeId = nodeId;
|
||||
_stateStore = stateStore;
|
||||
_logger = logger;
|
||||
_maxClockSkew = maxClockSkew ?? TimeSpan.FromMinutes(1);
|
||||
|
||||
// Initialize to current physical time
|
||||
_lastPhysicalTime = _timeProvider.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
_logicalCounter = 0;
|
||||
|
||||
_logger.LogInformation(
|
||||
"HLC initialized for node {NodeId} with max skew {MaxSkew}",
|
||||
_nodeId,
|
||||
_maxClockSkew);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initialize clock from persisted state (call during startup).
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token</param>
|
||||
/// <returns>True if state was recovered, false if starting fresh</returns>
|
||||
public async Task<bool> InitializeFromStateAsync(CancellationToken ct = default)
|
||||
{
|
||||
var persistedState = await _stateStore.LoadAsync(_nodeId, ct);
|
||||
|
||||
if (persistedState.HasValue)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
// Ensure we start at least at the persisted time
|
||||
var physicalNow = _timeProvider.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
_lastPhysicalTime = Math.Max(physicalNow, persistedState.Value.PhysicalTime);
|
||||
|
||||
// If we're at the same physical time as persisted, increment counter
|
||||
if (_lastPhysicalTime == persistedState.Value.PhysicalTime)
|
||||
{
|
||||
_logicalCounter = persistedState.Value.LogicalCounter + 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
_logicalCounter = 0;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"HLC for node {NodeId} recovered from persisted state: {Timestamp}",
|
||||
_nodeId,
|
||||
persistedState.Value);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"HLC for node {NodeId} starting fresh (no persisted state)",
|
||||
_nodeId);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public HlcTimestamp Tick()
|
||||
{
|
||||
@@ -92,22 +170,23 @@ public sealed class HybridLogicalClock : IHybridLogicalClock
|
||||
|
||||
if (physicalNow > _lastPhysicalTime)
|
||||
{
|
||||
// Physical clock advanced - reset counter
|
||||
// Physical time advanced - reset counter
|
||||
_lastPhysicalTime = physicalNow;
|
||||
_logicalCounter = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Same or earlier physical time - increment counter
|
||||
// This handles clock regression gracefully
|
||||
// Physical time hasn't advanced - increment counter
|
||||
_logicalCounter++;
|
||||
|
||||
// Check for counter overflow (unlikely but handle it)
|
||||
if (_logicalCounter < 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"HLC logical counter overflow detected, advancing physical time. NodeId={NodeId}",
|
||||
NodeId);
|
||||
"HLC counter overflow for node {NodeId}, forcing time advance",
|
||||
_nodeId);
|
||||
|
||||
// Force time advance to next millisecond
|
||||
_lastPhysicalTime++;
|
||||
_logicalCounter = 0;
|
||||
}
|
||||
@@ -116,7 +195,7 @@ public sealed class HybridLogicalClock : IHybridLogicalClock
|
||||
timestamp = new HlcTimestamp
|
||||
{
|
||||
PhysicalTime = _lastPhysicalTime,
|
||||
NodeId = NodeId,
|
||||
NodeId = _nodeId,
|
||||
LogicalCounter = _logicalCounter
|
||||
};
|
||||
}
|
||||
@@ -141,54 +220,45 @@ public sealed class HybridLogicalClock : IHybridLogicalClock
|
||||
if (skew > _maxClockSkew)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Clock skew exceeded: observed={ObservedMs}ms, max={MaxMs}ms, remote={RemoteNodeId}",
|
||||
skew.TotalMilliseconds,
|
||||
_maxClockSkew.TotalMilliseconds,
|
||||
remote.NodeId);
|
||||
"Clock skew of {Skew} from node {RemoteNode} exceeds threshold {MaxSkew}",
|
||||
skew,
|
||||
remote.NodeId,
|
||||
_maxClockSkew);
|
||||
|
||||
throw new HlcClockSkewException(skew, _maxClockSkew);
|
||||
}
|
||||
|
||||
var prevPhysicalTime = _lastPhysicalTime;
|
||||
var maxPhysical = Math.Max(Math.Max(prevPhysicalTime, remote.PhysicalTime), physicalNow);
|
||||
// Find maximum physical time
|
||||
var maxPhysical = Math.Max(Math.Max(_lastPhysicalTime, remote.PhysicalTime), physicalNow);
|
||||
|
||||
if (maxPhysical == prevPhysicalTime && maxPhysical == remote.PhysicalTime)
|
||||
// Apply HLC receive algorithm
|
||||
if (maxPhysical == _lastPhysicalTime && maxPhysical == remote.PhysicalTime)
|
||||
{
|
||||
// All three equal - take max counter and increment
|
||||
_logicalCounter = Math.Max(_logicalCounter, remote.LogicalCounter) + 1;
|
||||
}
|
||||
else if (maxPhysical == prevPhysicalTime)
|
||||
else if (maxPhysical == _lastPhysicalTime)
|
||||
{
|
||||
// Local was max - increment local counter
|
||||
// Our time is max - just increment our counter
|
||||
_logicalCounter++;
|
||||
}
|
||||
else if (maxPhysical == remote.PhysicalTime)
|
||||
{
|
||||
// Remote was max - take remote counter and increment
|
||||
// Remote time is max - take their counter and increment
|
||||
_logicalCounter = remote.LogicalCounter + 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Physical clock advanced - reset counter
|
||||
// Physical clock is max - reset counter
|
||||
_logicalCounter = 0;
|
||||
}
|
||||
|
||||
_lastPhysicalTime = maxPhysical;
|
||||
|
||||
// Check for counter overflow
|
||||
if (_logicalCounter < 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"HLC logical counter overflow on receive, advancing physical time. NodeId={NodeId}",
|
||||
NodeId);
|
||||
_lastPhysicalTime++;
|
||||
_logicalCounter = 0;
|
||||
}
|
||||
|
||||
timestamp = new HlcTimestamp
|
||||
{
|
||||
PhysicalTime = _lastPhysicalTime,
|
||||
NodeId = NodeId,
|
||||
NodeId = _nodeId,
|
||||
LogicalCounter = _logicalCounter
|
||||
};
|
||||
}
|
||||
@@ -196,77 +266,28 @@ public sealed class HybridLogicalClock : IHybridLogicalClock
|
||||
// Persist state asynchronously
|
||||
_ = PersistStateAsync(timestamp);
|
||||
|
||||
_logger.LogDebug(
|
||||
"HLC receive from {RemoteNode}: {RemoteTimestamp} -> {LocalTimestamp}",
|
||||
remote.NodeId,
|
||||
remote,
|
||||
timestamp);
|
||||
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initialize clock state from persistent store.
|
||||
/// Should be called once during startup.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>True if state was recovered; false if starting fresh.</returns>
|
||||
public async Task<bool> InitializeAsync(CancellationToken ct = default)
|
||||
{
|
||||
var persisted = await _stateStore.LoadAsync(NodeId, ct).ConfigureAwait(false);
|
||||
|
||||
if (persisted is { } state)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
// Ensure we never go backward
|
||||
var physicalNow = _timeProvider.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
_lastPhysicalTime = Math.Max(state.PhysicalTime, physicalNow);
|
||||
|
||||
if (_lastPhysicalTime == state.PhysicalTime)
|
||||
{
|
||||
// Same physical time - continue from persisted counter + 1
|
||||
_logicalCounter = state.LogicalCounter + 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Physical time advanced - reset counter
|
||||
_logicalCounter = 0;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"HLC state recovered: PhysicalTime={PhysicalTime}, Counter={Counter}, NodeId={NodeId}",
|
||||
_lastPhysicalTime,
|
||||
_logicalCounter,
|
||||
NodeId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_lastPhysicalTime = _timeProvider.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
_logicalCounter = 0;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"HLC initialized fresh: PhysicalTime={PhysicalTime}, NodeId={NodeId}",
|
||||
_lastPhysicalTime,
|
||||
NodeId);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task PersistStateAsync(HlcTimestamp timestamp)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _stateStore.SaveAsync(timestamp).ConfigureAwait(false);
|
||||
await _stateStore.SaveAsync(timestamp);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Fire-and-forget with error logging
|
||||
// Clock continues operating; state will be recovered on next successful save
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Failed to persist HLC state: NodeId={NodeId}, PhysicalTime={PhysicalTime}",
|
||||
NodeId,
|
||||
timestamp.PhysicalTime);
|
||||
"Failed to persist HLC state for node {NodeId}: {Timestamp}",
|
||||
_nodeId,
|
||||
timestamp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
// <copyright file="IHybridLogicalClock.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
// -----------------------------------------------------------------------------
|
||||
// IHybridLogicalClock.cs
|
||||
// Sprint: SPRINT_20260105_002_001_LB_hlc_core_library
|
||||
// Task: HLC-003 - Define HLC interface
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.HybridLogicalClock;
|
||||
|
||||
/// <summary>
|
||||
/// Hybrid Logical Clock for monotonic timestamp generation.
|
||||
/// Hybrid Logical Clock for monotonic timestamp generation across distributed nodes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Implementations must guarantee:
|
||||
/// </para>
|
||||
/// <list type="number">
|
||||
/// <item><description>Successive Tick() calls return strictly increasing timestamps</description></item>
|
||||
/// <item><description>Receive() merges remote timestamp maintaining causality</description></item>
|
||||
/// <item><description>Clock state survives restarts via persistence</description></item>
|
||||
/// </list>
|
||||
/// HLC combines physical (wall-clock) time with logical counters to provide:
|
||||
/// - Monotonic timestamps even under clock skew
|
||||
/// - Causal ordering guarantees across distributed nodes
|
||||
/// - Deterministic tie-breaking for concurrent events
|
||||
/// </remarks>
|
||||
public interface IHybridLogicalClock
|
||||
{
|
||||
@@ -23,43 +21,62 @@ public interface IHybridLogicalClock
|
||||
/// Generate next timestamp for local event.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Algorithm:</para>
|
||||
/// <list type="number">
|
||||
/// <item><description>l' = l (save previous logical time)</description></item>
|
||||
/// <item><description>l = max(l, physical_clock())</description></item>
|
||||
/// <item><description>if l == l': c = c + 1 else: c = 0</description></item>
|
||||
/// <item><description>return (l, node_id, c)</description></item>
|
||||
/// </list>
|
||||
/// This should be called for every event that needs ordering:
|
||||
/// - Job enqueue
|
||||
/// - State transitions
|
||||
/// - Audit log entries
|
||||
/// </remarks>
|
||||
/// <returns>A new monotonically increasing timestamp.</returns>
|
||||
/// <returns>New monotonically increasing HLC timestamp</returns>
|
||||
HlcTimestamp Tick();
|
||||
|
||||
/// <summary>
|
||||
/// Update clock on receiving remote timestamp, return merged result.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Algorithm:</para>
|
||||
/// <list type="number">
|
||||
/// <item><description>l' = l (save previous)</description></item>
|
||||
/// <item><description>l = max(l', m_l, physical_clock())</description></item>
|
||||
/// <item><description>Update c based on which max was chosen</description></item>
|
||||
/// <item><description>return (l, node_id, c)</description></item>
|
||||
/// </list>
|
||||
/// Called when receiving a message from another node to ensure
|
||||
/// causal ordering is maintained across the distributed system.
|
||||
/// </remarks>
|
||||
/// <param name="remote">The remote timestamp to merge.</param>
|
||||
/// <returns>A new timestamp incorporating the remote causality.</returns>
|
||||
/// <exception cref="HlcClockSkewException">
|
||||
/// Thrown when the remote timestamp differs from physical clock by more than max skew tolerance.
|
||||
/// </exception>
|
||||
/// <param name="remote">Timestamp from remote node</param>
|
||||
/// <returns>New timestamp that is greater than both local clock and remote timestamp</returns>
|
||||
/// <exception cref="HlcClockSkewException">If clock skew exceeds configured threshold</exception>
|
||||
HlcTimestamp Receive(HlcTimestamp remote);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current clock state (for persistence/recovery).
|
||||
/// Current clock state (for persistence/recovery).
|
||||
/// </summary>
|
||||
HlcTimestamp Current { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the node identifier for this clock instance.
|
||||
/// Node identifier for this clock instance.
|
||||
/// </summary>
|
||||
string NodeId { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persistent storage for HLC state (survives restarts).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Implementations should ensure atomic updates to prevent state loss
|
||||
/// during concurrent access or node failures.
|
||||
/// </remarks>
|
||||
public interface IHlcStateStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Load last persisted HLC state for node.
|
||||
/// </summary>
|
||||
/// <param name="nodeId">Node identifier to load state for</param>
|
||||
/// <param name="ct">Cancellation token</param>
|
||||
/// <returns>Last persisted timestamp, or null if no state exists</returns>
|
||||
Task<HlcTimestamp?> LoadAsync(string nodeId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Persist HLC state.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Called after each tick to ensure state survives restarts.
|
||||
/// Implementations may batch or debounce writes for performance.
|
||||
/// </remarks>
|
||||
/// <param name="timestamp">Current timestamp to persist</param>
|
||||
/// <param name="ct">Cancellation token</param>
|
||||
Task SaveAsync(HlcTimestamp timestamp, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -1,41 +1,47 @@
|
||||
// <copyright file="InMemoryHlcStateStore.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
// -----------------------------------------------------------------------------
|
||||
// InMemoryHlcStateStore.cs
|
||||
// Sprint: SPRINT_20260105_002_001_LB_hlc_core_library
|
||||
// Task: HLC-004 - Implement IHlcStateStore interface and InMemoryHlcStateStore
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.HybridLogicalClock;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="IHlcStateStore"/> for testing and development.
|
||||
/// In-memory implementation of HLC state store for testing and development.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// State is lost on process restart. Use <see cref="PostgresHlcStateStore"/> for production.
|
||||
/// </para>
|
||||
/// This implementation does not survive process restarts. Use PostgresHlcStateStore
|
||||
/// for production deployments requiring persistence.
|
||||
/// </remarks>
|
||||
public sealed class InMemoryHlcStateStore : IHlcStateStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, HlcTimestamp> _store = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, HlcTimestamp> _states = new(StringComparer.Ordinal);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<HlcTimestamp?> LoadAsync(string nodeId, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(nodeId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(nodeId);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
return Task.FromResult<HlcTimestamp?>(
|
||||
_store.TryGetValue(nodeId, out var timestamp) ? timestamp : null);
|
||||
return Task.FromResult(
|
||||
_states.TryGetValue(nodeId, out var timestamp)
|
||||
? timestamp
|
||||
: (HlcTimestamp?)null);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task SaveAsync(HlcTimestamp timestamp, CancellationToken ct = default)
|
||||
{
|
||||
_store.AddOrUpdate(
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
_states.AddOrUpdate(
|
||||
timestamp.NodeId,
|
||||
timestamp,
|
||||
(_, existing) =>
|
||||
{
|
||||
// Only update if new timestamp is greater (prevents regression on concurrent saves)
|
||||
// Only update if new timestamp is greater (maintain monotonicity)
|
||||
return timestamp > existing ? timestamp : existing;
|
||||
});
|
||||
|
||||
@@ -43,12 +49,13 @@ public sealed class InMemoryHlcStateStore : IHlcStateStore
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear all stored state (for testing).
|
||||
/// Gets all stored states (for testing/debugging).
|
||||
/// </summary>
|
||||
public void Clear() => _store.Clear();
|
||||
public IReadOnlyDictionary<string, HlcTimestamp> GetAllStates() =>
|
||||
new Dictionary<string, HlcTimestamp>(_states);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of stored entries (for testing).
|
||||
/// Clears all stored states (for testing).
|
||||
/// </summary>
|
||||
public int Count => _store.Count;
|
||||
public void Clear() => _states.Clear();
|
||||
}
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
// <copyright file="PostgresHlcStateStore.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
// -----------------------------------------------------------------------------
|
||||
// PostgresHlcStateStore.cs
|
||||
// Sprint: SPRINT_20260105_002_001_LB_hlc_core_library
|
||||
// Task: HLC-005 - Implement PostgresHlcStateStore with atomic update semantics
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Globalization;
|
||||
using Dapper;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Npgsql;
|
||||
|
||||
namespace StellaOps.HybridLogicalClock;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="IHlcStateStore"/> with atomic update semantics.
|
||||
/// PostgreSQL implementation of HLC state store for production deployments.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Requires the following table (created via migration or manually):
|
||||
/// Uses atomic upsert with conditional update to ensure:
|
||||
/// - State is never rolled back (only forward updates accepted)
|
||||
/// - Concurrent saves from same node are handled correctly
|
||||
/// - Node restarts resume from persisted state
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Required schema:
|
||||
/// <code>
|
||||
/// CREATE TABLE scheduler.hlc_state (
|
||||
/// node_id TEXT PRIMARY KEY,
|
||||
@@ -25,147 +29,200 @@ namespace StellaOps.HybridLogicalClock;
|
||||
/// updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
/// );
|
||||
/// </code>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class PostgresHlcStateStore : IHlcStateStore
|
||||
{
|
||||
private readonly string _connectionString;
|
||||
private readonly string _schema;
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly ILogger<PostgresHlcStateStore> _logger;
|
||||
private readonly string _schema;
|
||||
private readonly string _tableName;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PostgresHlcStateStore"/> class.
|
||||
/// Creates a new PostgreSQL HLC state store.
|
||||
/// </summary>
|
||||
/// <param name="connectionString">PostgreSQL connection string.</param>
|
||||
/// <param name="schema">Schema name (default: "scheduler").</param>
|
||||
/// <param name="logger">Optional logger.</param>
|
||||
/// <param name="dataSource">Npgsql data source</param>
|
||||
/// <param name="logger">Logger</param>
|
||||
/// <param name="schema">Database schema (default: "scheduler")</param>
|
||||
/// <param name="tableName">Table name (default: "hlc_state")</param>
|
||||
public PostgresHlcStateStore(
|
||||
string connectionString,
|
||||
NpgsqlDataSource dataSource,
|
||||
ILogger<PostgresHlcStateStore> logger,
|
||||
string schema = "scheduler",
|
||||
ILogger<PostgresHlcStateStore>? logger = null)
|
||||
string tableName = "hlc_state")
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(connectionString);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(schema);
|
||||
|
||||
_connectionString = connectionString;
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_schema = schema;
|
||||
_logger = logger ?? NullLogger<PostgresHlcStateStore>.Instance;
|
||||
_tableName = tableName;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<HlcTimestamp?> LoadAsync(string nodeId, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(nodeId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(nodeId);
|
||||
|
||||
var sql = string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"""
|
||||
var sql = $"""
|
||||
SELECT physical_time, logical_counter
|
||||
FROM {_schema}.hlc_state
|
||||
WHERE node_id = @NodeId
|
||||
""");
|
||||
FROM {_schema}.{_tableName}
|
||||
WHERE node_id = @node_id
|
||||
""";
|
||||
|
||||
await using var connection = new NpgsqlConnection(_connectionString);
|
||||
await connection.OpenAsync(ct).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, connection);
|
||||
cmd.Parameters.AddWithValue("node_id", nodeId);
|
||||
|
||||
var result = await connection.QuerySingleOrDefaultAsync<HlcStateRow>(
|
||||
new CommandDefinition(
|
||||
sql,
|
||||
new { NodeId = nodeId },
|
||||
cancellationToken: ct)).ConfigureAwait(false);
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
|
||||
if (result is null)
|
||||
if (!await reader.ReadAsync(ct))
|
||||
{
|
||||
_logger.LogDebug("No HLC state found for node {NodeId}", nodeId);
|
||||
return null;
|
||||
}
|
||||
|
||||
return new HlcTimestamp
|
||||
var physicalTime = reader.GetInt64(0);
|
||||
var logicalCounter = reader.GetInt32(1);
|
||||
|
||||
var timestamp = new HlcTimestamp
|
||||
{
|
||||
PhysicalTime = result.physical_time,
|
||||
PhysicalTime = physicalTime,
|
||||
NodeId = nodeId,
|
||||
LogicalCounter = result.logical_counter
|
||||
LogicalCounter = logicalCounter
|
||||
};
|
||||
|
||||
_logger.LogDebug("Loaded HLC state for node {NodeId}: {Timestamp}", nodeId, timestamp);
|
||||
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task SaveAsync(HlcTimestamp timestamp, CancellationToken ct = default)
|
||||
{
|
||||
// Atomic upsert with monotonicity guarantee:
|
||||
// Only update if new values are greater than existing
|
||||
var sql = string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"""
|
||||
INSERT INTO {_schema}.hlc_state (node_id, physical_time, logical_counter, updated_at)
|
||||
VALUES (@NodeId, @PhysicalTime, @LogicalCounter, NOW())
|
||||
ON CONFLICT (node_id) DO UPDATE
|
||||
SET physical_time = GREATEST({_schema}.hlc_state.physical_time, EXCLUDED.physical_time),
|
||||
logical_counter = CASE
|
||||
WHEN EXCLUDED.physical_time > {_schema}.hlc_state.physical_time THEN EXCLUDED.logical_counter
|
||||
WHEN EXCLUDED.physical_time = {_schema}.hlc_state.physical_time
|
||||
AND EXCLUDED.logical_counter > {_schema}.hlc_state.logical_counter THEN EXCLUDED.logical_counter
|
||||
ELSE {_schema}.hlc_state.logical_counter
|
||||
END,
|
||||
// Atomic upsert with conditional update (only update if new state is greater)
|
||||
var sql = $"""
|
||||
INSERT INTO {_schema}.{_tableName} (node_id, physical_time, logical_counter, updated_at)
|
||||
VALUES (@node_id, @physical_time, @logical_counter, NOW())
|
||||
ON CONFLICT (node_id) DO UPDATE SET
|
||||
physical_time = EXCLUDED.physical_time,
|
||||
logical_counter = EXCLUDED.logical_counter,
|
||||
updated_at = NOW()
|
||||
""");
|
||||
WHERE
|
||||
-- Only update if new timestamp is greater (maintains monotonicity)
|
||||
EXCLUDED.physical_time > {_schema}.{_tableName}.physical_time
|
||||
OR (
|
||||
EXCLUDED.physical_time = {_schema}.{_tableName}.physical_time
|
||||
AND EXCLUDED.logical_counter > {_schema}.{_tableName}.logical_counter
|
||||
)
|
||||
""";
|
||||
|
||||
await using var connection = new NpgsqlConnection(_connectionString);
|
||||
await connection.OpenAsync(ct).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, connection);
|
||||
cmd.Parameters.AddWithValue("node_id", timestamp.NodeId);
|
||||
cmd.Parameters.AddWithValue("physical_time", timestamp.PhysicalTime);
|
||||
cmd.Parameters.AddWithValue("logical_counter", timestamp.LogicalCounter);
|
||||
|
||||
try
|
||||
var rowsAffected = await cmd.ExecuteNonQueryAsync(ct);
|
||||
|
||||
if (rowsAffected > 0)
|
||||
{
|
||||
await connection.ExecuteAsync(
|
||||
new CommandDefinition(
|
||||
sql,
|
||||
new
|
||||
{
|
||||
timestamp.NodeId,
|
||||
timestamp.PhysicalTime,
|
||||
timestamp.LogicalCounter
|
||||
},
|
||||
cancellationToken: ct)).ConfigureAwait(false);
|
||||
_logger.LogDebug("Saved HLC state for node {NodeId}: {Timestamp}", timestamp.NodeId, timestamp);
|
||||
}
|
||||
catch (NpgsqlException ex)
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Failed to save HLC state to PostgreSQL: NodeId={NodeId}, PhysicalTime={PhysicalTime}",
|
||||
_logger.LogDebug(
|
||||
"HLC state not updated for node {NodeId}: {Timestamp} (existing state is newer)",
|
||||
timestamp.NodeId,
|
||||
timestamp.PhysicalTime);
|
||||
throw;
|
||||
timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensure the HLC state table exists (for development/testing).
|
||||
/// In production, use migrations.
|
||||
/// Ensures the HLC state table exists in the database.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>A task representing the async operation.</returns>
|
||||
/// <param name="ct">Cancellation token</param>
|
||||
public async Task EnsureTableExistsAsync(CancellationToken ct = default)
|
||||
{
|
||||
var sql = string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"""
|
||||
var sql = $"""
|
||||
CREATE SCHEMA IF NOT EXISTS {_schema};
|
||||
|
||||
CREATE TABLE IF NOT EXISTS {_schema}.hlc_state (
|
||||
CREATE TABLE IF NOT EXISTS {_schema}.{_tableName} (
|
||||
node_id TEXT PRIMARY KEY,
|
||||
physical_time BIGINT NOT NULL,
|
||||
logical_counter INT NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_hlc_state_updated
|
||||
ON {_schema}.hlc_state(updated_at DESC);
|
||||
""");
|
||||
CREATE INDEX IF NOT EXISTS idx_{_tableName}_updated
|
||||
ON {_schema}.{_tableName}(updated_at DESC);
|
||||
""";
|
||||
|
||||
await using var connection = new NpgsqlConnection(_connectionString);
|
||||
await connection.OpenAsync(ct).ConfigureAwait(false);
|
||||
await connection.ExecuteAsync(new CommandDefinition(sql, cancellationToken: ct)).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, connection);
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
|
||||
_logger.LogInformation("HLC state table ensured in schema {Schema}", _schema);
|
||||
_logger.LogInformation("Ensured HLC state table exists: {Schema}.{Table}", _schema, _tableName);
|
||||
}
|
||||
|
||||
#pragma warning disable IDE1006 // Naming Styles - matches DB column names
|
||||
private sealed record HlcStateRow(long physical_time, int logical_counter);
|
||||
#pragma warning restore IDE1006
|
||||
/// <summary>
|
||||
/// Gets all stored states (for monitoring/debugging).
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token</param>
|
||||
/// <returns>Dictionary of node IDs to their HLC states</returns>
|
||||
public async Task<IReadOnlyDictionary<string, HlcTimestamp>> GetAllStatesAsync(CancellationToken ct = default)
|
||||
{
|
||||
var sql = $"""
|
||||
SELECT node_id, physical_time, logical_counter
|
||||
FROM {_schema}.{_tableName}
|
||||
ORDER BY updated_at DESC
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, connection);
|
||||
|
||||
var results = new Dictionary<string, HlcTimestamp>(StringComparer.Ordinal);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
var nodeId = reader.GetString(0);
|
||||
results[nodeId] = new HlcTimestamp
|
||||
{
|
||||
NodeId = nodeId,
|
||||
PhysicalTime = reader.GetInt64(1),
|
||||
LogicalCounter = reader.GetInt32(2)
|
||||
};
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes stale HLC states for nodes that haven't updated in the specified duration.
|
||||
/// </summary>
|
||||
/// <param name="staleDuration">Duration after which a state is considered stale</param>
|
||||
/// <param name="ct">Cancellation token</param>
|
||||
/// <returns>Number of deleted states</returns>
|
||||
public async Task<int> CleanupStaleStatesAsync(TimeSpan staleDuration, CancellationToken ct = default)
|
||||
{
|
||||
var sql = $"""
|
||||
DELETE FROM {_schema}.{_tableName}
|
||||
WHERE updated_at < NOW() - @stale_interval
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, connection);
|
||||
cmd.Parameters.AddWithValue("stale_interval", staleDuration);
|
||||
|
||||
var rowsDeleted = await cmd.ExecuteNonQueryAsync(ct);
|
||||
|
||||
if (rowsDeleted > 0)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Cleaned up {Count} stale HLC states (older than {StaleDuration})",
|
||||
rowsDeleted,
|
||||
staleDuration);
|
||||
}
|
||||
|
||||
return rowsDeleted;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +1,29 @@
|
||||
# StellaOps.HybridLogicalClock
|
||||
|
||||
A Hybrid Logical Clock (HLC) implementation for deterministic, monotonic job ordering across distributed nodes. HLC combines physical time with logical counters to provide causally-ordered timestamps even under clock skew.
|
||||
A Hybrid Logical Clock (HLC) library for deterministic, monotonic job ordering across distributed nodes. HLC combines physical (wall-clock) time with logical counters to provide causally-ordered timestamps even under clock skew.
|
||||
|
||||
## Overview
|
||||
|
||||
Traditional wall-clock timestamps are susceptible to clock skew across distributed nodes. HLC addresses this by combining:
|
||||
### Problem Statement
|
||||
|
||||
- **Physical time**: Unix milliseconds UTC, advances with wall clock
|
||||
- **Node ID**: Unique identifier for the generating node
|
||||
- **Logical counter**: Increments when events occur at the same physical time
|
||||
Distributed systems face challenges with event ordering:
|
||||
- Wall-clock timestamps are susceptible to clock skew between nodes
|
||||
- Logical clocks alone don't provide real-time context
|
||||
- Concurrent events from different nodes need deterministic tie-breaking
|
||||
|
||||
This provides:
|
||||
- **Monotonicity**: Successive timestamps always increase
|
||||
- **Causality**: If event A happens-before event B, then HLC(A) < HLC(B)
|
||||
- **Bounded drift**: Physical component stays close to wall clock
|
||||
### Solution
|
||||
|
||||
HLC addresses these by combining:
|
||||
- **Physical time** (Unix milliseconds UTC) for real-time context
|
||||
- **Logical counter** for events at the same millisecond
|
||||
- **Node ID** for deterministic tie-breaking across nodes
|
||||
|
||||
## Installation
|
||||
|
||||
```csharp
|
||||
// In your Startup.cs or Program.cs
|
||||
services.AddHybridLogicalClock(options =>
|
||||
{
|
||||
options.NodeId = "scheduler-east-1";
|
||||
options.MaxClockSkew = TimeSpan.FromMinutes(1);
|
||||
options.PostgresConnectionString = configuration.GetConnectionString("Default");
|
||||
});
|
||||
Reference the project in your `.csproj`:
|
||||
|
||||
```xml
|
||||
<ProjectReference Include="..\__Libraries\StellaOps.HybridLogicalClock\StellaOps.HybridLogicalClock.csproj" />
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
@@ -32,112 +31,118 @@ services.AddHybridLogicalClock(options =>
|
||||
### Basic Usage
|
||||
|
||||
```csharp
|
||||
public class JobScheduler
|
||||
using StellaOps.HybridLogicalClock;
|
||||
|
||||
// Create a clock instance
|
||||
var clock = new HybridLogicalClock(
|
||||
TimeProvider.System,
|
||||
nodeId: "scheduler-east-1",
|
||||
stateStore: new InMemoryHlcStateStore(),
|
||||
logger: logger);
|
||||
|
||||
// Generate timestamps for local events
|
||||
var ts1 = clock.Tick(); // e.g., 1704067200000-scheduler-east-1-000000
|
||||
var ts2 = clock.Tick(); // e.g., 1704067200000-scheduler-east-1-000001
|
||||
|
||||
// Timestamps are always monotonically increasing
|
||||
Debug.Assert(ts2 > ts1);
|
||||
|
||||
// When receiving a message from another node
|
||||
var remoteTs = HlcTimestamp.Parse("1704067200100-scheduler-west-1-000005");
|
||||
var mergedTs = clock.Receive(remoteTs); // Merges clocks, returns new timestamp > both
|
||||
```
|
||||
|
||||
### Dependency Injection
|
||||
|
||||
```csharp
|
||||
// Program.cs or Startup.cs
|
||||
|
||||
// Option 1: In-memory state (development/testing)
|
||||
services.AddHybridLogicalClock(
|
||||
nodeId: Environment.MachineName,
|
||||
maxClockSkew: TimeSpan.FromMinutes(1));
|
||||
|
||||
// Option 2: PostgreSQL persistence (production)
|
||||
services.AddHybridLogicalClock<PostgresHlcStateStore>(
|
||||
nodeId: Environment.MachineName,
|
||||
maxClockSkew: TimeSpan.FromMinutes(1));
|
||||
|
||||
// Option 3: Custom state store factory
|
||||
services.AddHybridLogicalClock(
|
||||
nodeId: Environment.MachineName,
|
||||
stateStoreFactory: sp => new PostgresHlcStateStore(
|
||||
sp.GetRequiredService<NpgsqlDataSource>(),
|
||||
sp.GetRequiredService<ILogger<PostgresHlcStateStore>>()),
|
||||
maxClockSkew: TimeSpan.FromMinutes(1));
|
||||
```
|
||||
|
||||
Then inject the clock:
|
||||
|
||||
```csharp
|
||||
public class JobScheduler(IHybridLogicalClock clock)
|
||||
{
|
||||
private readonly IHybridLogicalClock _clock;
|
||||
|
||||
public JobScheduler(IHybridLogicalClock clock)
|
||||
public void EnqueueJob(Job job)
|
||||
{
|
||||
_clock = clock;
|
||||
}
|
||||
|
||||
public Job EnqueueJob(JobPayload payload)
|
||||
{
|
||||
// Generate monotonic timestamp for the job
|
||||
var timestamp = _clock.Tick();
|
||||
|
||||
return new Job
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Timestamp = timestamp,
|
||||
Payload = payload
|
||||
};
|
||||
job.EnqueuedAt = clock.Tick();
|
||||
// Jobs are now globally ordered across all scheduler nodes
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Receiving Remote Timestamps
|
||||
|
||||
When processing messages from other nodes:
|
||||
|
||||
```csharp
|
||||
public void ProcessRemoteMessage(Message message)
|
||||
{
|
||||
// Merge remote timestamp to maintain causality
|
||||
var localTimestamp = _clock.Receive(message.Timestamp);
|
||||
|
||||
// Now localTimestamp > message.Timestamp is guaranteed
|
||||
ProcessPayload(message.Payload, localTimestamp);
|
||||
}
|
||||
```
|
||||
|
||||
### Initialization from Persistent State
|
||||
|
||||
During application startup, initialize the clock from persisted state:
|
||||
|
||||
```csharp
|
||||
var host = builder.Build();
|
||||
|
||||
// Initialize HLC from persistent state before starting
|
||||
await host.Services.InitializeHlcAsync();
|
||||
|
||||
await host.RunAsync();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
## Core Types
|
||||
|
||||
### HlcTimestamp
|
||||
|
||||
A readonly record struct representing an HLC timestamp.
|
||||
A readonly record struct representing an HLC timestamp:
|
||||
|
||||
```csharp
|
||||
public readonly record struct HlcTimestamp : IComparable<HlcTimestamp>
|
||||
{
|
||||
// Unix milliseconds UTC
|
||||
public required long PhysicalTime { get; init; }
|
||||
|
||||
// Unique node identifier
|
||||
public required string NodeId { get; init; }
|
||||
|
||||
// Logical counter for same-time events
|
||||
public required int LogicalCounter { get; init; }
|
||||
|
||||
// Convert to sortable string: "0001704067200000-node-id-000042"
|
||||
public string ToSortableString();
|
||||
|
||||
// Parse from sortable string
|
||||
public static HlcTimestamp Parse(string value);
|
||||
public static bool TryParse(string? value, out HlcTimestamp result);
|
||||
|
||||
// Get physical time as DateTimeOffset
|
||||
public DateTimeOffset PhysicalDateTime { get; }
|
||||
public required long PhysicalTime { get; init; } // Unix milliseconds UTC
|
||||
public required string NodeId { get; init; } // e.g., "scheduler-east-1"
|
||||
public required int LogicalCounter { get; init; } // Events at same millisecond
|
||||
}
|
||||
```
|
||||
|
||||
**Key Methods:**
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `ToSortableString()` | Returns `"1704067200000-scheduler-east-1-000042"` |
|
||||
| `Parse(string)` | Parses from sortable string format |
|
||||
| `TryParse(string, out HlcTimestamp)` | Safe parsing without exceptions |
|
||||
| `ToDateTimeOffset()` | Converts physical time to DateTimeOffset |
|
||||
| `CompareTo(HlcTimestamp)` | Total ordering comparison |
|
||||
| `IsBefore(HlcTimestamp)` | Returns true if causally before |
|
||||
| `IsAfter(HlcTimestamp)` | Returns true if causally after |
|
||||
| `IsConcurrent(HlcTimestamp)` | True if same time/counter, different nodes |
|
||||
|
||||
**Comparison Operators:**
|
||||
|
||||
```csharp
|
||||
if (ts1 < ts2) { /* ts1 happened before ts2 */ }
|
||||
if (ts1 > ts2) { /* ts1 happened after ts2 */ }
|
||||
if (ts1 <= ts2) { /* ts1 happened at or before ts2 */ }
|
||||
if (ts1 >= ts2) { /* ts1 happened at or after ts2 */ }
|
||||
```
|
||||
|
||||
### IHybridLogicalClock
|
||||
|
||||
The main interface for HLC operations.
|
||||
The main clock interface:
|
||||
|
||||
```csharp
|
||||
public interface IHybridLogicalClock
|
||||
{
|
||||
// Generate next timestamp for local event
|
||||
HlcTimestamp Tick();
|
||||
|
||||
// Merge with remote timestamp, return new local timestamp
|
||||
HlcTimestamp Receive(HlcTimestamp remote);
|
||||
|
||||
// Current clock state
|
||||
HlcTimestamp Current { get; }
|
||||
|
||||
// Node identifier
|
||||
string NodeId { get; }
|
||||
HlcTimestamp Tick(); // Generate timestamp for local event
|
||||
HlcTimestamp Receive(HlcTimestamp remote); // Merge with remote timestamp
|
||||
HlcTimestamp Current { get; } // Current clock state
|
||||
string NodeId { get; } // This node's identifier
|
||||
}
|
||||
```
|
||||
|
||||
### IHlcStateStore
|
||||
|
||||
Interface for persisting clock state across restarts.
|
||||
Persistence interface for clock state:
|
||||
|
||||
```csharp
|
||||
public interface IHlcStateStore
|
||||
@@ -147,42 +152,15 @@ public interface IHlcStateStore
|
||||
}
|
||||
```
|
||||
|
||||
Built-in implementations:
|
||||
- `InMemoryHlcStateStore`: For testing (state lost on restart)
|
||||
- `PostgresHlcStateStore`: Persists to PostgreSQL
|
||||
**Implementations:**
|
||||
- `InMemoryHlcStateStore` - For development and testing
|
||||
- `PostgresHlcStateStore` - For production with durable persistence
|
||||
|
||||
## Configuration
|
||||
## Persistence
|
||||
|
||||
### HlcOptions
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `NodeId` | string? | auto | Unique node identifier (e.g., "scheduler-east-1") |
|
||||
| `MaxClockSkew` | TimeSpan | 1 minute | Maximum allowed difference from remote timestamps |
|
||||
| `PostgresConnectionString` | string? | null | Connection string for PostgreSQL persistence |
|
||||
| `PostgresSchema` | string | "scheduler" | PostgreSQL schema for HLC tables |
|
||||
| `UseInMemoryStore` | bool | false | Force in-memory store (for testing) |
|
||||
|
||||
### Configuration via appsettings.json
|
||||
|
||||
```json
|
||||
{
|
||||
"HybridLogicalClock": {
|
||||
"NodeId": "scheduler-east-1",
|
||||
"MaxClockSkew": "00:01:00",
|
||||
"PostgresConnectionString": "Host=localhost;Database=stellaops;Username=app",
|
||||
"PostgresSchema": "scheduler"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## PostgreSQL Schema
|
||||
|
||||
Create the required table:
|
||||
### PostgreSQL Schema
|
||||
|
||||
```sql
|
||||
CREATE SCHEMA IF NOT EXISTS scheduler;
|
||||
|
||||
CREATE TABLE scheduler.hlc_state (
|
||||
node_id TEXT PRIMARY KEY,
|
||||
physical_time BIGINT NOT NULL,
|
||||
@@ -193,128 +171,197 @@ CREATE TABLE scheduler.hlc_state (
|
||||
CREATE INDEX idx_hlc_state_updated ON scheduler.hlc_state(updated_at DESC);
|
||||
```
|
||||
|
||||
### PostgresHlcStateStore
|
||||
|
||||
Uses atomic upsert with conditional update to maintain monotonicity:
|
||||
|
||||
```csharp
|
||||
var stateStore = new PostgresHlcStateStore(
|
||||
dataSource,
|
||||
logger,
|
||||
schemaName: "scheduler", // default
|
||||
tableName: "hlc_state" // default
|
||||
);
|
||||
```
|
||||
|
||||
## Serialization
|
||||
|
||||
### JSON (System.Text.Json)
|
||||
|
||||
HlcTimestamp includes a built-in JSON converter that serializes to the sortable string format:
|
||||
Two converters are provided:
|
||||
|
||||
1. **String format** (default) - Compact, sortable:
|
||||
```json
|
||||
"1704067200000-scheduler-east-1-000042"
|
||||
```
|
||||
|
||||
2. **Object format** - Explicit properties:
|
||||
```json
|
||||
{
|
||||
"physicalTime": 1704067200000,
|
||||
"nodeId": "scheduler-east-1",
|
||||
"logicalCounter": 42
|
||||
}
|
||||
```
|
||||
|
||||
Register converters:
|
||||
|
||||
```csharp
|
||||
var timestamp = clock.Tick();
|
||||
var json = JsonSerializer.Serialize(timestamp);
|
||||
// Output: "0001704067200000-scheduler-east-1-000042"
|
||||
|
||||
var parsed = JsonSerializer.Deserialize<HlcTimestamp>(json);
|
||||
var options = new JsonSerializerOptions();
|
||||
options.Converters.Add(new HlcTimestampJsonConverter()); // String format
|
||||
// or
|
||||
options.Converters.Add(new HlcTimestampObjectJsonConverter()); // Object format
|
||||
```
|
||||
|
||||
### Dapper
|
||||
### Database (Npgsql/Dapper)
|
||||
|
||||
Register the type handler for Dapper:
|
||||
Extension methods for reading/writing HLC timestamps:
|
||||
|
||||
```csharp
|
||||
HlcTimestampTypeHandler.Register();
|
||||
// NpgsqlCommand extension
|
||||
await using var cmd = dataSource.CreateCommand();
|
||||
cmd.CommandText = "INSERT INTO events (timestamp) VALUES (@ts)";
|
||||
cmd.AddHlcTimestamp("ts", timestamp);
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
|
||||
// Now you can use HlcTimestamp in Dapper queries
|
||||
var job = connection.QuerySingle<Job>(
|
||||
"SELECT * FROM jobs WHERE timestamp > @Timestamp",
|
||||
new { Timestamp = minTimestamp });
|
||||
// NpgsqlDataReader extension
|
||||
await using var reader = await cmd.ExecuteReaderAsync();
|
||||
var ts = reader.GetHlcTimestamp("timestamp");
|
||||
var nullableTs = reader.GetHlcTimestampOrNull("timestamp");
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### HlcClockSkewException
|
||||
|
||||
Thrown when a remote timestamp differs from local physical clock by more than `MaxClockSkew`:
|
||||
Dapper type handlers:
|
||||
|
||||
```csharp
|
||||
// Register handlers at startup
|
||||
HlcTypeHandlerRegistration.Register(services);
|
||||
|
||||
// Then use normally with Dapper
|
||||
var results = await connection.QueryAsync<MyEntity>(
|
||||
"SELECT * FROM events WHERE timestamp > @since",
|
||||
new { since = sinceTimestamp });
|
||||
```
|
||||
|
||||
## Clock Skew Handling
|
||||
|
||||
The clock detects and rejects excessive clock skew:
|
||||
|
||||
```csharp
|
||||
var clock = new HybridLogicalClock(
|
||||
timeProvider,
|
||||
nodeId,
|
||||
stateStore,
|
||||
logger,
|
||||
maxClockSkew: TimeSpan.FromMinutes(1)); // Default: 1 minute
|
||||
|
||||
try
|
||||
{
|
||||
var localTs = clock.Receive(remoteTimestamp);
|
||||
var merged = clock.Receive(remoteTimestamp);
|
||||
}
|
||||
catch (HlcClockSkewException ex)
|
||||
{
|
||||
logger.LogError(
|
||||
"Clock skew exceeded: observed {ObservedMs}ms, max {MaxMs}ms",
|
||||
ex.ObservedSkew.TotalMilliseconds,
|
||||
ex.MaxSkew.TotalMilliseconds);
|
||||
|
||||
// Reject the message or alert operations
|
||||
// Remote clock differs by more than maxClockSkew
|
||||
logger.LogWarning(
|
||||
"Clock skew detected: {Actual} exceeds threshold {Max}",
|
||||
ex.ActualSkew, ex.MaxAllowedSkew);
|
||||
}
|
||||
```
|
||||
|
||||
## Recovery from Restart
|
||||
|
||||
After a node restart, initialize the clock from persisted state:
|
||||
|
||||
```csharp
|
||||
var clock = new HybridLogicalClock(timeProvider, nodeId, stateStore, logger);
|
||||
|
||||
// Load last persisted state
|
||||
bool recovered = await clock.InitializeFromStateAsync();
|
||||
if (recovered)
|
||||
{
|
||||
logger.LogInformation("Clock recovered from state: {Current}", clock.Current);
|
||||
}
|
||||
|
||||
// First tick after restart is guaranteed > last persisted tick
|
||||
var ts = clock.Tick();
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
For unit tests, use FakeTimeProvider and InMemoryHlcStateStore:
|
||||
### FakeTimeProvider
|
||||
|
||||
For deterministic testing, use a fake time provider:
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void Tick_ReturnsMonotonicallyIncreasingTimestamps()
|
||||
public class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
var stateStore = new InMemoryHlcStateStore();
|
||||
var clock = new HybridLogicalClock(timeProvider, "test-node", stateStore);
|
||||
private DateTimeOffset _now = DateTimeOffset.UtcNow;
|
||||
|
||||
var t1 = clock.Tick();
|
||||
var t2 = clock.Tick();
|
||||
var t3 = clock.Tick();
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
|
||||
Assert.True(t1 < t2);
|
||||
Assert.True(t2 < t3);
|
||||
public void SetUtcNow(DateTimeOffset value) => _now = value;
|
||||
public void Advance(TimeSpan duration) => _now = _now.Add(duration);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_Advances_Counter()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider();
|
||||
var clock = new HybridLogicalClock(timeProvider, "test", new InMemoryHlcStateStore(), logger);
|
||||
|
||||
var ts1 = clock.Tick();
|
||||
var ts2 = clock.Tick();
|
||||
|
||||
Assert.Equal(0, ts1.LogicalCounter);
|
||||
Assert.Equal(1, ts2.LogicalCounter);
|
||||
}
|
||||
```
|
||||
|
||||
## Algorithm
|
||||
|
||||
### On Local Event (Tick)
|
||||
|
||||
```
|
||||
l' = l
|
||||
l = max(l, physical_clock())
|
||||
if l == l':
|
||||
c = c + 1
|
||||
else:
|
||||
c = 0
|
||||
return (l, node_id, c)
|
||||
```
|
||||
|
||||
### On Receive
|
||||
|
||||
```
|
||||
l' = l
|
||||
l = max(l', m_l, physical_clock())
|
||||
if l == l' == m_l:
|
||||
c = max(c, m_c) + 1
|
||||
elif l == l':
|
||||
c = c + 1
|
||||
elif l == m_l:
|
||||
c = m_c + 1
|
||||
else:
|
||||
c = 0
|
||||
return (l, node_id, c)
|
||||
```
|
||||
|
||||
## Performance
|
||||
|
||||
Benchmarks on typical hardware:
|
||||
|
||||
| Operation | Throughput | Allocation |
|
||||
|-----------|------------|------------|
|
||||
| Tick | ~5M ops/sec | 0 bytes |
|
||||
| Receive | ~3M ops/sec | 0 bytes |
|
||||
| ToSortableString | ~10M ops/sec | 80 bytes |
|
||||
| Parse | ~5M ops/sec | 48 bytes |
|
||||
| Operation | Throughput |
|
||||
|-----------|------------|
|
||||
| Tick (single-thread) | > 100,000/sec |
|
||||
| Tick (multi-thread) | > 50,000/sec |
|
||||
| Receive | > 50,000/sec |
|
||||
| Parse | > 500,000/sec |
|
||||
| ToSortableString | > 500,000/sec |
|
||||
| CompareTo | > 10,000,000/sec |
|
||||
|
||||
Run benchmarks:
|
||||
```bash
|
||||
cd src/__Libraries/StellaOps.HybridLogicalClock.Benchmarks
|
||||
dotnet run -c Release
|
||||
```
|
||||
|
||||
## Algorithm
|
||||
|
||||
The HLC algorithm (Lamport + Physical Clock Hybrid):
|
||||
|
||||
**On local event or send (Tick):**
|
||||
```
|
||||
l' = l # save previous logical time
|
||||
l = max(l, physical_clock()) # advance to at least physical time
|
||||
if l == l':
|
||||
c = c + 1 # same physical time, increment counter
|
||||
else:
|
||||
c = 0 # new physical time, reset counter
|
||||
return (l, node_id, c)
|
||||
```
|
||||
|
||||
**On receive (Receive):**
|
||||
```
|
||||
l' = l
|
||||
l = max(l', m_l, physical_clock())
|
||||
if l == l' == m_l:
|
||||
c = max(c, m_c) + 1 # all equal, take max counter + 1
|
||||
elif l == l':
|
||||
c = c + 1 # local was max, increment local counter
|
||||
elif l == m_l:
|
||||
c = m_c + 1 # remote was max, take remote counter + 1
|
||||
else:
|
||||
c = 0 # physical clock advanced, reset
|
||||
return (l, node_id, c)
|
||||
```
|
||||
Memory: `HlcTimestamp` is a value type (struct) with minimal allocation.
|
||||
|
||||
## References
|
||||
|
||||
- [Logical Physical Clocks and Consistent Snapshots](https://cse.buffalo.edu/tech-reports/2014-04.pdf) - Original HLC paper
|
||||
- [Time, Clocks, and the Ordering of Events](https://lamport.azurewebsites.net/pubs/time-clocks.pdf) - Lamport clocks
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0-or-later
|
||||
- [Logical Physical Clocks and Consistent Snapshots in Globally Distributed Databases](https://cse.buffalo.edu/tech-reports/2014-04.pdf) - Kulkarni et al.
|
||||
- [Time, Clocks, and the Ordering of Events in a Distributed System](https://lamport.azurewebsites.net/pubs/time-clocks.pdf) - Lamport
|
||||
|
||||
@@ -4,18 +4,15 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<Description>Hybrid Logical Clock (HLC) implementation for deterministic, monotonic job ordering across distributed nodes.</Description>
|
||||
<Description>Hybrid Logical Clock library for deterministic, monotonic job ordering across distributed nodes</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
<PackageReference Include="Dapper" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -18,15 +18,17 @@ namespace StellaOps.Verdict;
|
||||
public sealed class PolicyLockGenerator : IPolicyLockGenerator
|
||||
{
|
||||
private readonly ILogger<PolicyLockGenerator> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private const string SchemaVersion = "1.0";
|
||||
private const string EngineVersion = "1.0.0";
|
||||
|
||||
// TODO: Inject actual policy repository when available
|
||||
// private readonly IPolicyRepository _policyRepository;
|
||||
|
||||
public PolicyLockGenerator(ILogger<PolicyLockGenerator> logger)
|
||||
public PolicyLockGenerator(ILogger<PolicyLockGenerator> logger, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async ValueTask<PolicyLock> GenerateAsync(
|
||||
@@ -41,10 +43,10 @@ public sealed class PolicyLockGenerator : IPolicyLockGenerator
|
||||
|
||||
var policyLock = new PolicyLock(
|
||||
SchemaVersion: SchemaVersion,
|
||||
PolicyVersion: $"{policyId}-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}",
|
||||
PolicyVersion: $"{policyId}-{_timeProvider.GetUtcNow():yyyyMMddHHmmss}",
|
||||
RuleHashes: ruleHashes,
|
||||
EngineVersion: EngineVersion,
|
||||
GeneratedAt: DateTimeOffset.UtcNow
|
||||
GeneratedAt: _timeProvider.GetUtcNow()
|
||||
);
|
||||
|
||||
_logger.LogInformation(
|
||||
@@ -74,7 +76,7 @@ public sealed class PolicyLockGenerator : IPolicyLockGenerator
|
||||
PolicyVersion: version,
|
||||
RuleHashes: ruleHashes,
|
||||
EngineVersion: EngineVersion,
|
||||
GeneratedAt: DateTimeOffset.UtcNow
|
||||
GeneratedAt: _timeProvider.GetUtcNow()
|
||||
);
|
||||
|
||||
return policyLock;
|
||||
@@ -101,7 +103,7 @@ public sealed class PolicyLockGenerator : IPolicyLockGenerator
|
||||
if (policyLock.RuleHashes.Count == 0)
|
||||
errors.Add("At least one rule hash is required");
|
||||
|
||||
if (policyLock.GeneratedAt > DateTimeOffset.UtcNow.AddMinutes(5))
|
||||
if (policyLock.GeneratedAt > _timeProvider.GetUtcNow().AddMinutes(5))
|
||||
errors.Add("GeneratedAt timestamp is in the future");
|
||||
|
||||
// TODO: Validate rule hashes against stored policy configurations
|
||||
|
||||
@@ -21,6 +21,7 @@ public sealed class VerdictBuilderService : IVerdictBuilder
|
||||
{
|
||||
private readonly ILogger<VerdictBuilderService> _logger;
|
||||
private readonly IDsseSigner? _signer;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private static readonly JsonSerializerOptions CanonicalJsonOptions = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
@@ -33,12 +34,15 @@ public sealed class VerdictBuilderService : IVerdictBuilder
|
||||
/// </summary>
|
||||
/// <param name="logger">Logger instance</param>
|
||||
/// <param name="signer">Optional DSSE signer (e.g., KeylessDsseSigner for Fulcio). Null for air-gapped deployments.</param>
|
||||
/// <param name="timeProvider">Time provider for deterministic timestamps</param>
|
||||
public VerdictBuilderService(
|
||||
ILogger<VerdictBuilderService> logger,
|
||||
IDsseSigner? signer = null)
|
||||
IDsseSigner? signer = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_logger = logger;
|
||||
_signer = signer;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
|
||||
if (_signer == null)
|
||||
{
|
||||
@@ -73,7 +77,7 @@ public sealed class VerdictBuilderService : IVerdictBuilder
|
||||
Verdict: verdict,
|
||||
Dsse: dsse,
|
||||
Trace: trace,
|
||||
ComputedAt: DateTimeOffset.UtcNow
|
||||
ComputedAt: _timeProvider.GetUtcNow()
|
||||
);
|
||||
|
||||
var signingMode = _signer != null ? "signed" : "unsigned (air-gap)";
|
||||
|
||||
@@ -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