VM reliability plan: monitoring, backups, deploys and incident runbooks¶
Historical plan (2026-10-14)
Drafted on 29 Sep 2026 for the fantasy soft launch, and kept as the record. Of what it planned, the deploy
pause-and-resume, the recorder and schedule timers, the backup-first rule for every writing script and staging
on the same VM are built and described on VM deploy; the nightly dump push to the bucket is still
an open item (todo), and the rest (an uptime monitor on a health route, scheduled
dumps, an on-call rota) has no counterpart in the repo today. The task ids, dates
and paste-ready blocks below are not current: use the vm.sh commands on VM deploy.
racinglines.bet moves from the owner's Mac to one Compute Engine VM, racinglines-vm (e2-small, 2 GB, 30 GB
disk, us-west1-b), on Wed 30 Sep, the day before round 16's book opens. Eight days later, on Thu 8 Oct, the same
box is meant to take its first outside users: invited fantasy players, through the Singapore weekend. Today
nothing tells the owner when the site is down, and nothing backs the VM up on a schedule. The Kalshi sync runs only
when someone types it, and the recorder keeps running after it stops recording. This plan closes those gaps in the
order the calendar needs them. Monitoring, backups and the Kalshi timer go live before the R16 book opens (Thu 1 Oct
20:30 PDT / Fri 2 Oct 03:30 UTC). R16 is a closed dry run. A resize decision follows on Mon 5 Oct, and an on-call
plan covers Singapore. It is the reliability part of the fantasy soft-launch set. The overview is
Fantasy soft launch; the calendar, decisions and go/no-go are in the
runbook; the other parts are
Fantasy accounts, Fantasy trading and Email setup.
Status: draft for the owner, Tue 2026-09-29. Nothing here has run on the VM yet. The owner runs every VM step from the paste-ready blocks below. Numbers marked estimate are replaced by SCOUT-1's and DRY-1's measurements.
Assumptions.
- The sprint window is Tue 29 Sep to Sun 11 Oct 2026. "Next weekend" is F1 R17, Singapore (sprint format), 9–11 Oct. R16 (2–4 Oct, the Bahrain GP at Sepang) is the closed dry run.
- PR #71 (
work/deploy-pause) is merged first (OWNER-1). There is no live-event freeze and no--force: deploys pause the VM's timers, deploy, run a catch-up step and resume them. Between sessions is still the kindest time. - The cutover happens Wed 30 Sep per VM deploy. If it slips, the P1 ops items move with it.
- "Fantasy" is paper money only.
POLYMARKET_TRADING_ENABLEDandKALSHI_TRADING_ENABLEDare never touched.vm.sh public onis never part of the launch (DEC-8). - Owner-facing times are in PDT with UTC in brackets; systemd
OnCalendarlines are in UTC.
Targets (DEC-18)¶
| Target | Value | How it is measured |
|---|---|---|
| Availability | 99% during the soft launch | The external uptime monitor's report: a check of https://racinglines.bet/healthz from outside Google every 5 minutes or faster (OPS-7). The VM's own health log (OPS-2) is the second source, and it explains the failures |
| RPO (data loss) | 24 h; 6 h from Thu 8 Oct 18:00 PDT to Mon 12 Oct 18:00 PDT (Fri 9 Oct 01:00 to Tue 13 Oct 01:00 UTC); plus a dump before every DB-writing change, including each event's first settlement | Nightly dump 10:00 UTC (OPS-3), the 6-hourly launch-weekend dumps (a drop-in, Launch-weekend dumps), disk snapshot 11:00 UTC (OPS-7), racinglines-before-<task> dumps (CLAUDE.md), the tick's pre-settle-<event> dump (Fantasy trading) |
| RTO (time to restore) | 4 h | The recovery paths below; the DB restore part is timed in the OPS-4 drill |
The budget in minutes. The R17 weekend window, from sign-up opening Thu 8 Oct 18:00 PDT (Fri 9 Oct 01:00 UTC) to Sun 11 Oct 18:00 PDT (Mon 12 Oct 01:00 UTC), is 72 h: 864 samples, so 1% is 43 minutes (about 8 samples). The whole soft launch, to Thu 22 Oct 01:00 UTC, is 312 h: 3,744 samples, so 1% is 3 h 7 min. The P4 exit criterion is stricter still: no unplanned outage over 15 minutes.
What counts as downtime.
- A sample where the external check fails: not a 200 within 10 s, or
"db"is not"ok". A Cloudflare 502, 530 or 1033 counts. So does a 503 from the app (up, but its database is not). - The external monitor keeps sampling while the VM is down or a deploy has paused the health timer, so those minutes are measured, not inferred from a gap. A gap in the health log alone is not downtime; it is a finding for the incident log (the web app is down only for its restart during a deploy).
- Not downtime: stale data (tracked as freshness, below), an error on one page, the demo pages,
mcp.racinglines.bet.
What a deploy does to users. The web app restarts for a few seconds (Cloudflare shows a 502 meanwhile).
Sessions survive because the cookie is signed with APP_SECRET (racinglines/web/app.py:47; without it every
restart picks a random secret and signs everyone out). Three things reset: the in-memory login throttle
(_failures, racinglines/web/app.py:58) and the demo sessions' overlays (_sessions,
racinglines/web/demo.py:31), and a Lab job still running is marked "interrupted: the app restarted"
(racinglines/web/jobs.py:241-244). SEC-1's cookie format change signs everyone out once, so it ships in a window.
Current state (29 Sep)¶
From the repo (deploy/vm/systemd/, scripts/deploy/vm.sh, VM deploy) and the 29 Sep entry in
Data changes. What is actually enabled on the VM is SCOUT-1's first finding.
Units and restart policies¶
| Unit | Type | Restart | Started by | Expected on 29 Sep (SCOUT-1 confirms) |
|---|---|---|---|---|
racinglines-web |
simple | always, 5 s | vm.sh start web |
Running on the VM; racinglines.bet still served by the Mac |
racinglines-recorder |
simple (markets record --interval 60) |
always, 60 s | vm.sh start at cutover |
Off on the VM (a second recorder splits the book history); running on the Mac |
racinglines-signals + .timer |
oneshot every 5 min (OnBootSec=1min, OnUnitActiveSec=5min) |
none; no TimeoutStartSec |
vm.sh start at cutover |
Off on the VM; running on the Mac |
racinglines-live-f1@<event> + .timer |
oneshot every 5 min | none; no TimeoutStartSec |
by hand per event | None yet; 2026-16 is enabled before Thu 1 Oct 20:30 PDT |
racinglines-live-dh@<event> |
simple poll loop | on-failure, 10 s | by hand per event | None (no downhill until 2027) |
racinglines-mcp |
simple | always, 5 s | by hand (done 28 Sep) | Running; mcp.racinglines.bet |
cloudflared |
its own unit from cloudflared service install |
SCOUT-1 records it | step 7 of VM deploy (done 28 Sep) | Tunnel racinglines-vm, one connector |
Postgres 17 (docker compose db) |
container | restart: unless-stopped; docker enabled at boot |
vm.sh setup |
Running on 127.0.0.1:5433 |
What runs unattended, and what is manual¶
| Unattended (after cutover) | Manual today |
|---|---|
| Web app, MCP server, tunnel, Postgres | Deploys (vm.sh deploy) |
| Polymarket recorder: books every 60 s, re-sync every 30 min, Parquet archive hourly | Every Kalshi command: sync, trades, books, history (U2 is open) |
| Signals every 5 min (with the FastF1 fetch and the profile venue's prices and trades) | Closed syncs, so market_links.resolved_yes fills in |
| F1 live step every 5 min, once the event's timer is enabled | Tape-only sports (U9's first run was by hand on 29 Sep) and OG.com |
Backups: only pre-change dumps by hand; the bucket copy comes from the Mac's bucket.sh push |
|
| Settle, reconcile, scorecard and report (the Monday routine) | |
| Enabling and disabling live-event timers; env edits; reboots |
Gaps¶
| # | Gap | Evidence | Effect | Fix |
|---|---|---|---|---|
| G1 | No health check, no ops alerts | PUBLIC_PATHS = ("/login", "/static", "/racinglines101") (racinglines/web/app.py:53); racinglines/markets/alerts.py sends market alerts only |
A user finds an outage first | OPS-1, OPS-2, OPS-7 (external monitor at cutover) |
| G2 | The recorder swallows errors | except Exception prints and loops (racinglines/cli/f1.py:493); the process never exits, so Restart=always never acts |
Books stop silently; they can't be backfilled (no historical book API) | OPS-6 |
| G3 | No Kalshi, tape or OG.com timer | U2 and the OG.com follow-ups are open (Roadmap) | Kalshi links, books and resolutions move only when someone runs a command | OPS-5 (Kalshi F1, this sprint); tapes and OG.com in NEXT-2 |
| G4 | No VM backup unit | Only hand dumps (29 Sep: racinglines-before-history-20260929T082054Z.sql.gz, 14.5 MB) |
A disk loss loses everything since the last hand dump | OPS-3 |
| G5 | No snapshot schedule | VM deploy step 4 creates the disk without one | No point-in-time copy of the whole box | OPS-7 |
| G6 | bucket.sh is Mac-only, and a push overwrites the bucket's dump |
PG_BIN is a Mac conda path (scripts/cloud/bucket.sh:15, used at :31); push replaces db/ (:38-39); the default bucket is the old one (:14) |
No VM-to-bucket path; after cutover a Mac push can replace the VM's newer dump | OPS-3, DEC-19 |
| G7 | One tunnel connector on one VM | VM deploy step 7 | VM down means site down | Open item; recovery paths B and C |
| G8 | 2 GB box, no swap | e2-small in VM deploy's settings | OOM kills when steps overlap | OPS-7 (swap), DEC-17 (resize) |
| G9 | --year 2026 hard-coded |
racinglines/cli/f1.py:126 (pm-record), :72 (pm-sync); racinglines/cli/markets.py:158 (Kalshi sync) |
From January the recorder's 30-minute sync never sees 2027 events | A dated item before 1 Dec (Data freshness) |
| G10 | Oneshot steps have no timeout | racinglines-signals.service and [email protected] set no TimeoutStartSec; systemd's default for oneshot is none |
A hung step stops its timer (it stays activating, not failed, so the failed-unit check misses it). Every deploy then waits 5 minutes and gives up (PR #71) |
OPS-6 (signals 45 min, live-f1@ 20 min); OPS-2's activating check |
| G11 | Logs uncapped | No journald or docker log settings in deploy/vm/ |
Disk fills slowly | OPS-6 (docker), OPS-7 (journald) |
| G12 | A rollback past a migration fails | deploy/vm/update.sh:26 always runs alembic upgrade head; older code can't find the DB's revision |
The rollback deploy stops, and PR #71 leaves the timers paused | OPS-8 |
| G13 | Secrets exist only on the VM | /etc/racinglines.env is in neither git nor the bucket |
A rebuild can't get them | OPS-7: a copy in the owner's password manager |
| G14 | In-memory state resets on restart | Throttle, demo overlays, Lab jobs (Targets above) | A deploy resets them | Accepted; deploy in windows |
| G15 | Pooled DB connections go stale after a Postgres restart | create_engine(database_url(url), future=True) with no pool_pre_ping (racinglines/db/config.py:24) |
After an OOM kill, a dockerd upgrade or OPS-6's container recreate, users get 500s until each stale connection is dropped | OPS-1 |
Deploys after PR #71¶
How a deploy works now¶
bash scripts/deploy/vm.sh deploy [ref], run on the Mac:
- Lists the VM's active
racinglines-*.timerunits (live-event steps and signals today; after this plan also health, backup, kalshi-sync and fantasy), plus any left in/var/lib/racinglines/deploy-pausedby an earlier deploy. - Writes that list to
/var/lib/racinglines/deploy-paused, stops the timers, and waits up to 5 minutes for a step already running. A signals pass still running alone is stopped; if another step is still running, or the connection drops, nothing is deployed and the timers resume. - Runs
deploy/vm/update.sh <ref>: checkout,pip install,docker compose up -d --wait db,alembic upgrade head,racinglines db seed. - Installs the unit files, runs
daemon-reload, and restarts web, recorder and MCP (try-restart: only those already running). - Runs each live event's catch-up step (up to 5 minutes), starts the timers again and deletes the paused file.
- Runs the smoke check on the VM against
http://127.0.0.1:8000.
If update.sh fails, the timers stay paused on purpose, so no step runs on a half-updated checkout. Fix it and
deploy again; vm.sh status prints a PAUSED by an unfinished deploy line while the file exists. A running
downhill loop gets a warning, not a pause. --force is gone (PR #71 ignores it with a message). The pause logic
lives in the Mac's copy of vm.sh, so git pull main before deploying.
A step still running after the 5-minute wait blocks every deploy, a rollback included. The steps that can run long are the backup (up to 60 minutes, from 10:00 UTC), signals (45) and kalshi-sync (15, OPS-5). Bad deploy has the block that finds and stops the blocking step.
The new timers are paused too. The health timer takes no samples during a deploy; the dead-man grace (20 minutes)
covers the pause, and the external monitor, which no deploy pauses, still sees the site. Backup and kalshi-sync use OnCalendar with Persistent=true, so a slot missed while paused runs
as soon as the timer resumes. The fantasy timer is monotonic (every 10 minutes, Fantasy trading),
so it runs again soon after it resumes. A deploy that fails and leaves the timers paused also stops the health
timer, and then the dead-man is what alerts.
Deploy windows¶
Deploys are allowed at any time; these are the kindest windows. Outside them the web restart lands on a session or a book update. These tables are the single operational copy: other docs link here rather than repeat them. They leave an extra 30 minutes after the FP2 and Quali updates, and no deploy lands between sign-up opening (Fri 9 Oct 01:00 UTC) and the book opening.
R16 (closed dry run). Sessions: FP1 Fri 2 Oct 04:30 UTC, FP2 08:00, FP3 Sat 3 Oct 04:30, Quali 08:00, Race Sun 4 Oct 07:00, results about 10:00 (F1 live test).
| Window | UTC | PDT |
|---|---|---|
| Before the book opens | until Fri 2 Oct 02:30 | until Thu 1 Oct 19:30 |
| Avoid: book opens, FP1, FP2 and their updates | Fri 2 Oct 02:30 – 10:30 | Thu 1 Oct 19:30 – Fri 2 Oct 03:30 |
| W1 | Fri 2 Oct 10:30 – Sat 3 Oct 03:30 | Fri 2 Oct 03:30 – 20:30 |
| Avoid: FP3, Quali and their updates | Sat 3 Oct 03:30 – 10:30 | Fri 2 Oct 20:30 – Sat 3 Oct 03:30 |
| W2 | Sat 3 Oct 10:30 – Sun 4 Oct 05:00 | Sat 3 Oct 03:30 – 22:00 |
| Avoid: race and results | Sun 4 Oct 05:00 – 11:00 | Sat 3 Oct 22:00 – Sun 4 Oct 04:00 |
| W3 | from Sun 4 Oct 11:00 | from Sun 4 Oct 04:00 |
R17 (soft launch). Book opens Fri 9 Oct 07:30 UTC; FP1 08:30, SQ 12:30, Sprint Sat 10 Oct 09:00, Quali 13:00, Race Sun 11 Oct 12:00, results about 15:00 UTC.
| Window | UTC | PDT |
|---|---|---|
| Before the soft launch | until Fri 9 Oct 00:00 | until Thu 8 Oct 17:00 |
| Avoid: sign-up opens, book opens, FP1, SQ | Fri 9 Oct 00:00 – 14:00 | Thu 8 Oct 17:00 – Fri 9 Oct 07:00 |
| W1 | Fri 9 Oct 14:00 – Sat 10 Oct 08:00 | Fri 9 Oct 07:00 – Sat 10 Oct 01:00 |
| Avoid: Sprint, Quali | Sat 10 Oct 08:00 – 15:00 | Sat 10 Oct 01:00 – 08:00 |
| W2 | Sat 10 Oct 15:00 – Sun 11 Oct 10:00 | Sat 10 Oct 08:00 – Sun 11 Oct 03:00 |
| Avoid: race, results, settlement | Sun 11 Oct 10:00 – 16:00 | Sun 11 Oct 03:00 – 09:00 |
| W3 | after Sun 11 Oct 16:00 | after Sun 11 Oct 09:00 |
The migration guard (OPS-8)¶
OPS-8 turns every VM migration into an explicit, backed-up owner step, as CLAUDE.md requires. It builds on PR #71
and must merge before ACC-1 (fantasy_schema_v1).
| Case | What vm.sh deploy does |
|---|---|
| The target sha adds no Alembic revision | Deploys as today, but runs no alembic upgrade |
The target adds revisions the DB lacks, no --migrate |
Refuses before anything is paused, and names the revisions |
The target adds revisions, with --migrate |
Dumps first to data/backups/db/racinglines-before-deploy-<sha>-<UTC>.sql.gz (through docker compose exec), writes a data_changes row (racinglines db changes --add) naming the dump and the alembic from and to, then upgrades. It prints the paste-ready docs/data-changes.md line |
| The DB is ahead of the target (a rollback past a migration) | Warns and deploys without running alembic. The fantasy schema is additive, so older code runs on it. If it doesn't, see Bad migration |
--migrate works before or after the ref (vm.sh deploy --migrate, vm.sh deploy <sha> --migrate). After the
on-VM smoke, OPS-8 also runs the smoke check through Cloudflare from the Mac. That check confirms /healthz
reports the deployed sha.
Owner sign-off
A migration against the VM needs the owner's explicit sign-off in the same conversation turn. Claude sessions hand over the block; the owner runs it. The migration is tried on a restored copy first (OPS-4, ACC-2).
Smoke through Cloudflare (OPS-9)¶
scripts/deploy/smoke.sh stays GET-only, and its demo checks are unchanged. OPS-9 adds:
/healthzreturns 200 with"db": "ok", and the freshness fields are within the thresholds below (parsed withpython3, which both the Mac and the VM have)./terms,/privacyand/rulesreturn 200, and/signupreturns 200 whenRACINGLINES_SIGNUPis notoff. These checks sit behind smoke arguments (new; OPS-9 names them) that stay off until LEGAL-1 and ACC-3 are deployed. Otherwise every deploy before them would fail its smoke. The go/no-go runs smoke with them on.
Rollback by sha¶
Write the running sha into the deploy log before every deploy (vm.sh status prints deployed: <sha>). A bad
deploy is undone with bash scripts/deploy/vm.sh deploy <previous sha>, then a git revert of the merge on main, so
the next plain deploy doesn't bring it back. Blocks: Bad deploy.
vm.sh public on is never part of the launch (DEC-8)¶
vm.sh public on opens plain-HTTP tcp:8000 to the internet, around Cloudflare. It gives no TLS, no Turnstile and no
rate limits, and clients can spoof the CF-Connecting-IP header that the throttle trusts (client_ip,
racinglines/web/app.py). The launch switch is RACINGLINES_SIGNUP=invite in /etc/racinglines.env. OWNER-4
confirms it is off at cutover, and the go/no-go checks again: no firewall rule allows tcp:8000, and
racinglines-web listens on 127.0.0.1 only (blocks under OWNER-4 below). Running it at all needs the owner's
explicit sign-off in the same turn.
Monitoring and alerting (OPS-1, OPS-2, OPS-7)¶
Four layers, each catching what the one before can't:
| Layer | Catches | Misses | Owner |
|---|---|---|---|
| External uptime monitor (UptimeRobot or Better Stack, free tier), outside Google | https://racinglines.bet/healthz not 200 from the internet, every 5 minutes or faster: VM down, tunnel or Cloudflare down, web down, DB down (503). First alert about 5–10 minutes after the failure. Its report is the availability number |
Stale data, disk, memory, backups | OPS-7, at the cutover (Wed 30 Sep) |
racinglines-health.timer on the VM (every 5 min) |
App, DB, tunnel (the check goes out through Cloudflare), freshness, disk, memory, backups, config, hung steps | The VM itself being down or its network gone | OPS-2 |
Healthchecks.io dead-man (RACINGLINES_HEALTHCHECK_PING_URL) |
No ping for 25 minutes: the health timer itself stopped (paused by a failed deploy, VM down, network down) | Nothing on the app level; slow by design, because the external monitor covers the site | OPS-7 |
| Cloudflare tunnel health notification | Tunnel racinglines-vm degraded or down |
App-level faults | OPS-7, at the cutover |
Every alert goes to the owner's phone (ntfy) or Gmail, never to an @racinglines.bet address, because a DNS or
Proton problem must not hide an ops alert.
/healthz (OPS-1)¶
New: racinglines/web/health.py (a router included by racinglines/web/app.py), with /healthz added to
PUBLIC_PATHS. It returns 200 when the DB answers and 503 otherwise. Stale data does not change the status code:
the health timer judges ages, so an uptime probe sees the app as up. The payload is computed at most every 15
seconds and cached. It carries Cache-Control: no-store, and it has no secrets, no user counts, no switch states,
no hostnames or IPs. The handler is async and serves the cached payload without a worker thread; only the refresh
runs the SELECT 1 off the event loop. So a burst that fills the sync request threadpool slows the pages, not
/healthz, and doesn't raise a false P1.
OPS-1 also fixes the engine (G15): get_engine in racinglines/db/config.py sets pool_pre_ping=True,
pool_recycle=1800, and an explicit pool_size=10, max_overflow=10 (the defaults are 5 and 10). Postgres's default
max_connections of 100 covers web, MCP, the recorder and the timers at that size. Test (tests/test_health.py): the
engine carries those options.
| Field | Meaning | Source |
|---|---|---|
ok |
true when db is ok |
|
db |
ok, or error (a SELECT 1 with a 2 s timeout) |
Postgres |
sha |
Deployed git sha (12 characters), read once at start-up | /opt/racinglines checkout |
started_at, now |
Process start and response time, UTC | |
ages_s.recorder |
Seconds since the recorder's last good loop | data/runs/heartbeat/recorder (new, OPS-6); until then max(market_book_snapshots.ts) |
ages_s.kalshi_sync |
Seconds since the last Kalshi sync | data/runs/heartbeat/kalshi-sync (new, OPS-5); until then max(market_links.synced_at) for exchange = 'kalshi' |
ages_s.signals |
Seconds since the last successful signals run | data/runs/heartbeat/signals (new, OPS-6) |
ages_s.fantasy_tick |
Seconds since the last fantasy tick | data/runs/heartbeat/fantasy-tick (new, SETTLE-1) |
disk_pct |
Root filesystem used, % | shutil.disk_usage("/") |
An age is null when its unit isn't installed yet. The heartbeat directory data/runs/heartbeat/ (under
RACINGLINES_DATA) is new; each writer touches its file after a successful run. The plan's OPS-5 check says
"updated_at"; the column is market_links.synced_at (racinglines/db/models.py:301).
Example response (illustrative values):
{"ok": true, "db": "ok", "sha": "1a2b3c4d5e6f", "started_at": "2026-10-02T03:12:09Z", "now": "2026-10-02T03:40:00Z",
"ages_s": {"recorder": 41, "kalshi_sync": 812, "signals": 133, "fantasy_tick": null}, "disk_pct": 38}
Tests (tests/test_health.py, new): 200 with every field; 503 when the DB is unreachable; no auth needed; only the
listed keys (a whitelist test); the 15 s cache. Verify: python -m pytest tests/test_health.py -m "not live".
The health timer (OPS-2)¶
New code: racinglines/ops/health.py, plus the health subcommand in racinglines/cli/ops.py. OPS-3 creates
racinglines/ops/__init__.py and racinglines/cli/ops.py and adds ops to GROUPS in
racinglines/cli/__init__.py:20 (without it racinglines ops prints the usage and exits 2, :28-30). OPS-2
branches from work/ops-3 and adds its health subcommand to that file, so the two branches don't both add the
file, and OPS-3 merges first. racinglines ops health
prints the findings; --alert also pushes and pings. The command exits 0 whenever the check itself ran, so findings
never turn the unit into a failed unit. It imports no pandas or numpy (target: under 120 MB).
# deploy/vm/systemd/racinglines-health.service (new, OPS-2)
[Unit]
Description=racinglines ops health check
After=network-online.target docker.service
[Service]
Type=oneshot
User=racinglines
WorkingDirectory=/opt/racinglines
EnvironmentFile=/etc/racinglines.env
ExecStart=/opt/racinglines/.venv/bin/racinglines ops health --alert
TimeoutStartSec=2min
# deploy/vm/systemd/racinglines-health.timer (new, OPS-2)
[Unit]
Description=racinglines ops health check every 5 minutes
[Timer]
OnCalendar=*-*-* *:00/5:00 UTC
AccuracySec=30s
[Install]
WantedBy=timers.target
Checks and thresholds.
| Check | How | Alert when | Severity |
|---|---|---|---|
External /healthz |
GET $RACINGLINES_URL/healthz (https://racinglines.bet), 10 s timeout |
Not 200 or db not ok, on 2 runs in a row |
P1 |
Loopback /healthz |
GET http://127.0.0.1:8000/healthz |
The same | P1 (external fails with loopback OK: "unreachable through Cloudflare") |
| Failed units | systemctl list-units --state=failed 'racinglines-*' 'cloudflared*' |
Any | P1 for web and cloudflared; P2 for the rest |
| Hung steps | systemctl list-units --state=activating 'racinglines-*' and each unit's ActiveEnterTimestampMonotonic |
Activating longer than its expected run: health 3 min, kalshi-sync 15, fantasy 15 (its TimeoutStartSec=15min), live-f1@ 15, signals 30, backup 45 |
P2 (it also blocks deploys) |
| Recorder | ages_s.recorder |
Over 5 min | P2 |
| Kalshi sync | ages_s.kalshi_sync |
Over 35 min | P2 |
| Closed sync | data/runs/heartbeat/closed-sync |
Over 26 h | P2 |
| F1 live step | Age of latest.json in the run folder of each enabled racinglines-live-f1@<event>.timer |
Over 15 min while the timer is enabled | P2 |
| Signals | ages_s.signals |
Over 15 min, inside the weekend window only (4 days before the race to 24 h after: WEEKEND_LEAD, WEEKEND_TAIL, racinglines/pipelines/signals.py:66-67) |
P2 |
| Fantasy tick | ages_s.fantasy_tick |
Over 30 min | P2 |
| Disk | / used |
80% or more (P2); 90% or more (P1) | P2 or P1 |
| Memory and swap | /proc/meminfo; the oom_kill counter in /proc/vmstat |
MemAvailable under 10% on 2 runs; swap over 50% used; any new OOM kill | P2 |
| Backup | Newest racinglines-nightly-* dump and data/runs/heartbeat/backup (written after the bucket upload) |
Over 26 h | P2 |
activity_log rows for failed sends in the last hour (MAIL-1's action mail_failed, Email setup; n/a until MAIL-1 ships) |
3 or more | P2 | |
| Unsettled | Fantasy book markets and positions still open 24 h after their race (racinglines/fantasy/settle.py, SETTLE-1) |
Any | P2, pushed by the tick itself, once per item per day (Fantasy trading); the health check records the count and doesn't push it again |
| Config | The service's own environment and the filesystem | APP_SECRET empty; a trading flag present; racinglines-web.service.d/public.conf present; web listening beyond 127.0.0.1 |
P1 |
| Reboot required | /var/run/reboot-required |
Exists | Info, once a day |
Each check reports ok, bad or n/a (unit or table not there yet), so the timer can ship before the fantasy code.
The health log is new: data/runs/logs/health/<YYYY-MM-DD>.jsonl, one line per run. Each line holds the time, both
HTTP results with their latency, sha, every age, disk %, memory, swap, the OOM count, per-unit MemoryCurrent,
failed units and the findings. DRY-2 and REPORT-1 compute availability from it.
Pushes. racinglines/markets/alerts.py deliver() gains a topic argument (default RACINGLINES_NTFY_TOPIC,
unchanged for market alerts) and a click URL; _ntfy hard-codes /markets/polymarket today
(racinglines/markets/alerts.py:130). Ops pushes go to RACINGLINES_OPS_NTFY_TOPIC with mac=False. P1 uses ntfy
priority 5 (urgent), P2 priority 4, info priority 3.
De-dupe (1 hour). A check alerts when it turns bad, again once an hour while it stays bad, and sends one
"recovered" push when it clears. The state lives in data/runs/heartbeat/health-state.json (new).
Dead-man. At the end of each run the service pings RACINGLINES_HEALTHCHECK_PING_URL, or <url>/fail when any
P1 is open, so P1s also reach Gmail through Healthchecks.io. The Healthchecks.io check uses a 5-minute period and a
20-minute grace: a deploy pauses the timer for up to about 15 minutes (a 5-minute wait, the update, and up to 5
minutes of catch-up). The long grace is acceptable because the dead-man no longer detects a site outage; the external
monitor does, within about 5–10 minutes. A shorter grace would need vm.sh deploy to pause the check through the
Healthchecks.io API, with an API key on the Mac: not worth it this sprint.
Tests (tests/test_ops_health.py, new; tests/test_alerts.py extended): each check's ok, bad and n/a (the hung-step,
closed-sync, live-step and mail checks included); the weekend-window rule; severity; de-dupe (suppressed within the hour, repeated after it, recovery once); ping versus
/fail; exit 0 with findings; the config checks. No network: httpx is mocked. Verify:
python -m pytest tests/test_ops_health.py tests/test_alerts.py -m "not live". On the VM, stop the recorder for 15
minutes in a quiet window and the phone gets the P2, then the recovery (Test alert).
Sample alert¶
racinglines P1 · site down through Cloudflare
External https://racinglines.bet/healthz: timeout after 10 s on 2 runs (since 08:05 UTC).
Loopback /healthz: 200, db ok, sha 1a2b3c4d5e6f. cloudflared: active.
Likely the tunnel or Cloudflare, not the app. Runbook: docs/vm-reliability.md, "Site down".
Every push names the check, its value and threshold, when it turned bad, and the runbook heading.
On-call (R16, R17)¶
| R16 closed dry run (Thu 1 – Sun 4 Oct) | R17 soft launch (Thu 8 – Sun 11 Oct) | |
|---|---|---|
| Who | The owner | The owner; a Haiku scout reads logs on request |
| P1 response | Next waking hour. Overnight PDT sessions run unattended; nobody outside is using the site | Within 30 min, 07:00–23:00 PDT. Overnight (FP1 01:30, SQ 05:30 Fri; Sprint 02:00, Quali 06:00 Sat; Race 05:00 Sun, all PDT) only if the owner lets ntfy's urgent priority through Do Not Disturb |
| P2 response | Same day | Within 2 h, 07:00–23:00 PDT |
| Watch live | Book opens Thu 20:30 PDT; results Sun about 03:00 PDT | Sign-up opens Thu 18:00 PDT; book opens Fri 00:30 PDT; results Sun about 08:00 PDT; everything settled or queued by Sun 18:00 PDT |
| Deploys | The R16 windows only | The R17 windows only |
| Off switch | Not needed (no sign-up) | RACINGLINES_SIGNUP=off and RACINGLINES_FANTASY=0, then restart web (runbook off switch) |
Data freshness¶
| Feed | Unit | Cadence | Freshness signal | Alert |
|---|---|---|---|---|
| Polymarket order books | racinglines-recorder |
Every 60 s | ages_s.recorder |
Over 5 min |
| Polymarket F1 links (new markets, prices) | The recorder's re-sync (--sync-every 30); new-market pushes go to RACINGLINES_NTFY_TOPIC |
Every 30 min | market_links.synced_at |
Covered by the recorder check |
Parquet archive (data/archive/markets/) |
The recorder | Hourly | "archived" journal line | None |
| Kalshi F1 open events: links, trades, books (U2) | racinglines-kalshi-sync (new, OPS-5) |
Every 30 min | ages_s.kalshi_sync |
Over 35 min |
Closed sync, Kalshi and Polymarket (resolved_yes) |
racinglines-kalshi-sync |
Daily, in the 20:05 UTC run | data/runs/heartbeat/closed-sync (new) |
Over 26 h |
| Signals, with the FastF1 fetch | racinglines-signals.timer |
Every 5 min | ages_s.signals |
Over 15 min in the weekend window |
| F1 live step | racinglines-live-f1@<event>.timer |
Every 5 min, live weekends | The run folder's latest.json |
Over 15 min while the timer is enabled; failed or hung unit |
| Fantasy tick: close, settle, stats | racinglines-fantasy (new, SETTLE-1) |
Every 10 min | ages_s.fantasy_tick |
Over 30 min |
| Nightly backup | racinglines-backup (new, OPS-3) |
Daily 10:00 UTC | data/runs/heartbeat/backup |
Over 26 h |
| Live venue books for fantasy fills | None: fetched per order (EXCH-1, racinglines/markets/books.py) |
10 s cache | Refused above 30 s | Not a timer |
| NASCAR, MotoGP, IndyCar tapes; OG.com | None (U9, OG.com follow-ups) | By hand | None | After the sprint: NEXT-2 |
Polymarket recorder. racinglines markets record --interval 60 snapshots every open, modeled Polymarket market:
season-long markets and races not yet run (snapshot_books, racinglines/markets/polymarket/sync.py). It re-syncs
F1 events every 30 minutes and moves stale rows to Parquet hourly. Books are the one feed that can't be backfilled.
Prices and trades can (racinglines markets history, trades).
Kalshi sync (OPS-5, U2). It must run before the R16 book opens: Kalshi has listed 7 R16 events since 28 Sep. The VM reached Kalshi's live API on 29 Sep (the tape-only sync in Data changes), so U2's reachability check is done.
# deploy/vm/systemd/racinglines-kalshi-sync.service (new, OPS-5)
[Unit]
Description=racinglines Kalshi sync: F1 open events every 30 minutes, closed sync daily
After=network-online.target docker.service
[Service]
Type=oneshot
User=racinglines
WorkingDirectory=/opt/racinglines
EnvironmentFile=/etc/racinglines.env
# a new subcommand in racinglines/cli/markets.py; "tick" is a proposed name, OPS-5 fixes it
ExecStart=/opt/racinglines/.venv/bin/racinglines markets --exchange kalshi tick
TimeoutStartSec=15min
# deploy/vm/systemd/racinglines-kalshi-sync.timer (new, OPS-5)
[Unit]
Description=racinglines Kalshi sync every 30 minutes
[Timer]
OnCalendar=*-*-* *:05,35:00 UTC
Persistent=true
[Install]
WantedBy=timers.target
What each run does:
- The Kalshi F1 sync (links, quotes). This is the existing
markets/kalshi/sync.pypath, with the year taken from the clock, not the2026default. - Trades and books for open F1 race events only;
tradesandbookswithout--eventstake every F1 event today. - Once a day, in the 20:05 UTC run (13:05 PDT; after every R16 and R17 session and its results, and inside a
deploy window), the closed sync for both venues (the existing
markets --exchange kalshi sync --closedandmarkets sync --closedpaths). A fixed slot, not "when the stamp is older than 23 h", so a long run never lands at an unpredictable time and blocks a deploy. If that run was missed (VM down, paused), the next run catches up when the stamp is over 26 h old. OPS-5 times the closed sync on the VM; if it needs more than 10 minutes, it moves to a unit of its own (a new name, raised with the owner) instead of stretchingTimeoutStartSec. Near settlement, SETTLE-1's on-demand closed sync covers the gap between results and 20:05 UTC. - The heartbeats.
On an HTTP 429 the run stops and exits non-zero with the reason, with no retry storm; the next run is 30 minutes
later. Tests (tests/test_cli_markets.py, new): open-event selection, the 20:05 UTC slot and the 26 h catch-up, a
429 exits non-zero, the year from the clock. Verify on the VM: journalctl -u racinglines-kalshi-sync shows a run every 30 minutes, and the
KXF1RACE-BAH26 links have synced_at under 35 minutes old. U2 is ticked in docs/todo.md.
Signals and FastF1. The signals timer runs every 5 minutes all the time. Inside the weekend window it fetches
FastF1 when a finished session's data is missing, plus the profile venue's prices and trades
(racinglines/pipelines/signals.py, step 1). Between weekends nothing fetches FastF1. The F1 live step waits for
the signals stage run, so a late FastF1 archive delays that update and nothing else. R16's window runs Wed 30 Sep
07:00 UTC to Mon 5 Oct 07:00 UTC; R17's runs Wed 7 Oct 12:00 UTC to Mon 12 Oct 12:00 UTC.
Fantasy tick. racinglines-fantasy.timer runs racinglines fantasy tick every 10 minutes. SETTLE-1 defines
the unit (Fantasy trading) and writes heartbeat/fantasy-tick after each tick. It is
enabled once SETTLE-1 is deployed, before the staging rehearsal (STAGE-1), with a backup first.
OPS-6 changes.
- The recorder loop (
racinglines/cli/f1.py,pm-record) counts consecutive failed loops. After 10 (about 10 minutes at--interval 60) it exits 1, andRestart=alwaysrestarts it 60 s later. A good loop resets the count and touchesheartbeat/recorder. What it records does not change. Test:tests/test_recorder_loop.py(new). racinglines-signals.servicegetsTimeoutStartSec=45minand anExecStartPost=that touchesheartbeat/signals. A hung run is killed instead of stopping the timer and blocking deploys.- ⚠️
[email protected](a live-event unit) getsTimeoutStartSec=20min, one line. A normal step, catch-up included, takes under 5 minutes; a hung one is killed, shows as failed, and the next timer tick runs (the run folder's lock stops overlaps). OPS-6's branch is ready Wed 30 Sep; it is merged and deployed Mon 5 Oct after OWNER-5 (runbook P2 step 0), so R16 runs on the unchanged units and R17 is the first event with the timeout. deploy/vm/compose.override.yml: thedbservice logs withjson-file,max-size: 10m,max-file: 3. ⚠️ Compose recreates the Postgres container once to apply it, on the firstupdate.shafter the merge (docker compose up -d --wait db). That deploy restarts the database for a few seconds, so ship it in a window.- The journald cap is a host setting, not a repo file: it is an OPS-7 owner block.
--year 2026 rollover (January). The recorder's re-sync and the Kalshi sync default to 2026 (G9). Polymarket
and Kalshi may list 2027 championship markets in December. Before Tue 1 Dec 2026, change those defaults to the
current year, or pass --year in the units. New code (OPS-5, SETTLE-1) takes the year from the clock.
Backups and restore (OPS-3, OPS-4, DEC-19)¶
What is kept where¶
| Layer | What | When | Where | Kept |
|---|---|---|---|---|
| Nightly dump | Whole DB, plain SQL gzipped | 10:00 UTC (03:00 PDT) | data/backups/db/racinglines-nightly-<UTC>.sql.gz on the VM, and gs://$RACINGLINES_GCS_BUCKET/db/nightly/ |
7 on disk; 35 days in the bucket |
| Latest copy | The newest nightly, scrubbed of player data (below) | After each nightly | gs://$RACINGLINES_GCS_BUCKET/db/racinglines.sql.gz + db/manifest.json (what bucket.sh restore, the Mac and cloud sessions read) |
Overwritten; old versions 30 days |
| Launch-weekend dump | Whole DB, as the nightly | 04:00, 16:00 and 22:00 UTC as well, from Thu 8 Oct 16:00 to Tue 13 Oct 04:00 UTC | Same files and prefix as the nightly | As the nightly (so about 42 h on disk that weekend) |
| Pre-change dump | Whole DB, before a DB-writing change | Before migrations, restores, re-settles, each event's first settlement (pre-settle-<event>, taken by the tick), launch steps |
data/backups/db/racinglines-before-<task>-<UTC>.sql.gz on the VM, and gs://…/db/before/ (new) |
Never pruned automatically |
| Files | data/runs/live, data/archive/markets |
Daily, after the nightly | Bucket prefixes live/, archive/markets/ (the prefixes bucket.sh uses) |
Copied, never deleted by the sync; overwritten versions 30 days (archive/ 90 days) |
| Disk snapshot | The whole boot disk racinglines-vm |
Daily 11:00 UTC (04:00 PDT) | GCE snapshot schedule racinglines-daily, stored in us-west1 |
14 days |
Not in any backup: /etc/racinglines.env (APP_SECRET, ADMIN_PASSWORD, later the SMTP token, the ntfy topic, the
ping URL). OPS-7 keeps a copy in the owner's password manager. The cloudflared token can be copied again from the
Cloudflare dashboard, and vm.sh setup regenerates the deploy key.
Player data in backups. From ACC-1 on, the database holds player emails, password hashes, IPs in
activity_log, terms and 18+ timestamps, email_tokens and invite_codes. Four rules follow:
- The latest copy is scrubbed.
db/racinglines.sql.gzis what the Mac'sbucket.sh restoreand ephemeral cloud sessions load, so OPS-3 writes it without player data. It carries no rows fromemail_tokens,invite_codesandactivity_log, and everyusersrow keeps its id, handle, role and account type, withemail,password_hashandprefsblanked. Theusersrows can't simply be left out:house_bets,season_membersand the ledger reference them, and a plain-SQL load adds those foreign keys after the data. OPS-3 picks the mechanism, for example a sectioned dump (pre-data, data with theusersrows written through a scrubbingSELECT, post-data) under one exported snapshot. Its test loads the scrubbed file withON_ERROR_STOPand greps it for@. The manifest says"scrubbed": true. The full dumps (db/nightly/,db/before/) stay whole, because they are what a production restore needs. Only the owner's account and the VM's service account read them; the cloud sessions' credentials are the open item under DEC-19. - The bucket is locked down. Uniform bucket-level access and public access prevention are on (a check and fix in Bucket lifecycle).
- Retention is stated. A deleted account's data stays in backups for up to 35 days (nightly dumps in the
bucket), plus 30 days of overwritten object versions, plus 14 days of disk snapshots. LEGAL-1's
/privacypage has to say so (Fantasy accounts). - A restore re-applies deletions. Every restore runbook here ends with a step that re-runs account deletion for
each
account_deletein the before-restore state that is newer than the restored dump.
Nightly backup (OPS-3)¶
# deploy/vm/systemd/racinglines-backup.service (new, OPS-3)
[Unit]
Description=racinglines nightly database backup
After=network-online.target docker.service
[Service]
Type=oneshot
User=racinglines
WorkingDirectory=/opt/racinglines
EnvironmentFile=/etc/racinglines.env
ExecStart=/opt/racinglines/.venv/bin/racinglines ops backup
Nice=10
IOSchedulingClass=idle
TimeoutStartSec=60min
# deploy/vm/systemd/racinglines-backup.timer (new, OPS-3)
[Unit]
Description=racinglines nightly backup at 10:00 UTC
[Timer]
OnCalendar=*-*-* 10:00:00 UTC
Persistent=true
[Install]
WantedBy=timers.target
racinglines ops backup [--label <task>] (new, racinglines/ops/backup.py):
- Refuses to run when
RACINGLINES_GCS_BUCKETis unset.bucket.shwould fall back to the old bucket (scripts/cloud/bucket.sh:14); the VM's service account can't write there (DEC-19). - Dumps with
docker compose exec -T db pg_dump --no-owner --no-privileges -U racinglines racinglines | gzip -6, the Linux pathbucket.sh restorealready uses (:71), underset -o pipefailwithpg_dump's own exit status checked (bucket.sh:38has neither). No MacPG_BIN. The file isracinglines-nightly-<UTC>.sql.gz, orracinglines-before-<task>-<UTC>.sql.gzwith--label. - Checks the file:
gzip -tpasses; the decompressed stream ends with pg_dump's trailer line-- PostgreSQL database dump complete(a dump that died midway still gzips cleanly, but has no trailer); and the size is at least half the previous nightly (a shrinking dump alerts). A failed check deletes the file, alerts and exits non-zero, and nothing is uploaded. - Writes a manifest:
created_at,alembic_head,bytes,sha256,git_sha,label,duration_s,scrubbed, andsource: "racinglines-vm". The first three matchbucket.sh's manifest. - Keeps the newest 7 nightly files on disk and never touches
racinglines-before-*. - Uploads the full dump to
db/nightly/(ordb/before/with a label) and checks the upload's size. Only after that, and only for a nightly, it writes the scrubbed copy (Player data in backups), checks it the same way (trailer, size), and replacesdb/racinglines.sql.gzanddb/manifest.json. - Nightly only: copies
data/runs/liveanddata/archive/marketstolive/andarchive/markets/withgcloud storage rsync --recursiveand without--delete-unmatched-destination-objects. A partial or empty data folder on the VM (a rebuild, a restore beforepullfinished, a disk swap) can then never delete the bucket's copy of the order-book archive, which can't be backfilled. The cost is that compacted Parquet parts leave their hourly parts behind in the bucket: cents a month, and reads deduplicate (store.merge). - Touches
heartbeat/backup, then prints the path and the timings.
OPS-3 also gives bucket.sh restore an optional dump argument (restore --yes [gs://…/db/nightly/<file>], default
the latest copy) and has vm.sh restore pass its second argument through. Path C needs it, because the latest copy
is scrubbed. It touches scripts/deploy/vm.sh in a different place from OPS-8; OPS-3 merges first.
The Mac's bucket.sh push guard. After cutover the VM writes the bucket's db/, live/ and archive/markets/.
OPS-3 changes scripts/cloud/bucket.sh push to read db/manifest.json first. When source is racinglines-vm,
push refuses those three prefixes and says to pull instead. Whether runs/f1 and raw/mtb_dh (Mac-side inputs
today) stay pushable is OPS-3's call, stated in its PR.
Tests (tests/test_ops_backup.py, new): file names with and without --label, pruning (7 kept, before-* untouched),
manifest fields, refusal without a bucket, a truncated dump (no trailer) and a non-zero pg_dump both refused with
nothing uploaded, the latest copy replaced only after the nightly upload, the scrubbed copy has no @ and loads with
ON_ERROR_STOP (on the test database), no delete flag in the rsync call, and the push guard (a fake gcloud).
pg_dump and gcloud are mocked except in the scrub-load test. Done when two consecutive nightly dumps are listed
in the bucket.
If OPS-3 slips. The snapshot schedule (at the cutover) is the first backup either way. If OPS-3 isn't deployed
by Thu 1 Oct 12:00 PDT, the owner runs the stopgap in Fallback backup: a
full dump to db/nightly/ once a day, without the scrub, the rsync or the manifest.
Launch-weekend dumps¶
From Thu 8 Oct 16:00 UTC (09:00 PDT) to Tue 13 Oct 04:00 UTC the backup timer also fires at 04:00, 16:00 and 22:00
UTC, which makes the RPO 6 h while outside players place bets. A dump is about 15–150 MB (estimate) and takes
minutes. It is a drop-in, installed and removed by the owner (blocks under
Launch-weekend backup drop-in). On race day it adds to, and doesn't replace, the
tick's own pre-settle-2026-17 dump, which is taken just before the first settlement write
(Fantasy trading).
GCE snapshot schedule (OPS-7)¶
racinglines-daily: daily at 11:00 UTC, an hour after the dump, so each snapshot holds the newest dump too. It keeps
14 days, keeps its snapshots if the disk is deleted, and is attached to disk racinglines-vm. Snapshots are
incremental and crash-consistent: Postgres recovers from one as from a power cut. Blocks:
Snapshot schedule.
Pre-change dumps (CLAUDE.md)¶
Before any step that writes to the database, back up first and name the dump in a data_changes row and a
docs/data-changes.md line. On the VM that is racinglines ops backup --label <task>. Before OPS-3 is deployed,
use the manual line from bucket.sh restore (:71). This sprint's labelled dumps are:
| Label | Before |
|---|---|
r16-testers |
Creating the R16 tester accounts |
kalshi-sync-timer |
Enabling racinglines-kalshi-sync.timer (it adds links on a schedule) |
deploy-<sha> |
vm.sh deploy --migrate (automatic, OPS-8) |
staging |
The staging rehearsal (STAGE-1) |
before-soft-launch |
LAUNCH-1 |
pre-settle-<event> |
The tick's first settlement write for each event (automatic, SETTLE-1; pre-settle-2026-17 for R17) |
r16-settle |
OWNER-5, the R16 settle and reconcile (Mon 5 Oct) |
kalshi-sprints |
Setting RACINGLINES_KALSHI_SPRINTS=1 for R17 (DEC-12, OWNER-9) |
before-r17-settle |
SETTLE-2 step 3, before the owner's settle-day writes |
after-r17-settle |
SETTLE-2 (after settlement, to keep the settled state) |
abuse-cleanup |
Revoking invites and suspending members in Sign-up abuse wave |
resize |
OWNER-7 |
resettle-<event>, restore-<sha> |
The runbooks below |
Restore drill (OPS-4)¶
scripts/ops/restore_scratch.sh (new) runs on the Mac:
- Takes the newest
gs://$RACINGLINES_GCS_BUCKET/db/nightly/dump, or a path given as its argument. - Downloads it to
data/backups/db/. - Drops and recreates the database
racinglines_scratchon the Mac's own Postgres, the serverDATABASE_URLpoints at (port 5433). It refuses any target namedracinglines.bucket.sh restoredropsracinglinesin place, which is why it isn't used. - Loads the dump.
- Runs
alembic upgrade headagainst the scratch database. For ACC-2 it also runsdowngrade -1andupgradeagain. - Prints row counts (
alembic_version,users,market_links,market_trades,market_book_snapshots,market_price_history,strategy_signals,paper_positions,house_markets,house_bets,activity_log,data_changes, and the fantasy tables when present), then the elapsed time per step and in total.
It leaves the scratch database for comparisons; --drop removes it. It is the "try the migration on a restored copy
first" path for ACC-2, and the comparison path for the settlement runbook.
Timings (filled in by OPS-4 and DRY-2):
| Drill | Date | Dump | Size | Download | Load | alembic |
Total | By |
|---|---|---|---|---|---|---|---|---|
| 1 | by Sun 4 Oct | newest nightly | OPS-4 | |||||
| 2 | Mon 5 Oct (ACC-2) | newest nightly + fantasy_schema_v1 |
ACC-2 |
The Mac drill times the load step only. Path B is timed separately, on a throwaway instance booted from the newest snapshot, before Thu 8 Oct (Path B drill):
| Drill | Date | Snapshot | Disk created | Instance booted | Postgres up | alembic current matches |
Total | By |
|---|---|---|---|---|---|---|---|---|
| B-1 | Tue 6 Oct 16:00–18:00 UTC (09:00–11:00 PDT) | newest racinglines-daily |
owner |
Recovery paths and RTO¶
The times are estimates until drilled. This sprint times path A's load step (OPS-4) and path B's boot from a snapshot (drill B-1); path C is not rehearsed.
| Path | When | Steps | Expected time | Data lost |
|---|---|---|---|---|
| A. Restore the DB on the same VM | Bad migration, wrong or corrupt data | Stop writers, dump what's there, load the dump, start (Bad migration) | 30–60 min | Everything since the dump (kept aside in the before-restore dump) |
| B. New VM from the latest snapshot | VM or disk lost, zone fine | Disk from snapshot, instance, tunnel reconnects by itself (blocks below) | 30–60 min | Since 11:00 UTC (at most 24 h) |
| C. Rebuild from scratch and the bucket | Snapshots unusable, or a different zone or provider | VM deploy steps 4–7, env from the password manager, vm.sh restore with the newest full nightly (it pulls the data folders first), vm.sh start, then the timers, backup last, smoke |
2–3 h | Since the newest nightly (at most 24 h; 6 h on the launch weekend) |
Every path ends the same way: re-apply account deletions made after the restored point (rule 4 under
What is kept where), then a data_changes row and a docs/data-changes.md line.
Path B, LOCAL (Mac). The snapshot carries /etc/racinglines.env and the cloudflared credentials, so no secret is
needed. Stop or delete the old instance first. Two recorders would split the book history, and two connectors on the
same tunnel would split traffic between two databases. ⚠️ Deleting an instance is the owner's call; --keep-disks=all
keeps its disk.
# LOCAL (Mac) — path B, step 1: the newest snapshots
gcloud compute snapshots list --project=racinglines --filter="sourceDisk~racinglines-vm" --sort-by=~creationTimestamp --limit=5
# LOCAL (Mac) — path B, step 2: ⚠️ only if the old VM is broken; its disk is kept
gcloud compute instances delete racinglines-vm --project=racinglines --zone=us-west1-b --keep-disks=all
# LOCAL (Mac) — path B, step 3: a disk from the snapshot, an instance with the same name, the schedule on the new disk
gcloud compute disks create racinglines-vm-r1 --project=racinglines --zone=us-west1-b --type=pd-balanced --source-snapshot=<SNAPSHOT>
gcloud compute instances create racinglines-vm --project=racinglines --zone=us-west1-b --machine-type=e2-small \
--disk=name=racinglines-vm-r1,boot=yes,auto-delete=no --shielded-secure-boot \
--service-account=[email protected] --scopes=cloud-platform
gcloud compute disks add-resource-policies racinglines-vm-r1 --project=racinglines --zone=us-west1-b --resource-policies=racinglines-daily
# VM (production) — path B, step 4, from the Mac: check and record
bash scripts/deploy/vm.sh status
bash scripts/deploy/smoke.sh https://racinglines.bet
Then re-apply account deletions made since the snapshot (from activity_log on the old disk, if it can still be read,
or from the players' requests to [email protected]), add a data_changes note
(restored from snapshot <snapshot name>) and a line in Data changes. The
boot disk is now racinglines-vm-r1, so change "disk racinglines-vm" in the snapshot and resize blocks to match.
Path C. Follow VM deploy steps 4–7 on a new instance (set RL_ZONE for another zone). Fill
/etc/racinglines.env from the password manager. Run sudo cloudflared service install <TOKEN> with the token of
the existing tunnel racinglines-vm (Zero Trust > Networks > Tunnels > racinglines-vm); the hostnames stay on the
tunnel. Then run vm.sh restore gs://$RACINGLINES_GCS_BUCKET/db/nightly/<newest>: not the default, because
db/racinglines.sql.gz is the scrubbed copy without player logins (the dump argument is OPS-3's). vm.sh restore
runs bucket.sh pull for the data folders before it loads the dump; let it finish. Then vm.sh start, and enable
the timers (Installing the new units) with racinglines-backup.timer last, only after
the pull has finished and data/archive/markets holds as many files as the bucket's archive/markets/
(gcloud storage ls -r … | wc -l). Then re-apply deletions and smoke. Path C is not rehearsed this sprint (Open
items).
Path B drill (before Thu 8 Oct)¶
Drill B-1 boots the newest snapshot as a throwaway instance, racinglines-drill, and times it. The clone carries
production's env file and tunnel credentials, and its timers start at boot. So it gets no external IP and no service
account: it can't reach the internet (no tunnel connector, no ntfy, no pings, no venue calls) and can't write the
bucket. It is reached over IAP only. Run it in a quiet window, Tue 6 Oct 16:00–18:00 UTC (09:00–11:00 PDT).
# LOCAL (Mac) — drill B-1, step 1: the newest snapshot, the VM's network tags (IAP ssh needs them), and whether the network has a Cloud NAT
gcloud compute snapshots list --project=racinglines --filter="sourceDisk~racinglines-vm" --sort-by=~creationTimestamp --limit=1
gcloud compute instances describe racinglines-vm --project=racinglines --zone=us-west1-b --format='value(tags.items)'
gcloud compute routers list --project=racinglines --format='table(name,region,nats.list())'
If routers list shows a NAT, stop. The drill instance could then reach the internet and join the production
tunnel, so the drill needs a different design; raise it before going on.
# LOCAL (Mac) — drill B-1, step 2: a disk from the snapshot and a throwaway instance with no external IP and no service account, timed
time gcloud compute disks create racinglines-drill --project=racinglines --zone=us-west1-b --type=pd-balanced --source-snapshot=<SNAPSHOT>
time gcloud compute instances create racinglines-drill --project=racinglines --zone=us-west1-b --machine-type=e2-small \
--disk=name=racinglines-drill,boot=yes,auto-delete=yes --shielded-secure-boot \
--no-address --no-service-account --no-scopes --tags=<the tags from step 1>
# LOCAL (Mac) — drill B-1, step 3: on the drill instance (not production), over IAP: Postgres up, alembic matches, the tunnel not connected
gcloud compute ssh racinglines-drill --project=racinglines --zone=us-west1-b --tunnel-through-iap --command '
date -u; uptime
cd /opt/racinglines && sudo -u racinglines docker compose ps
sudo -u racinglines bash -c "set -a; . /etc/racinglines.env; set +a; cd /opt/racinglines && .venv/bin/alembic current"
curl -s --max-time 5 http://127.0.0.1:8000/healthz; echo
sudo journalctl -u cloudflared -n 5 --no-pager'
Expect Postgres healthy, alembic current equal to production's on the snapshot's day, /healthz 200 with "db":
"ok", and cloudflared failing to connect. Record the times in the B-1 row, including from disks create to the
first good /healthz.
# LOCAL (Mac) — drill B-1, step 4: ⚠️ delete the drill instance only (check the name); its disk goes with it
gcloud compute instances delete racinglines-drill --project=racinglines --zone=us-west1-b --quiet
gcloud compute disks list --project=racinglines --filter="name=racinglines-drill" # expect nothing listed
Capacity (DEC-17)¶
Memory¶
These are estimates from the process mix, not measurements. SCOUT-1 records the real numbers, and the health log records the peaks during DRY-1.
| Process | Runs | Estimate |
|---|---|---|
| Postgres 17 (docker) | Always | 250–400 MB |
racinglines-web (uvicorn) |
Always | 250–400 MB |
A Lab job (subprocess; one at a time in web, one in MCP: racinglines/web/jobs.py) |
On demand; staff-only after ROLE-1 | 300–800 MB |
racinglines-mcp |
Always | 200–300 MB |
racinglines-recorder |
Always | 200–300 MB |
| Signals step (FastF1, pricing) | Every 5 min | 400–1,000 MB at peak |
| F1 live step | Every 5 min on live weekends | 300–600 MB |
| Kalshi sync (new) | Every 30 min | 150–300 MB |
| Fantasy tick (new) | Every 10 min | 150–300 MB |
| Health (new) | Every 5 min | 60–120 MB |
Backup (new; pg_dump in the container, gzip) |
Daily | 50–150 MB |
| cloudflared | Always | 30–60 MB |
| OS, dockerd, journald, guest agent | Always | 250–400 MB |
| Always-on subtotal | about 1.2–1.9 GB | |
| Worst overlap (signals + live step + a Lab job + Kalshi sync) | +1.2–2.7 GB |
On 2 GB the always-on set nearly fills the box, and an overlap needs swap or triggers an OOM kill. The plan is:
- Swapfile (OPS-7): 2 GB at
/swapfile,vm.swappiness=10. It is a cushion, not capacity: sustained swapping on a pd-balanced disk is slow. - Resize (DEC-17, OWNER-7): e2-medium (4 GB) in the Mon 5 Oct window, unless R16 and load run 1 (Load run) both show memory peaks under 60% with swap unused. E2 shared-core types burst above a baseline CPU share, and e2-medium's baseline is higher, which helps the signals step too. The machine price roughly doubles (about $12 to $24 a month at list price, estimate; check the calculator). Downtime is 2–3 minutes. The ephemeral external IP changes on stop and start, and nothing depends on it (the tunnel and IAP are outbound or Google-side). Blocks: OWNER-7.
- Open item, in no task yet:
OOMScoreAdjust=on the oneshot units, so the kernel kills a step before web or Postgres.
Concurrency¶
Memory is only half of it. The web app is one uvicorn process (racinglines/cli/web.py:13, no workers) with sync
handlers in a threadpool, and SQLAlchemy's default pool (5 plus 10 overflow; OPS-1 sets it explicitly). An exchange
order makes a venue HTTP call, up to about 12 s, inside a request thread (Fantasy trading).
Book fetches, the Kalshi sync timer and the tick's on-demand closed sync all share the VM's one IP, with no pacing
across processes. A burst at book open or lights-out, with up to 50 players (RACINGLINES_SIGNUP_CAP), can use up
the threads or the pool, or draw Kalshi 429s that then stall the sync. R16's staff-only traffic won't show this, so
the load run measures it.
Load run (OPS-11)¶
scripts/ops/loadtest.py (new, a locust file; the owner installs locust in the Mac's .venv by hand, and it is not
a project dependency). Simulated users sign in with the tester accounts (read from a git-ignored file under data/,
never from the command line), then browse; in run 2 they also place private-book bets and exchange paper orders in
season 2026-dryrun. It runs from the Mac through Cloudflare, so it tests the path players use.
| Run | When | Load | Feeds |
|---|---|---|---|
| 1 | Mon 5 Oct 08:00–08:15 PDT (15:00–15:15 UTC), before the Monday settle | 25 sessions, browse only (/, /markets, /book, event pages), 15 minutes |
DEC-17 at 12:00 PDT, with R16's memory peaks |
| 2 | In STAGE-1, Wed 7 Oct, after the functional rehearsal | 25 sessions browsing, placing book bets and exchange orders, 15 minutes | The go/no-go |
Pass criteria (both runs). Page p95 under 2 s and order p95 under 4 s (an order includes the venue's book
fetch); no 5xx from the app; /healthz 200 throughout (the external monitor and the health log); no 429 from Kalshi
or Polymarket in the journal; no OOM kill; the MemAvailable minimum and swap maximum recorded. A Cloudflare challenge
page in the results makes the run invalid: stop and note it.
If run 2 fails on e2-small, OWNER-7 resizes on Thu 8 Oct 09:00–11:00 PDT (16:00–18:00 UTC) and run 2 is repeated for 5 minutes. If it fails on e2-medium, the go/no-go falls back per the runbook.
# LOCAL (Mac) — load run 1 (run 2 is the same with --tags browse,bet,order and load-2 in the names)
.venv/bin/locust -f scripts/ops/loadtest.py --host https://racinglines.bet --headless -u 25 -r 5 -t 15m --tags browse --csv data/backups/load-1 2>&1 | tee data/backups/load-1-$(date -u +%Y%m%dT%H%M%SZ).log
# VM (production) — right after each run (block 0 with NAME=load first): memory, OOMs and 429s in the last 20 minutes
{ free -m; grep oom_kill /proc/vmstat; sudo journalctl --since "-20min" --no-pager | grep -cE ' 429|Too Many'; } 2>&1 | sudo -u racinglines tee -a "$LOG"
Disk (30 GB pd-balanced)¶
| Item | Now | Note |
|---|---|---|
| Ubuntu, snaps (Google Cloud CLI), packages | about 5 GB (estimate) | Slow growth |
Docker image postgres:17 |
about 0.5 GB | |
| venv and uv's Python 3.14 | about 2 GB (estimate) | |
Postgres (docker volume under /var/lib/docker) |
The Mac's data/pg is 668 MB (29 Sep). The VM is larger after 1.9M Kalshi trades loaded 29 Sep; SCOUT-1 measures it |
Hot rows move to Parquet (recent 7 days kept); plain VACUUM reuses the space, it doesn't return it |
data/archive/markets |
Mac: Kalshi 30 MB, Polymarket 33 MB | Hourly parts; compaction below |
data/raw/f1 (FastF1), raw/nascar, raw/mtb_dh |
Mac: 25, 13 and 4 MB | Per weekend |
data/runs |
Mac: 21 MB (live 16 MB) | |
| Dumps | 7 nightly (15–150 MB each, estimate) plus pre-change dumps | OPS-3 prunes nightly only |
| journald | Capped at 1 GB (OPS-7) | Ubuntu's default cap is larger |
| Docker logs | Capped at 30 MB (OPS-6) | |
| Swapfile | 2 GB | |
| Total | about 13–15 GB of 30 (45–50%, estimate) | Alert at 80% (24 GB); the go/no-go wants under 70% |
Archive compaction. The hourly archive writes one Parquet part per store and hour. racinglines markets archive
--compact rewrites each month as one deduplicated file (compact, racinglines/markets/store.py:263). It is safe
alongside the recorder, because a part written after the listing isn't deleted. Reads deduplicate anyway
(store.merge), so old parts cost space, not correctness. The owner runs it monthly in a quiet window, the first
time on Tue 13 Oct at 16:00 UTC (09:00 PDT). The next nightly rsync mirrors the change to the bucket.
OS updates and the reboot window¶
Ubuntu 24.04 installs security updates by itself (unattended-upgrades). OPS-7 pins that behaviour down:
- Security origins only; no automatic reboot.
- The daily run moves to 16:00 UTC (09:00 PDT), which clears every R16 and R17 session and most US-afternoon ones.
docker.io,containerdandruncare held back from automatic upgrades. Upgrading them restarts dockerd and with it Postgres, so the owner applies them in the reboot window.needrestartonly lists services, so a library upgrade doesn't restart web or cloudflared mid-session.
Reboot window. When /var/run/reboot-required appears, the health check says so once a day. Reboot Tue or Wed,
16:00–18:00 UTC (09:00–11:00 PDT), never Thu–Sun of a race weekend. Every unit is enabled and Postgres has
restart: unless-stopped, so all of it comes back; live-event timers fire 1 minute after boot. A reboot test after
the cutover proves it (OPS-7, optional block).
R16 closed dry run (DRY-1) and R17 on-call (OPS-10)¶
What is on (Thu 1 Oct 18:00 PDT to Sun 4 Oct 12:00 PDT; Fri 2 Oct 01:00 to Sun 4 Oct 19:00 UTC)¶
- Production on the VM after the cutover: web, recorder, signals, MCP, tunnel.
[email protected]is enabled before the book opens. The R16 plan in F1 live test runs unchanged: T1 on Kalshi, which already lists R16, with the private book as the demo. - The P1 ops items: the health, backup and kalshi-sync timers; OPS-7's swap, journald, updates, snapshot schedule, external uptime monitor, dead-man and tunnel notification.
- 5 or fewer staff testers, admin-created at
/admin/usersafterops backup --label r16-testers. They bet the in-app private book only if BOOK-1 and BOOK-2 are merged and deployed by Thu 1 Oct 12:00 PDT. No sign-up, no public invite, no trading flag. The A, C and K records are untouched. LAUNCH-1 deactivates the testers.
What to watch¶
The window is 66 h, or 792 five-minute slots.
| Signal | Source | Target |
|---|---|---|
| Health timer runs | Health log lines | At least 753 of 792 slots (95%), gaps explained by deploys |
External /healthz |
The external monitor's report; the health log for the causes | At least 99% of samples OK |
| Alerts | ntfy, Healthchecks.io, the incident log | Every P1 explained; at most 2 false P2s a day (otherwise DRY-2 tunes the threshold) |
| Nightly backup | heartbeat/backup; gcloud storage ls |
Fri 2, Sat 3 and Sun 4 Oct at 10:00 UTC all succeed; duration and size recorded |
| Snapshots | gcloud compute snapshots list |
One a day |
| Kalshi sync | ages_s.kalshi_sync; 429 count in the journal |
Under 35 min throughout; 429s counted |
| Recorder | ages_s.recorder |
Under 5 min, except restarts |
| Memory | Health log: MemAvailable minimum, swap maximum, OOM delta, per-unit MemoryCurrent |
Peak recorded (the DEC-17 input: under 60% with swap unused keeps e2-small); no OOM kill |
| Disk | Health log | Under 70% |
| Deploys | The Mac's deploy logs | Only in windows; duration recorded; nothing left paused |
| Live steps | journalctl -u racinglines-live-f1@2026-16 |
All seven updates on time (F1 live test) |
Incident log¶
One file per weekend on the VM: /opt/racinglines/data/backups/r16-dryrun-<UTC>.log, and
/opt/racinglines/data/backups/r17-oncall-<UTC>.log for R17. One line per incident, times in UTC:
# id | start (UTC) | end (UTC) | sev | check or unit | what was seen | action taken | cause | follow-up (task id or none)
I-01 | <start> | <end> | P2 | <check> | <what was seen> | <action> | <cause> | <follow-up>
# VM (production) — append a line (block 0 with NAME=r16-dryrun sets $LOG)
echo "I-01 | <start> | <end> | P2 | <check> | <seen> | <action> | <cause> | <follow-up>" | sudo -u racinglines tee -a "$LOG"
Exit criteria (feed the go/no-go)¶
- The external monitor shows at least 99% for the window, the health timer met its 95% run target, and every P1 is explained in the incident log.
- Two consecutive nightly dumps are in the bucket, and one snapshot a day.
- The first restore drill is done on the Mac, with its time recorded (OPS-4).
- Kalshi sync stayed under 35 minutes, or each breach is explained.
- Peak memory, swap and disk are recorded for DEC-17, with load run 1's numbers (Mon 5 Oct).
- No trading flag was touched, and the A, C and K records are unaffected.
Retro (DRY-2, Opus, Mon 5 Oct)¶
Inputs: the health log, the incident log and journal extracts, copied to the Mac with the SCOUT-1 copy block. Output: an "R16 dry-run results" section appended to this doc. It records availability, alerts by check with the false positives, backup durations and sizes, the restore drill time, the memory peak, swap and disk. It also gives the DEC-17 recommendation and a list of fixes with owners. The memory and disk numbers are due by Mon 5 Oct 10:00 PDT (17:00 UTC), before DEC-17's 12:00 PDT deadline; the rest is due by 18:00 PDT.
Settlement replay on R16 data (REPLAY-1)¶
R16 runs none of the fantasy code (it deploys on Tue 6 and Wed 7 Oct), so without a replay the first real run of
the settlement code would be R17, with players. The replay is the runbook's
REPLAY-1:
the real tick on a restored copy of the VM's dump, on the Mac, writing the scratch database only. Nothing fake is
written to the production database. On the VM, STAGE-1 exercises only the tick's live behaviour on 2026-dryrun
markets (lines 5i and 5j: a void refunded once, and settlement continuing with RACINGLINES_FANTASY=0). The result
is go/no-go line 19.
R17 on-call (OPS-10)¶
- Phone alerts on from Thu 8 Oct 17:00 PDT to Mon 12 Oct; the response times are in the on-call table above.
- Deploys only in the between-session windows: Fri 9 Oct 14:00 UTC – Sat 10 Oct 08:00 UTC, Sat 10 Oct 15:00 UTC – Sun 11 Oct 10:00 UTC, and after Sun 11 Oct 16:00 UTC.
- Incident log
r17-oncall-<UTC>.log. A Haiku scout reads logs on request, after the owner copies them to the Mac. - Abuse response:
RACINGLINES_SIGNUP=off, revoke invite codes, suspend accounts through/admin/fantasy(Sign-up abuse wave). - The external monitor's report gives the weekend's availability for REPORT-1; the health log explains the misses.
Incident runbooks¶
Start each incident with block 0 (NAME=incident), so every write step lands in one log. Read-only diagnostics
don't need the tee. Record each incident in the weekend's incident log.
Site down¶
Check in this order: outside view, VM, loopback, tunnel, web, DB.
# LOCAL (Mac) — step 1: what the outside sees
curl -s -o /dev/null -w '%{http_code} in %{time_total}s\n' --max-time 15 https://racinglines.bet/healthz
curl -s --max-time 15 https://racinglines.bet/healthz; echo
| Result | Meaning | Go to |
|---|---|---|
200, "db": "ok" |
Up; a blip, or the phone's network | Close |
| 503 from the app | Web is up, Postgres is not | Step 5 |
| 502 | cloudflared is up, web is not | Step 4 |
| 530 / Cloudflare error 1033 | No tunnel connector | Step 3 |
| Timeout | Cloudflare or the VM | Steps 2 and 3 |
# VM (production) — step 2, from the Mac: is the VM there, and what runs?
gcloud compute instances describe racinglines-vm --project=racinglines --zone=us-west1-b --format='value(status)'
bash scripts/deploy/vm.sh status
# VM (production) — step 3, in a vm.sh ssh session: loopback first, then the tunnel
curl -s --max-time 10 http://127.0.0.1:8000/healthz; echo
systemctl status cloudflared --no-pager | head -5
sudo journalctl -u cloudflared -n 30 --no-pager
If loopback answers and cloudflared is failed or erroring, restart it. If Cloudflare itself is down (cloudflarestatus.com), wait.
# VM (production) — step 3b: restart the tunnel
{ date -u; sudo systemctl restart cloudflared; sleep 10; systemctl status cloudflared --no-pager | head -5; } 2>&1 | sudo -u racinglines tee -a "$LOG"
# VM (production) — step 4: web down
sudo journalctl -u racinglines-web -n 80 --no-pager
{ date -u; sudo systemctl restart racinglines-web; sleep 8; curl -s --max-time 10 http://127.0.0.1:8000/healthz; echo; } 2>&1 | sudo -u racinglines tee -a "$LOG"
If web crashes on start right after a deploy, see Bad deploy.
# VM (production) — step 5: the database
cd /opt/racinglines && sudo -u racinglines docker compose ps && sudo -u racinglines docker compose logs --tail 50 db
df -h /; free -m
{ date -u; cd /opt/racinglines && sudo -u racinglines docker compose up -d --wait db; } 2>&1 | sudo -u racinglines tee -a "$LOG"
A full disk goes to Disk full. An OOM kill: restart, record it, and it feeds DEC-17.
# LOCAL (Mac) — step 6: the VM is stopped or hung (⚠️ reset is a hard reset; Postgres recovers as after a power cut)
gcloud compute instances start racinglines-vm --project=racinglines --zone=us-west1-b # status TERMINATED
gcloud compute instances reset racinglines-vm --project=racinglines --zone=us-west1-b # RUNNING but unreachable
gcloud compute instances get-serial-port-output racinglines-vm --project=racinglines --zone=us-west1-b | tail -50
If it isn't back within 30 minutes, take recovery path B.
Recorder stale¶
# VM (production) — what the recorder says, then a restart
sudo journalctl -u racinglines-recorder -n 60 --no-pager | grep -E 'error|books|sync|archived' | tail -20
{ date -u; sudo systemctl restart racinglines-recorder; } 2>&1 | sudo -u racinglines tee -a "$LOG"
After OPS-6 the recorder exits by itself after 10 failed loops and systemd restarts it; the alert fires earlier, at 5
minutes. Errors from Polymarket's API (5xx, timeouts) need no fix: wait, and the hourly de-dupe limits the pushes.
Errors from the DB go to Site down step 5. Write the gap in the incident log. Books can't be
backfilled; prices and trades can, afterwards (racinglines markets history, trades).
Kalshi sync failing or 429¶
# VM (production) — what failed, and when the next run is
sudo journalctl -u racinglines-kalshi-sync -n 300 --no-pager | grep -E '429|Too Many|error|Traceback' | tail -20
systemctl list-timers racinglines-kalshi-sync.timer --no-pager
What goes stale: new Kalshi markets, market_links.last_bid/last_ask (taker equity marks on the leaderboard) and
resolved_yes (settlement). Exchange paper fills don't: they read a live book per order (EXCH-1). One failed run is
fine, since the next is 30 minutes later. If 429s repeat, slow the timer to hourly:
# VM (production) — temporary: hourly instead of every 30 minutes (health flags over 35 min; acknowledge it)
{
sudo mkdir -p /etc/systemd/system/racinglines-kalshi-sync.timer.d
printf '[Timer]\nOnCalendar=\nOnCalendar=*-*-* *:05:00 UTC\n' | sudo tee /etc/systemd/system/racinglines-kalshi-sync.timer.d/hourly.conf
sudo systemctl daemon-reload && sudo systemctl restart racinglines-kalshi-sync.timer
systemctl list-timers racinglines-kalshi-sync.timer --no-pager
} 2>&1 | sudo -u racinglines tee -a "$LOG"
# VM (production) — undo (vm.sh deploy doesn't remove drop-ins)
{ sudo rm /etc/systemd/system/racinglines-kalshi-sync.timer.d/hourly.conf && sudo systemctl daemon-reload && sudo systemctl restart racinglines-kalshi-sync.timer; } 2>&1 | sudo -u racinglines tee -a "$LOG"
Near settlement, SETTLE-1's on-demand closed sync and the admin queue cover a lagging resolution.
Fantasy tick stuck or settlement wrong¶
"Stuck" means ages_s.fantasy_tick is over 30 minutes or the unit failed. "Wrong" means a player's balance is off,
or a spot check of fantasy_stats against bankroll_entries fails. ⚠️ Settlement: every fix goes through the admin
route with a reason, never through SQL.
# VM (production) — 1. pause the tick (a disabled timer stays off across deploys and reboots)
{ date -u; sudo systemctl disable --now racinglines-fantasy.timer; cat /var/lib/racinglines/deploy-paused 2>/dev/null; systemctl list-timers racinglines-fantasy.timer --all --no-pager; } 2>&1 | sudo -u racinglines tee -a "$LOG"
sudo journalctl -u racinglines-fantasy -n 100 --no-pager
If deploy-paused lists racinglines-fantasy.timer, the next deploy would resume it; re-check after any deploy.
# VM (production) — 2. a dump before any fix
rl ops backup --label resettle-<event> 2>&1 | sudo -u racinglines tee -a "$LOG"
# LOCAL (Mac) — 3. the tick's dump from just before the bad settle, into racinglines_scratch
bash scripts/ops/restore_scratch.sh gs://<bucket>/db/before/racinglines-before-pre-settle-<event>-<UTC>.sql.gz
Use the tick's pre-settle-<event> dump: it holds every order and bet up to the first settlement write. A nightly or
launch-weekend dump from before the race misses the last hours of orders. If the bad settle came from a later tick
(a re-settle, a lagging resolution), use the newest dump in db/before/ or db/nightly/ from before that tick.
Then compare, per user, on scratch (psql) and on production (read-only, /admin/sql):
SELECT user_id, kind, sum(amount) FROM bankroll_entries WHERE season_id = <id> AND ts >= '<race start>' GROUP BY 1, 2 ORDER BY 1, 2;
Do the same for fantasy_positions of the event's markets.
4. Fix at /admin/fantasy/settle: re-settle with a reason, which writes activity_log fantasy_resettle.
# VM (production) — 5. one tick by hand, a second to prove it changes nothing, then the timer back on
{ rl fantasy tick; rl fantasy tick; sudo systemctl enable --now racinglines-fantasy.timer; } 2>&1 | sudo -u racinglines tee -a "$LOG"
6. Add a data_changes row and a docs/data-changes.md line naming the dump, plus an incident log line. Tell the
affected players from [email protected].
Disk full¶
# VM (production) — what is big
df -h /
sudo du -xh --max-depth=2 /opt/racinglines/data /var/lib/docker /var/log 2>/dev/null | sort -h | tail -15
journalctl --disk-usage
ls -lhS /opt/racinglines/data/backups/db | head
Fix in this order, each step tee'd:
sudo journalctl --vacuum-size=500M.- Old pre-change dumps: copy them to
gs://$RACINGLINES_GCS_BUCKET/db/before/, check the copy withgcloud storage ls -l, and only then remove them on the VM. Keep the newest one per task. sudo docker image prune -f(dangling images only).rl markets archivemoves old rows to Parquet.--vacuum-fullreturns the space to the OS but locks tables briefly, so run it only in a deploy window.- Grow the disk (⚠️ costs more per month):
# LOCAL (Mac) — grow the disk online
gcloud compute disks resize racinglines-vm --project=racinglines --zone=us-west1-b --size=50GB
# VM (production) — grow the partition and filesystem (check the device names with lsblk first)
lsblk
{ sudo growpart /dev/sda 1; sudo resize2fs /dev/sda1; df -h /; } 2>&1 | sudo -u racinglines tee -a "$LOG"
A Postgres with a full disk stops accepting writes. Free space first, then docker compose up -d --wait db if it
stopped.
Bad deploy¶
# VM (production) — from the Mac: what is deployed, and is anything paused?
bash scripts/deploy/vm.sh status
bash scripts/deploy/vm.sh logs racinglines-web
# VM (production) — from the Mac: back to the previous good sha (from the deploy log, or git log --first-parent main)
bash scripts/deploy/vm.sh deploy <previous sha> 2>&1 | tee data/backups/deploy-rollback-$(date -u +%Y%m%dT%H%M%SZ).log
bash scripts/deploy/smoke.sh https://racinglines.bet
curl -s https://racinglines.bet/healthz; echo
# LOCAL (Mac) — so the next plain deploy doesn't bring it back
git checkout main && git pull
git revert -m 1 <merge sha>
python -m pytest -m "not live"
git push
If update.sh failed half-way, PR #71 left the timers paused, and the rollback deploy resumes them. Before OPS-8, a
rollback across a migration stops at alembic (G12): use Bad migration. After OPS-8 it deploys
with a warning.
The rollback refuses: "a step in progress". PR #71 waits 5 minutes for a running step, then deploys nothing and
resumes the timers. There is no --force. Find the step and stop it, then deploy again. Stopping the backup costs one
dump (run rl ops backup by hand afterwards); stopping kalshi-sync or signals costs one run; stopping a live step
costs one update, which the deploy's catch-up step redoes.
# VM (production) — block 0 with NAME=incident first: which step blocks the deploy, and since when
systemctl list-units --no-legend --plain --state=activating 'racinglines-*'
for u in $(systemctl list-units --no-legend --plain --state=activating 'racinglines-*' | awk '{print $1}'); do systemctl show "$u" -p ActiveEnterTimestamp; done
# VM (production) — stop it (one unit name from the list above), then deploy again from the Mac
{ date -u; sudo systemctl stop <unit>; systemctl list-units --state=activating 'racinglines-*' --no-pager; } 2>&1 | sudo -u racinglines tee -a "$LOG"
Bad migration¶
Owner sign-off and backup first
A DB write on the VM: it needs the owner's explicit sign-off in the same turn. The current state is dumped before
the restore, and a data_changes row names both dumps. Everything written between the migration and the restore
(bets, fills, sign-ups, book snapshots) is only in the before-restore dump. The owner decides what to re-apply.
# VM (production) — from the Mac: 1. old code first (after OPS-8 it deploys without running alembic, with a warning)
bash scripts/deploy/vm.sh deploy <sha before the migration>
# VM (production) — in a vm.sh ssh session, block 0 with NAME=restore: 2. stop writers, dump what's there, load the pre-deploy dump
{
date -u
systemctl list-units --no-legend --plain --state=active 'racinglines-*.timer' | awk '{print $1}' | sudo tee /var/lib/racinglines/restore-paused
sudo systemctl stop $(cat /var/lib/racinglines/restore-paused) racinglines-web racinglines-recorder racinglines-mcp
rl ops backup --label restore-<sha>
cd /opt/racinglines
sudo -u racinglines docker compose exec -T db psql -U racinglines -d postgres -v ON_ERROR_STOP=1 \
-c "drop database if exists racinglines with (force)" -c "create database racinglines owner racinglines"
gunzip -c data/backups/db/racinglines-before-deploy-<sha>-<UTC>.sql.gz | sudo -u racinglines docker compose exec -T db psql -U racinglines -d racinglines -v ON_ERROR_STOP=1 -q >/dev/null
sudo -u racinglines bash -c 'set -a; . /etc/racinglines.env; set +a; cd /opt/racinglines && .venv/bin/alembic current'
} 2>&1 | sudo -u racinglines tee -a "$LOG"
# VM (production) — 3. start everything again
{ sudo systemctl start racinglines-web racinglines-recorder racinglines-mcp $(cat /var/lib/racinglines/restore-paused) && sudo rm /var/lib/racinglines/restore-paused; systemctl list-units 'racinglines-*' --no-pager | head -20; } 2>&1 | sudo -u racinglines tee -a "$LOG"
# VM (production) — 5. the record (plus a docs/data-changes.md line in the next docs PR)
rl db changes --add "restored racinglines-before-deploy-<sha>-<UTC>.sql.gz after a bad migration; the state before the restore is racinglines-before-restore-<sha>-<UTC>.sql.gz" 2>&1 | sudo -u racinglines tee -a "$LOG"
6. Re-apply account deletions. Load the before-restore dump into racinglines_scratch on the Mac
(scripts/ops/restore_scratch.sh <path>), list activity_log rows with action account_delete newer than the
restored dump's created_at, and delete each of those accounts again through the account-deletion path (ACC-3,
Fantasy accounts). Record the count in the incident log.
This is the one rollback runbook for a migration. The OWNER-8 rollback in Fantasy accounts points here; if the two ever read differently, this order (old code first, then stop, dump, restore) is the one to follow.
Mail failing¶
Symptoms: players report no verification mail, the web journal shows SMTP errors, or racinglines mail test fails.
DNS, token and Proton limits (535 auth, 421 4.7.0, 550 5.4.5) are covered in Email setup.
# VM (production) — what the mailer says
sudo journalctl -u racinglines-web --since "-2h" --no-pager | grep -iE 'mail|smtp' | tail -30
rl mail test --to <your Gmail>
Fall back to the log backend (mail text, links included, goes to the journal and activity_log): set
RACINGLINES_MAIL_BACKEND=log with the env edit blocks (copy, edit, check, restart).
Then verify the stuck players by hand. Use the admin member page if ADM-1 includes a verify action
(Fantasy accounts); otherwise send each player the verification link from the journal, from
[email protected]. Switch back to smtp once rl mail test delivers.
Sign-up abuse wave¶
Symptoms: sign-ups spike, RACINGLINES_SIGNUP_CAP is reached, signup_rejected rows pile up, or the mail cap is hit.
1. Close sign-up now: set RACINGLINES_SIGNUP=off with the env edit blocks (copy,
edit, check, restart).
# VM (production) — 2. a dump, then see and revoke the codes in use (argument form per ACC-4)
{ rl ops backup --label abuse-cleanup; rl fantasy invite list; rl fantasy invite revoke <code>; } 2>&1 | sudo -u racinglines tee -a "$LOG"
3. Suspend accounts at /admin/fantasy/members/{user_id} (ADM-1). Suspension bumps session_epoch, so their
sessions end.
4. Turn on Turnstile (DEC-16): create the widget in Cloudflare, put RACINGLINES_TURNSTILE_SITEKEY and
RACINGLINES_TURNSTILE_SECRET in /etc/racinglines.env with the env edit blocks, and restart web. Add a Cloudflare rate-limiting rule on
/login, /signup and /password if the plan allows one.
5. Re-open with RACINGLINES_SIGNUP=invite and fresh codes once it's quiet. Record it in the incident log.
Owner command blocks¶
Each block is one step, in order. VM (production) blocks run in a bash scripts/deploy/vm.sh ssh session and tee
to /opt/racinglines/data/backups/<name>-<UTC>.log. Blocks marked "from the Mac" use vm.sh, which acts on the VM.
LOCAL (Mac) blocks are gcloud, git or dashboard steps.
Block 0: a session log¶
# VM (production) — block 0, once per ssh session; set NAME for the job (hardening, baseline, r16-dryrun, incident, restore, ...)
export NAME=hardening
export LOG=/opt/racinglines/data/backups/$NAME-$(date -u +%Y%m%dT%H%M%SZ).log
sudo -u racinglines mkdir -p /opt/racinglines/data/backups && sudo -u racinglines touch "$LOG" && echo "logging to $LOG"
rl() { sudo -u racinglines bash -c 'set -a; . /etc/racinglines.env; set +a; cd /opt/racinglines && exec .venv/bin/racinglines "$@"' rl "$@"; }
If the connection drops, run block 0 again: that starts a new log.
Editing /etc/racinglines.env¶
Every switch, secret and the off switch live in this one file, and a typo in it is an outage: the web unit restarts
every 5 seconds, ACC-3 refuses to start with sign-up on and a short APP_SECRET, and an unquoted value with < or
> (a mail From line) breaks every rl command, which sources the file with bash. So every edit is copy, edit,
check, restart, with the revert ready. The copy, the check and the revert are run as written; only step 2 changes.
# VM (production) — env edit, step 1: a timestamped copy (block 0 first)
export ENVBAK=/etc/racinglines.env.before-$(date -u +%Y%m%dT%H%M%SZ)
sudo cp -p /etc/racinglines.env "$ENVBAK" && echo "env copy: $ENVBAK" | sudo -u racinglines tee -a "$LOG"
# VM (production) — env edit, step 2: the edit itself (not tee'd: the file holds secrets). Quote any value with spaces, < or >
sudoedit /etc/racinglines.env
# VM (production) — env edit, step 3: check before any restart; every line should say OK, and the diff lists only the keys you meant to add or remove
{
sudo bash -n /etc/racinglines.env && echo "OK: bash parses it"
sudo bash -c 'set -a; . /etc/racinglines.env; set +a; for k in APP_SECRET DATABASE_URL RACINGLINES_URL; do [ -n "${!k}" ] && echo "OK: $k set" || echo "PROBLEM: $k empty"; done'
sudo grep -E '^(POLYMARKET|KALSHI)_TRADING_ENABLED=' /etc/racinglines.env >/dev/null && echo "PROBLEM: a trading flag is present" || echo "OK: trading flags absent"
rl db -h >/dev/null && echo "OK: the CLI starts with this file"
diff <(sudo cut -d= -f1 "$ENVBAK") <(sudo cut -d= -f1 /etc/racinglines.env) && echo "OK: same keys (only values changed)"
} 2>&1 | sudo -u racinglines tee -a "$LOG"
# VM (production) — env edit, step 4: restart web and check it answers
{ date -u; sudo systemctl restart racinglines-web; sleep 8; systemctl is-active racinglines-web; curl -s --max-time 10 http://127.0.0.1:8000/healthz; echo; } 2>&1 | sudo -u racinglines tee -a "$LOG"
# VM (production) — env edit, revert: only if step 3 or 4 went wrong
{ date -u; sudo cp -p "$ENVBAK" /etc/racinglines.env && sudo systemctl restart racinglines-web; sleep 8; systemctl is-active racinglines-web; } 2>&1 | sudo -u racinglines tee -a "$LOG"
The .before-* copies hold secrets; they stay in /etc (root-only, like the file) and are never copied to the Mac or
the bucket. A checker command in code (racinglines ops env-check) would be tidier, but it is not in the plan's
names; these four blocks do the same job without new code.
OWNER-1: merge and deploy PR #71 (Wed 30 Sep, by 12:00 PDT)¶
# LOCAL (Mac) — merge PR #71, run in order
git checkout main && git pull
git merge --no-ff origin/work/deploy-pause # ⚠️ needs special attention: replaces the live-event freeze in vm.sh deploy with pause, catch-up and resume of the VM's timers; read the diff first
racinglines check
python -m pytest -m "not live"
mkdocs build --strict
git push
If you merge on GitHub instead, git pull main before the next block. The PR's diff touches scripts/deploy/vm.sh,
CLAUDE.md and docs/vm-deploy.md.
# VM (production) — from the Mac: deploy it, then confirm
bash scripts/deploy/vm.sh deploy 2>&1 | tee data/backups/deploy-$(date -u +%Y%m%dT%H%M%SZ).log
bash scripts/deploy/vm.sh status # 'deployed:' shows the merge; no "PAUSED by an unfinished deploy" line
OWNER-4: cutover checks (Wed 30 Sep)¶
Do the cutover itself per VM deploy. When re-pointing racinglines.bet, edit only that hostname and never touch the MX, TXT or DKIM records (Email setup).
# LOCAL (Mac) — no firewall rule opens tcp:8000
gcloud compute firewall-rules list --project=racinglines --format=json | grep -n '"8000"' || echo "OK: no tcp:8000 rule"
If it lists rl-test-web, run bash scripts/deploy/vm.sh public off. It also restarts web, and it errors if the rule
is already gone.
# VM (production) — block 0 with NAME=cutover first; loopback only, no public drop-in, APP_SECRET set, trading flags absent
{
ss -ltn | grep -E ':(8000|8100|5433) '
test -e /etc/systemd/system/racinglines-web.service.d/public.conf && echo "PROBLEM: public.conf present" || echo "OK: no public.conf"
sudo awk -F= '$1=="APP_SECRET"{print ($2=="" ? "PROBLEM: APP_SECRET empty" : "OK: APP_SECRET set")}' /etc/racinglines.env
sudo grep -E '^(POLYMARKET|KALSHI)_TRADING_ENABLED=' /etc/racinglines.env && echo "PROBLEM: a trading flag is present" || echo "OK: trading flags absent"
} 2>&1 | sudo -u racinglines tee -a "$LOG"
Expect 127.0.0.1:8000, 127.0.0.1:8100 and 127.0.0.1:5433 only. Then sign in on the site, run sudo systemctl
restart racinglines-web, and reload: you should still be signed in (APP_SECRET works). Add
RACINGLINES_OPS_NTFY_TOPIC with the env block under OPS-7.
SCOUT-1: read-only baseline (Wed 30 Sep, after the cutover)¶
The scout runs these read-only commands over ssh. If the permission classifier refuses, the owner pastes them.
# VM (production) — block 0 with NAME=baseline first; read-only
{
date -u; uname -r; grep PRETTY /etc/os-release
sudo -u racinglines git -C /opt/racinglines log -1 --format='deployed: %h %s (%cr)'
cat /var/lib/racinglines/deploy-paused 2>/dev/null || echo "no deploy-paused file"
systemctl list-unit-files 'racinglines-*' 'cloudflared*' --no-pager
systemctl list-units 'racinglines-*' 'cloudflared*' --all --no-pager
systemctl list-timers --all --no-pager
for u in racinglines-web racinglines-recorder racinglines-mcp cloudflared; do systemctl show $u -p Restart,RestartUSec,MemoryCurrent,ActiveEnterTimestamp; done
free -m; swapon --show
df -h /; sudo du -sh /var/lib/docker /opt/racinglines/.venv /opt/racinglines/data/* 2>/dev/null
journalctl --disk-usage; ls -ld /var/log/journal; cat /etc/systemd/journald.conf.d/*.conf 2>/dev/null
cd /opt/racinglines && sudo -u racinglines docker compose ps && sudo -u racinglines docker stats --no-stream
sudo -u racinglines docker compose exec -T db psql -U racinglines -d racinglines -Atc "select pg_size_pretty(pg_database_size('racinglines'))"
sudo awk -F= '/^[A-Z_]+=/{print $1, ($2 == "" ? "(empty)" : "(set)")}' /etc/racinglines.env
sudo bash -c '. /etc/racinglines.env; echo "bucket: ${RACINGLINES_GCS_BUCKET:-UNSET}"'
ss -ltn
test -e /etc/systemd/system/racinglines-web.service.d/public.conf && echo "public.conf PRESENT" || echo "no public.conf"
apt-config dump | grep -E '^Unattended-Upgrade::(Allowed-Origins|Automatic-Reboot)'
cat /var/run/reboot-required 2>/dev/null || echo "no reboot required"
ls -lh /opt/racinglines/data/backups/db 2>/dev/null | tail -5
} 2>&1 | sudo -u racinglines tee -a "$LOG"
# LOCAL (Mac) — the Google side, read-only
gcloud compute instances describe racinglines-vm --project=racinglines --zone=us-west1-b --format='value(machineType.basename(),status)'
gcloud compute disks describe racinglines-vm --project=racinglines --zone=us-west1-b --format='value(sizeGb,type.basename(),resourcePolicies)'
gcloud compute firewall-rules list --project=racinglines --format='table(name,sourceRanges.list(),targetTags.list())'
gcloud compute resource-policies list --project=racinglines
# LOCAL (Mac) — copy the VM's logs to the Mac for the scout (data/ is git-ignored)
mkdir -p data/backups/vm-logs
gcloud compute scp --project=racinglines --zone=us-west1-b --tunnel-through-iap 'racinglines-vm:/opt/racinglines/data/backups/baseline-*.log' data/backups/vm-logs/
The scout's output is a table of findings, with NOT FOUND for anything missing. The same copy block works for
r16-dryrun-*, r17-oncall-* and data/runs/logs/health/*.jsonl.
OPS-7: VM hardening (finish before Thu 1 Oct 18:00 PDT / Fri 2 Oct 01:00 UTC)¶
OPS-7 comes in two parts, because the P1 code (eight branches) might not all land by Thu 1 Oct and the VM should not serve production for 30 hours with no backup and no outside eyes.
- At the cutover, Wed 30 Sep, right after OWNER-4 (no code needed): the snapshot schedule (step 8), the external uptime monitor and the Cloudflare tunnel notification (step 4b), the ntfy topic (4a), and, as soon as the bucket exists (DEC-19), the bucket lockdown and lifecycle (step 9).
- By Thu 1 Oct 18:00 PDT: swap, journald, updates (steps 1–3), the env keys and the password-manager copy (4c–5), Healthchecks.io, the test alert.
- Before
RACINGLINES_SIGNUPleavesoff(STAGE-1, Wed 7 Oct): Cloudflare Access on/admin(step 10) and HSTS (step 11).
The minimum if P1 code slips. Without OPS-1/OPS-2 the external monitor and the tunnel notification still alert; without OPS-3 the fallback backup runs once a day; without OPS-5 the owner runs the Kalshi sync by hand before the R16 book opens. OPS-6 and OPS-8 are not part of P1 anyway: they merge Mon 5 Oct, and OPS-8 only has to precede ACC-1.
# VM (production) — block 0 with NAME=hardening first. Step 1: 2 GB swapfile, swappiness 10
{
if [ -e /swapfile ]; then echo "/swapfile exists, skipping"; else
sudo fallocate -l 2G /swapfile && sudo chmod 600 /swapfile && sudo mkswap /swapfile && sudo swapon /swapfile
fi
grep -q '^/swapfile ' /etc/fstab || echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
echo 'vm.swappiness=10' | sudo tee /etc/sysctl.d/99-racinglines.conf
sudo sysctl -p /etc/sysctl.d/99-racinglines.conf
swapon --show; free -m
} 2>&1 | sudo -u racinglines tee -a "$LOG"
# VM (production) — step 2: persistent journal, capped at 1 GB (a drop-in; same effect as editing /etc/systemd/journald.conf)
{
sudo mkdir -p /etc/systemd/journald.conf.d
printf '[Journal]\nStorage=persistent\nSystemMaxUse=1G\n' | sudo tee /etc/systemd/journald.conf.d/racinglines.conf
sudo systemctl restart systemd-journald
journalctl --disk-usage
} 2>&1 | sudo -u racinglines tee -a "$LOG"
# VM (production) — step 3: security updates only, no auto-reboot, daily at 16:00 UTC, docker held back, needrestart list-only
{
apt-config dump | grep -E '^Unattended-Upgrade::(Allowed-Origins|Automatic-Reboot)'
sudo tee /etc/apt/apt.conf.d/52racinglines >/dev/null <<'EOF'
Unattended-Upgrade::Automatic-Reboot "false";
Unattended-Upgrade::Package-Blacklist { "docker.io"; "containerd"; "runc"; };
EOF
sudo mkdir -p /etc/systemd/system/apt-daily-upgrade.timer.d
sudo tee /etc/systemd/system/apt-daily-upgrade.timer.d/racinglines.conf >/dev/null <<'EOF'
[Timer]
OnCalendar=
OnCalendar=*-*-* 16:00:00 UTC
RandomizedDelaySec=0
EOF
[ -d /etc/needrestart/conf.d ] && echo "\$nrconf{restart} = 'l';" | sudo tee /etc/needrestart/conf.d/racinglines.conf
sudo systemctl daemon-reload
systemctl list-timers apt-daily-upgrade.timer --no-pager
sudo unattended-upgrade --dry-run 2>&1 | tail -5
} 2>&1 | sudo -u racinglines tee -a "$LOG"
# LOCAL (Mac) — step 4a: a hard-to-guess ntfy topic (ntfy.sh topics are readable by anyone who knows the name)
echo "racinglines-ops-$(openssl rand -hex 8)"
Step 4b, dashboards (the owner's Gmail on every one; menu names may differ slightly):
- ntfy app (phone): subscribe to that topic on ntfy.sh. Allow priority-5 pushes through Do Not Disturb if you want night pages for R17.
- Healthchecks.io (free tier): create a check "racinglines-vm health" with a 5-minute period and a 20-minute grace, and copy its ping URL. Email to Gmail is the default integration; ntfy is optional.
- Cloudflare: Notifications > Add > Tunnel Health Alert for tunnel
racinglines-vm, delivered by email to Gmail. At the cutover. - External uptime monitor (UptimeRobot or Better Stack, free tier; at the cutover): an HTTP(S) monitor on
https://racinglines.bet/loginevery 5 minutes (Better Stack's free tier allows 3), with a 10 s timeout, alerting to Gmail and to the ntfy app if the free plan has a webhook or ntfy integration. Once OPS-1 is deployed, point it athttps://racinglines.bet/healthz, and use a keyword check for"db": "ok"if the plan offers one (a 503 fails it either way). Keep its monthly report: it is the availability number for DRY-2, the go/no-go and REPORT-1.
# VM (production) — step 4c: add the ops keys with the env edit blocks (Editing /etc/racinglines.env): copy, sudoedit, check, restart
# RACINGLINES_OPS_NTFY_TOPIC=racinglines-ops-<16 hex from step 4a>
# RACINGLINES_HEALTHCHECK_PING_URL=https://hc-ping.com/<uuid from Healthchecks.io>
# RACINGLINES_GCS_BUCKET=racinglines-data-<project number> (if missing; DEC-19)
# VM (production) — step 4d: which keys are set (names only)
sudo awk -F= '/^[A-Z_]+=/{print $1, ($2 == "" ? "(empty)" : "(set)")}' /etc/racinglines.env 2>&1 | sudo -u racinglines tee -a "$LOG"
# VM (production) — step 5: copy every value into your password manager (not tee'd: it prints secrets)
sudo cat /etc/racinglines.env
Test alert¶
# VM (production) — step 6: a test push to the ops topic; it should reach the phone within a minute
sudo bash -c 'set -a; . /etc/racinglines.env; set +a; curl -s -o /dev/null -w "%{http_code}\n" -H "Title: racinglines ops test" -H "Priority: high" -d "test from racinglines-vm at $(date -u +%H:%M) UTC" "https://ntfy.sh/$RACINGLINES_OPS_NTFY_TOPIC"' 2>&1 | sudo -u racinglines tee -a "$LOG"
The OPS-2 end-to-end test runs after the health timer is on, in a quiet window: before Thu 1 Oct 18:00 PDT, or after the R16 weekend. It costs about 15 book snapshots per open Polymarket token.
# VM (production) — the recorder off for 15 minutes; expect the P2 push after about 10 minutes
{ date -u; sudo systemctl stop racinglines-recorder; } 2>&1 | sudo -u racinglines tee -a "$LOG"
# VM (production) — 15 minutes later: back on; expect the "recovered" push
{ date -u; sudo systemctl start racinglines-recorder; } 2>&1 | sudo -u racinglines tee -a "$LOG"
# VM (production) — step 7 (optional): a reboot test, Wed 30 Sep after the cutover or Thu 1 Oct before 12:00 PDT
echo "reboot at $(date -u)" | sudo -u racinglines tee -a "$LOG"; sudo systemctl reboot
# VM (production) — from the Mac, 3 minutes later: everything came back
bash scripts/deploy/vm.sh status
bash scripts/deploy/smoke.sh https://racinglines.bet
Snapshot schedule¶
# LOCAL (Mac) — step 8: the daily snapshot schedule (14 days), attached to the VM's disk
gcloud compute resource-policies create snapshot-schedule racinglines-daily \
--project=racinglines --region=us-west1 \
--daily-schedule --start-time=11:00 \
--max-retention-days=14 --on-source-disk-delete=keep-auto-snapshots \
--storage-location=us-west1
gcloud compute disks add-resource-policies racinglines-vm \
--project=racinglines --zone=us-west1-b --resource-policies=racinglines-daily
gcloud compute disks describe racinglines-vm --project=racinglines --zone=us-west1-b --format='value(resourcePolicies)'
# LOCAL (Mac) — the next day: one snapshot listed
gcloud compute snapshots list --project=racinglines --filter="sourceDisk~racinglines-vm" --sort-by=~creationTimestamp --limit=3
Bucket lifecycle¶
# LOCAL (Mac) — step 9a (DEC-19, at the cutover if the bucket exists): the bucket's top-level prefixes, and whether it is locked down
export RACINGLINES_GCS_BUCKET=racinglines-data-$(gcloud projects describe racinglines --format='value(projectNumber)')
gcloud storage ls gs://$RACINGLINES_GCS_BUCKET
gcloud storage buckets describe gs://$RACINGLINES_GCS_BUCKET --format=yaml | grep -iE 'uniform_bucket_level_access|public_access_prevention|versioning'
# LOCAL (Mac) — step 9b: uniform bucket-level access and public access prevention on (the full dumps hold player data)
gcloud storage buckets update gs://$RACINGLINES_GCS_BUCKET --uniform-bucket-level-access --public-access-prevention
gcloud storage buckets describe gs://$RACINGLINES_GCS_BUCKET --format=yaml | grep -iE 'uniform_bucket_level_access|public_access_prevention'
# LOCAL (Mac) — step 9c: nightly dumps kept 35 days; overwritten versions kept 30 days, 90 under archive/ (the order-book archive can't be backfilled)
cat > "$TMPDIR/rl-lifecycle.json" <<'EOF'
{"rule": [
{"action": {"type": "Delete"}, "condition": {"age": 35, "matchesPrefix": ["db/nightly/"]}},
{"action": {"type": "Delete"}, "condition": {"isLive": false, "daysSinceNoncurrentTime": 30, "matchesPrefix": ["db/", "live/", "runs/", "raw/"]}},
{"action": {"type": "Delete"}, "condition": {"isLive": false, "daysSinceNoncurrentTime": 90, "matchesPrefix": ["archive/"]}}
]}
EOF
gcloud storage buckets update gs://$RACINGLINES_GCS_BUCKET --lifecycle-file="$TMPDIR/rl-lifecycle.json"
gcloud storage buckets describe gs://$RACINGLINES_GCS_BUCKET --format=yaml | grep -A 30 -i lifecycle
The bucket has versioning on (VM deploy step 2), so the noncurrent rules are what stop old versions piling up. A
lifecycle rule acts when any rule matches, which is why the 30-day rule names its prefixes rather than covering
archive/ too. If step 9a lists a top-level prefix not named in the rules, add it to the 30-day list. At 150 MB a
dump, 35 nightly dumps plus the launch-weekend extras are about 6 GB, which costs cents a month at list price
(estimate).
Cloudflare Access on /admin¶
Step 10, before RACINGLINES_SIGNUP leaves off (STAGE-1, Wed 7 Oct). Public sign-up puts players on the same
origin as /admin, which includes /admin/sql with its write toggle and /admin/db, and today only a password
guards it. SEC-1's per-username lock also lets anyone lock the admin handle for 15 minutes, which would block
suspend, settle and adjust during an incident. Cloudflare Access (Zero Trust free tier, up to 50 users) adds a
second factor in front of the app.
- Zero Trust > Access > Applications > Add > Self-hosted: application domain
racinglines.bet, pathadmin(covers/adminand everything under it). Policy: Allow, Include "Emails": the owner's Gmail (and any staff admin's). Login method: one-time PIN to email, or Google. Session duration: 24 h. - Leave
mcp.racinglines.betout for now: its clients send only a bearer token and can't answer an Access login. An Access service token for them is the open item below. - Check from a private window:
https://racinglines.bet/adminshows the Cloudflare login,/login,/signupand/healthzdon't. The external monitor still passes.
If Access isn't in place when sign-up opens, the fallback is SEC-1's side (in Fantasy accounts):
key the per-username lock on username plus IP for admin and staff handles, and refuse the /admin/sql write toggle
while RACINGLINES_SIGNUP is not off. The go/no-go needs one or the other.
HSTS¶
Step 11, with step 10. Cloudflare > SSL/TLS > Edge Certificates > HTTP Strict Transport Security: on, max-age 1 month, include subdomains off, preload off. Every hostname is served only through the tunnel over https, so nothing breaks, and a short max-age keeps it reversible. The app's own security headers (frame, referrer, content-type, CSP) are SEC-1's, not this doc's: see What this doesn't cover.
P1 ops batch: merge, deploy, enable¶
# LOCAL (Mac) — P1 ops batch in this order, one branch per task (branch names as the workers report them).
# OPS-6 and OPS-8 are not in this batch: they merge Mon 5 Oct as runbook P2 step 0, after OWNER-5.
git checkout main && git pull
BASE=$(git rev-parse HEAD)
git merge --no-ff work/ops-1 # /healthz and its PUBLIC_PATHS entry; the engine's pool settings (every process that uses the DB)
git merge --no-ff work/ops-9 # smoke.sh: /healthz through Cloudflare
git merge --no-ff work/ops-3 # ⚠️ needs special attention: new VM backup unit, the scrubbed latest copy, bucket.sh push guard and restore argument, vm.sh restore (the backup and restore path); creates the ops CLI group
git merge --no-ff work/ops-2 # ⚠️ needs special attention: new VM units racinglines-health.service/.timer and the ops alert path; branched from work/ops-3
git merge --no-ff work/ops-4 # the Mac-only restore script; never touches the racinglines database
git merge --no-ff work/ops-5 # ⚠️ needs special attention: new VM unit racinglines-kalshi-sync (U2); writes market_links on a timer
racinglines check
python -m pytest -m "not live"
mkdocs build --strict
git diff --stat $BASE HEAD -- tests/golden # must print nothing
git push
# VM (production) — from the Mac: deploy the batch by Thu 1 Oct 12:00 PDT (19:00 UTC), then smoke through Cloudflare
bash scripts/deploy/vm.sh deploy 2>&1 | tee data/backups/deploy-$(date -u +%Y%m%dT%H%M%SZ).log
bash scripts/deploy/smoke.sh https://racinglines.bet
curl -s https://racinglines.bet/healthz; echo
Installing the new units¶
vm.sh deploy already copies the unit files and runs daemon-reload; it doesn't enable new timers.
# VM (production) — block 0 with NAME=units first. A dump (the Kalshi timer adds links on a schedule), then enable the three timers
{
rl ops backup --label kalshi-sync-timer
sudo systemctl enable --now racinglines-health.timer racinglines-backup.timer racinglines-kalshi-sync.timer
systemctl list-timers 'racinglines-*' --no-pager
} 2>&1 | sudo -u racinglines tee -a "$LOG"
# VM (production) — the data_changes row (CLAUDE.md), naming the dump printed above
rl db changes --add "U2: racinglines-kalshi-sync.timer enabled on the VM (every 30 min, daily closed sync); backup data/backups/db/racinglines-before-kalshi-sync-timer-<UTC>.sql.gz" 2>&1 | sudo -u racinglines tee -a "$LOG"
# VM (production) — first run of each by hand, then the last lines
{
sudo systemctl start racinglines-health.service racinglines-kalshi-sync.service racinglines-backup.service
for u in racinglines-health racinglines-kalshi-sync racinglines-backup; do journalctl -u $u -n 15 --no-pager; done
ls -lh /opt/racinglines/data/backups/db | tail -5
rl ops health
} 2>&1 | sudo -u racinglines tee -a "$LOG"
The matching docs/data-changes.md line goes in the next docs PR. racinglines-fantasy.timer is enabled the same
way once SETTLE-1 is deployed, after rl ops backup --label staging (STAGE-1).
Fallback backup (only if OPS-3 slips)¶
Only if OPS-3 isn't deployed by Thu 1 Oct 12:00 PDT. A full dump to db/nightly/ once a day, checked for pg_dump's
trailer; no scrubbed copy, no rsync, no manifest. The transient timer is named rl-…, not racinglines-…, so a
deploy's pause doesn't stop (and so unload) it. It does not survive a reboot: run step 2 again after one.
# VM (production) — fallback, step 1, block 0 with NAME=fallback-backup first: the script
sudo tee /usr/local/bin/rl-fallback-backup >/dev/null <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
set -a; . /etc/racinglines.env; set +a
: "${RACINGLINES_GCS_BUCKET:?RACINGLINES_GCS_BUCKET is not set}"
cd /opt/racinglines && mkdir -p data/backups/db
f=data/backups/db/racinglines-nightly-$(date -u +%Y%m%dT%H%M%SZ).sql.gz
docker compose exec -T db pg_dump --no-owner --no-privileges -U racinglines racinglines | gzip -6 > "$f"
gunzip -c "$f" | tail -n 5 | grep -q 'PostgreSQL database dump complete' || { echo "no trailer: $f"; rm -f "$f"; exit 1; }
gcloud storage cp "$f" "gs://$RACINGLINES_GCS_BUCKET/db/nightly/" --quiet
ls -1t data/backups/db/racinglines-nightly-*.sql.gz | tail -n +8 | xargs -r rm --
echo "ok: $f"
EOF
sudo chmod 755 /usr/local/bin/rl-fallback-backup && echo "rl-fallback-backup installed" | sudo -u racinglines tee -a "$LOG"
# VM (production) — fallback, step 2: a daily transient timer at 10:00 UTC, then one run now
{
sudo systemd-run --unit=rl-fallback-backup --uid=racinglines --on-calendar='*-*-* 10:00:00 UTC' /usr/local/bin/rl-fallback-backup
sudo systemctl start rl-fallback-backup.service
sudo journalctl -u rl-fallback-backup -n 5 --no-pager
systemctl list-timers rl-fallback-backup.timer --no-pager
} 2>&1 | sudo -u racinglines tee -a "$LOG"
# VM (production) — fallback, step 3: once OPS-3's racinglines-backup.timer is enabled, stop the stopgap
{ sudo systemctl stop rl-fallback-backup.timer; systemctl list-timers --all --no-pager | grep -E 'backup'; } 2>&1 | sudo -u racinglines tee -a "$LOG"
Launch-weekend backup drop-in¶
# VM (production) — install by Thu 8 Oct 16:00 UTC (09:00 PDT), block 0 with NAME=launch-backups first: the backup timer also at 04:00, 16:00 and 22:00 UTC
{
sudo mkdir -p /etc/systemd/system/racinglines-backup.timer.d
printf '[Timer]\nOnCalendar=*-*-* 04,16,22:00:00 UTC\n' | sudo tee /etc/systemd/system/racinglines-backup.timer.d/launch-weekend.conf
sudo systemctl daemon-reload && sudo systemctl restart racinglines-backup.timer
systemctl list-timers racinglines-backup.timer --no-pager
} 2>&1 | sudo -u racinglines tee -a "$LOG"
A second OnCalendar= in a drop-in adds to the unit's 10:00 UTC line rather than replacing it; list-timers shows
the next of the four.
# VM (production) — remove on Tue 13 Oct after the 04:00 UTC run (vm.sh deploy doesn't remove drop-ins)
{ sudo rm /etc/systemd/system/racinglines-backup.timer.d/launch-weekend.conf && sudo systemctl daemon-reload && sudo systemctl restart racinglines-backup.timer; systemctl list-timers racinglines-backup.timer --no-pager; } 2>&1 | sudo -u racinglines tee -a "$LOG"
OWNER-7: resize to e2-medium¶
Only if DEC-17 says yes: Mon 5 Oct 13:00–15:00 PDT (20:00–22:00 UTC), after DEC-17 is signed at 12:00 PDT, and not while the Monday settle or reconcile commands run. ⚠️ It restarts every unit on the VM.
# VM (production) — block 0 with NAME=resize first: a dump
rl ops backup --label resize 2>&1 | sudo -u racinglines tee -a "$LOG"
# LOCAL (Mac) — a disk snapshot, then stop, resize, start (2–3 minutes down)
gcloud compute disks snapshot racinglines-vm --project=racinglines --zone=us-west1-b --snapshot-names=racinglines-vm-before-resize-$(date -u +%Y%m%d)
gcloud compute instances stop racinglines-vm --project=racinglines --zone=us-west1-b
gcloud compute instances set-machine-type racinglines-vm --project=racinglines --zone=us-west1-b --machine-type=e2-medium
gcloud compute instances start racinglines-vm --project=racinglines --zone=us-west1-b
# VM (production) — from the Mac: check
bash scripts/deploy/vm.sh status
bash scripts/deploy/smoke.sh https://racinglines.bet
curl -s https://racinglines.bet/healthz; echo
Rollback: the same three gcloud lines with --machine-type=e2-small. Afterwards add a data_changes note and
update the machine type in VM deploy (DOC-2).
Go/no-go evidence for the reliability lines¶
| Go/no-go line | Evidence |
|---|---|
| PR #71 and OPS-8 in the running sha; no paused timers | vm.sh status (no PAUSED line) |
vm.sh public off; loopback only |
The two OWNER-4 check blocks |
| APP_SECRET set; trading flags absent | The OWNER-4 VM block; rl ops health config checks |
| Two consecutive nightly dumps in the bucket; the latest copy scrubbed; the bucket locked down | gcloud storage ls -l gs://$RACINGLINES_GCS_BUCKET/db/nightly/ (the newest lines); "scrubbed": true in db/manifest.json; step 9a's describe line |
| Launch-weekend backup drop-in installed | systemctl list-timers racinglines-backup.timer shows the 16:00 or 22:00 UTC run next |
| Restore drills done, RTO recorded: the Mac load (OPS-4) and path B from a snapshot (B-1) | The two timings tables above |
| Snapshot schedule attached | gcloud compute disks describe racinglines-vm … --format='value(resourcePolicies)' |
| Health timer green; a test alert reached the phone | rl ops health; the push in the log |
External uptime monitor on /healthz, at least 99% since the cutover; dead-man and tunnel notification configured |
The monitor's report; Healthchecks.io shows "up"; the Cloudflare notification is listed |
Cloudflare Access in front of /admin, or SEC-1's fallback (admin lock keyed on username plus IP, /admin/sql writes refused while sign-up is on) |
A private window: /admin shows the Cloudflare login; or the SEC-1 tests |
| HSTS on | curl -sI https://racinglines.bet/login lists a strict-transport-security header |
| Load run 2 passed | The locust CSV and the VM block after it (Load run) |
| Settlement replay on R16 data passed, elapsed time recorded | The REPLAY-1 log on the Mac (Settlement replay) |
| Kalshi under 35 min, recorder under 5 min, disk under 70% | curl -s https://racinglines.bet/healthz |
| Swap on, or resized per DEC-17 | swapon --show; free -m |
| R16 incidents closed or accepted in writing | DRY-2's section in this doc |
Task list¶
The merge order for this doc's code tasks: the P1 ops batch OPS-1 → OPS-9 → OPS-3 → OPS-2 → OPS-4 → OPS-5 (Thu 1
Oct), then OPS-6 → OPS-8 on Mon 5 Oct (runbook P2 step 0), with OPS-8 before ACC-1. OPS-2 depends on OPS-3 (it adds
health to the racinglines/cli/ops.py that OPS-3 creates), so its branch starts from work/ops-3. OPS-11 (a Mac-only script) merges
on its own by Sun 4 Oct. Branches are work/<task id lowercase> (work/ops-1, …). Every task passes racinglines check, python -m pytest -m "not live" and
mkdocs build --strict before merge, with tests/golden unchanged.
| Task | What | Role | Model | Files | Size | Depends on | Due | Verify | ⚠️ |
|---|---|---|---|---|---|---|---|---|---|
| OWNER-1 | Merge PR #71 and deploy it | owner | owner | scripts/deploy/vm.sh, deploy/vm/update.sh (the PR's diff: vm.sh, CLAUDE.md, docs/vm-deploy.md) |
S | — | Wed 30 Sep 12:00 PDT | vm.sh status: its sha, no PAUSED line |
⚠️ needs special attention: the VM deploy path and timer handling; replaces the freeze |
| OWNER-4 | VM cutover; vm.sh public off, APP_SECRET, RACINGLINES_OPS_NTFY_TOPIC |
owner | owner | /etc/racinglines.env (VM), the tunnel's racinglines.bet hostname |
M | OWNER-1 | Wed 30 Sep | The OWNER-4 blocks; smoke through Cloudflare | ⚠️ needs special attention: moves production and starts the recorder and signals on the VM |
| SCOUT-1 | Read-only VM baseline | scout | Haiku | — | S (~6 calls) | OWNER-4 | Wed 30 Sep | A findings table, NOT FOUND where missing | — |
| OPS-1 | /healthz (async, cached); the engine's pool_pre_ping, pool_recycle and pool size (G15) |
implement | Sonnet | racinglines/web/health.py (new), racinglines/web/app.py, racinglines/db/config.py, tests/test_health.py (new) |
S (~12 calls, 4 files) | — | Wed 30 Sep | python -m pytest tests/test_health.py -m "not live" |
— |
| OPS-2 | racinglines ops health and the health timer, including the hung-step, closed-sync, live-step and mail checks |
own | Opus | racinglines/ops/health.py (new), racinglines/cli/ops.py (adds health to OPS-3's file), racinglines/markets/alerts.py, deploy/vm/systemd/racinglines-health.service/.timer (new), deploy/vm/racinglines.env.example, tests/test_ops_health.py (new) |
M (~26 calls, 7 files) | OPS-1, OPS-3 (branches from work/ops-3) |
Thu 1 Oct 12:00 PDT | The pytest line above; the recorder-stop test on the VM | ⚠️ new VM systemd units |
| OPS-3 | Nightly backup (pipefail, trailer check), scrubbed latest copy, rsync without deletes, bucket.sh push guard and restore argument |
own | Opus | racinglines/ops/__init__.py, racinglines/ops/backup.py, racinglines/cli/ops.py (all new; registers the ops group), racinglines/cli/__init__.py (GROUPS), deploy/vm/systemd/racinglines-backup.service/.timer (new), scripts/cloud/bucket.sh, scripts/deploy/vm.sh (restore passes a dump through), tests/test_ops_backup.py (new) |
M (~32 calls, 9 files) | — (needs DEC-19) | Thu 1 Oct 12:00 PDT | python -m pytest tests/test_ops_backup.py -m "not live"; two nightly dumps in the bucket |
⚠️ VM systemd unit and the backup path |
| OPS-4 | scripts/ops/restore_scratch.sh and the first timed drill |
implement | Sonnet | scripts/ops/restore_scratch.sh (new) |
S (~8 calls, 1 file) | OPS-3 | Sun 4 Oct | The script prints counts and timings; the timings table filled in | — |
| OPS-5 | U2: racinglines-kalshi-sync every 30 min (TimeoutStartSec=15min), closed sync in the 20:05 UTC run |
own | Opus | deploy/vm/systemd/racinglines-kalshi-sync.service/.timer (new), racinglines/cli/markets.py, tests/test_cli_markets.py (new), docs/todo.md |
M (~20 calls, 5 files) | EXCH-0 | Thu 1 Oct 12:00 PDT (before the book opens at 20:30) | python -m pytest tests/test_cli_markets.py -m "not live"; on the VM a run every 30 min, KXF1RACE-BAH26 synced_at under 35 min |
⚠️ new VM unit that writes market_links |
| OPS-6 | Recorder exits after 10 failed loops; signals TimeoutStartSec=45min; live-f1@ TimeoutStartSec=20min; docker log cap |
implement | Sonnet | racinglines/cli/f1.py, deploy/vm/systemd/racinglines-recorder.service, deploy/vm/systemd/racinglines-signals.service, deploy/vm/systemd/[email protected], deploy/vm/compose.override.yml, tests/test_recorder_loop.py (new) |
S (~11 calls, 6 files) | — | Branch Wed 30 Sep; merged and deployed Mon 5 Oct (after OWNER-5) | python -m pytest tests/test_recorder_loop.py -m "not live"; systemctl show racinglines-signals racinglines-live-f1@2026-17 -p TimeoutStartUSec on the VM |
⚠️ systemd unit edits, one of them a live-event unit; the first deploy recreates the Postgres container |
| OPS-7 | VM hardening. At the cutover: snapshot schedule, external uptime monitor, tunnel notification, ntfy topic, bucket lockdown and lifecycle. By Thu: swap, journald, updates, Healthchecks.io, secrets copy. Before sign-up: Cloudflare Access on /admin, HSTS. Plus the launch-weekend backup drop-in (Thu 8 – Tue 13 Oct) and, only if OPS-3 slips, the fallback backup |
owner | owner | /swapfile, /etc/systemd/journald.conf (as a drop-in), GCE policy racinglines-daily, the bucket's settings, Cloudflare notifications, Access and HSTS, the uptime monitor, /etc/racinglines.env, the backup timer drop-in |
S (~1.5 h over four sittings) | OWNER-4 | Cutover part Wed 30 Sep; the rest Thu 1 Oct 18:00 PDT; Access and HSTS Wed 7 Oct before STAGE-1 | The checks inside each block; the test push; the monitor's first report | ⚠️ VM configuration and the admin's access path |
| OPS-8 | Migration guard, dump before --migrate, smoke through Cloudflare |
own | Opus | scripts/deploy/vm.sh, deploy/vm/update.sh, scripts/deploy/smoke.sh |
M (~18 calls, 3 files) | OWNER-1, OPS-1 | Branch Wed 30 Sep; merged and deployed Mon 5 Oct (after OWNER-5), before ACC-1 merges (OWNER-8, Tue 6 Oct) | A fake-gcloud dry run of the four cases in the table above, as PR #71 was tested |
⚠️ needs special attention: the deploy path; makes every VM migration an explicit, backed-up owner step |
| OPS-9 | smoke.sh: /healthz through Cloudflare, freshness, legal pages and /signup behind arguments |
implement | Sonnet | scripts/deploy/smoke.sh |
S (~6 calls, 1 file) | OPS-1 | Wed 30 Sep | bash scripts/deploy/smoke.sh http://127.0.0.1:8000, then against https://racinglines.bet |
— |
| OPS-11 | Load run: scripts/ops/loadtest.py (locust), browse-and-bet on Mon 5 Oct, orders in STAGE-1 (Load run) |
implement (script) · owner (runs) | Sonnet | scripts/ops/loadtest.py (new) |
S (~8 calls, 1 file) | OPS-1; for the STAGE-1 run, the P2 fantasy batch | Script Sun 4 Oct; run 1 Mon 5 Oct 08:00–08:15 PDT (before OWNER-5); run 2 in STAGE-1 part B (5o) | The pass criteria in Load run; the numbers in DRY-2 and the go/no-go | — |
| DRY-1 | R16 closed reliability dry run | owner | owner + Haiku (log readback) | /opt/racinglines/data/backups/r16-dryrun-<UTC>.log (VM) |
weekend | OPS-2, OPS-3, OPS-5, OPS-7 | Thu 1 Oct 18:00 PDT – Sun 4 Oct 12:00 PDT | The exit criteria above | — |
| DRY-2 | R16 retro: incidents, alert noise, backup and restore timings, memory and disk peaks | own | Opus | docs/vm-reliability.md |
S (~8 calls) | DRY-1, OPS-4 | Mon 5 Oct (memory by 10:00 PDT) | The results section with every number | — |
| OWNER-7 | Resize to e2-medium if DEC-17 says yes | owner | owner | GCE instance racinglines-vm |
S (~15 min, 2–3 min down) | OWNER-6 | Mon 5 Oct 13:00–15:00 PDT | smoke, free -m |
⚠️ restarts every unit on the VM |
| OPS-10 | R17 on-call: phone alerts, deploys in windows, incident log | owner | owner + Haiku | /opt/racinglines/data/backups/r17-oncall-<UTC>.log (VM) |
weekend | LAUNCH-1 | Thu 8 – Sun 11 Oct | No unplanned outage over 15 min in the health log | — |
Names used across these docs¶
This subset of the conventions block is copied word for word. The master copy is in Fantasy soft launch.
TERMS
- account type = users.account_type: player (signed up through /signup), staff (owner, admin, collaborators, R16 testers), demo (maker, taker), system (polymarket-takers). Only players appear in leaderboards and fantasy stats.
- closed dry run = R16, Thu 1 Oct to Sun 4 Oct, 5 or fewer admin-created staff tester accounts, no sign-up. staging rehearsal = Wed 7 Oct on the VM with RACINGLINES_SIGNUP=invite and tester codes in season 2026-dryrun. soft launch = RACINGLINES_SIGNUP=invite from Thu 8 Oct 18:00 PDT (Fri 9 Oct 01:00 UTC). open sign-up = RACINGLINES_SIGNUP=open (not before Thu 22 Oct).
- Times: owner-facing times in PDT with UTC in brackets; systemd OnCalendar in UTC.
TABLES: one additive Alembic revision fantasy_schema_v1, file migrations/versions/2026MMDD_<rev>_fantasy_schema_v1.py, down_revision = the single head at branch time (c8e3f6a2d4b1 on 29 Sep)
- bankroll_entries: id, season_id, user_id, ts, kind (grant|stake|payout|refund|trade|fee|settle|book_pnl|adjust), amount Numeric(12,2) signed, ref_table, ref_id, note, created_by. UNIQUE(ref_table, ref_id, kind). balance = SUM(amount).
- fantasy_positions: id, season_id, user_id, market_link_id, exchange, yes_shares, no_shares, cost, fees, status (open|settled|void), outcome Boolean NULL, payout, settled_at. UNIQUE(season_id, user_id, market_link_id).
- fantasy_stats: season_id, user_id, venue (all|kalshi|polymarket|book), role, starting_bankroll, balance, equity, pnl, roi, turnover, bets, settled, won, hit_rate, max_drawdown (positive number), sharpe (x sqrt(24); NULL under 4 events), events, rank, computed_at. PK(season_id, user_id, venue).
ENV (in /etc/racinglines.env on the VM; every new one is off or safe by default)
RACINGLINES_FANTASY=0|1 (master switch: nav, /fantasy, /leaderboard); RACINGLINES_SIGNUP=off|invite|open (default off); RACINGLINES_SIGNUP_CAP=50; RACINGLINES_FANTASY_BOOK=0|1; RACINGLINES_FANTASY_EXCHANGE=0|1; RACINGLINES_FANTASY_EXCHANGE_MAKERS=0 (stays 0 this sprint); RACINGLINES_MAIL_BACKEND=log|smtp (default log); RACINGLINES_SMTP_HOST=smtp.protonmail.ch; RACINGLINES_SMTP_PORT=587; [email protected]; RACINGLINES_SMTP_TOKEN (secret, never in git); RACINGLINES_MAIL_FROM=racinglines <[email protected]>; [email protected]; RACINGLINES_MAIL_DAILY_CAP=200; RACINGLINES_TURNSTILE_SITEKEY and RACINGLINES_TURNSTILE_SECRET (empty = widget off); RACINGLINES_OPS_NTFY_TOPIC (ops alerts, separate from RACINGLINES_NTFY_TOPIC for market alerts); RACINGLINES_HEALTHCHECK_PING_URL (dead-man ping). Existing: RACINGLINES_URL=https://racinglines.bet (base for mail links); RACINGLINES_GCS_BUCKET; APP_SECRET (must be set); MAX_STAKE (per-bet cap, F$100); RACINGLINES_KALSHI_SPRINTS; RACINGLINES_CANCELLED_RACE_RULES (not changed by fantasy); RACINGLINES_DEMO_USERS; RACINGLINES_DEMO_CONTEXT. Never touched: POLYMARKET_TRADING_ENABLED, KALSHI_TRADING_ENABLED.
COOKIES: rl_session = uid|expires|sid|epoch|HMAC (epoch = users.session_epoch; bumping it ends every session). rl_csrf = signed double-submit token for unauthenticated forms.
ROUTES
Public (add to PUBLIC_PATHS): /signup, /verify/{token}, /verify/resend, /password/forgot, /password/reset/{token}, /terms, /privacy, /rules, /healthz.
Admin: /admin/fantasy, /admin/fantasy/seasons/{id}, /admin/fantasy/invites, /admin/fantasy/members/{user_id}, /admin/fantasy/settle.
CLI: racinglines fantasy season create|set|close|list; racinglines fantasy invite create|revoke|list; racinglines fantasy tick [--full]; racinglines fantasy stats [--full]; racinglines mail test --to <addr>; racinglines ops health [--alert]; racinglines ops backup [--label <task>].
CODE: racinglines/fantasy/{seasons,ledger,book,exchange,settle,stats}.py; racinglines/markets/books.py (read-only live order books; does not import polymarket/trade.py); racinglines/web/{accounts,mailer,health}.py; racinglines/ops/{health,backup}.py; racinglines/cli/{fantasy,ops,mail}.py; TERMS_VERSION = '2026-10-08' in racinglines/web/accounts.py. Templates: signup, verify, forgot, reset, account, terms, privacy, rules, fantasy, fantasy_markets, fantasy_book, leaderboard, admin_fantasy (.html); mail/verify.txt, mail/reset.txt, mail/welcome.txt, mail/email_changed.txt. Tests: tests/test_auth.py, test_signup.py, test_mailer.py, test_fantasy_schema.py, test_fantasy_seasons.py, test_ledger.py, test_private_book.py, test_fantasy_book.py, test_fantasy_exchange.py, test_fantasy_settle.py, test_fantasy_stats.py, test_health.py, test_ops_health.py, test_ops_backup.py; fixtures in tests/fixtures/fantasy/.
activity_log actions (new): signup, signup_rejected, email_verify, verify_resend, password_reset_request, password_reset, password_change, email_change, account_delete, role_override, bankroll_adjust, member_suspend, invite_create, invite_revoke, season_update, fantasy_order, fantasy_order_rejected, fantasy_settle, fantasy_resettle, mail_sent, mail_failed.
SYSTEMD (deploy/vm/systemd/): racinglines-health.service + .timer (every 5 min); racinglines-backup.service + .timer (daily 10:00 UTC); racinglines-kalshi-sync.service + .timer (every 30 min, daily closed sync; this is U2); racinglines-fantasy.service + .timer (every 10 min: close, settle, stats). Existing and unchanged in name: racinglines-web, racinglines-recorder, racinglines-signals(.timer), racinglines-live-f1@<event>(.timer), racinglines-live-dh@, racinglines-mcp, cloudflared. GCE: snapshot resource policy racinglines-daily (14-day retention) on disk racinglines-vm.
BACKUPS: pre-change data/backups/db/racinglines-before-<task>-<UTC>.sql.gz (CLAUDE.md); nightly data/backups/db/racinglines-nightly-<UTC>.sql.gz (7 kept on disk) and gs://$RACINGLINES_GCS_BUCKET/db/nightly/ (35-day lifecycle); owner-run logs /opt/racinglines/data/backups/<name>-<UTC>.log.
EMAIL (Proton Mail Plus, 10-address cap): [email protected] (owner mailbox), [email protected] (catch-all target and reply-to), [email protected] (app sender, holds the SMTP token). DMARC reports go to the rua address from Cloudflare DMARC Management.
What this doesn't cover¶
- The fantasy features themselves: sign-up, seasons, the ledger and stats are in Fantasy accounts; exchange paper trading, the private book and settlement are in Fantasy trading.
- Mail DNS and the Proton setup: Email setup.
- The launch-day command blocks, the off switch and the full go/no-go: Fantasy launch runbook.
- High availability. There is one VM in one zone with one tunnel connector. A second region, a standby or a managed database is U12 (a Google Cloud move, December) and the Google Cloud proposal.
- A staging environment separate from production. The staging rehearsal runs on the production VM in season
2026-dryrun. - The Mac's own backups, and the cloud sessions' copy of the old bucket.
- Security review of the app beyond the config checks, Cloudflare Access and HSTS here. SEC-1 in
Fantasy accounts covers auth hardening. Two items this review raised belong there, not
here: a constant-time login (one scrypt verify against a dummy hash when the handle or email is unknown, inactive
or deleted, with a timing test), and the app's security headers (
X-Frame-Options: DENYorframe-ancestors 'none',X-Content-Type-Options: nosniff,Referrer-Policy: no-referrer, and a CSP allowing self pluschallenges.cloudflare.comfor Turnstile). - The
/privacywording on backup retention: LEGAL-1 writes it from the numbers under What is kept where. - Cost review beyond the rough monthly figures marked as estimates.
Open items¶
- SCOUT-1: which units and timers were enabled on 29 Sep; the VM's DB size after the Kalshi tape load; which env keys exist; cloudflared's restart policy; whether journald was already persistent.
- The tunnel's second connector or a standby. A second
cloudflaredon the same VM guards only against a process crash. A connector elsewhere would serve a different database, so it is only safe with a shared DB. For now the answer is recovery path B. Owner's call whether that is enough for the soft launch. - Cloudflare Access for
mcp.racinglines.bet./admingets Access before sign-up opens (step 10); the MCP host doesn't yet, because its clients send only the bearer token (MCP server) and can't answer an Access login. An Access service token for those clients would add the second factor; it needs a check that each MCP client can send the two service-token headers. - Cloud sessions and the full dumps (DEC-19). The scrubbed latest copy is what cloud sessions should read. Whether
their credentials can also read
db/nightly/anddb/before/depends on how DEC-19 grants them access; if they can, the full dumps move to a second, owner-only bucket or a managed folder with its own IAM. - When to move off one VM: the triggers proposed are more than about 200 active players, memory peaks over 80% on e2-medium, or U12 in December.
OOMScoreAdjust=on the oneshot units (Capacity), in no task yet.- Path C (full rebuild) is not rehearsed. A timed rebuild into a scratch project after the sprint would turn its 2–3 h estimate into a number.
- The restore drill timings (OPS-4, ACC-2, B-1), the load run numbers (OPS-11) and DRY-2's results section are still to fill in.
- Decisions this doc depends on: DEC-17 (resize), DEC-18 (targets) and DEC-19 (bucket), with DEC-18 and DEC-19 due Thu 1 Oct 12:00 PDT (Roadmap).
- Unverified details: Kalshi's read rate limits (EXCH-0 records the headers); the dashboard menu names at
Healthchecks.io, Cloudflare (Access, HSTS) and the uptime monitor, and which free plans offer a webhook or ntfy
integration and a keyword check; whether the closed sync fits in 10 minutes (OPS-5 times it); whether
gcloudaccepts a snapshot--start-timethat is not on the hour (11:00 is used, so this doesn't bite);needrestart's default mode on the VM (SCOUT-1'sapt-configoutput and step 3 settle it). - The
--year 2026rollover before Tue 1 Dec 2026 (G9).