From fbd371fc03c67304c80af69b6f669bc676a1810a Mon Sep 17 00:00:00 2001 From: Alexander Nicolay Date: Mon, 6 Jul 2026 07:56:24 +0200 Subject: [PATCH] Retry pending journal sync when the machine comes back online --- src/matebot/cli.py | 15 ++++++++++++-- src/matebot/sync.py | 29 ++++++++++++++++++++++----- tests/test_sync_retry.py | 43 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 7 deletions(-) create mode 100644 tests/test_sync_retry.py diff --git a/src/matebot/cli.py b/src/matebot/cli.py index ef2d9e5..bb1ab4c 100644 --- a/src/matebot/cli.py +++ b/src/matebot/cli.py @@ -112,12 +112,12 @@ def events(self): messenger = create_messenger(config) - def schedule_sync(): + def schedule_sync(*, quiet: bool = False): if config.sync_enabled and config.data_repo: asyncio.create_task( sync_soon( client, config.data_repo, messenger.send, - site_title=config.site_title, + site_title=config.site_title, state=state, quiet=quiet, ) ) @@ -171,9 +171,20 @@ async def pump_events(): async def pump_shots(): import re as _re + import time as _time + + last_frame_at = 0.0 async def tee(source): + nonlocal last_frame_at async for frame in source: + now = _time.monotonic() + gap = now - last_frame_at if last_frame_at else None + last_frame_at = now + if state.get("sync_pending") and (gap is None or gap > 60): + log.info("machine is back; retrying pending journal sync") + state.set("sync_pending", False) # avoid re-trigger storms + schedule_sync(quiet=True) cache_frame(frame) await router.on_frame(frame) yield frame diff --git a/src/matebot/sync.py b/src/matebot/sync.py index 98b0e81..df0443a 100644 --- a/src/matebot/sync.py +++ b/src/matebot/sync.py @@ -9,7 +9,6 @@ from __future__ import annotations -import asyncio import fcntl import json import logging @@ -17,6 +16,8 @@ import subprocess from pathlib import Path +import aiohttp + from .machine import GaggiMateClient, MachineError from .sitegen import generate @@ -130,15 +131,33 @@ async def sync( async def sync_soon( - client: GaggiMateClient, repo: str | Path, notify, *, site_title: str = "Shot Journal" + client: GaggiMateClient, repo: str | Path, notify, *, + site_title: str = "Shot Journal", state=None, quiet: bool = False, ) -> None: - """Post-shot sync wrapper: run, report conflicts to the user, never raise.""" + """Post-shot sync wrapper: run, report problems, never raise. + + A failed sync is remembered in *state* (``sync_pending``) so the caller + can retry when the machine comes back online. + """ try: await sync(client, repo, site_title=site_title) except SyncConflict as exc: await notify(f"⚠️ Shot journal sync hit a git conflict — fix it manually:\n{exc}") + except (TimeoutError, aiohttp.ClientError, OSError) as exc: + log.warning("sync failed, machine unreachable: %r", exc) + if state is not None: + state.set("sync_pending", True) + if not quiet: + await notify( + "📡 The machine went offline before the journal could sync — " + "I'll catch up as soon as it's back on." + ) except Exception as exc: # noqa: BLE001 log.exception("sync failed") - await notify(f"⚠️ Shot journal sync failed: {exc}") + if state is not None: + state.set("sync_pending", True) + if not quiet: + await notify(f"⚠️ Shot journal sync failed: {exc!r}") else: - await asyncio.sleep(0) + if state is not None: + state.set("sync_pending", False) diff --git a/tests/test_sync_retry.py b/tests/test_sync_retry.py new file mode 100644 index 0000000..58de77a --- /dev/null +++ b/tests/test_sync_retry.py @@ -0,0 +1,43 @@ +import aiohttp +import pytest + +from matebot import sync as sync_mod +from matebot.state import State +from matebot.sync import sync_soon + + +class Notify: + def __init__(self): + self.sent = [] + + async def __call__(self, text): + self.sent.append(text) + + +@pytest.mark.asyncio +async def test_machine_offline_sets_pending_flag(tmp_path, monkeypatch): + async def boom(client, repo, site_title="x"): + raise aiohttp.ClientConnectionError("machine off") + + monkeypatch.setattr(sync_mod, "sync", boom) + state = State(tmp_path / "state.json") + notify = Notify() + await sync_soon(None, tmp_path, notify, state=state) + assert state.get("sync_pending") is True + assert "offline" in notify.sent[0] + + # quiet retry path: no message even on repeated failure + await sync_soon(None, tmp_path, notify, state=state, quiet=True) + assert len(notify.sent) == 1 + + +@pytest.mark.asyncio +async def test_success_clears_pending_flag(tmp_path, monkeypatch): + async def ok(client, repo, site_title="x"): + return True + + monkeypatch.setattr(sync_mod, "sync", ok) + state = State(tmp_path / "state.json") + state.set("sync_pending", True) + await sync_soon(None, tmp_path, Notify(), state=state) + assert state.get("sync_pending") is False