Operator Handbook

Infrastructure

Troubleshooting

Symptom first, then cause, then fix. If your problem is not here, the backend logs in Railway are the next stop — every scheduled job logs what it did and why it skipped.

No question appeared this morning

Check in this order.

Is it a weekend or a Swiss public holiday? The SMI job skips Saturday and Sunday outright. Holidays are not detected in advance — a question is created, then deleted later when no close data arrives.

Did the cron job fire? Look at cron-job.org's execution history for /admin/smi/daily. A 403 means the token is wrong, 404 the URL, a timeout usually a cold start on a sleeping container.

Run it by hand and read the log:

curl -X POST https://your-backend.up.railway.app/admin/smi/daily \
  -H "Authorization: Bearer $ADMIN_TOKEN"

The log array tells you exactly what happened:

Log message Meaning
skipped — market is closed on weekends Working as intended
question for <date> already exists It worked; the question is there
skipped — voting for <date> already closed Cron fired after 12:00 Zurich. Check your schedule against the DST table
no previous close available yet Data provider returned nothing usable
yahoo failed: … / stooq failed: … See SMI data providers below

If it is late morning and nothing works, click Publish SMI Question in the admin console — same code, run immediately. It still refuses after 12:00.

A question is stuck unresolved

SMI: resolution only attempts today's question after 18:00 Zurich, and only once the provider publishes the close. A question unresolved at 19:00 usually means the provider is failing. Run /admin/smi/resolve by hand and read the log.

YouTube: the tick needs a baseline before it can resolve. If race_start_views_a was never captured — because the API was down at 12:00, or the key was missing — the question can never resolve automatically, since the resolve half requires a non-null baseline.

That case needs a manual decision: either resolve it yourself in the admin console using public view counts, or delete it. There is no way to reconstruct a baseline after the fact.

Exact tie: the log says tie (ΔA=…, ΔB=…), skipping. It retries every tick and almost always separates within minutes. If it genuinely stays tied, resolve or delete manually.

SMI data providers failing

The app tries Yahoo Finance first, then Stooq, and gives up if both fail.

Note: As of mid-2026, Stooq serves a browser-verification page to non-browser clients, so it usually yields zero rows. It is kept only in case that gate is lifted. In practice Yahoo is the only working provider.

Yahoo is keyless and may be blocked from some cloud egress IPs — a provider that works from your laptop can fail from Railway. Test from the deployed backend, not locally.

This has been investigated at length. Twelve Data, Alpha Vantage, and Financial Modeling Prep were all evaluated and none of them serve SMI (^SSMI) closes on a free tier — do not spend time retesting them. The leftover FINANCIAL_MODELLING_PREP_API_KEY in .env.example is a relic of that search and is read by nothing.

If both providers are down for a day, the honest options are to resolve the question manually from any public SMI quote, or delete it. Do not leave it pending — the analysis skips unresolved questions, but a stale card confuses students.

YouTube API quota exceeded

The YouTube Data API has a daily quota. Symptoms: /admin/youtube/suggest returns a 502 mentioning quota, or the tick logs baseline fetch incomplete / closing fetch incomplete repeatedly.

The quota resets at midnight Pacific time, which is mid-morning in Zurich — so a quota exhausted during the day typically recovers before the next morning's suggestion but after that night's resolution window.

If the 24:00 resolution was missed, the tick keeps retrying and will close the race as soon as quota returns. The measured window ends up longer than twelve hours; because race_end_at is recorded, you can see exactly how much longer. Both videos are read in the same call, so the extra time does not favour either option.

To reduce consumption, regenerate suggestions less often — each Regenerate costs a fresh set of API calls.

Students cannot log in

"Invalid credentials" for everyone, suddenly. JWT_SECRET changed, which invalidates every existing token. Students need to log in again; their accounts are intact.

One student cannot log in. Login accepts either pseudonym or email in the identifier field. Have them try the other one. If neither works, they should use password reset.

Password reset emails not arriving. Check RESEND_API_KEY is set on the backend — without it, reset links are only logged to the console. Then check CORS_ORIGIN, since it is also the base URL for the reset link; a wrong value produces emails with dead links. Reset tokens expire after one hour and are single-use.

The forgot-password endpoint always returns a generic success, whether or not the email matches an account. That is deliberate — it prevents anyone probing which emails are registered — but it does mean "I got the confirmation" is not evidence the address was correct.

Nobody was emailed about today's question

Work down this list — the causes are ordered by how often they are the answer.

Is the notification cron job configured? POST /admin/notifications/dispatch is what sends the announcements, and it is a separate job from the SMI and YouTube ones. Call it by hand; the response tells you what it did:

curl -X POST https://your-backend.up.railway.app/admin/notifications/dispatch \
  -H "Authorization: Bearer $ADMIN_TOKEN"

Is RESEND_API_KEY set? Without it the dispatcher reports success and lists the recipients in its log array without sending anything. Look for (not sent — RESEND_API_KEY unset).

Was the question published more than 24 hours ago? Those are marked as processed and deliberately skipped — the log says skipped — published more than 24 h ago, and the outcome carries "skipped": "stale". An announcement that arrives after voting has nearly closed is worse than none.

Has voting already closed? Only questions with deadline > now are announced. If the cron has been down since before 12:00, today's question will never be emailed about.

Did the students turn it off? The Settings page has an Email notifications switch, default on. Only accounts with it on receive announcements.

Note that notified_at is a per-question ledger, not per-recipient: someone who registers after the announcement went out is not backfilled.

A student says their vote is missing

Did they actually submit? Voting is one-shot: the API rejects a second vote with 409 You have already voted on this question. There is no edit. Check the votes panel for that question in the admin console.

Was the question deleted? A public-holiday SMI question gets deleted along with its votes. That is the usual explanation for history that is shorter than remembered.

Did they miss the window? Voting closes at 12:00 Zurich, not at midnight. Students who assume "sometime today" miss it. This is worth saying explicitly in the first lecture.

CORS errors in the browser

The frontend is calling a backend that does not list its origin. Set CORS_ORIGIN on the backend to the frontend's exact public URL — scheme included, no trailing slash — and redeploy.

If the frontend is calling the wrong backend entirely, the cause is VITE_API_URL, which is baked in at build time. Changing it requires a rebuild, not just a restart.

Everything is gone after a deploy

The backend is running without its volume, so SQLite wrote to the container filesystem and it vanished on redeploy.

Confirm the Railway volume is attached and mounted at /data, and that DATABASE_PATH=/data/app.db. Data written to a non-volume path is not recoverable.

Docker problems locally

Health check keeps failing. Run docker compose logs backend. If it started fine, you are probably on a stale cached image — docker compose up --build.

Port already in use. Something else holds 3000 or 5173. Stop it, or change the ports in docker-compose.yml and .env.

npm run dev fails. Node 20+ required (node --version), and npm install must have been run at the repository root.

Next

Semester reset: starting clean for a new cohort.

ETH Zurich — Expert Algorithm voting app. Generated from docs-site/content/.