SYSTEM DESIGN · DESIGN PROBLEMS · PLATFORM AND ASYNC · MEDIUM · 40 OF 41

Design an Online Code Judge: Sandboxed Runs, Fair Verdicts and Contest Surges

How to design a site where users submit code that is compiled and run against hidden tests: an asynchronous submission flow, a judge queue and worker pool, sandboxes that contain hostile code with no network and strict CPU, memory and process limits, consistent timing for fair verdicts, checkers and rejudges, and a contest mode that survives a fifty-thousand-person start with live standings.

  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  8. 8
  9. 9
  10. 10
  11. 11
  12. 12
  13. 13
  14. 14
  15. 15
  16. 16
  17. 17
  18. 18
  19. 19
  20. 20
  21. 21
  22. 22
  23. 23
  24. 24
  25. 25
  26. 26
  27. 27
  28. 28
  29. 29
  30. 30
  31. 31
  32. 32
  33. 33
  34. 34
  35. 35
  36. 36
  37. 37
  38. 38
  39. 39
  40. 40
  41. 41
1 / 18

Press play to hear the narration, or step through at your own pace.

Notes

Listen

Listen. Users read a problem with its limits and sample tests, then submit code in one of about twenty languages. We compile it and run it against hidden tests, and return a verdict: accepted, wrong answer, time limit exceeded, memory limit exceeded, runtime error or compile error. Contest mode adds a timer, live standings and penalties. Plagiarism detection is out of scope.

Order the qualities. First, security: user code must never reach the network, the host, other submissions or the hidden tests. Second, fairness: the same code should always get the same verdict and a similar run time. Third, speed: most verdicts within ten seconds. And contests must survive a ten times burst without losing a single submission.

Functional
  • browse problems: statement,
  • limits, sample tests
  • submit in ~20 languages
  • compile, run on hidden tests
  • verdicts: Accepted, Wrong
  • Answer, Time Limit, Memory
  • Limit, Runtime Error,
  • Compile Error
  • contests: timed, live
  • standings, penalties
Non-functional
  • secure: user code can never
  • reach the network, the host,
  • other users or the tests
  • fair: same code → same
  • verdict and similar time
  • fast: verdict in ≤ 10 s
  • for most submissions
  • contest start: survive 10x
  • bursts, no lost submissions

Ask scale

Scale. Practice traffic is about two million submissions a day, only twenty-five a second. Contests are the real load: fifty thousand people, with about a hundred and fifty submissions a second in the first fifteen minutes. Each one costs around seven CPU seconds, two to compile and a hundred tests at fifty milliseconds. That is about a thousand busy cores, so roughly a hundred and thirty eight-core judges.

Back of the envelope
practice: 2M submissions/day
  ≈ 25/s average

contest: 50,000 people, 2 hours
  first 15 min ≈ 150 submits/s

cost per submission:
  compile 2 s + 100 tests × 50 ms
  ≈ 7 CPU-seconds
150/s × 7 s ≈ 1,000 busy cores
  → ~130 eight-core judges

Define API

The API. Submitting returns two oh two with a submission id right away, because judging takes seconds. The client then gets progress, like running test thirty-seven, over a WebSocket, or polls the submission. A separate run endpoint executes code on the user's own input. Contests have a paged standings endpoint.

API
GET  /v1/problems/{slug}
POST /v1/submissions
  { problem_id, language,
    source, contest_id? }
  → 202 { submission_id }
GET  /v1/submissions/{id}
  → { status, verdict, time_ms,
      memory_kb, failed_test }
WS   /v1/submissions/{id}/events
POST /v1/run  (custom input)
GET  /v1/contests/{id}/standings

Data model

Data model. Problems and submissions are relational and modest in size, so SQL works well. A problem records its time and memory limits, its checker, and which version of the tests to use. Test files live in object storage under a versioned path. Each submission stores its status, verdict, time, memory, and the first failing test, which is shown only for samples.

Data model
problems (SQL)
  id, slug, time_ms, memory_mb,
  checker, tests_version
