# version: v2 """ ================================================================================ Quantum Terminal Consumer — Periodic Data Sync Poller ================================================================================ Background asyncio task that polls the VPS at a configurable interval and broadcasts a 'data_sync_complete' WebSocket event whenever fresh data arrives. This solves the problem where a long-running terminal would never re-check the server after the initial startup gate. Configurable via consumer_config.ini: [sync] poll_interval_seconds = 300 Default interval: 300s (5 minutes). Floor: 60s (no hammering the server). Rule C1 compliance: this module performs ZERO calculations. It only re-runs the same display-data sync as the startup gate (consumer_gate -> sync_all). This module does NOT introduce any calculation triggers. ================================================================================ """ import asyncio import logging import os # v2: AppData dir name (env-var driven; default "QuantumTerminal" preserves v1 path). APP_DIR = os.environ.get("MK_APP_DIR_NAME", "QuantumTerminal") import configparser from datetime import datetime, timezone from pathlib import Path from typing import Awaitable, Callable from pulse_room.sync_hook import on_sync_complete as _pulse_on_sync_complete log = logging.getLogger("mk.periodic_sync") DEFAULT_INTERVAL_SECONDS = 300 # 5 minutes MIN_INTERVAL_SECONDS = 60 # safety floor def _read_interval() -> int: """ Read sync.poll_interval_seconds from consumer_config.ini. Searches the same locations as data_sync_client._read_config(). Returns DEFAULT_INTERVAL_SECONDS if not configured. Floor is enforced at MIN_INTERVAL_SECONDS. """ project_root = Path(__file__).resolve().parent candidates = [ project_root / "consumer_config.ini", project_root.parent / "consumer_config.ini", ] import sys as _sys if getattr(_sys, "frozen", False): candidates.append(Path(_sys.executable).parent / "consumer_config.ini") appdata = os.environ.get("APPDATA", "") if appdata: candidates.append(Path(appdata) / APP_DIR / "consumer_config.ini") for p in candidates: if not p.exists(): continue try: cp = configparser.ConfigParser() cp.read(str(p), encoding="utf-8-sig") if cp.has_option("sync", "poll_interval_seconds"): val = cp.getint("sync", "poll_interval_seconds") return max(MIN_INTERVAL_SECONDS, val) break except Exception as e: log.warning(f"Could not parse sync interval from {p.name}: {e}") break return DEFAULT_INTERVAL_SECONDS def create_periodic_sync_task( broadcast_event: Callable[[dict], Awaitable[None]], ) -> Callable: """ Returns an async coroutine factory that polls sync_all() at a configurable interval. Pass the result to asyncio.create_task() inside the FastAPI lifespan. Usage in data_server.py lifespan: from periodic_sync import create_periodic_sync_task sync_task = create_periodic_sync_task(manager.broadcast_event) tasks.append(asyncio.create_task(sync_task())) Each tick the task: 1. Calls data_sync_client.sync_all() in a worker thread 2. Logs the result (synced / skipped / offline) 3. If new files arrived (synced > 0), broadcasts WS event: {"type": "data_sync_complete", "synced": N, ...} Frontend (useTerminalData.js) listens and refreshes panels. Errors do not kill the task — they back off and retry on the next interval. """ interval = _read_interval() log.info(f"Periodic sync task configured: interval={interval}s") async def _task(): # Wait one full interval before first poll — # the startup gate has already done the initial sync. try: await asyncio.sleep(interval) except asyncio.CancelledError: return while True: try: from data_sync_client import get_sync_client client = get_sync_client() # sync_all() does blocking HTTP — run in a thread summary = await asyncio.to_thread(client.sync_all) synced = summary.get("synced", 0) skipped = summary.get("skipped", 0) offline = summary.get("offline_mode", False) log.info( f"Periodic sync tick: synced={synced}, " f"skipped={skipped}, offline={offline}" ) # Only broadcast when fresh data actually arrived. # Skipping silent ticks keeps frontend quiet during quiet hours. if synced > 0: try: await broadcast_event({ "type": "data_sync_complete", "synced": synced, "skipped": skipped, "offline_mode": offline, "timestamp": datetime.now(timezone.utc).isoformat(), }) except Exception as e: log.warning(f"Failed to broadcast data_sync_complete: {e}") # Pulse Room auto-rebuild — only when fresh source data # arrived. Fires only if scanner ON + watchlist non-empty # (eligibility check is inside on_sync_complete itself); # daemon thread, fire-and-forget; never breaks sync pipeline. try: _pulse_on_sync_complete() except Exception: log.exception("pulse_room.sync_hook threw") except asyncio.CancelledError: log.info("Periodic sync task cancelled") break except Exception as e: log.error(f"Periodic sync task error: {e}", exc_info=True) # Back off on error but never longer than the configured interval try: await asyncio.sleep(min(interval, 120)) except asyncio.CancelledError: break continue try: await asyncio.sleep(interval) except asyncio.CancelledError: break return _task