mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-11 15:08:11 +01:00
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
65 lines
2.9 KiB
Python
65 lines
2.9 KiB
Python
"""Event-driven sync triggers: publish when state changes, not on a clock.
|
|
|
|
diet_guard used to run a ``diet-guard-sync.timer`` every ~15 min. That cadence
|
|
was a poor proxy for the two moments the shared log actually changes on this
|
|
device, and it paid a network round-trip on every idle tick in between:
|
|
|
|
* **before a lock** -- already handled in :func:`diet_guard._cli_gate._should_lock`,
|
|
which pulls only when a lock is otherwise due, so a meal just logged on the
|
|
phone clears the lock without a manual re-entry.
|
|
* **after a meal is logged here** -- this module. Publishing immediately is
|
|
what stops the *phone* nagging for a slot already logged on the PC; the
|
|
mirror image of the rule in ``CLAUDE.md`` that keeps the phone's tick
|
|
unconditional.
|
|
|
|
Both go through :func:`~diet_guard._sync.pull_shared_log`, which despite its
|
|
name runs a *full* tick (pull, merge, re-sign, persist, push, plus budget and
|
|
both food banks) and fails closed -- a sync outage returns a reason string
|
|
rather than raising, so it can never turn a successful local log into an error.
|
|
|
|
The trade this makes explicit: a PC that is on but neither logging nor locking
|
|
no longer pulls at all, so an inbound phone edit lands at the next log or lock
|
|
rather than within 15 min. That is bounded by the meal-slot spacing, since a
|
|
day that goes unlogged raises a lock by construction.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from importlib import import_module
|
|
import logging
|
|
import sys
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ``_sync`` pulls in ``crdt_sync`` -> ``requests`` (~78ms), and the gate's
|
|
# common path -- no lock due -- never publishes. Deferring the import is what
|
|
# lets ``gate --check`` decide without loading an HTTP stack it will not use.
|
|
#
|
|
# A module-level ``__getattr__`` (PEP 562) rather than a function-local import
|
|
# so ``patch.object(_sync_events, "pull_shared_log", ...)`` still resolves.
|
|
def __getattr__(name: str) -> object:
|
|
"""Resolve the deferred sync import on first attribute access."""
|
|
if name != "pull_shared_log":
|
|
msg = f"module {__name__!r} has no attribute {name!r}"
|
|
raise AttributeError(msg)
|
|
return import_module("diet_guard._sync").pull_shared_log
|
|
|
|
|
|
def publish_after_log() -> str | None:
|
|
"""Run a full sync tick after a local write, swallowing any outage.
|
|
|
|
Call this *after* the local log write is durable, never before: the local
|
|
entry is the source of truth for this device, and a network failure must
|
|
leave it intact and the caller's own success path untouched.
|
|
|
|
Returns:
|
|
``None`` when the tick completed, or a short human-readable reason when
|
|
it could not -- callers that have somewhere to show it (the CLI) print
|
|
it; callers that do not (the gate, the MCP tool) let it fall to the log.
|
|
"""
|
|
reason = sys.modules[__name__].pull_shared_log()
|
|
if reason is not None:
|
|
_logger.info("post-log sync skipped: %s", reason)
|
|
return reason
|