mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 21:07:15 +01:00
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
129 lines
4.3 KiB
Python
129 lines
4.3 KiB
Python
"""Record: a per-field LWW map with a sticky (monotonic) delete flag.
|
|
|
|
This is the unit CRDT merges operate on. It's deliberately domain-agnostic --
|
|
translating an app's own model (a note, a food-log entry, a workout entry,
|
|
a wake-time value) to and from ``Record`` is each app's job, not this
|
|
library's.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
from crdt_sync._hlc import Hlc
|
|
|
|
Field = tuple[Any, Hlc]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Record:
|
|
"""One CRDT record: an id, per-field LWW values, and a sticky tombstone.
|
|
|
|
Attributes:
|
|
id: Stable identifier, unique within one ``Log``.
|
|
fields: Field name -> ``(value, Hlc)``. Concurrent edits to the same
|
|
field converge to whichever side has the greater ``Hlc``.
|
|
deleted: Whether this record is tombstoned. Deletion is monotonic:
|
|
once true on either side of a merge, it stays true.
|
|
deleted_hlc: The clock value of the delete, if any -- kept for
|
|
informational/ordering purposes only; it does not participate in
|
|
the merge decision (that's a plain boolean OR, see
|
|
:func:`merge_record`).
|
|
"""
|
|
|
|
id: str
|
|
fields: dict[str, Field]
|
|
deleted: bool = False
|
|
deleted_hlc: Hlc | None = None
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
"""Serialize to a JSON-compatible dict."""
|
|
return {
|
|
"id": self.id,
|
|
"fields": {
|
|
name: [value, hlc.to_str()]
|
|
for name, (value, hlc) in self.fields.items()
|
|
},
|
|
"deleted": self.deleted,
|
|
"deleted_hlc": self.deleted_hlc.to_str()
|
|
if self.deleted_hlc is not None
|
|
else None,
|
|
}
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: dict[str, Any]) -> Record:
|
|
"""Parse the format produced by :meth:`to_dict`."""
|
|
fields = {
|
|
name: (value, Hlc.from_str(hlc_str))
|
|
for name, (value, hlc_str) in data["fields"].items()
|
|
}
|
|
deleted_hlc_str = data.get("deleted_hlc")
|
|
deleted_hlc = (
|
|
Hlc.from_str(deleted_hlc_str) if deleted_hlc_str is not None else None
|
|
)
|
|
return cls(
|
|
id=data["id"],
|
|
fields=fields,
|
|
deleted=bool(data["deleted"]),
|
|
deleted_hlc=deleted_hlc,
|
|
)
|
|
|
|
|
|
def merge_field(a: Field, b: Field) -> Field:
|
|
"""Return whichever of ``a``, ``b`` has the greater ``Hlc``.
|
|
|
|
Ties (equal ``Hlc``) keep ``a``; two ticks never compare equal across
|
|
different nodes (see :class:`crdt_sync._hlc.Hlc`), so a tie only happens
|
|
when both sides are literally the same tick re-merged, in which case the
|
|
value is expected to be identical anyway.
|
|
"""
|
|
return a if a[1] >= b[1] else b
|
|
|
|
|
|
def _merge_deleted_hlc(a: Record, b: Record) -> Hlc | None:
|
|
"""Return the delete-clock to keep, informational only (see class docs)."""
|
|
if a.deleted and b.deleted:
|
|
if a.deleted_hlc is None:
|
|
return b.deleted_hlc
|
|
if b.deleted_hlc is None:
|
|
return a.deleted_hlc
|
|
return max(a.deleted_hlc, b.deleted_hlc)
|
|
if a.deleted:
|
|
return a.deleted_hlc
|
|
if b.deleted:
|
|
return b.deleted_hlc
|
|
return None
|
|
|
|
|
|
def merge_record(a: Record, b: Record) -> Record:
|
|
"""Merge two versions of the same record.
|
|
|
|
Per-field last-writer-wins over the union of field names, plus a sticky
|
|
delete: ``deleted = a.deleted or b.deleted``, never the other way around.
|
|
A tombstone can never be resurrected by merging in an older, non-deleted
|
|
copy pulled from a device that hasn't seen the delete yet.
|
|
|
|
Commutative and idempotent: ``merge_record(a, b) == merge_record(b, a)``
|
|
and ``merge_record(a, a) == a``.
|
|
|
|
Raises:
|
|
ValueError: If ``a.id != b.id`` -- merging two different records is
|
|
a caller bug, not a case to silently paper over.
|
|
"""
|
|
if a.id != b.id:
|
|
msg = f"cannot merge records with different ids: {a.id!r} != {b.id!r}"
|
|
raise ValueError(msg)
|
|
merged_fields = dict(a.fields)
|
|
for name, b_field in b.fields.items():
|
|
a_field = merged_fields.get(name)
|
|
merged_fields[name] = (
|
|
b_field if a_field is None else merge_field(a_field, b_field)
|
|
)
|
|
return Record(
|
|
id=a.id,
|
|
fields=merged_fields,
|
|
deleted=a.deleted or b.deleted,
|
|
deleted_hlc=_merge_deleted_hlc(a, b),
|
|
)
|