Each owned account is tiered by the coordinator; the runner shapes its work to
match — correctness identical, only latency/socket cost differ.
- hot: persistent INBOX IDLE + secondary poll every POLL_WARM.
- warm: no persistent socket; transient INBOX+special poll every POLL_WARM.
- cold: same transient poll every POLL_COLD.
- engine: poll_all_loop (transient full poll); secondary_poll_loop interval is
now a parameter; POLL_INTERVAL removed in favor of POLL_WARM/POLL_COLD.
- coordinator: tier eval every 120s from session heat (sess:active:{user}) +
recent mail (max internal_date within TIER_HOT_ACTIVITY_H), with the
HOT_BUDGET_PER_WORKER guard shedding least-recently-active session-less hot
accounts; user.active event promotes a user's accounts to hot at once (≤5s).
- API: login + WS connect/ping publish user.active and refresh sess:active
(TTL TIER_SESSION_GRACE_MIN). New sync:tier:{id} key, default warm on claim.
- config: POLL_WARM/POLL_COLD/TIER_*/HOT_BUDGET_PER_WORKER.
- tests: 5 tier tests (session/recent-mail promotion, cold, user.active,
budget demotion) alongside the lease/lifecycle acceptance tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
142 lines
5.4 KiB
Python
142 lines
5.4 KiB
Python
"""Per-account sync runner (refactor §4, §5).
|
|
|
|
An ``AccountRunner`` owns the long-lived sync work for a single account and
|
|
exposes a ``start`` / ``stop`` / ``apply_tier`` lifecycle. The task set is shaped
|
|
by the account's activity tier (§5):
|
|
|
|
* **hot** — a persistent INBOX IDLE loop plus a secondary CONDSTORE poll of the
|
|
special folders every ``POLL_WARM`` seconds.
|
|
* **warm** — no persistent socket; a single transient poll of INBOX + special
|
|
folders every ``POLL_WARM`` seconds.
|
|
* **cold** — the same single transient poll, every ``POLL_COLD`` seconds.
|
|
|
|
Correctness is identical across tiers — only latency and socket cost differ.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import logging
|
|
import uuid
|
|
from collections.abc import Awaitable, Callable
|
|
|
|
from sqlalchemy import select
|
|
|
|
from app.config import get_settings
|
|
from app.db import session_scope
|
|
from app.models import Account
|
|
from app.sync.engine import live_loop, poll_all_loop, secondary_poll_loop
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_RECONNECT_BACKOFF = 30.0
|
|
_VERIFY_INTERVAL = 60.0
|
|
|
|
Tier = str # "hot" | "warm" | "cold"
|
|
|
|
|
|
class AccountRunner:
|
|
"""Owns the tier-shaped sync work for one account until stopped."""
|
|
|
|
def __init__(self, account_id: uuid.UUID, tier: Tier = "warm") -> None:
|
|
self.account_id = account_id
|
|
self.tier = tier
|
|
self._stop = asyncio.Event()
|
|
self._tasks: list[asyncio.Task[None]] = [] # tier-independent (verify)
|
|
self._work: list[asyncio.Task[None]] = [] # tier-shaped loops
|
|
# Set when the account is found no longer live (§3 defense in depth); the
|
|
# coordinator reaps such runners and releases their lease.
|
|
self.dead = asyncio.Event()
|
|
self.verify_interval = _VERIFY_INTERVAL
|
|
|
|
def start(self) -> None:
|
|
"""Launch the verify task and the current tier's work loops."""
|
|
self._stop.clear()
|
|
self._tasks = [asyncio.create_task(self._verify_loop())]
|
|
self._start_work()
|
|
|
|
def running(self) -> bool:
|
|
return any(not t.done() for t in (*self._tasks, *self._work))
|
|
|
|
async def stop(self) -> None:
|
|
"""Signal and cancel every loop, waiting for teardown."""
|
|
self._stop.set()
|
|
await self._cancel(self._work)
|
|
await self._cancel(self._tasks)
|
|
self._work = []
|
|
self._tasks = []
|
|
|
|
async def apply_tier(self, tier: Tier) -> None:
|
|
"""Reshape the work loops for a new tier (no-op if unchanged)."""
|
|
if tier == self.tier and self._work:
|
|
return
|
|
logger.info("account %s tier %s -> %s", self.account_id, self.tier, tier)
|
|
self.tier = tier
|
|
await self._cancel(self._work)
|
|
self._start_work()
|
|
|
|
# --- internals -----------------------------------------------------------
|
|
|
|
def _start_work(self) -> None:
|
|
settings = get_settings()
|
|
if self.tier == "hot":
|
|
self._work = [
|
|
asyncio.create_task(self._supervise(self._run_live, "live")),
|
|
asyncio.create_task(self._supervise(self._run_secondary, "secondary")),
|
|
]
|
|
else:
|
|
interval = float(settings.poll_warm if self.tier == "warm" else settings.poll_cold)
|
|
self._work = [
|
|
asyncio.create_task(self._supervise(lambda: self._run_poll(interval), "poll"))
|
|
]
|
|
|
|
async def _cancel(self, tasks: list[asyncio.Task[None]]) -> None:
|
|
for task in tasks:
|
|
task.cancel()
|
|
if tasks:
|
|
await asyncio.gather(*tasks, return_exceptions=True)
|
|
|
|
async def _run_live(self) -> None:
|
|
await live_loop(self.account_id, "INBOX", self._stop)
|
|
|
|
async def _run_secondary(self) -> None:
|
|
await secondary_poll_loop(self.account_id, self._stop, get_settings().poll_warm)
|
|
|
|
async def _run_poll(self, interval: float) -> None:
|
|
await poll_all_loop(self.account_id, interval, self._stop)
|
|
|
|
async def _verify_loop(self) -> None:
|
|
"""Periodically confirm the account is still live; flag ``dead`` if not.
|
|
|
|
This makes zombie loops structurally impossible even if a lifecycle event
|
|
is lost: an account that was disabled/deleted stops being synced within a
|
|
verify cycle regardless of the lease/event paths.
|
|
"""
|
|
while not self._stop.is_set():
|
|
with contextlib.suppress(TimeoutError):
|
|
await asyncio.wait_for(self._stop.wait(), timeout=self.verify_interval)
|
|
if self._stop.is_set():
|
|
return
|
|
if not await self._account_is_live():
|
|
logger.info("account %s no longer live; flagging runner dead", self.account_id)
|
|
self.dead.set()
|
|
return
|
|
|
|
async def _account_is_live(self) -> bool:
|
|
async with session_scope() as session:
|
|
state = await session.scalar(select(Account.state).where(Account.id == self.account_id))
|
|
return state == "live"
|
|
|
|
async def _supervise(self, run: Callable[[], Awaitable[None]], label: str) -> None:
|
|
"""Run a loop, restarting it (with backoff) until stopped or cancelled."""
|
|
while not self._stop.is_set():
|
|
try:
|
|
await run()
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception:
|
|
logger.exception("%s loop for account %s crashed; retrying", label, self.account_id)
|
|
with contextlib.suppress(TimeoutError):
|
|
await asyncio.wait_for(self._stop.wait(), timeout=_RECONNECT_BACKOFF)
|