brackt/docs/infrastructure-roadmap.md
Claude c175d70084 Phase 1: Replace per-second timer tick with event-driven scheduler
Eliminates the setInterval(1s) + per-second DB write by storing picksExpiresAt
in draft_timers and using a targeted setTimeout per pick. Clients count down
locally from the expiresAt timestamp, removing server-pushed timer-update events.

Key changes:
- database/schema.ts: add picksExpiresAt and picksStartedAt to draft_timers
- server/timer.ts: full rewrite — schedulePickForSeason, rescheduleTimer,
  30s recovery interval instead of 1s tick, overnight-pause resume scheduling
- server/socket.ts: new timer-pick-started / timer-overnight-paused events,
  updated draft-state-sync to include expiresAt for reconnect recovery
- draft.make-pick / draft.force-manual-pick: compute actual remaining from
  picksExpiresAt at pick time; call rescheduleTimer after the autodraft chain
- useDraftSocketEvents: handle new timer events, restore countdown on reconnect
- $leagueId.draft.$seasonId: client-side countdown useEffect from expiresAt
- plans/zero-downtime-scaling.md: full 4-phase scaling plan for future reference

Resolves 2351 unit tests (all passing).

https://claude.ai/code/session_019k5J6Ty7uP5HxSx6CsbiBK
2026-06-05 22:51:43 -07:00

23 KiB
Raw Blame History

Infrastructure Roadmap for brackt.com

Context

brackt.com today runs as a single Node.js process that bundles three concerns:

  1. The Express + React Router 7 SSR web server (server.ts)
  2. A 1-second setInterval tick loop driving draft timers and auto-picks (server/timer.ts)
  3. A Socket.IO server with in-memory room/connection state (server/socket.ts)

Plus a 24h snapshot interval (server/snapshots.ts). There is no job queue, no scheduler beyond setInterval, and no pub/sub layer. Deploys drop all socket connections; a crash takes down drafts, web, and the tick simultaneously.

Based on the discovery questions:

  • No HA fire. Drafts are infrequent enough that a 30-min outage isn't catastrophic yet.
  • Zero-downtime deploys are the immediate pain. Connections drop on every release.
  • Low concurrency today (<5 live drafts, <20 in 6mo) — capacity is not the constraint.
  • Avoid Redis unless clearly justified; prefer Postgres-backed.
  • Go rewrite is speculative. No measurements show Node is the bottleneck.

The roadmap below sequences work by risk-adjusted value: cheap, reversible wins first; expensive rewrites only when data demands them.


Current production topology

What's actually deployed today (per .production-info/docker-compose-production.yml):

  • Single brackt container running on Vultr, image pulled from sjc.vultrcr.com/chrisparsons/brackt:latest.
  • One-shot migrate container runs node /app/scripts/migrate.mjs before brackt starts (depends_on: condition: service_completed_successfully).
  • Traefik is already fronting the app — TLS via Let's Encrypt, brackt.com apex + www → apex redirect, port 3000.
  • External Postgres (not in this compose). PGBOUNCER=true is set in env — the app connects through Vultr's built-in pgbouncer. Vultr supports per-pool mode selection (session/transaction/statement), so worst case is "add a second pool" rather than re-architecting.
  • No observability stack. No Prometheus, Loki, Grafana, no log shipper. Logs live in docker logs brackt.
  • No Redis, no separate worker, no tick container. Everything is one process.
  • restart_policy: condition: on-failure, max_attempts: 5 — after 5 crashes the container stays down with no alerting.
  • Hidden blockers for any multi-replica future: container_name: brackt is hardcoded (Docker won't allow two containers with the same name), no healthcheck on the brackt service, and the image tag is :latest (rollbacks are painful and you can't tell which replica is on which version mid-deploy).

These facts shape the phases below — particularly the deadline-based timer recommendation that sidesteps several of these constraints entirely.


Roadmap

Phase 0 — Instrumentation (12 days) do this first

You cannot make further sequencing decisions without data. Almost everything below ("is the tick slow?", "do we need multi-instance?", "is the timer refactor working?") becomes guesswork without basic metrics.

Add:

  • Tick drift measurement in server/timer.ts — log when actual interval > 1100ms.
  • Auto-pick latency: time from timeRemaining <= 0executeAutoPick() resolved.
  • Concurrent socket connections + active draft rooms gauge.
  • Basic HTTP request duration histogram (any lightweight middleware).

Where to look at the data — pick one, don't leave this abstract:

