utils/crdt-sync/crdt_sync/_hlc.py
Krzysztof Rudnicki 1c49abe8c2 Initial implementation: unified CRDT merge scheme + GitHub sync transport
Extracted and generalized from diet_guard's existing tombstone-merge scheme
and GitHub Contents API client, extended with per-field HLC LWW so the same
library can also cover todo's mutable notes and wake_alarm's single-value
sync (Workstream D scoping decision, 2026-07-04). 100% branch coverage,
clean ruff/mypy/pylint/bandit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017McuA1nAi6jBT1EaS5BrVf
2026-07-05 20:40:09 +02:00

98 lines
3.7 KiB
Python

"""Hybrid Logical Clock: a totally-ordered, monotonic per-node timestamp.
An HLC combines wall-clock time with a logical counter so that two ticks
issued by the same node in the same millisecond still get a strict order,
and two ticks from different nodes are always comparable (the node id breaks
ties), without requiring synchronized clocks across devices.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
import time
_ISO_PREFIX_LEN = 23 # len("YYYY-MM-DDTHH:MM:SS.mmm")
_EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)
@dataclass(frozen=True, order=True)
class Hlc:
"""A Hybrid Logical Clock value.
Ordered by ``(wall_time_ms, counter, node_id)`` tuple comparison, which
is also the order ``@dataclass(order=True)`` derives from the field
declaration order below -- two clocks from different nodes never compare
equal, since ``node_id`` is the final, always-distinguishing tiebreaker.
"""
wall_time_ms: int
counter: int
node_id: str
@staticmethod
def new_tick(
node_id: str,
previous: Hlc | None = None,
wall_time_ms: int | None = None,
) -> Hlc:
"""Return the next clock value for ``node_id``.
Args:
node_id: The issuing node's identifier.
previous: This node's last-issued clock value, if any. Passing
the previous value is what makes the clock monotonic even
when the wall clock hasn't advanced (or has gone backwards).
wall_time_ms: Override for the current wall-clock time in
milliseconds since the Unix epoch; defaults to the real
clock. Exposed for deterministic tests.
Returns:
A new ``Hlc`` strictly greater than ``previous`` (if given).
"""
now = wall_time_ms if wall_time_ms is not None else int(time.time() * 1000)
if previous is None:
return Hlc(wall_time_ms=now, counter=0, node_id=node_id)
new_wall = max(now, previous.wall_time_ms)
counter = previous.counter + 1 if new_wall == previous.wall_time_ms else 0
return Hlc(wall_time_ms=new_wall, counter=counter, node_id=node_id)
def to_str(self) -> str:
"""Serialize to a human-readable, lexicographically sortable string.
Format: ``<iso8601-millis>Z-<counter:04x>-<node_id>``. The iso8601
portion is fixed-width (always exactly ``YYYY-MM-DDTHH:MM:SS.mmm``),
which is what makes plain string comparison agree with ``Hlc``
comparison for the wall-clock component.
"""
seconds, millis = divmod(self.wall_time_ms, 1000)
dt = datetime.fromtimestamp(seconds, tz=timezone.utc)
iso = dt.strftime("%Y-%m-%dT%H:%M:%S")
return f"{iso}.{millis:03d}Z-{self.counter:04x}-{self.node_id}"
@classmethod
def from_str(cls, text: str) -> Hlc:
"""Parse the format produced by :meth:`to_str`.
Raises:
ValueError: If ``text`` is not in the expected format.
"""
iso_part, sep, rest = text.partition("Z-")
if not sep or len(iso_part) != _ISO_PREFIX_LEN:
msg = f"not a valid Hlc string: {text!r}"
raise ValueError(msg)
counter_hex, sep, node_id = rest.partition("-")
if not sep:
msg = f"not a valid Hlc string: {text!r}"
raise ValueError(msg)
dt = datetime.strptime(iso_part, "%Y-%m-%dT%H:%M:%S.%f").replace(
tzinfo=timezone.utc
)
delta = dt - _EPOCH
wall_time_ms = (
delta.days * 86_400_000 + delta.seconds * 1000 + delta.microseconds // 1000
)
return cls(
wall_time_ms=wall_time_ms, counter=int(counter_hex, 16), node_id=node_id
)