← Portfolio CodeMas — Architecture
ACTORS API LAYER EXECUTION 👨‍💻 Student Browser 👨‍🏫 Trainer Dashboard Django REST API DRF · Simple JWT · Gunicorn · AWS Lambda POST /submit → attempt check (SELECT FOR UPDATE) → INSERT submission → 201 AWS Lambda · Gunicorn AWS SQS execute_submission queue FIFO · per-exam · Lambda dequeues Lambda Sandbox Sandboxed execution 512MB · 15s timeout · ephemeral PostgreSQL Submissions · Results Attempts · Exam state SSE Stream EventSource API one connection per student POST /submit exam CRUD 201 + SSE open enqueue check + INSERT dequeue write result Postgres poll (500ms) result pushed to browser
TRIGGERS AI FEATURES SERVICES Trainer: Exam Create topic · difficulty · count Submission Result pass or fail scored Submit Failed test cases didn't pass Exam Closed is_active → False Similarity Flagged cosine > 0.80 Exam Generator Questions + test cases HITL: review before persist human-in-the-loop gate Rubric Scorer 4 dimensions · 0–2 each per-dim justification async after result Socratic Hint Socratic nudge only concept hint · no answer on 3rd failed attempt Trainer Dashboard Cohort narrative per-student summaries polls every 10s Plagiarism Engine Behavioural + TF-IDF 5% → 95% · 19× lift GPT evidence brief GPT-4o-mini OpenAI API · async calls all features share this endpoint PostgreSQL ExamDraft · RubricScore AIHint · PlagiarismFlag shared endpoint scores · hints · flags
Key Design Decisions
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
Architecture Trade-Offs — Quick Revision
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.