Files
webmail-client/sync-engine-v2-refactor.md
T
2026-07-18 19:42:05 +02:00

120 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `MailProvider` interface (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 with `state='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}`, TTL `LEASE_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`: iterate `sync: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):
```lua
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|`; if `owned > 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 to `sync:live`; some worker claims it within one claimer cycle. **No restart, ever.**
- `account.state_changed → disabled|deleted`: API removes the id from `sync:live`; the owning worker (it can check `GET sync:lease:{id}==me` cheaply, 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 every `POLL_WARM` s on a transient connection.
- **warm:** no persistent connection. Single task: transient CONDSTORE poll of INBOX + special folders every `POLL_WARM` s.
- **cold:** same single task, every `POLL_COLD` s.
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.active` received for the owning user (all their accounts promote within ≤ 5 s, IDLE established), OR the account received new mail in the last `TIER_HOT_ACTIVITY_H` (24 h). Session-based heat persists while WS pings continue + `TIER_SESSION_GRACE_MIN` (30 min) after the last one (API refreshes `sess: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_D` days. 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 `bulk` queue 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_MB` per process, independent of mailbox sizes.
- `sync.progress` publication 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)
1. **Roles + queues.** Add `WORKER_ROLE`; move job registration to the two named queues with the §6 routing/timeouts; compose gets `worker-sync` / `worker-jobs` services with `nofile` ulimits. Supervisor behavior untouched.
2. **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.
3. **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.
4. **Event-driven lifecycle.** Implement §3 including the new `user.active` event in the API and the per-cycle state re-verification. Delete the startup-only discovery code path.
5. **Tiers.** Implement §5 tier storage, evaluation, promotions/demotions, and the tier-shaped task sets in `AccountRunner`.
6. **Memory bounds.** Implement §7 streaming threshold and semaphores.
7. **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)
1. **[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).
2. **[test]** `kill -9` one 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.
3. **[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).
4. **[test]** Publish `user.active`: all of that user's accounts reach `hot` (IDLE established on mock) within 5 s. After session grace expiry with no activity, they demote to `warm` on the next evaluation cycle.
5. **[test]** Queue isolation: enqueue 10 bulk backfills + 20 interactive sends; all sends complete in < 5 s p95 while backfills run.
6. **[test]** SIGTERM a sync worker: leases released and adopted by the peer within 15 s; no fencing violations logged.
7. **[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_COLD` for every account.
8. Backfilling a mailbox containing a 40 MB message never raises process RSS by more than ~`PARSE_CONCURRENCY × STREAM_PARSE_MB` above 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.