diet-guard/diet_guard/_cli_sync.py
Krzysztof kuhy Rudnicki b53ac92c59 perf(gate): narrow the pre-lock pull and defer the heavy imports
The gate's pre-lock refresh ran a *full* sync tick -- peer logs, budget,
both food banks, a rebuild and a push -- in front of the fullscreen lock.
Measured against the live remote: ~18-27s, of which the curated-bank loop
alone was 17s spent fetching nothing (absent on Firebase for every peer, so
each of ~21 peers fell through to a GitHub round trip returning `{}`).

It was also failing: five `sync unavailable (network error trying to refresh
the session)` entries in the last five days, one of them a meal that logged
locally but never published.

The gate has one question -- "has a peer already logged this slot?" -- and
only the peer logs can answer it. `_sync_refresh` does that and nothing
else: one bulk revision-map read, a fetch of only the peers whose revision
moved, merge, re-sign, write. No budget, no banks, no push. The full tick
still runs, after the window closes, so nothing is permanently skipped --
the exhaustive pass just moves off the path the user waits on.

Three state invariants have their own regression tests, because each is
silent when broken: `pushed_rev` survives verbatim (this pass never pushes,
and overwriting it would make the device stop publishing forever),
`peer_revs` is merged rather than replaced (only changed peers are visited),
and the log is written before the state.

Separately, `gate --check` was importing an HTTP stack and a GUI it never
uses. `requests` (~78ms) arrived via `_cli_sync` -> `crdt_sync` and via
`_resolve` -> `_estimator_off`; both are now resolved through module-level
`__getattr__` (PEP 562) and `import_module`, which keeps every existing
`patch.object` target and needs no lint suppression.

Measured: `gate --check` 216ms -> ~110ms; narrow pull 92ms against the live
remote. Accepted trade, documented in `_sync_refresh`: a budget edited on
the phone is not visible in the lock dashboard until the post-window tick.
It cannot affect the lock decision, which is why it is safe to defer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LJ5YnA3CERitQ57HjMu2Qz
2026-08-21 12:58:44 +02:00

82 lines
3.0 KiB
Python

"""CLI handler for the ``sync`` subcommand.
Split out from :mod:`diet_guard._cli` to keep that module under the repo's
500-line cap (see ``CLAUDE.md``'s "feat: split oversized modules" history) --
the same reason the gate window logic lives across ``_gatelock*.py`` instead
of one file.
"""
from __future__ import annotations
from importlib import import_module
import sys
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import argparse
from collections.abc import Callable
# ``_cli_args`` imports this module just to register the subparser, but
# ``crdt_sync``/``_sync`` drag in ``requests`` (~78ms). Only the ``sync``
# command itself needs them, so every other subcommand -- notably the gate's
# frequent ``--check`` -- no longer pays for an HTTP stack it will not use.
#
# A module-level ``__getattr__`` (PEP 562) rather than function-local imports
# so ``patch.object(_cli_sync, "run_sync", ...)`` still resolves.
_LAZY_ATTRS = {
"run_sync": ("diet_guard._sync", "run_sync"),
"SyncError": ("diet_guard._sync", "SyncError"),
"RemoteSyncError": ("crdt_sync", "RemoteSyncError"),
}
def __getattr__(name: str) -> object:
"""Resolve a deferred sync import on first attribute access."""
target = _LAZY_ATTRS.get(name)
if target is None:
msg = f"module {__name__!r} has no attribute {name!r}"
raise AttributeError(msg)
module_name, attr = target
return getattr(import_module(module_name), attr)
def register_sync_subparser(sub: argparse._SubParsersAction) -> None:
"""Register the ``sync`` subcommand on ``sub``."""
sub.add_parser(
"sync",
help="Pull/merge/push the log with other devices via GitHub.",
)
def cmd_sync(emit: Callable[[str], None]) -> int:
"""Run one sync tick and report what happened via ``emit``.
Errors are caught here rather than left to propagate: a sync failure
(missing PAT, network error, repo misconfigured) is routine enough on a
timer-driven command that the CLI should report it and exit non-zero,
not crash with a traceback.
Args:
emit: A one-line output sink (``_cli._emit``, kept private to that
module -- passed in rather than imported, so this module has no
reach-in dependency on ``_cli``'s internals).
Returns:
0 on a successful sync, 1 if it could not run or failed partway.
"""
module = sys.modules[__name__]
try:
merged = module.run_sync()
except module.SyncError as exc:
emit(f"sync not configured: {exc}")
return 1
except module.RemoteSyncError as exc:
# The shared base, not GitHubSyncError: Firebase's errors are siblings
# of GitHub's, so catching only the latter let the primary backend's
# failures crash this timer-driven command with a traceback.
emit(f"sync failed: {exc}")
return 1
total_entries = sum(len(entries) for entries in merged.values())
emit(f"synced: {total_entries} entries across {len(merged)} day(s).")
return 0