Option Effort What you get
Structured JSON logs to stdout, view via docker logs brackt | jq ~2 hours Good enough for low-volume signals (tick drift, auto-pick latency). Start here.
Grafana + Loki + Promtail as sidecars in the compose ~1 day Real dashboards, log retention, query history
Hosted (Axiom / Better Stack / Grafana Cloud free tier) ~half day No infra to run; ship logs over the wire

Recommendation: start with structured JSON + jq for a week, escalate only if you find yourself wanting historical queries you can't get from logs.

Why first: every decision below is currently a guess. A week of metrics turns guesses into decisions, and gives the Phase 1 timer refactor a baseline to compare against.


Phase 1 — Deadline-based draft timer (35 days) biggest win

The problem: the 1-second setInterval in server/timer.ts is the cause of nearly every downstream problem in this roadmap. Per active draft, every second it issues an UPDATE on draftTimers, several SELECTs, and a timer-update socket broadcast. It also makes the process un-killable without dropping drafts, makes multi-instance hard, and is the only thing in this stack that looks like it might need Go.

The insight: the timer state is deterministic from a deadline. If you store pickDeadlineAt (absolute UTC) instead of timeRemaining (seconds), the remaining time at any moment is just pickDeadlineAt now(). The server doesn't need to tick — it only needs to wake up at the deadline to run the auto-pick.

The change:

Schema (one new column total):

seasons
  + pickDeadlineAt timestamptz NULL   -- the active deadline for the current pick;
                                      -- NULL while paused, pre-draft, or completed

Why on seasons and not draftTimers: only one team's deadline matters at a time (the current picker's). One row per draft matches the semantics and gives a trivial bootstrap query (SELECT id FROM seasons WHERE status='draft' AND pickDeadlineAt IS NOT NULL). The existing draftTimers.timeRemaining stays where it is — still needed for chess-clock bank tracking and as the "saved remaining seconds while paused" storage.

Server behavior — funnel every deadline write through a single helper:

async function setNextPickDeadline(tx, { seasonId, teamId, budgetSeconds }) {
  // 1. UPDATE seasons SET pickDeadlineAt = now() + budgetSeconds
  // 2. UPDATE draftTimers SET timeRemaining = budgetSeconds (chess-clock: add increment)
  // 3. (re)schedule in-memory setTimeout for this seasonId
  // 4. emit { deadline, serverNow } on draft-${seasonId}
}

Callsites (audit surface):

  • Draft auto-start (app/services/draft-autostart.ts) — first pick's deadline.
  • Manual pick (app/routes/api/draft.make-pick.ts) — next pick's deadline (currently writes timeRemaining only).
  • Auto-pick (server/timer.ts rewrite) — next pick's deadline after auto-picking.
  • Admin time-bank adjust (app/routes/api/draft.adjust-time-bank.ts) — recompute pickDeadlineAt to match new timeRemaining.
  • Resume from pause — same as auto-start but for the current picker.

Other behaviors:

  • Process restart: on boot, query active drafts where pickDeadlineAt IS NOT NULL and re-schedule a setTimeout for each. Already-expired deadlines auto-pick immediately.
  • Pause: save remaining into existing draftTimers.timeRemaining (= pickDeadlineAt now()), null out seasons.pickDeadlineAt, clear the in-memory timeout. No new "paused remaining" column neededtimeRemaining already serves this purpose.
  • Overnight pause: when scheduling, if the deadline falls inside the pause window, schedule for resumeAtUTC instead and emit a frozen flag.
  • while_on autodraft: unchanged semantics — fire immediately on pick transition, capped at totalTeams iterations. The logic moves from the tick to the pick-completion handler (inside setNextPickDeadline or just before it).

Client behavior:

  • Receive { deadline, serverNow }; compute clock offset once.
  • Render countdown locally from deadline clientNow() + offset. Already has to do this between 1Hz emits today, so the interpolation code likely exists.

Wins vs today:

1s setInterval (today) Deadline-based
DB writes per active draft per pick ~30120 1
Socket emits per active draft per pick ~30120 1 (deadline) + 1 (pick made)
Server wake-ups per second 1 always 0 (sleeps until deadline)
Survives process restart dies one-line bootstrap query
Cross-process / multi-instance hard (singleton tick required) trivial (whichever instance handles the pick schedules the next deadline)

