sync-v2 step 3: lease-owned account claimer (replaces startup supervisor)
Ownership of an account now moves through a Redis lease so any number of sync
workers can run against the same account set with no double-IDLE.
- leases.py: sync:live registry + sync:lease:{id} (SET NX EX) with owner-checked
Lua renew/release; mark_live / mark_not_live helpers.
- coordinator.py: SyncCoordinator claims from sync:live up to MAX_ACCOUNTS_PER_
WORKER, renews every LEASE_RENEW (fences the runner on lease loss), reconciles
sync:live against Postgres, heartbeats a worker key, and rebalances when it
holds well above a fair share.
- supervisor start/stop now drive a coordinator; engine._set_state and
delete_account maintain sync:live membership (+ lease drop = fencing teardown).
- config: MAX_ACCOUNTS_PER_WORKER, LEASE_TTL, LEASE_RENEW.
- tests: acceptance 1-3 (single ownership, crash failover, create/delete
lifecycle) driving the real coordinator + Redis with faked IMAP loops.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,46 +1,29 @@
|
||||
"""Live-sync supervisor (refactor §4, transitional).
|
||||
"""Live-sync entrypoint (refactor §2, step 3).
|
||||
|
||||
Runs inside the sync worker: on startup it launches one :class:`AccountRunner`
|
||||
per live account. This step (v2 §9.2) only extracts the per-account loops into a
|
||||
runner; account discovery is still startup-only and is replaced by the lease
|
||||
claimer in a later step.
|
||||
The sync worker's ``on_startup`` starts a :class:`SyncCoordinator`, which claims
|
||||
accounts from the ``sync:live`` registry via Redis leases and runs one
|
||||
:class:`~app.sync.runner.AccountRunner` per owned account. ``on_shutdown`` stops
|
||||
it, releasing every lease so peers can take the accounts over immediately.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db import session_scope
|
||||
from app.models import Account
|
||||
from app.sync.runner import AccountRunner
|
||||
from app.sync.coordinator import SyncCoordinator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _live_account_ids() -> list[uuid.UUID]:
|
||||
async with session_scope() as session:
|
||||
return list(await session.scalars(select(Account.id).where(Account.state == "live")))
|
||||
|
||||
|
||||
async def start_supervisor(ctx: dict[str, Any]) -> None:
|
||||
"""Launch an AccountRunner per live account (called from the worker on_startup)."""
|
||||
runners: dict[uuid.UUID, AccountRunner] = {}
|
||||
for account_id in await _live_account_ids():
|
||||
runner = AccountRunner(account_id)
|
||||
runner.start()
|
||||
runners[account_id] = runner
|
||||
ctx["runners"] = runners
|
||||
logger.info("live supervisor started for %d account(s)", len(runners))
|
||||
coordinator = SyncCoordinator()
|
||||
await coordinator.start()
|
||||
ctx["coordinator"] = coordinator
|
||||
|
||||
|
||||
async def stop_supervisor(ctx: dict[str, Any]) -> None:
|
||||
"""Stop and tear down all account runners (called from the worker on_shutdown)."""
|
||||
runners: dict[uuid.UUID, AccountRunner] = ctx.get("runners", {})
|
||||
if runners:
|
||||
await asyncio.gather(*(r.stop() for r in runners.values()), return_exceptions=True)
|
||||
ctx["runners"] = {}
|
||||
coordinator: SyncCoordinator | None = ctx.get("coordinator")
|
||||
if coordinator is not None:
|
||||
await coordinator.stop()
|
||||
ctx["coordinator"] = None
|
||||
|
||||
Reference in New Issue
Block a user