Core Architecture
Lambda + SQS decoupling
Code submission returns 201 immediately and opens an SSE stream. Execution is queued to SQS — deadline bursts queue up without stalling the API. The Lambda worker dequeues and executes code in its own isolated invocation — ephemeral, no shared host.
AWS LambdaSQS FIFOSSE
Plagiarism Detection
Reframing the problem
"Are two submissions similar?" → "Did this student write this?" Layer 1 scores behavioural signals (paste ratio, speed vs difficulty, tab switches) at exam close. Layer 2 runs TF-IDF cosine only on suspects — O(K×N) not O(N²).
TF-IDFBehavioural signalspre_save signal
AI Feature Design
Async, never on delivery path
All 5 LLM features are triggered by events but execute fully async — none sit between submission and result. Rubric scoring, hints, and narratives appear after the result is already delivered. Zero latency added to the student experience.
Async queueGPT-4o-miniIdempotent
Code Execution Safety
Lambda as the sandbox
Every submission executes in its own Lambda invocation — fully isolated, ephemeral, with a hard 15-second timeout. No shared state between executions. Lambda's per-invocation isolation gives sandboxing without the overhead of running Docker on a persistent host.
AWS Lambdaephemeral15s timeout
Concurrency Safety
Attempt gating with row lock
Before accepting a submission, the API checks attempt count using SELECT FOR UPDATE. Prevents race conditions when two tab submissions arrive within milliseconds — common at exam end when every student submits at the same deadline.
PostgreSQLSELECT FOR UPDATE
Real-time Delivery
SSE over WebSocket
Code execution results are unidirectional — server pushes once. SSE chosen over WebSockets for native browser reconnection, HTTP/1.1 proxy compatibility, and simpler Lambda scaling. The SSE endpoint polls Postgres every 500ms for status change, then pushes the result and closes the stream.
SSEEventSource APIPostgres poll
Migration — Redis + Celery + Docker → SQS + Lambda
What we traded when moving the execution layer to Lambda. Each row is a talking point.
| Dimension |
Redis + Celery + Docker |
SQS + Lambda |
Winner |
| Cost at low traffic |
Redis + workers running 24/7, paying for idle |
Pay per invocation — zero idle cost |
Lambda |
| Cost at peak |
Pre-provisioned workers, fixed capacity |
Scales elastically, per-invocation cost adds up |
Draw |
| Burst scaling |
Manual — provision more Celery workers |
Automatic — Lambda scales to account concurrency limit |
Lambda |
| Execution isolation |
Docker container, shared host kernel |
Fully ephemeral per invocation, complete isolation |
Lambda |
| Ops overhead |
Manage Redis, Celery, Docker daemon, worker fleet |
SQS + Lambda fully managed, zero infra to operate |
Lambda |
| Result delivery |
Redis pub/sub → true push, near-instant |
Postgres poll every 500ms → up to 500ms added delay |
Redis/Celery |
| Cold start latency |
Always warm (persistent workers) |
1–3s cold start (provisioned concurrency mitigates) |
Redis/Celery |
| Max execution time |
Configurable, no hard ceiling |
15-minute Lambda hard cap |
Docker |
| Real-time feel |
Truly event-driven end-to-end |
Event-driven execution, polling last mile |
Redis/Celery |
Migration rationale
We traded true event-driven push and always-warm workers for pay-per-use cost, automatic burst scaling, and per-invocation sandbox isolation. The cost is 500ms of polling latency on result delivery — acceptable for an exam platform, not acceptable for a live coding game. Lambda's ephemeral model means one student's rogue process literally cannot affect another's.
Real-Time Delivery — SSE vs Client Polling after Lambda
SSE was right when result delivery was Redis pub/sub. After Lambda, Django polls Postgres and forwards the result. The tables have turned.
| Aspect |
SSE — current |
Client Polling — alternative |
| HTTP overhead |
1 persistent connection per user |
1 new request per poll tick (~500ms) |
| Result latency |
Same — both wait on Postgres poll |
Same — both wait on Postgres poll |
| Progressive updates |
Natural — stream queued → running → done as separate events |
Endpoint must return current state per request |
| Proxy / infra compat |
Sometimes breaks — buffering proxies drop events |
Always works — standard HTTP request-response |
| Stateless server |
No — Django holds the connection open until result arrives |
Yes — each request is independent |
| Works fully serverless |
No — needs persistent Django (Lambda can't hold SSE) |
Yes — any server including Lambda |
| Architectural honesty |
Polling theater — SSE without true push underneath |
Clean match to what's actually happening |
Honest architectural reflection
SSE was the right last-mile when the architecture was Redis pub/sub — truly event-driven end-to-end. After migrating to Lambda, result delivery became Postgres polling. SSE now holds a connection open just to forward poll results.
Client polling is architecturally equivalent with less infrastructure complexity. The honest next step: either drop SSE for client polling, or invest in a real push layer (API Gateway WebSocket, Pusher, Ably) to restore true event-driven delivery.