screen-locker/screen_locker/_sync_retry.py
Krzysztof kuhy Rudnicki e7fa7fb44c
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Let the PC see phone workouts again, and stop losing progression on reinstall
A real StrongLifts session on 2026-08-10 never reached this machine, so it
had to be logged as a manual workout -- spending a manual budget slot on a
workout that was genuinely verified. The session was in Firebase the whole
time; four separate defects each hid it.

- pull_synced_workout() read a hardcoded devices/phone/log.json. Since
  ffc6c72 the phone pushes under a per-install uuid, so that directory has
  been frozen since 2026-07-17 and the PC kept returning a stale session
  from 07-27 as if it were current. Both readers now walk EVERY device
  directory; reinstalls keep minting new ones, so no single path can be
  right for long.
- A session is identified by SHAPE (it has `exercises`), not by a `kind`
  field the phone never writes. That filter is why the one reader that did
  walk every directory discarded real sessions, and it also kept PC-origin
  runnerup/phone_verified records from being handed to a caller expecting
  a session.
- FirebaseSyncError is a SIBLING of GitHubSyncError, not a subclass, so
  `except GitHubSyncError` never caught it and a DNS failure took the whole
  locker down (status=1/FAILURE, then a restart loop). Catch the common
  parent; a pull failure degrades to a warning.
- A GitHub token is no longer required to sync. Firebase has been primary
  since eb4ff01, so a Firebase-only machine was reporting "sync is OFF"
  while syncing fine.

Sync also only ran once per locker start, so a workout finished after login
was invisible until the next login. workout-sync.timer runs the new headless
--sync-only every 15 minutes; remove_systemd.sh now removes every unit the
installer creates, not just two of them.

workout_app: the 2026-08-05 reinstall wiped progression because the DB seeds
factory defaults before the backup is restored, and the first write then
exported those defaults over the only off-device copy. Storage permission is
now settled before the DB opens, a defaults-only DB refuses to overwrite a
richer backup, and the three swallowed storage errors say what broke.

Sessions that exist only in Firebase/GitHub are restored into local history,
so a reinstalled app has the history it actually earned rather than starting
at whatever survived. Matched on (date, start_time), so re-syncing never
duplicates and local rows always win.

Adds a target-reps editor: progression can only ever RAISE reps, so a rep
target a defaults re-seed set wrong could not be corrected from the UI at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JS5V44jkHZiH1wC7BH5h5n
2026-08-10 14:39:02 +02:00

101 lines
3.5 KiB
Python

"""Bounded retry for sync calls that run before the network is up.
Every sync entry point here is reached from the morning routine, which systemd
starts immediately after boot and after resume — routinely a few seconds before
DHCP/DNS have settled. A single-shot call in that window fails for a reason
that has nothing to do with the sync itself, and (before this module) reported
it as a token/permissions problem.
Retrying at the application layer rather than ordering the unit after
``network-online.target``: the same race happens on *resume*, where boot
ordering does not apply, and the user services involved cannot depend on a
system target anyway.
Any :class:`RemoteSyncError` is retried, deliberately including
:class:`RepoNotFoundError`. Catching the common parent rather than
:class:`GitHubSyncError` matters since the Firebase cutover:
:class:`FirebaseSyncError` is a *sibling* of :class:`GitHubSyncError`, not a
subclass, so catching the latter let every Firebase failure escape uncaught and
take the whole locker down (``status=1/FAILURE``) instead of degrading to "no
synced workout". Classifying "transient" vs "permanent" would mean
depending on which exception ``crdt_sync`` chains where, which is internal to
that library and has changed before; a few wasted seconds on a genuinely
misconfigured token is a much cheaper mistake than skipping a real retry.
"""
from __future__ import annotations
import logging
from time import sleep
from typing import TYPE_CHECKING, TypeVar
from crdt_sync import RemoteSyncError
from screen_locker._constants import (
SYNC_RETRY_ATTEMPTS,
SYNC_RETRY_DELAY_SECONDS,
)
if TYPE_CHECKING:
from collections.abc import Callable
_logger = logging.getLogger(__name__)
T = TypeVar("T")
def with_sync_retry(
operation: Callable[[], T],
*,
description: str,
attempts: int = SYNC_RETRY_ATTEMPTS,
delay_seconds: float = SYNC_RETRY_DELAY_SECONDS,
) -> T:
"""Run *operation*, retrying a failed sync with exponential backoff.
Args:
operation: The zero-argument sync call to run.
description: Human phrase naming the operation, used in the retry
logs (e.g. ``"push PC workouts"``).
attempts: Total tries, including the first. Values below 1 still run
the operation exactly once.
delay_seconds: Base backoff; attempt *n* waits ``delay * 2**(n-1)``.
Returns:
Whatever *operation* returns.
Raises:
RemoteSyncError: If every attempt failed. The caller keeps its own
handling — this only buys time, it never hides the outcome.
"""
last_attempt = max(attempts, 1)
# Every attempt but the last swallows-and-waits; the last one is run
# outside the loop so its failure propagates with no unreachable branch.
for attempt in range(1, last_attempt):
try:
return operation()
except RemoteSyncError as exc:
wait = delay_seconds * (2 ** (attempt - 1))
_logger.warning(
"Could not %s (attempt %d/%d): %s — retrying in %.0fs; "
"this is normal right after boot or resume, when the "
"network is not up yet",
description,
attempt,
last_attempt,
exc,
wait,
)
sleep(wait)
try:
return operation()
except RemoteSyncError as exc:
_logger.warning(
"Could not %s after %d attempt(s): %s — giving up",
description,
last_attempt,
exc,
)
raise