utils/crdt-sync/crdt_sync/_mirror.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

232 lines
9.5 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki
"""A remote store that writes to two backends and reads from both.
Exists for the GitHub -> Firebase cutover: each app can move to Firebase
while still mirroring to the old repo, so rolling back is a constructor
change rather than a data recovery.
Mirrors ``crdt_sync_dart``'s ``lib/src/mirror_store.dart``; keep the two in
step.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, TypeVar
from crdt_sync._remote import RemoteSyncError
if TYPE_CHECKING:
from collections.abc import Callable
from crdt_sync._remote import RemoteStore
_logger = logging.getLogger(__name__)
# PEP 695 type parameters need Python 3.12; this package targets 3.10.
_T = TypeVar("_T")
class MirrorSyncClient:
"""Writes to both backends; reads from both, preferring the primary.
**Reads consult both on purpose.** Each app spans two devices, and they
cut over one at a time. If reads were primary-only, a migrated PC would
never see an un-migrated phone's writes while still mirroring its own
back -- silent one-directional convergence, with no error raised. Because
:func:`crdt_sync.merge_logs` is commutative and idempotent, a union read
costs nothing semantically and makes the cutover order irrelevant.
Write asymmetry is deliberate too: the primary is authoritative, so a
primary failure fails the tick (fail-closed), while a mirror failure is
reported and otherwise tolerated. Once the mirror is retired, the old
backend going away must not break sync.
**Reads are resilient on BOTH sides; writes are not.** A read is only
fail-closed when neither backend can answer. Reads used to call the
primary unguarded, so a Firebase outage produced no data at all and the
mirror was never consulted -- the union read silently degraded to nothing,
contradicting the paragraph above. Writes keep the asymmetry: a write the
primary did not accept has not happened, so it must fail the tick.
"""
def __init__(
self,
primary: RemoteStore,
mirror: RemoteStore,
*,
on_mirror_failure: Callable[[str, Exception], None] | None = None,
) -> None:
"""Wrap ``primary`` (authoritative) and ``mirror`` (best effort).
Args:
primary: The backend whose failures fail the tick.
mirror: The backend kept in step but never allowed to fail a tick.
on_mirror_failure: Notified when the mirror misbehaves. Defaults
to a warning log -- a silent mirror failure would let the
fallback rot unnoticed until the day it was needed.
"""
self.primary = primary
self.mirror = mirror
self._on_mirror_failure = on_mirror_failure or _log_mirror_failure
def list_directory(self, path: str) -> list[str]:
"""Return the union of both backends' entries under ``path``.
A reachable primary that lists nothing is a real answer; a primary
that *raises* is not. Reads are the half of this class that exists to
be resilient, so a primary failure degrades to the mirror's entries
rather than failing the read -- returning nothing because one side is
down breaks the union promise exactly when the fallback is most
needed. Only a failure on BOTH sides propagates, since then there is
genuinely no answer to give.
"""
primary_names = self._try_primary_read(
f"list_directory {path}", lambda: self.primary.list_directory(path)
)
mirror_names = self._try_mirror(
f"list_directory {path}", lambda: self.mirror.list_directory(path)
)
if primary_names is None and mirror_names is None:
self._raise_both_failed(f"list_directory {path}")
names = list(primary_names or [])
seen = set(names)
for name in mirror_names or []:
if name not in seen:
seen.add(name)
names.append(name)
return names
def get_file_text(self, path: str) -> str | None:
"""Return ``path``'s text from the primary, else from the mirror.
Absent from the primary means "this device has not migrated yet",
not "no data" -- so it falls through rather than reporting nothing.
A primary that *raises* falls through for the same reason (see
:meth:`list_directory`); only a failure on both sides propagates.
"""
try:
text = self.primary.get_file_text(path)
except RemoteSyncError as error:
self._on_primary_read_failure(f"get_file_text {path}", error)
mirror_text = self._try_mirror(
f"get_file_text {path}", lambda: self.mirror.get_file_text(path)
)
if mirror_text is None:
self._raise_both_failed(f"get_file_text {path}")
return mirror_text
if text is not None:
return text
return self._try_mirror(
f"get_file_text {path}", lambda: self.mirror.get_file_text(path)
)
def put_file_text(self, path: str, text: str, *, message: str) -> None:
"""Write to the primary, then best-effort to the mirror.
Raises:
RemoteSyncError: If the *primary* write fails.
"""
self.primary.put_file_text(path, text, message=message)
self._try_mirror(
f"put_file_text {path}",
lambda: self.mirror.put_file_text(path, text, message=message),
)
def delete_file(self, path: str, *, message: str = "crdt_sync: delete") -> None:
"""Delete from the primary, then best-effort from the mirror.
Raises:
RemoteSyncError: If the *primary* delete fails.
"""
self.primary.delete_file(path, message=message)
self._try_mirror(
f"delete_file {path}",
lambda: self.mirror.delete_file(path, message=message),
)
def can_access_remote(self) -> bool:
"""Return whether the **primary** is reachable.
Only the primary: a settings "Test connection" button must not report
success because the backend being retired happens to answer.
"""
return self.primary.can_access_remote()
def get_string_map(self, path: str) -> dict[str, str]:
"""Merge both backends' revision maps, primary winning on conflict.
A device that has not migrated publishes revisions only to the
mirror; without this it would look revision-less and be
re-downloaded every tick for the whole trial period.
Like the other reads, a primary failure degrades to the mirror rather
than failing the tick: revisions are a cache, and losing them re-reads
data rather than losing it.
"""
merged: dict[str, str] = {}
mirror_read = getattr(self.mirror, "get_string_map", None)
# A mirror that CANNOT do bulk reads contributes nothing, but that is
# not a failure -- it has no revisions to give, which is a real answer.
# Only a mirror that tried and threw leaves the read with no answer,
# and only then can a failing primary make this raise.
mirror_answered = mirror_read is None
if mirror_read is not None:
mirror_map = self._try_mirror(
f"get_string_map {path}", lambda: mirror_read(path)
)
mirror_answered = mirror_map is not None
merged.update(mirror_map or {})
primary_read = getattr(self.primary, "get_string_map", None)
if primary_read is not None:
primary_map = self._try_primary_read(
f"get_string_map {path}", lambda: primary_read(path)
)
if primary_map is None and not mirror_answered:
self._raise_both_failed(f"get_string_map {path}")
merged.update(primary_map or {})
return merged
def _try_mirror(self, operation: str, run: Callable[[], _T]) -> _T | None:
"""Run a mirror operation, reporting rather than propagating failure."""
try:
return run()
except RemoteSyncError as error:
self._on_mirror_failure(operation, error)
return None
def _try_primary_read(self, operation: str, run: Callable[[], _T]) -> _T | None:
"""Run a primary READ, reporting rather than propagating failure.
Reads only -- writes stay fail-closed, since a write the primary did
not accept has not happened and must fail the tick.
"""
try:
return run()
except RemoteSyncError as error:
self._on_primary_read_failure(operation, error)
return None
def _on_primary_read_failure(self, operation: str, error: Exception) -> None:
"""Warn that a read fell back to the mirror."""
_logger.warning(
"Primary backend failed during %s: %s — falling back to the mirror",
operation,
error,
)
def _raise_both_failed(self, operation: str) -> None:
"""Raise when neither backend could answer a read.
Fail closed rather than returning an empty result: an empty read is
indistinguishable from "no data", and callers act on that by treating
remote state as absent.
"""
msg = f"both primary and mirror backends failed during {operation}"
raise RemoteSyncError(msg)
def _log_mirror_failure(operation: str, error: Exception) -> None:
"""Warn loudly, so a rotting fallback is visible before it is needed."""
_logger.warning("Mirror backend failed during %s: %s", operation, error)