mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 21:07:15 +01:00
Some checks are pending
crdt-sync pre-commit / pre-commit (push) Waiting to run
crdt-sync tests / test (3.10) (push) Waiting to run
crdt-sync tests / test (3.11) (push) Waiting to run
crdt-sync tests / test (3.12) (push) Waiting to run
gatelock pre-commit / pre-commit (push) Waiting to run
gatelock tests / test (3.10) (push) Waiting to run
gatelock tests / test (3.11) (push) Waiting to run
gatelock tests / test (3.12) (push) Waiting to run
palette drift / palette (push) Waiting to run
_sync.py 456 -> 245 + _syncstate 131 + _pull 106 + _revisions 34 _firebase_auth 412 -> 242 + _credentials 146 + _autherrors 69 _firebase.py 336 -> 250 + _fbkeys 100 google_id_token 316 -> 232 + tool/_oauth_callback 105 _github.py 255 -> 238 + _gherrors 30 revision_of became its own leaf module because both the push and pull halves need it; without that the two would have imported each other. _fbkeys holds the RTDB key escaping — the escape tables moved with the functions that read them, since the pair has to round-trip exactly or a device silently stops seeing its own writes. crdt-sync/tool/ is outside the coverage source, so the split there has no test net: verified by running it the documented way instead (`python3 -m tool.google_id_token --help` prints its usage, and `from tool.google_id_token import TokenError, fetch_id_token` — what seed_session.py does — still resolves). An AST pass over each pair caught what the suite would not have: _is_revoked needs the error type, so it stayed with it rather than following the credentials; _TERMINAL_AUTH_REASONS and _CONSENT_TIMEOUT_SECONDS had to be carried to their new callers. 259 passed at 100% branch coverage. crdt-sync/tool/seed_session.py and firebase-debug.log are pre-existing local changes and are deliberately not in this commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
246 lines
7.8 KiB
Python
246 lines
7.8 KiB
Python
"""Domain-agnostic pull/merge/push sync orchestration.
|
|
|
|
Generalizes diet_guard's original ``_sync.py`` (pull every other device's
|
|
pushed log, merge with the local one, push this device's own merged result
|
|
back up) so any app can reuse the loop while keeping its own on-disk JSON
|
|
shape via the ``encode``/``decode`` callbacks -- this module has no opinion
|
|
on what a ``Record``'s fields actually mean.
|
|
|
|
Mirrors ``crdt_sync_dart``'s ``lib/src/sync.dart``; keep the two in step.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
from typing import TYPE_CHECKING, Protocol
|
|
|
|
from crdt_sync._log import merge_logs
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable
|
|
from pathlib import Path
|
|
|
|
from crdt_sync._log import Log
|
|
from crdt_sync._remote import RemoteStore
|
|
|
|
from crdt_sync._pull import _PullContext, _pull_remote_logs, _remote_revs
|
|
from crdt_sync._revisions import revision_of
|
|
from crdt_sync._syncstate import (
|
|
FileSyncStateStore,
|
|
MemorySyncStateStore,
|
|
SyncState,
|
|
SyncStateStore,
|
|
)
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
_DEFAULT_FILENAME = "log.json"
|
|
|
|
|
|
def default_revs_path(path_prefix: str) -> str:
|
|
"""Return where revisions live for ``path_prefix``.
|
|
|
|
A ``revs`` sibling of the device directory, e.g.
|
|
``diet-guard-sync/devices`` -> ``diet-guard-sync/revs`` and
|
|
``todo-sync/notes`` -> ``todo-sync/revs``.
|
|
|
|
Parameters
|
|
----------
|
|
path_prefix : str
|
|
The directory holding one subdirectory per device.
|
|
|
|
Returns:
|
|
-------
|
|
str
|
|
The revisions directory.
|
|
|
|
"""
|
|
head, separator, _ = path_prefix.rpartition("/")
|
|
return f"{head}/revs" if separator else f"{path_prefix}/revs"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SyncTarget:
|
|
"""Which remote, which device, and where its logs live.
|
|
|
|
Args:
|
|
client: An authenticated :class:`RemoteStore` -- a
|
|
:class:`crdt_sync.GitHubSyncClient`, a
|
|
:class:`crdt_sync.FirebaseSyncClient`, or a mirror of both.
|
|
device_id: This device's identifier; also the directory name its own
|
|
log is pushed under.
|
|
path_prefix: The directory holding one subdirectory per device
|
|
(e.g. ``"devices"``).
|
|
legacy_device_id: The id this device pushed under before migrating to
|
|
a persisted uuid. Treated as this device's own for skip-own
|
|
purposes, so its pre-migration log is not pulled back and
|
|
re-merged as a peer's. Pass ``None`` once the old path has been
|
|
reclaimed.
|
|
"""
|
|
|
|
client: RemoteStore
|
|
device_id: str
|
|
path_prefix: str
|
|
legacy_device_id: str | None = None
|
|
|
|
@property
|
|
def own_ids(self) -> frozenset[str]:
|
|
"""Every id whose pushed log belongs to this device."""
|
|
if self.legacy_device_id is None:
|
|
return frozenset({self.device_id})
|
|
return frozenset({self.device_id, self.legacy_device_id})
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class LogCodec:
|
|
"""How a log is turned into pushed text and back.
|
|
|
|
Args:
|
|
encode: Serializes a merged log for pushing.
|
|
decode: Parses a remote device's pushed text back into a log.
|
|
Raising ``ValueError``, ``KeyError``, or ``TypeError`` is treated
|
|
as a corrupt/unparsable push, and that device is skipped for
|
|
this tick rather than aborting the whole sync.
|
|
filename: The file name each device pushes its log as.
|
|
commit_message: The commit message used for this device's push.
|
|
"""
|
|
|
|
encode: Callable[[Log], str]
|
|
decode: Callable[[str], Log]
|
|
filename: str = _DEFAULT_FILENAME
|
|
commit_message: str = "crdt_sync: update log"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RevisionTracking:
|
|
"""Optional skip-unchanged behaviour for a tick.
|
|
|
|
Omit it entirely and the tick behaves exactly as it always did: fetch
|
|
everything, push unconditionally.
|
|
|
|
Args:
|
|
state_store: Persists what this device last pushed and last merged.
|
|
revs_path: Where revisions live; defaults to
|
|
:func:`default_revs_path`.
|
|
"""
|
|
|
|
state_store: SyncStateStore | None = None
|
|
revs_path: str | None = None
|
|
|
|
|
|
def sync_log(
|
|
target: SyncTarget,
|
|
local_log: Log,
|
|
codec: LogCodec,
|
|
revisions: RevisionTracking | None = None,
|
|
) -> Log:
|
|
"""Run one full sync tick: pull every other device's log, merge, push.
|
|
|
|
Pulls from ``<path_prefix>/<other-device-id>/<filename>`` for every
|
|
device directory the remote reports under ``path_prefix``, merges each
|
|
into ``local_log`` with :func:`crdt_sync.merge_logs`, then pushes this
|
|
device's own merged result to ``<path_prefix>/<device_id>/<filename>``.
|
|
|
|
Args:
|
|
target: Which remote and device this tick is for.
|
|
local_log: This device's current full log (including tombstones).
|
|
codec: How to serialize and parse a log.
|
|
revisions: Pass one to skip downloading peers that have not changed
|
|
and skip pushing a log that has not changed. ``None`` fetches
|
|
everything and pushes unconditionally.
|
|
|
|
Returns:
|
|
The merged log, as pushed.
|
|
|
|
Notes:
|
|
Revisions live at ``<revs_path>/<device_id>`` -- one small text node
|
|
per device, each written only by its owner. Deliberately *not* one
|
|
shared map per namespace: a whole-map write would erase every other
|
|
device's entry, after which those peers would look permanently
|
|
unchanged and never be fetched again. Per-device keys make that
|
|
failure impossible rather than merely avoided.
|
|
"""
|
|
tracking = revisions if revisions is not None else RevisionTracking()
|
|
state_store = tracking.state_store
|
|
revs = (
|
|
tracking.revs_path
|
|
if tracking.revs_path is not None
|
|
else default_revs_path(target.path_prefix)
|
|
)
|
|
state = state_store.load() if state_store is not None else SyncState()
|
|
remote_revs = _remote_revs(target.client, revs) if state_store is not None else {}
|
|
|
|
seen_revs: dict[str, str] = {}
|
|
merged = _merge_peers(
|
|
_PullContext(
|
|
client=target.client,
|
|
own_ids=target.own_ids,
|
|
path_prefix=target.path_prefix,
|
|
filename=codec.filename,
|
|
decode=codec.decode,
|
|
remote_revs=remote_revs,
|
|
state=state,
|
|
),
|
|
local_log,
|
|
seen_revs,
|
|
)
|
|
|
|
encoded = codec.encode(merged)
|
|
revision = revision_of(encoded)
|
|
if not (state_store is not None and revision == state.pushed_rev):
|
|
_push(
|
|
target,
|
|
codec,
|
|
encoded,
|
|
_RevisionMarker(revs, revision) if state_store is not None else None,
|
|
)
|
|
if state_store is not None:
|
|
state_store.save(SyncState(pushed_rev=revision, peer_revs=seen_revs))
|
|
return merged
|
|
|
|
|
|
def _merge_peers(
|
|
ctx: _PullContext,
|
|
local_log: Log,
|
|
seen_revs: dict[str, str],
|
|
) -> Log:
|
|
"""Return ``local_log`` with every peer's pushed log merged in."""
|
|
merged = dict(local_log)
|
|
for remote_log in _pull_remote_logs(ctx, seen_revs):
|
|
merged = merge_logs(merged, remote_log)
|
|
return merged
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _RevisionMarker:
|
|
"""The revision node a tracked push publishes after its log."""
|
|
|
|
revs_path: str
|
|
revision: str
|
|
|
|
|
|
def _push(
|
|
target: SyncTarget,
|
|
codec: LogCodec,
|
|
encoded: str,
|
|
marker: _RevisionMarker | None,
|
|
) -> None:
|
|
"""Push this device's merged log, then its revision marker."""
|
|
target.client.put_file_text(
|
|
f"{target.path_prefix}/{target.device_id}/{codec.filename}",
|
|
encoded,
|
|
message=codec.commit_message,
|
|
)
|
|
if marker is not None:
|
|
# Published after the log, never before: a peer that cached
|
|
# "seen rev X" against a log it never received would skip it
|
|
# forever.
|
|
target.client.put_file_text(
|
|
f"{marker.revs_path}/{target.device_id}",
|
|
marker.revision,
|
|
message=codec.commit_message,
|
|
)
|