test sets (object storage)
  tests/{problem}/{version}/
  001.in 001.out … (hash-named)
submissions (SQL)
  id, user_id, problem_id, lang,
  source_ref, status, verdict,
  time_ms, memory_kb, failed_test
  contest_id, created_at

High-level design

The high-level design. The API checks the request, saves the submission as queued and puts a job on the judge queue. A judge worker claims it, fetches the test set, which it caches locally, compiles the code inside a sandbox, runs each test inside a sandbox, compares output with the expected answer, and writes the verdict back.

While judging, the worker publishes progress events: compiling, running test thirty-seven, then the verdict. A small push service holds the user's WebSocket and forwards them. If the socket drops, the client simply reads the submission record. Progress is a nicety; the database row is the truth.

Deep dive: sandbox

Deep dive one: the sandbox. Assume every submission is an attack. Code will try to open sockets, read the test files or other users' code, fork until the machine dies, allocate sixty-four gigabytes, print ten gigabytes of output, loop forever, or exploit the compiler itself with a tiny file that expands into gigabytes. Each needs its own defence.

Defend in layers. The outer layer is a throwaway micro VM or a user-space kernel like gVisor, one per submission. Inside, there is no network at all, a read-only file system except a small capped work directory, and cgroups limiting memory, CPU and process count, so a fork bomb stops at sixty-four processes. A seccomp allow-list blocks unusual system calls. Compilation runs in the same sandbox.

What hostile code tries
  • open a socket → steal tests,
  • mine coins, attack others
  • read /tests or other users'
  • files
  • fork bomb: while(1) fork()
  • allocate 64 GB
  • print 10 GB of output
  • infinite loop, sleep forever
  • compile bomb: 1 line that
  • expands to gigabytes
Defence in layers
  • outer: micro-VM or gVisor
  • per submission, thrown away
  • no network namespace
  • read-only root; only /work
  • writable, size-capped
  • cgroups: memory, CPU, pids
  • (pids ≤ 64 kills fork bombs)
  • seccomp: allow-list syscalls
  • unprivileged user, no caps
  • compile in the same sandbox

Deep dive: fair verdicts

Deep dive two: fairness. Limit CPU time, and add a wall clock cap of about double plus one second to catch code that just sleeps. Pin each run to its own core so neighbours cannot slow it down, and use one hardware class and the same compiler flags everywhere. A run that times out just past the limit can be retried once, so one noisy moment does not decide a contest.

Correct verdicts need more than string comparison. Ignore trailing whitespace, and when many answers are valid, like any shortest path or a floating point result, run a checker program that validates the output. Stop at the first failed test unless the problem gives partial credit. If a test turns out to be wrong, bump the test version and rejudge the affected submissions at low priority.

Fair timing
  • limit on CPU time, plus a
  • wall clock cap (2× + 1 s)
  • to catch sleep()
  • one run per pinned core
  • no noisy neighbours
  • one hardware class for all
  • judges; same compiler flags
  • language multipliers only if
  • the problem says so
  • re-run near-limit TLEs once
Correct verdicts
compare: exact, or ignore
  trailing whitespace
checker program when many
  answers are valid, or floats:
  |a − b| ≤ 1e-6
stop at first failed test
  (or run all for partial score)

tests fixed? bump tests_version
  → rejudge affected submissions
    at low priority

Deep dive: contests

Deep dive three: contests. Fifty thousand people submit in the first minutes. Unlike most spikes, this one is on the calendar, so scale judges up half an hour before the start, pre-warm sandboxes for each language and pre-load the test sets. Contest jobs go before practice jobs. Limit each user to one submission per problem every ten seconds. The queue is durable, so a slow verdict is acceptable and a lost one never is.

Live standings. Rank by problems solved, then by penalty: minutes taken plus twenty for each wrong attempt before solving. Pack both into one score, solved times a million minus penalty, and keep it in a Redis sorted set, exactly as in the leaderboard problem. Many contests freeze the public board in the last hour and reveal the final ranks at the end.

