mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 21:27:14 +01:00
MirrorSyncClient promised that reads consult both backends "so the cutover order is irrelevant", but every read called self.primary unguarded. A Firebase outage therefore produced no data at all and the GitHub mirror was never consulted -- the union read silently degraded to nothing, precisely when the fallback existed to help. This is not hypothetical: screen-locker's workout sync hit it on 2026-08-10, where a DNS failure reading the primary took down the whole locker rather than falling back. Reads (list_directory, get_file_text, get_string_map) now degrade to the mirror when the primary raises, and only fail when NEITHER backend can answer -- returning an empty result would be indistinguishable from "no data", which callers act on. Writes keep the old asymmetry on purpose: a write the primary did not accept has not happened, so it must still fail the tick. A mirror that cannot bulk-read (GitHub) contributes nothing but is not a failure, so it cannot by itself make a revision-map read raise. Both language ports agree on that. Same fix in the Dart port, which had all three read paths wrong too and runs on the phone. Two older tests asserted the buggy read-fail-closed behaviour and are renamed to describe what they actually exercise: the both-sides-failed case. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JS5V44jkHZiH1wC7BH5h5n
231 lines
9.5 KiB
Python
231 lines
9.5 KiB
Python
"""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)
|