223 lines
6.9 KiB
C#
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();
|
|
}
|