Files

173 lines
6.4 KiB
Python

# 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