Critical constraints:

  • Single scheduler assumption. Phase 1 runs in one process. Multi-instance leader-election (per-seasonId advisory lock + claim metadata) is deferred to Phase 5. Document this assumption explicitly in server/timer.ts.
  • Clock skew. Send serverNow with each deadline event so clients can correct.
  • Reconnect path. The loader must return pickDeadlineAt (and serverNow) so clients re-initializing after a reconnect render the correct countdown without waiting for a socket event.
  • Migration safety. Run both systems in parallel for one draft (deadline-based writes the new column; old setInterval still drives picks). Compare. Flip when confident.

Why this matters more than splitting the tick into its own process: it dissolves the problem rather than relocating it. Once the tick is deadline-based and idle 99% of the time, splitting it into its own container becomes a deploy-isolation play, not a correctness one. And the Phase 5 Go rewrite goes off the table permanently — there is no hot loop left to optimize.

Files: server/timer.ts (rewrite as a scheduler module), app/database/schema.ts + new migration (single column on seasons), app/models/draft-timer.ts (new setNextPickDeadline helper — see implementation guide), app/services/draft-autostart.ts (call helper on draft start), app/routes/api/draft.make-pick.ts (call helper on manual pick), app/routes/api/draft.adjust-time-bank.ts (recompute deadline on adjust), app/hooks/useDraftSocketEvents.ts (sole timer-update consumer — switch to deadline event), draft room loader (app/routes/leagues/$leagueId.draft.$seasonId.tsx), socket event types.

See docs/agents/deadline-based-timer.md for the implementation guide — schema, helper signature, callsite-by-callsite migration, test plan.

Gotchas to handle (load-bearing for correctness):

  1. Stale in-memory setTimeout after out-of-band DB writes. Anything that mutates pickDeadlineAt outside the scheduler (admin extend, retro edit) leaves the captured closure stale. Mitigation: the auto-pick callback must re-read the row and verify pickDeadlineAt still equals the expected value before firing; if changed, reschedule and return. This single guard also handles wall-clock vs. monotonic drift (NTP jumps, VM resume): the re-check catches "deadline already passed" and "deadline moved" with the same code path.
  2. Pause-during-flight race. setTimeout fires → enters auto-pick logic → admin pauses concurrently. The re-read in (1) must also check draftPaused before proceeding. Contract: in-flight picks complete; future scheduled picks don't fire after pause.
  3. Idempotency on restart. Bootstrap query (pickDeadlineAt IS NOT NULL) fires all expired deadlines on boot. Idempotency is already guaranteed by executeAutoPick (app/models/draft-utils.ts:499512 pre-check + :629650 ON CONFLICT DO NOTHING on (seasonId, pickNumber)). Add an explicit test: "auto-pick is idempotent across process restart."
  4. Transaction boundary. Pick row + next pickDeadlineAt write must share one transaction. Otherwise a crash between them leaves the draft "completed pick / no deadline" — the bootstrap query won't catch it.

Lower-severity edge cases (worth knowing, not blocking):

  • setTimeout 24.8-day overflow: irrelevant for per-pick deadlines; don't reuse this primitive for season.draftDateTime auto-start — keep that on a low-frequency (3060s) setInterval poll.
  • while_on cascade moves from the tick to the pick-completion handler: check next team's autodraft mode before scheduling next deadline; fire immediately if while_on, capped at totalTeams iterations.
  • Client tab backgrounding is better than today: browsers throttle timers, but visibilitychange → visible triggers a recompute from the current deadline, so countdowns snap to truth instead of waiting for the next server emit.
  • Clock skew: server includes serverNow with every deadline event; client computes a one-time offset.
  • Migration of in-flight drafts: two-stage deploy — first ship shadow writes alongside the existing tick (observe a day), then flip the authoritative path and drop the interval.
  • Multi-instance scheduling (Phase 5 inheritance): defer the leader-election design; document the "single scheduler" assumption explicitly. The bootstrap query recovers a crashed scheduler on restart, which is sufficient for one instance.
  • Observability swap: the Phase 0 "tick drift" metric becomes meaningless after Phase 1. Replace with deadline-fire accuracy = actualFireTime scheduledFireTime. Same intent.

Phase 2 — Scheduled work: snapshots off setInterval (1 day)

The problem: server/snapshots.ts uses a 24h setInterval that dies with the process, has no retry, and no visibility. As you add standings sync, scoring imports, EV snapshots, etc., this gets worse.

The realistic options (the original roadmap assumed pg-boss; given the production constraints, simpler choices win here):

