feat: add durable sync state transitions
Add state-transition methods to ActivityRepository for advancing activity stages (mark_downloaded, mark_converted, mark_imported, mark_duplicate, mark_failed) with proper retention of last_completed_stage on failure. Add list_pending_for_user to filter activities for processing. Implement SyncRunRepository for creating and finalizing sync runs with counts and summary errors. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import and_, or_, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.models import Activity, ActivityStatus, SyncUser
|
||||
from app.db.models import Activity, ActivityStatus, SyncRun, SyncRunStatus, SyncUser, utcnow
|
||||
|
||||
|
||||
class UserRepository:
|
||||
@@ -37,6 +37,15 @@ class ActivityRepository:
|
||||
def __init__(self, session: Session) -> None:
|
||||
self.session = session
|
||||
|
||||
def _require(self, activity_id: int) -> Activity:
|
||||
activity = self.session.get(Activity, activity_id)
|
||||
if activity is None:
|
||||
raise ValueError(f"activity {activity_id} not found")
|
||||
return activity
|
||||
|
||||
def get(self, activity_id: int) -> Activity | None:
|
||||
return self.session.get(Activity, activity_id)
|
||||
|
||||
def get_or_create_discovered(
|
||||
self,
|
||||
*,
|
||||
@@ -76,3 +85,101 @@ class ActivityRepository:
|
||||
raise
|
||||
return existing, False
|
||||
return activity, True
|
||||
|
||||
def mark_downloaded(self, activity_id: int, path: str) -> Activity:
|
||||
activity = self._require(activity_id)
|
||||
activity.source_fit_path = path
|
||||
activity.status = ActivityStatus.DOWNLOADED
|
||||
activity.last_completed_stage = ActivityStatus.DOWNLOADED
|
||||
activity.last_error = None
|
||||
activity.retryable = True
|
||||
self.session.commit()
|
||||
return activity
|
||||
|
||||
def mark_converted(self, activity_id: int, path: str) -> Activity:
|
||||
activity = self._require(activity_id)
|
||||
activity.converted_fit_path = path
|
||||
activity.status = ActivityStatus.CONVERTED
|
||||
activity.last_completed_stage = ActivityStatus.CONVERTED
|
||||
activity.last_error = None
|
||||
activity.retryable = True
|
||||
self.session.commit()
|
||||
return activity
|
||||
|
||||
def mark_imported(self, activity_id: int, garmin_activity_id: str | None) -> Activity:
|
||||
activity = self._require(activity_id)
|
||||
activity.status = ActivityStatus.IMPORTED
|
||||
activity.last_completed_stage = ActivityStatus.IMPORTED
|
||||
activity.garmin_activity_id = garmin_activity_id
|
||||
activity.last_error = None
|
||||
activity.retryable = False
|
||||
self.session.commit()
|
||||
return activity
|
||||
|
||||
def mark_duplicate(self, activity_id: int) -> Activity:
|
||||
activity = self._require(activity_id)
|
||||
activity.status = ActivityStatus.DUPLICATE
|
||||
activity.last_completed_stage = ActivityStatus.DUPLICATE
|
||||
activity.last_error = None
|
||||
activity.retryable = False
|
||||
self.session.commit()
|
||||
return activity
|
||||
|
||||
def mark_failed(self, activity_id: int, error: str, *, retryable: bool) -> Activity:
|
||||
activity = self._require(activity_id)
|
||||
activity.status = ActivityStatus.FAILED
|
||||
activity.last_error = error[:2000]
|
||||
activity.retryable = retryable
|
||||
self.session.commit()
|
||||
return activity
|
||||
|
||||
def list_pending_for_user(self, user_id: int) -> list[Activity]:
|
||||
return list(
|
||||
self.session.scalars(
|
||||
select(Activity).where(
|
||||
Activity.user_id == user_id,
|
||||
or_(
|
||||
Activity.status.in_([ActivityStatus.DISCOVERED, ActivityStatus.DOWNLOADED, ActivityStatus.CONVERTED]),
|
||||
and_(Activity.status == ActivityStatus.FAILED, Activity.retryable.is_(True)),
|
||||
),
|
||||
).order_by(Activity.id)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class SyncRunRepository:
|
||||
def __init__(self, session: Session) -> None:
|
||||
self.session = session
|
||||
|
||||
def start(self, user_id: int) -> SyncRun:
|
||||
sync_run = SyncRun(user_id=user_id, status=SyncRunStatus.RUNNING)
|
||||
self.session.add(sync_run)
|
||||
self.session.commit()
|
||||
return sync_run
|
||||
|
||||
def get(self, sync_run_id: int) -> SyncRun | None:
|
||||
return self.session.get(SyncRun, sync_run_id)
|
||||
|
||||
def finish(
|
||||
self,
|
||||
sync_run_id: int,
|
||||
*,
|
||||
status: SyncRunStatus,
|
||||
discovered: int,
|
||||
imported: int,
|
||||
skipped: int,
|
||||
failed: int,
|
||||
summary_error: str | None = None,
|
||||
) -> SyncRun:
|
||||
sync_run = self.session.get(SyncRun, sync_run_id)
|
||||
if sync_run is None:
|
||||
raise ValueError(f"sync_run {sync_run_id} not found")
|
||||
sync_run.finished_at = utcnow()
|
||||
sync_run.status = status
|
||||
sync_run.discovered_count = discovered
|
||||
sync_run.imported_count = imported
|
||||
sync_run.skipped_count = skipped
|
||||
sync_run.failed_count = failed
|
||||
sync_run.summary_error = summary_error[:2000] if summary_error else None
|
||||
self.session.commit()
|
||||
return sync_run
|
||||
|
||||
Reference in New Issue
Block a user