Build an online coding assessment platform: recruiters assemble timed tests, candidates solve problems in a browser IDE, and the system compiles and runs their code - code we must treat as hostile - safely, fairly, and fast enough to feel interactive.
Every other design in this practice set treats user input as data - bytes to store and serve. This system EXECUTES its user input. A submission is a program written by someone we have never met, under pressure, sometimes adversarial, and we compile and run it on our machines within seconds of receiving it. Think through what a hostile submission could try: read other candidates' code or the hidden test cases off the filesystem; open a network connection to fetch answers from a friend or an AI, or to exfiltrate our problem bank; fork-bomb the machine or allocate memory until the host dies; mine cryptocurrency on our CPUs; or plant a background process that tampers with the NEXT candidate's run. None of this is exotic - candidates have tried all of it on real platforms. So the central object in this design is the sandbox: a disposable, sealed environment where code can compute and nothing else. Almost every architectural choice - fresh container per submission, no network, read-only filesystem, hard resource caps, destroy after use - falls out of taking that threat model seriously. This is a security design that happens to grade code.
A verdict here is not a UI detail - it can decide whether a person gets a job. That raises the stakes on a subtle failure mode: nondeterminism from load. Suppose time limits were measured in wall-clock time - "the program must finish within 2 seconds of real time." On a quiet Tuesday a correct solution finishes in 1.4s and passes. During peak hiring season the same host runs twenty containers, everything is slower, the same code takes 2.3s, and the candidate is rejected with Time Limit Exceeded. Same code, different career outcome, decided by our capacity planning. That is not a bug; it is an injustice. The fix is to measure what the candidate controls: CPU time (how much computing the program actually did) rather than wall time (how long it waited its turn), and to pin resources - a dedicated CPU allocation and fixed memory per sandbox - so the environment is identical at 2am and at peak. Add fixed language versions and container images, and "the same code gets the same verdict" becomes a property of the system rather than a hope. The p95 < 5 second turnaround matters for a softer reason: the run-tweak-run loop is how people actually solve problems, and a laggy loop measures patience instead of skill. Fast feedback is part of assessment validity, not just user experience.
Most designs in this set are shaped by their data tier - sharding, caching, fan-out. This one is not. At ~1TB/year of code and double-digit QPS, a single PostgreSQL instance with a standby handles every table in this system without ceremony: assessments, sessions, submissions, results. If your instinct is to reach for NoSQL or read replicas here, the estimates say put it back. What the numbers do demand is elastic, isolated compute. The demand curve is brutally spiky - quiet weeks, then hiring season, then everyone submitting in the final five minutes of a 5,000-candidate assessment - while the supply (sandboxed CPU) is expensive to hold idle. The component that reconciles a spiky demand curve with a right-sized fleet is a queue: submissions land in it instantly (the candidate sees "queued" and then "running" - honest feedback instead of timeouts), workers pull at whatever rate the fleet sustains, and an autoscaler grows the fleet on queue depth. The queue also gives us the two control points this product needs: fairness (scheduling across assessments, so one giant client cannot starve everyone else) and reliability (a job leased to a runner that dies gets reclaimed and re-run). So the architecture inverts the usual proportions: the stateful tier is small and boring on purpose, and the engineering lives in the pipeline between "submit" and "verdict" - queue semantics, sandbox construction, resource governance, and verdict determinism. When the interviewer asks where this system scales, the answer is CPU-seconds, absorbed by a queue and an autoscaling fleet - not rows per second.
CREATE
[Recruiter] --> [Assessment Service] --> [PostgreSQL: assessments,
| problems (hidden tests stay here)]
+--> shareable link
TAKE + EXECUTE
[Candidate browser IDE]
| autosave (debounced) | run / submit
v v
[Session state] <-- deadline [Submission API] --> [PostgreSQL:
(server clock rules) | submission, status=queued]
v
[Job Queue (Redis Streams)]
consumer group: each job -> exactly one runner
leases: dead runner -> job reclaimed
|
v
[Runner Fleet (autoscales on queue depth)]
per job, every time:
1. take a FRESH sandbox (pre-warmed, used once)
- no network, read-only FS, pinned CPU/mem,
process cap, hard CPU-time limit
2. copy code in, compile (CE short-circuits)
3. run each test: stdin -> stdout, compare
4. capture verdict + outputs
5. DESTROY the sandbox
|
v
[PostgreSQL: results] [Object storage: logs/stderr]
|
v
[WebSocket] ==queued / running test 3 of 8 / verdict==>
candidate IDE
REPORT
[Recruiter] --> [Report endpoint] --> aggregate submissions + resultsA sandbox is a sealed room where a program can compute and do nothing else. Ours is built from three kinds of walls the Linux kernel provides. Namespaces control what the program can SEE: it gets its own view of the filesystem (read-only, plus a scratch /tmp), its own process list (it cannot see or signal anything else on the machine), and - crucially - a network namespace with no interfaces, so "open a connection" fails instantly; there is nothing to connect through. Cgroups control what it can USE: at most this much memory (exceed it and the kernel kills the process - that is our memory-limit verdict), this much CPU, this many processes (a fork bomb hits the cap and dies quietly). Seccomp controls what it can ASK the kernel: system calls outside an allowlist - loading kernel modules, rebooting, raw device access - are refused before they start. Now the honest tradeoff. Containers share the host's kernel, so a kernel vulnerability is a potential escape hatch - rare, but real. The isolation ladder, in increasing strength and cost: plain containers → hardened containers (what we run: all three walls plus non-root and no network) → gVisor (a user-space kernel that intercepts syscalls) → microVMs like Firecracker (each sandbox gets its own tiny virtual machine and kernel, booting in ~100ms) → full VMs. Public cloud providers running arbitrary tenant workloads sit at the microVM rung; for assessment code with short lifetimes, no network, and nothing valuable on the host, hardened containers are a defensible choice - but say the ladder out loud in an interview, because knowing what you are NOT paying for is the point.
Hidden tests exist to catch hardcoding: if a candidate can see every test, the degenerate strategy is to write if input == X: print Y for each one, which passes without solving anything. The hidden set is what makes "passes the tests" mean "probably solved the problem." The design consequence is a strict trust boundary: grading happens entirely server-side, and the API that serves problems to the candidate's browser must be shaped so hidden tests CANNOT appear in any response - not present-but-disabled, not behind a flag the UI hides, but absent from the payload. Anything that reaches the browser is public: candidates open devtools, inspect every response, and share what they find within hours. "The UI does not show it" is not a security property; "the endpoint never returns it" is. The same boundary governs verdicts: the runner compares outputs on the server and ships back pass/fail per hidden test - never the hidden input or expected output, even on failure. (Visible examples, by contrast, show full detail, because tight feedback on the examples is what makes the run loop useful.) This is the least glamorous note in this design and the most commonly botched in real products: the trust boundary is the API schema, not the interface.
Database Schemas (PostgreSQL throughout - the estimates say so)
- Assessments
assessment_id UUID PRIMARY KEY,
recruiter_id UUID NOT NULL,
title VARCHAR(255),
problem_ids UUID[] NOT NULL,
time_limit_minutes INT DEFAULT 90,
window_start TIMESTAMP, window_end TIMESTAMP,
settings JSONB, -- allowed languages, attempts per problem
created_at TIMESTAMP DEFAULT NOW()
- Problems
problem_id UUID PRIMARY KEY,
title VARCHAR(255), statement TEXT,
starter_code JSONB, -- { "python": "def solve():...", ... }
limits JSONB, -- { "cpu_seconds": 2, "memory_mb": 512 }
test_cases JSONB
-- [{ "input": "...", "expected": "...", "hidden": true }, ...]
-- hidden cases are NEVER serialized into any client-facing response;
-- the trust boundary is this API shape, not the UI
- Sessions (one per candidate per assessment)
session_id UUID PRIMARY KEY,
assessment_id UUID NOT NULL,
candidate_email VARCHAR(255),
started_at TIMESTAMP NOT NULL,
deadline TIMESTAMP NOT NULL, -- server clock; the only timer that counts
code JSONB, -- durable autosave target per problem
event_log JSONB, -- joins, saves, submits: the audit trail
UNIQUE (assessment_id, candidate_email)
- Submissions (append-only in spirit: the evidence table)
submission_id UUID PRIMARY KEY,
session_id UUID NOT NULL,
problem_id UUID NOT NULL,
mode run_mode_enum, -- 'run' (visible tests) | 'submit' (all)
language VARCHAR(20), code TEXT,
status status_enum DEFAULT 'queued', -- queued|running|done
verdict verdict_enum, -- accepted|wrong_answer|tle|mle|rte|ce
tests_passed INT, tests_total INT,
cpu_time_ms INT, memory_mb INT,
logs_ref VARCHAR(512), -- object-storage pointer
submitted_at TIMESTAMP DEFAULT NOW(),
INDEX (session_id, problem_id, submitted_at DESC)
Redis
- Job queue (Redis Streams)
XADD exec-jobs * submission_id ... language ... limits ...
Consumer group "runners": each job delivered to exactly one runner;
leases + XAUTOCLAIM reclaim jobs from dead runners.
The queue is DELIVERY, not truth - rows with status=queued re-enqueue
if the stream is ever lost.
- Autosave buffer
"code:{session_id}:{problem_id}" -> latest draft
flushed to the Sessions row every few seconds
Object storage
/artifacts/{submission_id}/compile.txt, test_{n}.stdout, test_{n}.stderr
-- access-controlled: candidate code is their IP and their PII{
"session_id": "sess_8f3e2d1c",
"problem_id": "prob_two_sum",
"mode": "submit",
"language": "python",
"code": "def solve(nums, target):\n seen = {}\n ..."
}{
"submission_id": "sub_7a2b8f4e",
"status": "queued",
"queue_position": 12
}
// Returns immediately - execution is asynchronous behind the queue.
// queue_position is honest feedback during deadline spikes: candidates
// see movement instead of a spinner. A submission after the session's
// server-side deadline is rejected here with 410, whatever the client's
// timer claimed.{ "type": "status", "submission_id": "sub_7a2b8f4e",
"status": "running", "current_test": 3, "total_tests": 8 }
// ...then, a few seconds later:
{ "type": "verdict", "submission_id": "sub_7a2b8f4e",
"verdict": "wrong_answer",
"tests_passed": 6, "tests_total": 8,
"cpu_time_ms": 340, "memory_mb": 41 }{
"submission_id": "sub_7a2b8f4e",
"verdict": "wrong_answer",
"tests": [
{ "test": 1, "hidden": false, "passed": true,
"input": "[2,7,11,15], 9", "expected": "[0,1]", "output": "[0,1]",
"cpu_time_ms": 12 },
{ "test": 7, "hidden": true, "passed": false,
"cpu_time_ms": 45 }
],
"logs_url": "https://artifacts.example.com/sub_7a2b8f4e/...(signed)"
}
// The trust boundary in action: visible tests return full detail
// (that is where feedback teaches); hidden tests return pass/fail and
// timing ONLY - never the input or expected output, even on failure.{
"assessment_id": "a_9d4f1a2b",
"candidates": [
{
"candidate_email": "dev@example.com",
"completed_in_minutes": 74,
"total_score": 2.5,
"problems": [
{ "problem_id": "prob_two_sum", "best_verdict": "accepted",
"attempts": 3, "best_cpu_time_ms": 120 },
{ "problem_id": "prob_intervals", "best_verdict": "wrong_answer",
"attempts": 7, "tests_passed": "6/8" }
]
}
]
}
// Computed on read - at this data size, aggregation needs an index,
// not a pipeline. Attempt counts and timing come straight from the
// Submissions evidence table.// 410 Gone - the server-side deadline has passed
{
"error": "session_expired",
"message": "The assessment time limit has been reached"
}
// 429 - submission rate limit (per session)
{
"error": "rate_limited",
"message": "Please wait before submitting again",
"retry_after_seconds": 5
}
// Rate limiting per session blunts both abuse and accidental
// submit-spamming during deadline panic.Consumer group: the queue feature that turns "many runners reading one stream" into "each job handled by exactly one runner." The group tracks which member received which entry; a member that stops heartbeating has its unacknowledged jobs reclaimed by others (XAUTOCLAIM in Redis Streams). It is the same deliver-once-track-and-reclaim shape a work queue always needs, whatever the technology under it. Cgroup (control group): the kernel mechanism that caps what a group of processes can consume - memory, CPU share, process count. The cap is enforced by the kernel itself: exceed the memory limit and the process is killed mid-allocation; hit the process cap and the fork bomb's next fork just fails. Our memory and fork-bomb defenses are one configuration line each, because the kernel does the enforcement. CPU time vs wall time: wall time is what a stopwatch measures - including every moment the program sat waiting for its turn on a busy host. CPU time counts only the computing the program actually did. Verdict limits use CPU time so that platform load can never fail a candidate; turnaround targets (p95 < 5s) use wall time, because the candidate's experience of waiting is exactly what wall time measures. Same clock on the wall, two different questions.