utils/crdt-sync/crdt_sync/_record.py
Krzysztof kuhy Rudnicki 6154dc09bf Pin pre-commit's ruff to 0.16.7 and satisfy the CPY001 it brings
The three Python packages pinned ruff twice at different versions:
requirements.txt at 0.16.7, .pre-commit-config.yaml at v0.15.2. Only the
older one ever ran, so `select = ["ALL"]` was enforced two minor versions
behind. Measured before aligning: v0.15.2 reports 0 findings, every 0.16.x
reports CPY001 on each file -- 70 crdt-sync, 23 freedays, 71 gatelock, and
no other rule.

Resolved by adding the notice rather than ignoring the rule, so the gate
keeps its teeth.

Two gatelock modules sat at exactly the 250-line cap, so a one-line header
tipped them to 251. Both are split along a real seam rather than shaved:

  _widget_fills.py    colour arithmetic over LockConfig, no tkinter import
  _surfaces_focus.py  selects among existing surfaces, never mutates them

The focus split keeps _surfaces.py's documented containment (it stays the
only module that creates, moves or destroys windows), and the static guard
in test_recovery_invariants.py globs _surfaces*.py, so the new sibling is
covered with no list to extend. widgets.py re-exports the fills helpers,
which keeps gatelock.widgets the single public surface its tests import.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019kMvSMtsVMB3d2uoZ56pbp
2026-09-12 18:11:24 +02:00

130 lines
4.4 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki
"""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),
)