diet-guard/docs/sync-latency.md
Krzysztof kuhy Rudnicki d75ed9c083 docs: track AGENTS.md and split the sync-latency rules into docs/
The previous commit turned tracked `CLAUDE.md` into a symlink to an *untracked*
`AGENTS.md`, deleting 237 lines of documentation from the repo -- the content
survived only in my working tree, and a fresh clone would have got a dangling
symlink. The symlink predates this session (it was already a type-change in the
working tree at the start); committing it without tracking its target is what
broke it.

Tracking `AGENTS.md` then tripped the 250-line cap, so the sync-latency
material moves to `docs/sync-latency.md` behind a pointer -- the same shape the
repo already uses for `docs/meal-schedule.md` and the Flutter rules. That file
carries the measurements, the three narrow-pull state invariants, the Tk
threading rules, why the gate's publish must stay synchronous under
`Type=oneshot`, the timeout asymmetry, and the `revs/` marker trap.

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

4.7 KiB

Sync latency: what must never block the user

Measured 2026-08-21 against the live remote. Before this work the gate ran a full sync tick before deciding whether to lock: ~27s, and the lock screen's "Fetch from sync" button ran the same tick inline on the Tk thread.

Path Before After
gate --check (nothing due) 216ms ~105ms
"Fetch from sync" button 18-27s ~84ms
diet-guard log ... 15.5s ~2ms
Background full tick ~27s ~8.9s

The narrow pull

_sync_refresh.pull_peer_logs() answers one question — "has a peer already logged this slot?" — and only the peer logs can answer it. No budget, no food banks, no rebuild_food_bank, and no push.

Three state invariants, each with a regression test, because each is silent when broken:

  • pushed_rev survives verbatim. This pass never pushes; overwriting it convinces the next full tick it has already published, and the device goes invisible to every peer, permanently.
  • peer_revs is merged, not replaced. Only changed peers are visited, so a wholesale replace drops every untouched entry and re-downloads them next tick.
  • The log is written before the state. The other order records a peer as merged while its records are absent — silent data loss.

Peer enumeration deliberately avoids list_directory (~445ms mirrored, because the GitHub half alone is ~360ms) and unions the revision map with peer_revs instead. Membership is tested with in, never .get(...) is not None: peer_revs legitimately holds explicit nulls, and treating those as "never seen" re-downloads them forever.

Threading

The fetch button runs the pull on a daemon worker and marshals the result back through a queue.Queue polled by root.after. The worker touches no widget — Tcl is not thread-safe, and that includes after. Polling also makes the completion path testable with no thread at all: put a result on the queue, call the poll, assert.

A second click while a fetch is in flight is ignored. That is the reentrancy guard and the write-race fix: the pull persists its merge, so two workers would race two write_raw_log calls and orphan one result.

diet-guard log publishes through publish_after_log_detached (non-daemon, so the process waits for the push at exit while the user already has their output). The gate does not: diet-guard-gate.service is Type=oneshot, so systemd reaps the unit the moment the main thread exits and a background push would be killed halfway.

Timeouts

INTERACTIVE_TIMEOUT_SECONDS (2s) bounds anything a user waits on. SYNC_TIMEOUT_SECONDS (10s) only ever reached GitHub: firebase_client_for accepts no timeout in crdt_sync v0.6.0, so Firebase silently used its 15s default and one hung session refresh stalled the lock window. The journal recorded five such failures in five days, one of them a meal that logged locally and never published.

_apply_timeout reaches for the private _timeout_seconds because the pinned library exposes no other lever. The clean fix is a timeout_seconds kwarg upstream; that is a cross-repo change (five consumers, tag bump).

Stale peers

Every install mints a uuid and pushes under it, and nothing cleaned them up. This remote reached 25 devices. Each dead one cost a round trip in the log pull and in each of the three bank syncs — roughly 400ms per peer per tick.

They were not phone reinstalls: seventeen were frozen at the same instant (2026-08-13T16:45:04) holding near-identical ~400-record copies of one log, the signature of repeated fresh-install runs. 20 were pruned on 2026-08-21 after verifying zero records would be lost.

prune-peers is dry-run by default, refuses --apply without --backup-dir, and never proposes pc/phone/desktop (they carry pre-migration history and SYNC_LEGACY_DEVICE_ID names one as this device's own former path).

It must delete each device's revs/ marker along with its data. _candidate_peers enumerates peers from that map rather than from list_directory, so an orphaned marker resurrects a deleted device as a candidate and buys a round trip discovering it is gone. Pruning without this took the narrow pull from ~90ms to ~15s — slower than before the prune.

Import cost

gate --check used to import requests (~78ms) and tkinter for a window it never opens. Both are now resolved through module-level __getattr__ (PEP 562) plus import_module, which keeps every existing patch.object target working and needs no lint suppression. The chains were _cli_args -> _cli_sync -> crdt_sync -> requests, and _cli_log -> _resolve -> _estimator_off -> requests.

Verify with python -X importtime -c "import diet_guard._cli_gate" | grep -c requests (expect 0).