System design interview
Design an online code judge
Sandboxed runs, a submission queue, contest spikes and repeatable verdicts.
At a glance
- Intermediate
- Queues and streams, Storage
- Submit API · Submission DB · Judge queue · Autoscaler · Judge worker · Test data · Sandbox
Press Play to follow a request through the design, or step through it.
- Client and edge
- Service
- Data store
- Cache and queue
- The user submits a C++ solution for a problem. The API checks the source size and the user's limit.
- The API saves the source with the status 'queued' and a new submission ID.
- It puts a small job, holding only the ID, on the queue and returns the ID to the user at once.
- An idle judge worker takes the job. If the worker dies, the job returns to the queue after a timeout.
- The worker compiles the code and runs each test in a fresh sandbox, with CPU, memory and time limits.
- It compares each output with the expected answer, decides the verdict and saves it.
- The API reads the verdict when the client asks for the status of the submission.
- The user sees 'Accepted' or the first failing test number.
- Client
- Submit API: checks, limits
- Submission DB: source, verdicts
- Judge queue: absorbs spikes
- Autoscaler: watches queue
- Judge worker: one job at a time
- Test data: cached on worker
- Sandbox: no network
On this page
In 2 minutes
- The numbers. The contest peak is about 24 times a normal day, and it is known in advance.
- Isolation. Hardened containers inside disposable worker VMs.
- Timing. CPU time on a dedicated core, with a wall-clock backstop.
- Scaling for contests. Scheduled scale-up plus a queue-age rule.
- Test data. Versioned and cached on each worker.
Read the 2-minute answerHide the 2-minute answer
A judge has to run untrusted code safely, and it must survive a contest spike that is about 24 times a normal day. The Submit API validates the request, saves the source with a queued status, puts a small job holding only the submission ID on a durable queue, and returns at once. Judge workers, one job per dedicated core, take jobs, use test data cached locally by problem and version, and run the compile and every test in a fresh sandbox.
The sandbox has no network, a seccomp filter, cgroup limits for memory, processes and output, and both a CPU limit and a wall-clock limit. The worker compares output outside the sandbox and writes the verdict, keyed by submission ID, so redelivery is harmless. Sizing is the arrival rate times the time per job: 83 per second times 8 core-seconds is about 667 cores, so with headroom I would plan 55 machines of 16 cores, started on schedule before the contest, with a queue-age rule as backup.
A per-user cap and separate lanes keep it fair. Pinned toolchain images and re-runs of borderline timings keep verdicts repeatable. For a bad test case, I would publish a new data version and rejudge in a low-priority lane.
Step 1 of 6: Clarify the scope
Questions to ask first
Ask these before you draw anything. Guess why each answer changes the design, then open it to check.
Which languages do we support, and does each need a compiler?
How many people take part in a contest, and how spiky is it?
Must the same code get the same verdict every time, even under load?
How large is the test data for one problem, and who can see it?
Do users see a single verdict, or a result for each test?
Requirements
Functional
- Accept source code, a language and a problem, and return a submission ID at once.
- Compile and run the code against every test case, within time and memory limits.
- Return one verdict: Accepted, Wrong Answer, Time Limit Exceeded, Memory Limit Exceeded, Runtime Error or Compile Error.
- Show progress and the final verdict live, and keep a history of every submission.
- Rejudge past submissions after a faulty test case is fixed.
Non-functional
- On a normal day, 95% of submissions get a verdict within 10 seconds.
- During a contest, 95% wait under 30 seconds in the queue.
- Untrusted code has no network, no shared files and no way to reach the host or other runs.
- Running the same submission again gives the same verdict at least 99.9% of the time.
Step 2 of 6: Size it
Show the mathsHide the maths
These numbers are assumptions for the exercise, not facts about a real system:
- A normal day brings 300,000 submissions.
- A contest has 20,000 participants who send 120,000 submissions in 2 hours. The busiest moments run at 5 times that average.
- Judging one submission takes 2 seconds to compile and 20 tests of 0.3 seconds each, on one dedicated CPU core.
- There are 2,000 problems, and the test data for one problem averages 20 MB.
The arithmetic:
- Submissions per second, normal day
- 300,000 ÷ 86,400 s ≈ 3.5
- ≈ 3.5/s
- Submissions per second, contest
- 120,000 ÷ 7,200 s ≈ 16.7; × 5 for the peak
- ≈ 17/s on average, ≈ 83/s at the peak
- Core-seconds per submission
- 2 s compile + 20 × 0.3 s
- 8 core-seconds
- Cores busy at the contest peak
- 83/s × 8 core-seconds
- ≈ 667 cores; with 30% headroom ≈ 870, or 55 machines of 16 cores
- Cores busy on a normal day
- 3.5/s × 8 core-seconds
- ≈ 28 cores, so the contest needs about 24 times more
- All test data
- 2,000 problems × 20 MB
- 40 GB, which fits on a worker's local disk
Step 3 of 6: Sketch the path of one request
Step through the diagram at the top of the page to watch a request move through these parts.
The Client posts source code to the Submit API, which checks the size, the language and a per-user limit. It saves the source and a status of 'queued' in the Submission DB, puts a small job, holding just the submission ID, on the Judge queue, and returns the ID straight away. The user never waits for the judging.
A Judge worker takes a job from the queue. It fetches the problem's cases from Test data, or uses its local cache, and runs the code inside a Sandbox: first the compile, then each test with fixed limits. It compares the output outside the sandbox, decides the verdict and writes it to the submission DB.
An Autoscaler watches the queue's depth and the age of its oldest job, and adds or removes workers. For a known contest it adds them in advance. The client polls the API, or listens for updates, to see the status change from queued to judging to a final verdict.
Other paths through the system
Run the tests
- The worker asks for the problem's test cases, naming the problem and the test data version.
- Test data returns the files. The worker keeps them on local disk for the next submission to this problem.
- The worker starts the sandbox with the compiled program and feeds it one test input.
- The sandbox returns the program's output, the CPU time used and the peak memory, or a kill reason.
- After the last test, or the first failure, the worker writes the verdict.
Add workers before a contest
- The autoscaler asks the queue for its depth and the age of its oldest job.
- The queue answers. A contest starts in ten minutes, and the schedule already calls for 55 machines.
- The autoscaler starts the extra workers early, so they are warm and have test data cached.
- The new workers start taking jobs from the queue, and the wait stays short.
Step 4 of 6: Go deep on the hard part
The sandbox
How do you run code you do not trust, without letting it harm the host or other users?Assume every submission is hostile.
Assume every submission is hostile. It can fork until the machine is full, fill the disk, read other files, open the network, or look for a kernel bug. The compiler is also at risk, since a tricky source file can make it use huge memory or read files it should not. Layer the defences.
- Isolation. Run each job in a container built from Linux namespaces: its own process list, mounts and an empty network, so there is no network at all. Add a seccomp filter that allows only the system calls a normal program needs, drop all privileges, and use an unprivileged user and a read-only root file system. A small writable scratch area is thrown away afterwards.
- Resource limits. Use cgroups. Cap memory at, say, 256 MB with no swap, so an overrun is killed and reported as Memory Limit Exceeded. Pin the run to one core. Cap the number of processes at 64 to stop fork bombs, and cap output size so a loop that prints forever cannot fill the disk.
- Two clocks. Enforce a CPU time limit, say 1 second, measured by the cgroup. Enforce a wall-clock limit of about 3 times that, to catch programs that sleep or wait for input. Either one gives Time Limit Exceeded.
- A fresh sandbox every time. Never reuse one between users, so nothing is left behind to read.
Keep the hidden answers out of the sandbox. The program receives each input on standard input and nothing else. The expected output stays outside, where the worker compares it.
Containers share the host's kernel, so a kernel bug could let code escape. If the contest is public and the stakes are high, run each job in a micro-VM, which has its own kernel and a stronger wall, at the price of a slower start. A cheaper middle path: run the containers inside disposable worker VMs that hold no secrets and no credentials. Then an escape reaches nothing valuable.
The queue, the worker pool and fairness
A contest starts and 80 submissions a second arrive. How do you keep up and stay fair?The Submit API does as little as possible.
The Submit API does as little as possible. It checks the source size and the language, applies a per-user limit, saves the source and puts a job on a durable queue that holds only the submission ID. It replies in milliseconds with the ID. The queue turns a sharp spike into a line the workers drain at their own pace.
Delivery is at least once. A worker hides a job while it works, and if it does not finish within a timeout, the job reappears for another worker. Judging takes tens of seconds, so the worker sends a heartbeat that extends the timeout. Because the same code and tests always give the same verdict, a duplicate run is harmless: the verdict is written once, keyed by the submission ID. A job that crashes its worker three times goes to a dead-letter queue for a person to review, and the user sees 'System error' rather than a hang.
Sizing. By Little's law, the cores busy are the arrival rate times the time per job. At the contest peak that is 83 per second × 8 core-seconds, about 667 cores. Add 30% headroom and you need about 870 cores, which is 55 machines of 16 cores. Judge one job per core, so tests do not compete for CPU and distort the timings.
Autoscaling that reacts to queue depth is too slow on its own, because a new machine takes minutes to start. For a known contest, scale on the schedule: bring machines up 15 minutes early, and warm their test-data caches. Keep the reactive rule as a backup, based on the age of the oldest job. If the queue's oldest job passes 30 seconds, add capacity.
Fairness. Cap how many of one user's jobs run at once, say 3, so a script that submits 200 times cannot starve everyone else. Use separate lanes for contest, practice and rejudge jobs, with the contest lane first. Show the user their place in line, and the progress, such as 'judging test 7 of 20'.
Repeatable verdicts, test data and rejudging
How do you make sure the same code always gets the same verdict, and fix a wrong test case later?Most verdicts are stable by nature.
Most verdicts are stable by nature. The trouble is borderline timing and moving tools, so remove the sources of drift.
- Pin the toolchain. Each language runs from an immutable image, identified by a digest, with fixed compiler flags. Record the image on the submission. An upgrade is a new image, never an in-place change.
- Measure CPU time, on a dedicated core. One job per core, no neighbours. Compare the time to a limit that was set from a reference solution, say 2 to 5 times its time, with a larger factor for slower languages.
- Re-run the borderline. If a run lands within about 10% of the limit, repeat it up to three times and keep the best. A noisy machine then cannot turn a pass into a fail.
- Special checkers. When a problem has many valid answers, run a checker program in its own sandbox. It reads the input, the jury's answer and the user's output.
Test data. Keep it in object storage with a version number per problem. A worker caches it on local disk by problem and version, and checks a checksum. The whole 40 GB fits on one disk, and a least-recently-used rule drops the oldest problems if space runs short. A new version means a new cache key, so there is no stale-data bug.
Rejudging. A test case is found to be wrong. Publish the corrected data as the next version, such as 5 in place of 4. Then a rejudge job finds the affected submissions, perhaps 30,000 of them, and puts them in the low-priority lane so live users are not delayed. Each new verdict is stored as a new row that names the test-data version. The old one stays in the history. Where the result changes in a contest, recompute the standings and announce it.
Step 5 of 6: Name the trade-offs
- Hardened containers inside disposable worker VMs
- A micro-VM for every run
Containers start fast and pack many jobs per machine, and the VM wall limits any escape. A micro-VM is stronger but slower to start and heavier to run.
- CPU time on a dedicated core, with a wall-clock backstop
- Wall-clock time only
A busy neighbour cannot slow a run and cause a false Time Limit Exceeded. The cost is one job per core and more machines.
- Scheduled scale-up plus a queue-age rule
- Reactive scaling on queue depth only
Capacity is warm when the first burst lands. The cost is paying for machines for a few idle minutes, and a calendar that must be correct.
- Versioned and cached on each worker
- Fetched from storage for every run
Runs start fast and storage is spared a flood of requests. The cost is cache management and a version in every cache key.
Step 6 of 6: Say what breaks next
Follow-up questions an interviewer asks once the design is on the board. Answer each one out loud, then open it to compare.