Skip to content

Latest commit

 

History

History
73 lines (54 loc) · 4.1 KB

engineering-decisions.md

File metadata and controls

73 lines (54 loc) · 4.1 KB

Engineering decisions

Each decision with the problem it solves and what it costs.

Kotlin end to end

Decision: Ktor for the API, Compose Multiplatform for the clients. Why: one language and shared data models across Web (WebAssembly), Android and Desktop; Ktor's coroutines keep thousands of polling requests cheap. Cost: Compose for Web draws to a canvas, so the code editor (Monaco) is an HTML layer kept aligned with the canvas, and hidden while a menu opens over it.

PostgreSQL as the queue, Redis as a doorbell

Problem: the first version pushed submission ids to a Redis list and a worker took them with BRPOP. BRPOP removes the id, so a worker that died mid-judge lost the job, an exception left the row in RUNNING forever, and a failed LPUSH after the insert left it in QUEUED forever. Decision: the submissions table is the queue. Workers claim with UPDATE ... WHERE id = (SELECT ... FOR UPDATE SKIP LOCKED), hold a lease that a heartbeat refreshes every 10 s, and a reaper re-queues expired leases. Redis only wakes workers up; they also poll. Cost: a few extra queries per minute. At this scale that is nothing, and it removed every lost-job path. Verified by killing the service mid-judge (the job was judged again after the lease expired) and by running with Redis stopped.

One harness, two sandboxes

Problem: the first executors ran programs with ProcessBuilder in the API's own container: the reported memory was the API's JVM heap, the memory limit was stored but never applied, every test case recompiled the program, compilation had no timeout, any stderr output counted as a runtime error, and output was read only after the process ended, so a program printing more than the pipe buffer looked like a time-out. Decision: a small harness that compiles once and runs every test with POSIX limits, files instead of pipes, and wait4() for exit status, CPU time and peak RSS. The backend runs it in a fresh Docker container per submission, or as a child process where Docker is not available. Cost: a Python file next to the Kotlin code. It keeps the limits in one place and is tested by attacking it: 42 cases across both modes.

CPU time decides TLE

Why: the free host is small and shared. Wall-clock time would fail correct programs whenever the host is busy; CPU time is what the program actually used. A wall-clock cap (twice the limit, at least the limit plus 3 s) still stops programs that sleep or block.

Java gets -Xmx, not RLIMIT_AS

Why: the JVM reserves far more virtual address space than it uses, so an address-space limit kills it before it starts. The heap flag enforces the problem's memory limit instead, and OutOfMemoryError maps to MLE.

Free hosting, honestly labelled

Decision: Render (API + workers), Neon (PostgreSQL), Upstash (Redis), Netlify (web). All free. Cost: Render's free web service has no Docker daemon, so the live demo uses process mode, which has no network isolation and runs programs as the backend's user. The documentation says so, secrets are kept out of the programs' reach, and the per-run limits are the same as in Docker mode. The free instance also sleeps after 15 minutes without traffic and takes about a minute to wake.

Additive API changes only

The deployed web client and the API are released separately (Netlify by hand, Render on every push). New response fields (per-test metrics, problem title, test count, limits) all have defaults, and the client ignores unknown fields, so either side can be deployed first.

Limits and abuse

A public demo that runs arbitrary code needs brakes: per-client rate limits (keyed on X-Forwarded-For behind the proxy), a cap on queued submissions, a 64 KB source limit and a language allow-list. Accounts are not implemented yet; rate limits are the protection for now.

What is next

  • Accounts, so history is per user and rate limits can follow the user.
  • Server-sent events instead of polling for submission status.
  • A "Run" button for custom input, reusing the harness without expected output.
  • gVisor (runsc) as the Docker runtime for a stronger kernel boundary.