mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 19:27:14 +01:00
`diet-guard log ...` blocked for ~15.5s after every meal. The publish itself has to happen -- until it lands the phone still believes the slot is unlogged and nags for it -- but only ~2.5s of that is the 293KB push the phone needs; the rest is pulling peers' banks, which publishing does not require. The local write is already durable before the publish starts, so the worst case is a late publish, which is the trade `_sync_events` already documents for the event-driven model. Moving it to a worker thread lets the caller print and return at once. Measured: 15.5s -> 2ms before the prompt comes back. The thread is deliberately non-daemon: the CLI's output is already flushed, so the process lingers only as long as the push, and the publish is never killed halfway. A failure still surfaces, via a callback the caller supplies, just after its own output rather than in the middle of it. Deliberately NOT applied to the gate. `diet-guard-gate.service` is `Type=oneshot`, so systemd reaps the unit the moment the main thread exits and a background publish would be killed mid-push. The gate keeps calling `publish_after_log` and waiting, which is correct there -- its publish already runs after the window is gone, where nobody is waiting on it. `conftest._isolate_state` follows the rename to `publish_after_log_detached`; without that every meal-logging test would reach the network through a newly unpatched seam. Verified with `scripts/check_patch_targets.py` and by timing the real command against a temporary data dir, never the live log. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LJ5YnA3CERitQ57HjMu2Qz
102 lines
4.3 KiB
Python
102 lines
4.3 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
|
|
import threading
|
|
from typing import TYPE_CHECKING
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable
|
|
|
|
_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
|
|
|
|
|
|
def publish_after_log_detached(on_failure: Callable[[str], None]) -> None:
|
|
"""Publish on a background thread, returning immediately.
|
|
|
|
For the *interactive* CLI, where blocking the terminal is the problem: the
|
|
full tick measures ~15.5s, of which only ~2.5s is the push the phone
|
|
actually needs. The local write is already durable by the time this is
|
|
called, so the worst case is a late publish -- exactly the trade
|
|
:func:`publish_after_log` already documents.
|
|
|
|
**Not for the gate.** ``diet-guard-gate.service`` is ``Type=oneshot``, so
|
|
systemd reaps the unit as soon as the main thread exits and a daemon
|
|
thread would be killed mid-push. The gate keeps calling
|
|
:func:`publish_after_log` and waiting for it.
|
|
|
|
The thread is non-daemon precisely so the interpreter waits for it at exit:
|
|
the CLI's output is already flushed, so the user sees their result at once
|
|
while the process lingers only as long as the push takes.
|
|
|
|
Args:
|
|
on_failure: Called with a reason if the publish could not complete.
|
|
Runs on the worker thread, after the caller has printed and
|
|
returned, so it must not touch the caller's output ordering.
|
|
"""
|
|
|
|
def _publish() -> None:
|
|
reason = publish_after_log()
|
|
if reason is not None:
|
|
on_failure(reason)
|
|
|
|
threading.Thread(target=_publish, daemon=False).start()
|