feat: isolate concurrent user syncs

Wrap SyncManager.sync_user in a per-user asyncio.Lock (raising
SyncAlreadyRunning on overlap) and add sync_all_enabled() to fan out
across all enabled users with per-user failure isolation via
asyncio.gather(return_exceptions=True). The prior sync_user body is
renamed to _sync_user_locked with no logic changes.
This commit is contained in:
Bastian Wagner
2026-08-15 16:08:00 +02:00
parent 4aaf490bc5
commit 1d414d6298
2 changed files with 196 additions and 3 deletions

View File

@@ -0,0 +1,163 @@
import asyncio
import pytest
from app.db.repositories import UserRepository
from app.mywhoosh.client import MyWhooshAuthError
from app.mywhoosh.models import MyWhooshActivity
from app.sync.manager import SyncAlreadyRunning, SyncManager
from tests.sync.conftest import FakeFitConverter, _create_user
from tests.sync.fakes import FakeGarminUploader
class BlockingMyWhooshClient:
"""Fake MyWhoosh client whose list_activities() blocks on test-controlled
events, so a test can deterministically observe "sync has started but not
finished" without any production-only test hooks."""
def __init__(self, first_started: asyncio.Event, release: asyncio.Event) -> None:
self.first_started = first_started
self.release = release
self.list_calls = 0
async def list_activities(self, email: str, password: str):
self.list_calls += 1
self.first_started.set()
await self.release.wait()
return []
async def download_fit(self, activity_file_id: str, email: str, password: str) -> bytes:
raise AssertionError("download_fit should not be reached in this test")
class ConditionalFailureMyWhooshClient:
"""Fake MyWhoosh client that raises MyWhooshAuthError only for a specific
account email, letting one user's sync fail while others succeed."""
def __init__(self, activities, fit_bytes: bytes, failing_email: str) -> None:
self.activities = activities
self.fit_bytes = fit_bytes
self.failing_email = failing_email
self.list_calls = 0
self.download_calls = 0
async def list_activities(self, email: str, password: str):
self.list_calls += 1
if email == self.failing_email:
raise MyWhooshAuthError("simulated auth failure")
return list(self.activities)
async def download_fit(self, activity_file_id: str, email: str, password: str) -> bytes:
self.download_calls += 1
return self.fit_bytes
@pytest.fixture
def user_a(session_factory, cipher):
with session_factory() as session:
return _create_user(session, cipher)
@pytest.fixture
def user_b(session_factory, cipher):
with session_factory() as session:
return _create_user(session, cipher)
@pytest.mark.asyncio
async def test_same_user_cannot_run_twice(session_factory, cipher, settings, seeded_user) -> None:
first_started = asyncio.Event()
release_first = asyncio.Event()
mywhoosh = BlockingMyWhooshClient(first_started, release_first)
manager = SyncManager(
session_factory=session_factory,
credential_cipher=cipher,
settings=settings,
mywhoosh_factory=lambda token_store: mywhoosh,
garmin_factory=lambda email, password, tokenstore: FakeGarminUploader(),
fit_converter=FakeFitConverter(),
)
first = asyncio.create_task(manager.sync_user(seeded_user.id))
await first_started.wait()
with pytest.raises(SyncAlreadyRunning):
await manager.sync_user(seeded_user.id)
release_first.set()
outcome = await first
assert outcome.user_id == seeded_user.id
assert mywhoosh.list_calls == 1
@pytest.mark.asyncio
async def test_different_users_can_run_concurrently(manager, user_a, user_b) -> None:
results = await asyncio.gather(manager.sync_user(user_a.id), manager.sync_user(user_b.id))
assert {result.user_id for result in results} == {user_a.id, user_b.id}
assert all(result.status == "success" for result in results)
@pytest.mark.asyncio
async def test_sync_all_enabled_isolates_failures(session_factory, cipher, settings) -> None:
with session_factory() as session:
failing_user = UserRepository(session).create(
name="Failing User",
enabled=True,
mywhoosh_email_enc=cipher.encrypt("failing-mw@example.com"),
mywhoosh_password_enc=cipher.encrypt("failing-mw-pass"),
garmin_email_enc=cipher.encrypt("failing-garmin@example.com"),
garmin_password_enc=cipher.encrypt("failing-garmin-pass"),
)
healthy_user = UserRepository(session).create(
name="Healthy User",
enabled=True,
mywhoosh_email_enc=cipher.encrypt("healthy-mw@example.com"),
mywhoosh_password_enc=cipher.encrypt("healthy-mw-pass"),
garmin_email_enc=cipher.encrypt("healthy-garmin@example.com"),
garmin_password_enc=cipher.encrypt("healthy-garmin-pass"),
)
remote = MyWhooshActivity(
id="mw-shared",
title="Shared Ride",
activity_file_id="file-mw-shared",
started_at=None,
)
mywhoosh = ConditionalFailureMyWhooshClient(
activities=[remote],
fit_bytes=b"source-bytes",
failing_email="failing-mw@example.com",
)
converter = FakeFitConverter()
garmin = FakeGarminUploader()
manager = SyncManager(
session_factory=session_factory,
credential_cipher=cipher,
settings=settings,
mywhoosh_factory=lambda token_store: mywhoosh,
garmin_factory=lambda email, password, tokenstore: garmin,
fit_converter=converter,
)
results = await manager.sync_all_enabled()
assert len(results) == 2
for result in results:
assert not isinstance(result, Exception)
by_user = {result.user_id: result for result in results}
failing_outcome = by_user[failing_user.id]
healthy_outcome = by_user[healthy_user.id]
# The failing user's auth error is classified by _sync_user_locked's own
# exception handling and returned as a non-success SyncOutcome rather than
# raised -- so asyncio.gather never sees an exception for this failure
# mode. It must not affect the healthy user's independent outcome.
assert failing_outcome.status != "success"
assert failing_outcome.message is not None
assert failing_outcome.discovered == 0
assert healthy_outcome.status == "success"
assert healthy_outcome.imported == 1
assert healthy_outcome.failed == 0