diet-guard/diet_guard/_sync_events.py
Krzysztof kuhy Rudnicki 8871408b5b perf(cli): publish off-thread so logging a meal returns immediately
`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
2026-08-21 13:22:26 +02:00

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()