// // Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later. // using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Text.Json.Serialization; namespace StellaOps.HybridLogicalClock; /// /// Hybrid Logical Clock timestamp providing monotonic, causally-ordered time /// across distributed nodes even under clock skew. /// /// /// /// HLC combines the benefits of physical time (human-readable, bounded drift) /// with logical clocks (guaranteed causality, no rollback). The timestamp /// consists of three components: /// /// /// PhysicalTime: Unix milliseconds UTC, advances with wall clock /// NodeId: Unique identifier for the generating node /// LogicalCounter: Increments when events occur at same physical time /// /// /// Total ordering is defined as: (PhysicalTime, LogicalCounter, NodeId) /// /// [JsonConverter(typeof(HlcTimestampJsonConverter))] public readonly record struct HlcTimestamp : IComparable, IComparable { /// /// Physical time component (Unix milliseconds UTC). /// public required long PhysicalTime { get; init; } /// /// Unique node identifier (e.g., "scheduler-east-1"). /// public required string NodeId { get; init; } /// /// Logical counter for events at same physical time. /// public required int LogicalCounter { get; init; } /// /// Gets the physical time as a . /// [JsonIgnore] public DateTimeOffset PhysicalDateTime => DateTimeOffset.FromUnixTimeMilliseconds(PhysicalTime); /// /// Gets a zero/uninitialized timestamp. /// public static HlcTimestamp Zero => new() { PhysicalTime = 0, NodeId = string.Empty, LogicalCounter = 0 }; /// /// String representation for storage: "0001704067200000-scheduler-east-1-000042". /// Format: {PhysicalTime:D13}-{NodeId}-{LogicalCounter:D6} /// /// A sortable string representation. public string ToSortableString() { return string.Create( CultureInfo.InvariantCulture, $"{PhysicalTime:D13}-{NodeId}-{LogicalCounter:D6}"); } /// /// Parse from sortable string format. /// /// The sortable string to parse. /// The parsed . /// Thrown when value is null. /// Thrown when value is not in valid format. 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; } /// /// Try to parse from sortable string format. /// /// The sortable string to parse. /// The parsed timestamp if successful. /// True if parsing succeeded; otherwise false. 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; } /// /// Compare for total ordering. /// Order: (PhysicalTime, LogicalCounter, NodeId). /// /// The other timestamp to compare. /// Comparison result. 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); } /// 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)); } /// /// Less than operator. /// public static bool operator <(HlcTimestamp left, HlcTimestamp right) => left.CompareTo(right) < 0; /// /// Less than or equal operator. /// public static bool operator <=(HlcTimestamp left, HlcTimestamp right) => left.CompareTo(right) <= 0; /// /// Greater than operator. /// public static bool operator >(HlcTimestamp left, HlcTimestamp right) => left.CompareTo(right) > 0; /// /// Greater than or equal operator. /// public static bool operator >=(HlcTimestamp left, HlcTimestamp right) => left.CompareTo(right) >= 0; /// public override string ToString() => ToSortableString(); }