Files
webmail-client/backend/app/sync/runner.py
T
lukasandClaude Opus 4.8 19335116da sync-v2 step 5: activity tiers (hot/warm/cold)
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>
2026-07-19 07:03:54 +02:00

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)