Surviving the start
  • scale judges up 30 min
  • before start (it is planned)
  • pre-warm sandboxes per
  • language; tests pre-cached
  • contest queue before practice
  • rate limit: 1 submit / 10 s
  • per user per problem
  • queue is durable: slow, never
  • lost; show "position 412"
Live standings
rank by solved desc, then
  penalty asc
penalty = minutes to solve
  + 20 per wrong attempt

ZADD standings:{contest}
  score = solved × 10⁶ − penalty
ZREVRANGE for a page,
  ZREVRANK for "my rank"
freeze public board in the
  last hour; reveal at the end

Trade-offs

Failure modes. If a judge dies mid-run, its lease on the job expires and another judge starts over, which is safe because judging the same code twice gives the same verdict. If a sandbox is ever escaped, the attacker lands in a throwaway VM on a judge that holds no secrets and can only write its own verdict. Standings can always be rebuilt from verdicts.

Monitor queue wait by priority, judge time per language, how often a rejudge flips a verdict, which means unfair timing, and sandbox violations, alerting on any new kind. At ten times the scale, add regional judge pools, cache test sets on local disks everywhere, and shard the submissions table by user.

Failure modes
  • judge dies mid-run → lease
  • expires, job re-queued;
  • judging is idempotent
  • sandbox escape → the VM is
  • thrown away; judges hold no
  • secrets and no DB writes
  • except their own verdict
  • Redis lost → rebuild
  • standings from verdicts
  • queue backlog → slow, safe
Monitor and grow
  • monitor:
  • queue wait p95 by priority
  • judge time per language
  • verdict flips on rejudge
  • sandbox violations blocked
  • (alert on new kinds)
  • at 10x:
  • regional judge pools
  • cache compiled samples
  • shard submissions by user

Say it like this

In the interview: submissions are accepted asynchronously into a durable queue. Judges lease jobs and cache test sets. Every compile and run happens in a throwaway micro VM with no network, a read-only file system, cgroup limits and a syscall filter. Timing is fair through CPU limits and pinned cores. Checkers and versioned tests keep verdicts right, and contests get pre-scaling, priority and sorted-set standings.

Interview summary
  • submit → 202, durable queue
  • judges: lease job, cache tests
  • sandbox: micro-VM, no net,
  • read-only, cgroups, seccomp
  • CPU limit + wall cap, pinned
  • cores, one hardware class
  • checkers, versioned tests,
  • rejudge
  • contest: pre-scale, priority,
  • rate limit, ZSET standings

Interview questions

How do you safely run untrusted user code?

Run every compile and execution in a throwaway micro-VM or gVisor sandbox with no network, a read-only root file system, cgroup limits on memory, CPU and process count, a seccomp syscall allow-list and an unprivileged user.

How do you stop a fork bomb or a memory bomb?

Cgroup limits cap the number of processes, for example 64 pids, and the memory a submission can use, so the kernel kills it at the limit and the judge reports a runtime or memory limit verdict.

Why is submission asynchronous?

Compiling and running a hundred tests takes several seconds and load is bursty, so the API stores the submission, enqueues a job and returns an ID, then pushes progress and the verdict over a WebSocket.

How do you make time limits fair?

Limit CPU time rather than wall time, add a wall-clock cap to catch sleeping code, pin each run to a dedicated core on one hardware class, and re-run submissions that time out just over the limit.

How do you judge problems with many correct answers?

Use a problem-specific checker program that validates the contestant's output against the input and constraints, or compares floating point values with a tolerance, instead of exact string matching.

How do you handle the surge at the start of a contest?

Because the start time is known, pre-scale judges, pre-warm sandboxes per language and pre-cache tests, prioritise contest jobs over practice, rate limit submissions per user, and rely on a durable queue so nothing is lost.

How do you compute live contest standings?

Keep a Redis sorted set per contest where the score packs problems solved and penalty time, such as solved times a million minus penalty, so pages and individual ranks are logarithmic-time queries.

What happens if a judge worker crashes mid-submission?

The worker holds a lease on the job, so when it stops heartbeating the job returns to the queue and another worker judges it from scratch, which is safe because judging is deterministic and idempotent.