12 KiB
Sync Engine v2 — Refactor Instructions (replaces SPEC §6.3)
This document is a self-contained repair/rebuild task for the implementing agent. It replaces the current single-supervisor sync design with a sharded, lease-owned, tiered sync engine targeting 1000+ accounts. Read it fully, then execute the migration steps in §9 in order.
0. Ground rules
- This is a workers-layer refactor only. Frozen and untouchable: the
MailProviderinterface (SPEC §5.1), the database schema (no migration is needed or allowed by this task), the REST/WS API surface, invariant I-1 (API never opens IMAP connections), and the idempotency discipline (UNIQUE (mailbox_id, uid)upserts). - Existing tests must stay green after every migration step. Each step ends with its own acceptance checks (§10 maps them).
- All new tunables go through
config.py(pydantic-settings) — no literals in code. New env vars are listed in §8. - Redis is already the queue and event bus; it now also carries leases, the live-account set, tier state, and worker heartbeats. No new infrastructure.
1. Process model: two roles
The single worker process splits into two entrypoints (same codebase, WORKER_ROLE env):
role=jobs— pure arq consumer for the discrete jobs (send, flags, move, drafts, backfill). Stateless, horizontally scalable; run N instances freely.role=sync— runs the lease claimer + per-account runners (long-lived loops). Scalable via the lease protocol in §2; run 1..N instances.
Default dev/prod-small topology: 2× sync + 2× jobs. docker-compose gets two services (worker-sync, worker-jobs) sharing the image, each with ulimits: { nofile: 65536 }.
2. Lease protocol (account ownership)
Exactly one sync worker owns each live account at any moment. Ownership is a Redis lease:
- Registry:
sync:live— a Redis SET of account ids withstate='live'. Maintained by the API on every account state change AND reconciled against Postgres by each sync worker every 300 s (belt and braces; reconcile only adds/removes ids, never touches leases). - Lease key:
sync:lease:{account_id}={worker_id}, TTLLEASE_TTL(60 s).worker_id={hostname}:{pid}:{random8}generated at startup. - Claim:
SET sync:lease:{id} {worker_id} NX EX {LEASE_TTL}— success starts the account runner (§4). - Claimer loop: every 10 s + random jitter 0–5 s, while
owned < MAX_ACCOUNTS_PER_WORKER: iteratesync:live(SSCAN), attempt claims on ids with no lease. Jitter prevents thundering-herd on mass restart. - Renewal: every
LEASE_RENEW(20 s), for each owned account run this Lua atomically (never a plain EXPIRE):if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('EXPIRE', KEYS[1], ARGV[2]) else return 0 end - Fencing (hard rule): if renewal returns 0 for an account (lease lost or taken), the worker cancels that account's runner tasks immediately, before any further provider or DB operation for it. In-flight operations are tolerable because all writes are idempotent upserts.
- Release: on graceful stop or account disable, cancel the runner, then delete the lease with the owner-checked Lua (
GET==me → DEL). - Worker registry & rebalance: each sync worker heartbeats
sync:workers:{worker_id}= JSON{owned, sockets_open, tiers:{hot,warm,cold}, ts}(TTL 30 s, refresh 10 s). Every 60 s:target = |sync:live| / |alive workers|; ifowned > target*1.2 + 5, release the excess, choosing least-recently-active accounts first — released accounts are claimed by under-loaded workers within one claimer cycle.
Failover story (emergent, no extra code): worker crash → leases expire ≤ LEASE_TTL → survivors claim orphans on the next claimer tick. Total re-ownership within ~90 s.
3. Event-driven account lifecycle (kills startup-only discovery + zombie loops)
Sync workers subscribe to the event bus:
account.state_changed → live(new account finished backfill, or re-enabled): API adds the id tosync:live; some worker claims it within one claimer cycle. No restart, ever.account.state_changed → disabled|deleted: API removes the id fromsync:live; the owning worker (it can checkGET sync:lease:{id}==mecheaply, or simply receives the event and checks ownership) stops the runner, closes connections, releases the lease — target ≤ 30 s from the API call.- Defense in depth: every runner poll cycle re-verifies the account row exists with
state='live'; if not, self-terminate and release. This makes zombie loops structurally impossible even if an event is lost. - New core event
user.active(add to SPEC §8.3 catalog): published by the API on successful login and on every WS connect, payload{user_id}. Consumed for tier promotion (§5).
4. Per-account runner (replaces the fixed 2-task model)
One AccountRunner per owned account, whose task set depends on the current tier:
- hot: task A = INBOX IDLE loop (one persistent connection; re-issue IDLE ≤ 25 min; on wake →
poll()INBOX). Task B = secondary CONDSTORE poll of Sent/Drafts/Archive/Trash/Junk everyPOLL_WARMs on a transient connection. - warm: no persistent connection. Single task: transient CONDSTORE poll of INBOX + special folders every
POLL_WARMs. - cold: same single task, every
POLL_COLDs.
Delta application, UIDVALIDITY handling, expunge soft-handling, and outbound flag write-then-enqueue semantics are unchanged from the current implementation.
Connection budgets:
- ≤ 2 concurrent IMAP connections per account from the sync side (IDLE + one transient); jobs may add short-lived connections but must acquire the same per-account semaphore (cap 4 total, as today).
- Per-destination-host semaphore (process-local), cap
PER_HOST_CONN_CAP(default 50): acquired around every IMAP/SMTP connect, so a worker never opens hundreds of simultaneous sockets to one provider. Sharding by account makes process-local sufficient.
5. Activity tiers
Tier state: sync:tier:{account_id} = hot|warm|cold (TTL none; default warm on claim). Evaluated by the owning worker every 120 s and on events:
- → hot when:
user.activereceived for the owning user (all their accounts promote within ≤ 5 s, IDLE established), OR the account received new mail in the lastTIER_HOT_ACTIVITY_H(24 h). Session-based heat persists while WS pings continue +TIER_SESSION_GRACE_MIN(30 min) after the last one (API refreshessess:active:{user_id}TTL; workers just read it). - → warm when neither hot condition holds and last activity <
TIER_COLD_AFTER_D(3 d). - → cold when no user session and no new mail for ≥
TIER_COLD_AFTER_Ddays. Any new mail found by a cold poll immediately re-promotes to hot (mail arriving = activity). - Optional global guard: if a worker's hot count exceeds
HOT_BUDGET_PER_WORKER(default 250), the least-recently-active hot accounts without live sessions degrade to warm. Session-hot accounts are never degraded by the budget.
Expected steady state at ~3000 accounts: roughly 15–25 % hot → 450–750 persistent sockets across the pool, the rest on cheap transient polls. Correctness is identical in all tiers — only latency differs (seconds / ≤ POLL_WARM / ≤ POLL_COLD).
6. Queue split (interactive vs bulk)
Two arq queues, consumed by role=jobs workers:
| Queue | Jobs | job_timeout |
Concurrency per worker |
|---|---|---|---|
interactive |
send_job, push_flags_job, move_job, move_thread_to_folder_job, delete_thread_job, push_draft_to_imap_job, remove_imap_draft_job, refresh_account_job | 120 s | 20 |
bulk |
sync_bootstrap_account (and future re-backfills) | 3600 s | BULK_CONCURRENCY (default 2) |
Every jobs worker consumes both queues (arq supports it) with the above per-queue caps — a storm of backfills can no longer delay a send. Retry/backoff and failure-revert semantics per job are unchanged.
7. Backfill memory bounds
- Global backfill parallelism is bounded by the
bulkqueue concurrency (above) — remove any other ad-hoc caps. - Per-process parse semaphore
PARSE_CONCURRENCY(default 4) around MIME parsing. - Streaming threshold: a raw message larger than
STREAM_PARSE_MB(default 8) is written to the blob store first and parsed from the file handle; smaller messages may parse in memory. Result: peak RAM ≈PARSE_CONCURRENCY × STREAM_PARSE_MBper process, independent of mailbox sizes. sync.progresspublication cadence unchanged (per batch).
8. New configuration (add to SPEC §14; all via config.py)
WORKER_ROLE=jobs|sync · MAX_ACCOUNTS_PER_WORKER=400 · LEASE_TTL=60 · LEASE_RENEW=20 · POLL_WARM=180 · POLL_COLD=900 · TIER_HOT_ACTIVITY_H=24 · TIER_SESSION_GRACE_MIN=30 · TIER_COLD_AFTER_D=3 · HOT_BUDGET_PER_WORKER=250 · PER_HOST_CONN_CAP=50 · BULK_CONCURRENCY=2 · PARSE_CONCURRENCY=4 · STREAM_PARSE_MB=8. Remove POLL_INTERVAL (superseded by POLL_WARM/POLL_COLD).
9. Migration steps (execute in order; suite green after each)
- Roles + queues. Add
WORKER_ROLE; move job registration to the two named queues with the §6 routing/timeouts; compose getsworker-sync/worker-jobsservices withnofileulimits. Supervisor behavior untouched. - Extract
AccountRunner. Refactor the current per-account loops into a self-contained runner class (start/stop/teardown API), still launched by the old startup supervisor. Pure refactor, no behavior change. - Lease manager + claimer. Implement §2 (worker_id, claim, Lua renewal, fencing, release, heartbeats, rebalance). Replace the startup supervisor with the claimer. Verify single-worker behavior is equivalent, then run two sync workers against the same accounts.
- Event-driven lifecycle. Implement §3 including the new
user.activeevent in the API and the per-cycle state re-verification. Delete the startup-only discovery code path. - Tiers. Implement §5 tier storage, evaluation, promotions/demotions, and the tier-shaped task sets in
AccountRunner. - Memory bounds. Implement §7 streaming threshold and semaphores.
- Observability + graceful shutdown. Structured log fields (
worker_id,account_id,tier), heartbeat JSON per §2, SIGTERM handler: stop claimer → cancel runners → release leases → drain (target < 10 s).
10. Acceptance criteria (all must pass; add as automated tests where marked)
- [test] Two sync workers, 20 mock accounts: at no point do two workers hold the same lease; each account has exactly one IDLE connection (assert via mock provider connection counter).
- [test]
kill -9one of two sync workers: every orphaned account is re-owned and syncing within 90 s; message table has zero duplicate(mailbox_id, uid)rows afterwards. - [test] Create an account via the API: its live loop starts within 30 s without any worker restart. Delete an account: all its connections closed and no further provider calls within 30 s (zombie regression test — mock provider records post-deletion calls, must be zero after the 30 s cutoff).
- [test] Publish
user.active: all of that user's accounts reachhot(IDLE established on mock) within 5 s. After session grace expiry with no activity, they demote towarmon the next evaluation cycle. - [test] Queue isolation: enqueue 10 bulk backfills + 20 interactive sends; all sends complete in < 5 s p95 while backfills run.
- [test] SIGTERM a sync worker: leases released and adopted by the peer within 15 s; no fencing violations logged.
- [load, manual/CI-nightly] 1000 simulated accounts (mock provider), 20 % with active sessions: steady-state persistent sockets ≤ hot count + 5 %; worker RSS stable over 1 h; oldest-sync-lag <
POLL_COLDfor every account. - Backfilling a mailbox containing a 40 MB message never raises process RSS by more than ~
PARSE_CONCURRENCY × STREAM_PARSE_MBabove baseline.
11. Out of scope for this task
Cross-worker (Redis-based) per-host connection accounting, pgbouncer deployment, JMAP provider, multi-region operation, per-user rate plans. Note them in docs/open-questions.md if the need is observed.