Files
git.stella-ops.org/src/__Libraries/StellaOps.HybridLogicalClock/HlcTimestamp.cs
StellaOps Bot 37e11918e0 save progress
2026-01-06 09:42:20 +02:00

223 lines
6.9 KiB
C#

// <copyright file="HlcTimestamp.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Text.Json.Serialization;
namespace StellaOps.HybridLogicalClock;
/// <summary>
/// Hybrid Logical Clock timestamp providing monotonic, causally-ordered time
/// 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>
/// </remarks>
[JsonConverter(typeof(HlcTimestampJsonConverter))]
public readonly record struct HlcTimestamp : IComparable<HlcTimestamp>, IComparable
{
/// <summary>
/// Physical time component (Unix milliseconds UTC).
/// </summary>
public required long PhysicalTime { get; init; }
/// <summary>
/// Unique node identifier (e.g., "scheduler-east-1").
/// </summary>
public required string NodeId { get; init; }
/// <summary>
/// Logical counter for events at same physical time.
/// </summary>
public required int LogicalCounter { get; init; }
/// <summary>
/// Gets the physical time as a <see cref="DateTimeOffset"/>.
/// </summary>
[JsonIgnore]
public DateTimeOffset PhysicalDateTime =>
DateTimeOffset.FromUnixTimeMilliseconds(PhysicalTime);
/// <summary>
/// Gets a zero/uninitialized timestamp.
/// </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}");
}
/// <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>
public static HlcTimestamp Parse(string value)
{
ArgumentNullException.ThrowIfNull(value);
if (!TryParse(value, out var result))
{
throw new FormatException($"Invalid HLC timestamp format: '{value}'");
}
return result;
}
/// <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)
{
result = default;
if (string.IsNullOrEmpty(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)
{
return false;
}
var lastDash = value.LastIndexOf('-');
if (lastDash <= firstDash || lastDash >= value.Length - 1)
{
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))
{
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
};
return true;
}
/// <summary>
/// Compare for total ordering.
/// Order: (PhysicalTime, LogicalCounter, NodeId).
/// </summary>
/// <param name="other">The other timestamp to compare.</param>
/// <returns>Comparison result.</returns>
public int CompareTo(HlcTimestamp other)
{
// Primary: physical time
var physicalCompare = PhysicalTime.CompareTo(other.PhysicalTime);
if (physicalCompare != 0)
{
return physicalCompare;
}
// Secondary: logical counter
var counterCompare = LogicalCounter.CompareTo(other.LogicalCounter);
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>
/// Less than operator.
/// </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;
/// <inheritdoc/>
public override string ToString() => ToSortableString();
}