Close the leaked httpx.AsyncClient in MyWhoosh sync runs, ensure hard failures finish sync_runs as FAILED instead of leaving them stuck at RUNNING, log (non-benign) exceptions surfaced by sync_all_enabled during scheduled ticks, classify GarminImportRejected as a non-retryable per-activity failure, fix a bug where a live Garmin-class action_required state could be silently cleared by a run that did no Garmin work, use the activity's DB primary key instead of the unsanitized remote id for filesystem paths, add regression/coverage tests for the health-state fix and MFA code threading through the real SyncManager, add idempotency coverage to the two-user acceptance test, and note the Dockerfile's single-process assumption. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
58 lines
2.0 KiB
Python
58 lines
2.0 KiB
Python
import asyncio
|
|
import logging
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from app.sync.manager import SyncAlreadyRunning
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class SyncScheduler:
|
|
def __init__(self, manager, *, interval_seconds: float) -> None:
|
|
self.manager = manager
|
|
self.interval_seconds = interval_seconds
|
|
self._task: asyncio.Task | None = None
|
|
self._stop = asyncio.Event()
|
|
self.last_tick = None
|
|
self.next_tick = None
|
|
|
|
async def run_once(self) -> None:
|
|
self.last_tick = datetime.now(timezone.utc)
|
|
try:
|
|
results = await self.manager.sync_all_enabled()
|
|
for result in results:
|
|
if not isinstance(result, Exception):
|
|
continue
|
|
# SyncAlreadyRunning is an expected, benign outcome when a
|
|
# manual sync and a scheduler tick overlap for the same user
|
|
# -- not an error worth logging.
|
|
if isinstance(result, SyncAlreadyRunning):
|
|
continue
|
|
logger.warning(
|
|
"sync_all_enabled: user sync failed during scheduled tick: %s: %s",
|
|
type(result).__name__,
|
|
str(result)[:200],
|
|
)
|
|
except Exception:
|
|
logger.exception("sync_all_enabled failed during scheduled tick")
|
|
finally:
|
|
self.next_tick = datetime.now(timezone.utc) + timedelta(seconds=self.interval_seconds)
|
|
|
|
async def _run(self) -> None:
|
|
while not self._stop.is_set():
|
|
await self.run_once()
|
|
try:
|
|
await asyncio.wait_for(self._stop.wait(), timeout=self.interval_seconds)
|
|
except TimeoutError:
|
|
pass
|
|
|
|
async def start(self) -> None:
|
|
self._stop.clear()
|
|
self._task = asyncio.create_task(self._run())
|
|
|
|
async def stop(self) -> None:
|
|
self._stop.set()
|
|
if self._task is not None:
|
|
await self._task
|
|
self._task = None
|