Option Pros Cons Verdict
External HTTP cron (healthchecks.io free / GitHub Actions schedule: / cron-job.org) hitting POST /admin/jobs/run-daily-snapshots Zero new infra; dead-man-switch alerting built-in; sidesteps pgbouncer entirely External dependency; needs auth on the endpoint start here
node-cron in a small worker container with Postgres advisory lock for singleton Self-contained; trivial; no 3rd-party Build basic retry/durability yourself; another container Good middle ground
pg-boss Real job system, retries, audit table Uses LISTEN/NOTIFY and prepared statements — incompatible with pgbouncer transaction pooling. On Vultr, this is solvable: create a second session-mode pool just for pg-boss and pass its DSN to that workload only. No built-in UI. Defer until you have 5+ scheduled jobs
pg_cron extension Runs inside Postgres; rock solid; no scheduler process. Supported on Vultr (CREATE EXTENSION IF NOT EXISTS pg_cron;). Logic lives in SQL; for app-level work you'd call out via pg_net → POST /admin/jobs/snapshots, which is just external HTTP cron with extra steps Skip for one job; reconsider if you accumulate many

Recommendation: external HTTP cron. The "job" becomes an authenticated POST /admin/jobs/run-daily-snapshots route that calls the existing createSnapshotsForAllSeasons(). Healthchecks.io's free tier gives you "you didn't run today" alerts for free.

Why now (not later): it's a 1-hour change once you have an admin endpoint, and it removes the last setInterval from server.ts — which makes Phase 3 (process restart safety) trivially correct rather than "hopefully correct."

Files: new admin route app/routes/admin/jobs.run-daily-snapshots.tsx (POST handler with shared-secret auth), remove startSnapshotSystem() call from server.ts, delete or repurpose server/snapshots.ts.


Phase 3 — Production hardening prerequisites (12 days)

Before any multi-replica / zero-downtime work, the production compose needs three small fixes. These are cheap and worth doing regardless of what comes next.

The changes:

  1. Remove hardcoded container_name: brackt. Docker rejects duplicate names — this alone blocks scaling to 2 replicas. Use the service name + replica index (Compose handles this automatically when container_name is omitted).
  2. Add a healthcheck to the brackt service (e.g., GET /healthz returns 200 once the server is listening and DB is reachable). Without this, Traefik will route to a half-started container during rolling deploys.
  3. Pin the image tag. Move off :latest to a Git SHA or version. Rollbacks become a one-line edit; you can tell which replica is running which version during a rolling deploy.

Files: .production-info/docker-compose-production.yml, new app/routes/healthz.tsx, deploy script (whatever sets the tag).


Phase 4 — Optional: split tick into its own process (1 week, no longer urgent)

Status after Phase 1: the "tick" is now an idle process that wakes up at deadlines. It can be co-located with web indefinitely without correctness or performance issues.

When to revisit: if you want pure deploy isolation — restart the web tier without touching the timer scheduler at all. This is a nice-to-have, not a blocker.

The change (if/when):

  • Extract timer scheduling into server/tick.ts standalone entrypoint.
  • Run as a second container in compose.
  • Tick still emits via Socket.IO — either via Postgres LISTEN/NOTIFY to the web tier (⚠️ verify pgbouncer mode supports session-scoped listeners) or via a pending-broadcasts table the web tier polls. With Phase 1 done, the broadcast volume is tiny — polling is fine.
  • Singleton enforced via Postgres advisory lock (same primitive Phase 1 already uses for per-draft scheduling).

Why this is downgraded vs. the old roadmap: Phase 1 already removed the per-second hot loop. The remaining motivation is purely "I want to deploy web independently of timer code." That's nice but not load-bearing.


Phase 5 — Zero-downtime web deploys via Traefik + sticky sessions (23 days)

The problem: even after Phase 1, restarting the web container still drops sockets.

The change:

  • Run 2 web containers behind Traefik (prerequisites from Phase 3 must be done first).
  • Enable sticky sessions in Traefik via labels: traefik.http.services.brackt.loadbalancer.sticky.cookie=true. Socket.IO needs this — clients must return to the same instance until you add a pub/sub adapter.
  • Rolling deploy: take instance A out of Traefik's pool → wait for connections to drain (or hard-cut, since reconnect works) → deploy A → put back in pool → repeat for B.
  • This works without Redis because each draft room's clients are sticky to one instance.
  • For broadcasts that need to reach both instances (e.g., timer scheduled on instance A, but admin action on instance B): use Postgres LISTEN/NOTIFY (⚠️ verify pgbouncer mode first) or a pending-broadcasts table.

