Inspiration
Obsidio challenged us to build a small analytics API that could remain responsive under heavy mixed traffic while running inside a strict Docker environment limited to 2 CPUs and 2 GB of RAM. The main challenge was not implementing the endpoints themselves, but keeping the lightweight /price and /stats requests responsive while /risk performed 50,000 sequential SHA-256 operations for every request. That turned the project into a resilience problem: how do we protect the fast path without making the heavy path unusably slow?
What it does
We built a Python FastAPI analytics service with three workload tiers: /price — a lightweight in-memory price lookup /stats — a medium-cost calculation over a 500-point price series /risk — a CPU-intensive workload that applies SHA-256 50,000 times The backend is containerised with Docker and designed specifically around the competition’s fixed 2-CPU resource limit.
How we built it
We began by isolating the CPU-heavy /risk calculation using ProcessPoolExecutor. The goal was to stop expensive hashing work from directly blocking FastAPI’s event loop. With two Uvicorn workers and one risk process per worker, this made the fast path extremely responsive: /price p95: 1.13 ms /stats p95: 1.32 ms /risk p95: 4.48 s Failure rate: 0% Work score: 326,138 This showed that process isolation protected /price and /stats, but risk requests accumulated behind the limited CPU-processing capacity.
We then tuned the number of risk processes. Increasing to four processes actually worsened /risk p95 to 5.58 s, confirming that more CPU-bound workers did not create more computing capacity on a 2-CPU container. Reducing the configuration back to one risk process per Uvicorn worker improved /risk to 3.27 s, while keeping /price at 0.826 ms, /stats at 0.944 ms, and the failure rate at 0%. However, /risk still remained above the 1.5-second target.
Next, we introduced bounded queueing and admission control using a ProcessPoolExecutor, an asyncio.Semaphore, and a queue timeout. With eight slots and a 0.75-second queue timeout, we achieved: /price p95: 60.8 ms /stats p95: 60.89 ms /risk p95: 997.67 ms Failure rate: 5.45% Work score: 480,528 All latency thresholds passed, but too many requests were rejected with HTTP 503. This solved queue latency by trading away availability, so it was not acceptable under the required <1% failure rate.
Our final approach replaced process-pool queueing with cooperative scheduling. Instead of running all 50,000 SHA-256 iterations in one uninterrupted block, /risk divides the work into chunks. Between chunks it calls await asyncio.sleep(0), voluntarily yielding control back to the event loop so /price and /stats can be served before risk computation resumes. This preserved the exact required 50,000 SHA-256 operations while avoiding both long event-loop blocking and process-pool backlog.
We tuned the chunk size experimentally. Smaller chunks yielded more frequently and protected /price, while larger chunks reduced scheduling overhead and improved /risk. A chunk size of 5000, for example, produced /risk around 1.31 s but pushed /price to approximately 215.62 ms, which failed its 200-ms target. After further tuning, a chunk size of 4250 gave us our best result: /price p95: 147.28 ms /stats p95: 146.92 ms /risk p95: 975.43 ms Failure rate: 0.00% Work score: 531,379 Throughput: 786.92 requests/s
This was the first architecture that passed all latency and reliability thresholds simultaneously without rejecting requests.
Challenges we ran into
One of the biggest challenges was that improvements to one endpoint often made another worse.
Process isolation made /price and /stats extremely fast, but introduced a growing backlog for /risk. Adding more risk processes seemed like an obvious solution, but because the container only had 2 CPUs, oversubscribing the CPU actually increased contention and made /risk slower.
Admission control created the opposite problem. Rejecting overloaded risk requests quickly kept /risk below its latency target, but raised the failure rate to more than 5%, well above the required 1%.
We also found that queueing and timeout behaviour could be deceptively difficult to reason about. A system can appear faster simply because requests are being rejected earlier rather than processed more efficiently.
The final challenge was tuning cooperative scheduling. Yield too often and /risk pays too much scheduling overhead; yield too rarely and /price starts missing its latency target. The final chunk size was therefore selected from measured k6 results rather than intuition.
Accomplishments that we're proud of
We are most proud that the final architecture passed all four grading thresholds at the same time:
/price p95: 147.28 ms under the 200-ms limit /stats p95: 146.92 ms under the 500-ms limit /risk p95: 975.43 ms under the 1.5-second limit Failure rate: 0.00% under the 1% limit It also achieved a work score of 531,379 and approximately 786.92 requests per second.
Another accomplishment was reducing the problem from an architectural bottleneck to a measurable scheduling trade-off. Rather than changing the required workload, we preserved all 50,000 SHA-256 iterations and changed only how CPU time was shared between request classes.
We also validated several approaches experimentally instead of assuming they would work. Process isolation, worker oversubscription, bounded queues, admission control, and cooperative scheduling were all tested under the same k6 workload.
What we learned
What's next for PLATea+
The current implementation passes all grading thresholds, but there is still room to improve the weighted work score. Our next step would be to automate chunk-size tuning across the grading workload instead of testing values manually. We would also explore adaptive chunk sizing, where the risk computation yields more frequently when the server is under heavy fast-path load and uses larger chunks when demand is lower. We would also like to improve observability around queue depth, active requests, event-loop delay, and per-endpoint latency so that future tuning decisions can be based on runtime signals rather than
Log in or sign up for Devpost to join the conversation.