Files
romm/backend/tasks/sync_push_pull_task.py
T
Georges-Antoine Assi 4c97eddfc3 fix trunk check
2026-03-22 16:30:14 -04:00

426 lines
14 KiB
Python

"""Background task for Push-Pull sync mode.
Connects to devices via SSH/SFTP, scans their save directories,
and performs bidirectional sync operations.
"""
import os
from datetime import datetime, timezone
from typing import Any
from anyio import Path as AnyioPath
from anyio import open_file
from config import ENABLE_SYNC_PUSH_PULL, SYNC_PUSH_PULL_CRON
from handler.database import (
db_device_handler,
db_device_save_sync_handler,
db_platform_handler,
db_save_handler,
db_sync_session_handler,
)
from handler.filesystem import fs_asset_handler
from handler.sync.comparison import compare_save_state
from handler.sync.ssh_handler import ssh_sync_handler
from logger.formatter import highlight as hl
from logger.logger import log
from models.device import Device, SyncMode
from models.sync_session import SyncSessionStatus
from tasks.tasks import PeriodicTask, TaskType
async def run_push_pull_sync(
device_id: str | None = None,
session_id: int | None = None,
force: bool = False,
) -> dict:
"""Execute push-pull sync for one or all push_pull devices."""
if not ENABLE_SYNC_PUSH_PULL and not force:
log.info("Push-pull sync not enabled, skipping")
return {"status": "disabled"}
if device_id:
device = db_device_handler.get_device_by_id(device_id)
if not device:
return {"status": "error", "message": f"Device {device_id} not found"}
devices = [device]
else:
devices = list(
db_device_handler.get_all_devices_by_sync_mode(SyncMode.PUSH_PULL)
)
if not devices:
log.info("No push_pull devices found")
return {"status": "no_devices"}
results = []
for device in devices:
if not device.sync_enabled:
continue
result = await _sync_device(device, session_id=session_id)
results.append(result)
return {"status": "completed", "device_results": results}
async def _sync_device(device: Device, session_id: int | None = None) -> dict:
"""Perform push-pull sync for a single device."""
sync_config = device.sync_config or {}
if not sync_config.get("ssh_host"):
log.warning(f"Push-pull device {device.id} has no ssh_host configured")
return {"device_id": device.id, "status": "error", "message": "No ssh_host"}
from endpoints.sockets.sync import (
emit_sync_completed,
emit_sync_conflict,
emit_sync_error,
emit_sync_progress,
emit_sync_started,
)
if session_id:
sync_session = db_sync_session_handler.get_session(
session_id=session_id, user_id=device.user_id
)
if not sync_session:
log.warning(
f"Push-pull: session {session_id} not found, creating new session"
)
sync_session = db_sync_session_handler.create_session(
device_id=device.id, user_id=device.user_id
)
else:
sync_session = db_sync_session_handler.create_session(
device_id=device.id, user_id=device.user_id
)
await emit_sync_started(
user_id=device.user_id,
device_id=device.id,
session_id=sync_session.id,
sync_mode="push_pull",
)
try:
conn = await ssh_sync_handler.connect(sync_config, device_id=device.id)
except Exception as e:
log.error(f"Push-pull: failed to connect to device {device.id}: {e}")
db_sync_session_handler.fail_session(
session_id=sync_session.id, error_message=str(e)
)
await emit_sync_error(
user_id=device.user_id,
device_id=device.id,
session_id=sync_session.id,
error_message=str(e),
)
return {"device_id": device.id, "status": "connection_failed", "error": str(e)}
completed = 0
failed = 0
try:
save_directories = sync_config.get("save_directories", [])
if not save_directories:
log.warning(
f"Push-pull device {device.id} has no save_directories configured"
)
db_sync_session_handler.complete_session(session_id=sync_session.id)
return {"device_id": device.id, "status": "no_directories"}
remote_saves = await ssh_sync_handler.list_remote_saves(conn, save_directories)
log.info(
f"Push-pull: found {len(remote_saves)} remote saves on device {device.id}"
)
db_sync_session_handler.update_session(
session_id=sync_session.id,
data={
"status": SyncSessionStatus.IN_PROGRESS,
"operations_planned": len(remote_saves),
},
)
operations_planned = len(remote_saves)
for remote_save in remote_saves:
try:
action = await _process_remote_save(device, conn, remote_save)
if action == "conflict":
await emit_sync_conflict(
user_id=device.user_id,
device_id=device.id,
session_id=sync_session.id,
file_name=remote_save.file_name,
rom_id=0,
reason=f"Conflict detected for {remote_save.file_name}",
)
if action != "skipped":
completed += 1
except Exception:
log.error(
f"Push-pull: failed to process {remote_save.file_name} "
f"on device {device.id}",
exc_info=True,
)
failed += 1
await emit_sync_progress(
user_id=device.user_id,
device_id=device.id,
session_id=sync_session.id,
operations_completed=completed + failed,
operations_planned=operations_planned,
current_file=remote_save.file_name,
)
push_count = await _push_missing_saves(
device, conn, remote_saves, save_directories
)
completed += push_count
except Exception as e:
log.error(f"Push-pull sync failed for device {device.id}: {e}", exc_info=True)
db_sync_session_handler.fail_session(
session_id=sync_session.id, error_message=str(e)
)
await emit_sync_error(
user_id=device.user_id,
device_id=device.id,
session_id=sync_session.id,
error_message=str(e),
)
return {"device_id": device.id, "status": "failed", "error": str(e)}
finally:
conn.close()
db_sync_session_handler.complete_session(
session_id=sync_session.id,
operations_completed=completed,
operations_failed=failed,
)
db_device_handler.update_last_seen(device_id=device.id, user_id=device.user_id)
await emit_sync_completed(
user_id=device.user_id,
device_id=device.id,
session_id=sync_session.id,
operations_completed=completed,
operations_failed=failed,
)
log.info(
f"Push-pull sync for device {device.id}: "
f"{completed} completed, {failed} failed"
)
return {
"device_id": device.id,
"status": "completed",
"completed": completed,
"failed": failed,
}
async def _process_remote_save(
device: Device,
conn,
remote_save,
) -> str:
"""Process a single remote save file. Returns action taken."""
# Look up platform
platform = db_platform_handler.get_platform_by_fs_slug(remote_save.platform_slug)
if not platform:
log.debug(f"Unknown platform slug: {remote_save.platform_slug}")
return "skipped"
# Find matching server save
saves = db_save_handler.get_saves(user_id=device.user_id, platform_id=platform.id)
matched_save = None
for save in saves:
if save.file_name == remote_save.file_name:
matched_save = save
break
if not matched_save:
log.info(
f"Push-pull: remote save {hl(remote_save.file_name)} "
f"on platform {remote_save.platform_slug} - no matching server save, skipping"
)
return "skipped"
# Compare with existing save
device_sync = db_device_save_sync_handler.get_sync(
device_id=device.id, save_id=matched_save.id
)
# Download remote file to get its hash
local_path, remote_hash = await ssh_sync_handler.download_save(
conn, remote_save.path
)
try:
result = compare_save_state(
client_hash=remote_hash,
client_updated_at=remote_save.mtime,
server_hash=matched_save.content_hash,
server_updated_at=matched_save.updated_at,
device_last_synced_at=device_sync.last_synced_at if device_sync else None,
)
if result.action == "no_op":
# Update sync tracking even for no-ops
db_device_save_sync_handler.upsert_sync(
device_id=device.id,
save_id=matched_save.id,
synced_at=datetime.now(timezone.utc),
)
return "no_op"
if result.action == "upload":
# Remote is newer - pull to server
log.info(
f"Push-pull: pulling {hl(remote_save.file_name)} from device {device.id}"
)
async with await open_file(local_path, "rb") as f:
file_data = await f.read()
await fs_asset_handler.write_file(
file=file_data,
path=matched_save.file_path,
filename=matched_save.file_name,
)
db_save_handler.update_save(
matched_save.id,
{
"file_size_bytes": remote_save.file_size,
"content_hash": remote_hash,
},
)
db_device_save_sync_handler.upsert_sync(
device_id=device.id,
save_id=matched_save.id,
synced_at=datetime.now(timezone.utc),
)
return "pulled"
if result.action == "download":
# Server is newer - push to device
log.info(
f"Push-pull: pushing {hl(matched_save.file_name)} to device {device.id}"
)
server_file_path = f"{matched_save.file_path}/{matched_save.file_name}"
server_full_path = fs_asset_handler.validate_path(server_file_path)
await ssh_sync_handler.upload_save(
conn, str(server_full_path), remote_save.path
)
db_device_save_sync_handler.upsert_sync(
device_id=device.id,
save_id=matched_save.id,
synced_at=datetime.now(timezone.utc),
)
return "pushed"
if result.action == "conflict":
log.warning(
f"Push-pull: conflict for {remote_save.file_name} "
f"on device {device.id}: {result.reason}"
)
return "conflict"
finally:
if await AnyioPath(local_path).exists():
os.unlink(local_path)
return "skipped"
async def _push_missing_saves(
device: Device,
conn,
remote_saves,
save_directories: list[dict],
) -> int:
"""Push server saves that are missing from the device."""
pushed = 0
# Build set of remote filenames per platform
remote_files: dict[str, set[str]] = {}
for rs in remote_saves:
remote_files.setdefault(rs.platform_slug, set()).add(rs.file_name)
# Build path lookup from save_directories config
platform_paths: dict[str, str] = {}
for dir_config in save_directories:
platform_paths[dir_config["platform_slug"]] = dir_config["path"]
# Check server saves for each configured platform
for dir_config in save_directories:
platform_slug = dir_config["platform_slug"]
platform = db_platform_handler.get_platform_by_fs_slug(platform_slug)
if not platform:
continue
server_saves = db_save_handler.get_saves(
user_id=device.user_id, platform_id=platform.id
)
remote_set = remote_files.get(platform_slug, set())
remote_dir = platform_paths.get(platform_slug, "")
for save in server_saves:
if save.file_name in remote_set:
continue
# Check if device has synced this before (intentional delete)
device_sync = db_device_save_sync_handler.get_sync(
device_id=device.id, save_id=save.id
)
if device_sync and device_sync.is_untracked:
continue
# Push to device
if remote_dir:
try:
server_file_path = f"{save.file_path}/{save.file_name}"
server_full_path = fs_asset_handler.validate_path(server_file_path)
remote_path = f"{remote_dir}/{save.file_name}"
await ssh_sync_handler.upload_save(
conn, str(server_full_path), remote_path
)
db_device_save_sync_handler.upsert_sync(
device_id=device.id,
save_id=save.id,
synced_at=datetime.now(timezone.utc),
)
pushed += 1
log.info(
f"Push-pull: pushed missing save {hl(save.file_name)} "
f"to device {device.id}"
)
except Exception:
log.error(
f"Push-pull: failed to push {save.file_name} to device {device.id}",
exc_info=True,
)
return pushed
class SyncPushPullTask(PeriodicTask):
"""Periodic task to run push-pull sync for all configured devices."""
def __init__(self) -> None:
super().__init__(
title="Push-Pull Sync",
description="Sync saves with devices via SSH/SFTP",
task_type=TaskType.SYNC,
enabled=ENABLE_SYNC_PUSH_PULL,
cron_string=SYNC_PUSH_PULL_CRON,
func="tasks.sync_push_pull_task.run_push_pull_sync",
)
async def run(self, *args: Any, **kwargs: Any) -> Any:
return await run_push_pull_sync(**kwargs)
sync_push_pull_task = SyncPushPullTask()