Why this works at your scale: with <20 concurrent drafts, a single web instance has zero capacity issues. The second instance is purely for deploy rotation.

Files: .production-info/docker-compose-production.yml (replicas + sticky labels), deploy script.


Phase 6 — Defer: Socket.IO Redis adapter (only when needed)

When to revisit: if you ever want a single draft's clients to span multiple web instances (e.g., 50+ concurrent connections per draft, or geo-distributed users). You're nowhere near that.

If/when you do: add Redis, install @socket.io/redis-adapter, drop sticky sessions. ~1-day change if Phases 15 are clean.


Phase 7 — Go tick rewrite (removed)

Phase 1 (deadline-based timer) eliminates the hot loop the Go rewrite was meant to optimize. The remaining scheduler is setTimeout and a Postgres query — Node handles this for free.

If you want to learn Go, pick a greenfield side service (a webhook receiver for scoring feeds, for instance). Don't put it on the critical path of live drafts.


Sequencing Summary

# Phase Effort Unblocks
0 Instrumentation 12d Every decision below
1 Deadline-based draft timer 35d Eliminates the 1s tick; makes everything else easier
2 Snapshots → external HTTP cron 1d Removes the last setInterval; clean restart semantics
3 Prod hardening (no container_name, healthcheck, pinned tag) 12d Required for any multi-replica work
4 Split tick into own process ~1wk Optional after Phase 1; deploy isolation only
5 2 web instances + Traefik sticky sessions 23d Zero-downtime deploys (the actual goal)
6 Redis + socket.io adapter deferred Cross-instance fan-out (not needed at current scale)

Total to "I can deploy without dropping connections": ~2 weeks of focused work (Phases 0, 1, 2, 3, 5; Phase 4 optional).


Open questions

Resolved (Vultr managed Postgres):

  • Pgbouncer modes: Vultr ships pgbouncer built-in and supports all three modes (session, transaction, statement). You can create multiple pools with different modes per cluster via the Vultr panel — so the "pgbouncer incompatibility" concern for pg-boss / LISTEN/NOTIFY is not a hard blocker; it's "use a session-mode pool for those workloads, transaction-mode for the main app." Action when relevant: confirm what pool mode PGBOUNCER=true currently routes through (transaction is the typical default) and add a second session-mode pool the day you adopt pg-boss or LISTEN/NOTIFY.
  • pg_cron: supported on Vultr (CREATE EXTENSION IF NOT EXISTS pg_cron;). Doesn't change the Phase 2 recommendation (external HTTP cron remains simpler for one job) but stays in the toolbox.

Still open:

  • Where do logs/metrics go? Pick one in Phase 0 and commit.

Critical files

  • server.ts — main entrypoint; stops calling startSnapshotSystem (Phase 2); timer system becomes deadline-scheduler (Phase 1).
  • server/timer.ts — rewrite to deadline-based scheduler (Phase 1); add tick drift metric before the rewrite to baseline (Phase 0).
  • server/snapshots.ts — delete or repurpose; logic moves behind an admin endpoint (Phase 2).
  • server/socket.ts — emit { deadline, serverNow } events instead of per-second timer-update (Phase 1).
  • app/hooks/useDraftSocketEvents.ts and draft room route — client-side countdown interpolation from deadline (Phase 1).
  • app/database/schema.ts — add pickDeadlineAt to seasons (Phase 1).
  • .production-info/docker-compose-production.yml — remove container_name, add healthcheck, pin tag, then add replicas + sticky labels (Phases 3, 5).
  • New app/routes/admin/jobs.run-daily-snapshots.tsx — HTTP-triggered snapshot job (Phase 2).
  • New app/routes/healthz.tsx — Traefik healthcheck target (Phase 3).

Verification

  • Phase 0: confirm metrics emit (grep logs for tick drift, auto-pick latency); leave running for a week before Phase 1 ships.
  • Phase 1: run side-by-side mode in a staging draft — deadline-based writes shadow columns, setInterval still drives picks; confirm zero drift, then flip. Then: restart the process mid-draft, confirm timer keeps the right deadline and auto-pick fires at the original time.
  • Phase 2: kill the web process mid-day; confirm the external cron still triggers tomorrow and the endpoint runs successfully.
  • Phase 3: curl /healthz returns 200 only after DB is reachable; deploy with a pinned tag and confirm you can roll back by changing one line.
  • Phase 5: rolling-deploy both web instances during a staging draft; users see at most a single brief reconnect (or none, with proper drain).