Skip to content

Latest commit

 

History

History
111 lines (91 loc) · 5.75 KB

File metadata and controls

111 lines (91 loc) · 5.75 KB

ta-course-match — Concurrency Analysis Task (150 concurrent users)

Objective

Determine whether the deployed ta-course-match web app can handle 150 concurrent users on its current VM, identify the bottleneck(s), and recommend concrete config changes. Produce findings + a tuning patch.

Deployment environment (already profiled — treat as fixed constraints)

  • Host: KVM guest ta-match, Ubuntu 24.04.1, kernel 6.8, x86_64
  • CPU: 4 vCPUs (QEMU virtual, 1 thread/core), currently near-idle
  • RAM: 3.8 GiB total. ~2.0 GiB used at baseline, ~1.8 GiB available headroom. RAM is the binding resource, not CPU or disk.
  • Swap: 3.8 GiB swapfile (slow — avoid relying on it)
  • Disk: 100 GB ext4, 38% used (58 GB free) — not a constraint
  • No GPU

Current container footprint (from docker stats, idle baseline)

Container RAM CPU Notes
platform-nginx-1 9.3 MiB 0.00% reverse proxy
platform-backend-1 498.6 MiB 0.22% main consumer, the app to scrutinize
postgres-app-1 36.0 MiB 0.02%
postgres-db-1 19.0 MiB 0.00%
Total ~563 MiB idle

Critical: all four containers show LIMIT 3.824GiB — i.e. no per-container memory caps set. Any one can OOM the whole VM.

What to analyze in the repo

Inspect the codebase and config in this priority order. The likely bottleneck ranking is: DB connection pool > RAM under load > CPU.

  1. Database connection pool (most likely first wall)

    • Find the pool config (pg.Pool, Prisma, TypeORM, Sequelize, etc.). Report the max pool size. Default for node-postgres is 10 — with 150 concurrent requests holding connections, requests queue behind the pool.
    • Find Postgres max_connections (default 100). Check both postgres-app-1 and postgres-db-1 — clarify what each is for.
    • Check whether requests hold a connection for their full lifetime or release promptly. Flag any long-held transactions.
    • Check for N+1 query patterns in the matching logic.
  2. Where the matching/compute work runs

    • Is the TA-to-course matching done in SQL (good, DB does the work) or in Node (CPU-bound, blocks the single-threaded event loop)?
    • If in Node: is it inside the request handler synchronously? That serializes under load. Flag it.
  3. Process model / concurrency

    • Is the backend a single Node process, or clustered (node:cluster, PM2, or multiple replicas)? Single process = one core for JS execution out of 4 available. Recommend clustering to ~3 workers if CPU-bound, leaving headroom for DB + OS.
    • Identify framework (Express / Fastify / Nest) and any per-request memory hot spots (loading full result sets into memory, large JSON serialization).
  4. Memory math under load

    • Backend baseline ~500 MiB. Estimate per-connection / per-request memory growth.
    • Budget: must stay under ~1.8 GiB available, ideally leave >1.2 GiB free. Confirm 150 concurrent does not push into swap.

Definition of "concurrent" — resolve this first

The verdict hinges on usage pattern:

  • 150 users with sessions open, occasional clicks (realistic for a per-term TA matching tool): ~5–20 req/s peak. Almost certainly fine on this hardware.
  • 150 users firing requests in the same second: real load; pool size and event-loop throughput decide it.

State which model the analysis assumes and ideally test the worst case.

Required: load test to replace guesses with numbers

Install k6 and test the real endpoint(s). Run from the VM against localhost to exclude network.

curl -fsSL https://github.com/grafana/k6/releases/latest/download/k6-linux-amd64.tar.gz | tar xz

cat > load.js <<'EOF'
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
  scenarios: {
    realistic: { executor: 'constant-vus', vus: 150, duration: '60s' },
  },
};
export default function () {
  // TODO: replace with the actual hot path (e.g. the matching/list endpoint)
  const res = http.get('http://localhost:5001/');
  check(res, { 'status 200': (r) => r.status === 200 });
  sleep(1); // models think-time; remove for worst-case hammer test
}
EOF

./k6 run load.js

While k6 runs, capture resource behavior in a second shell:

docker stats --no-stream --format 'table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}'
free -h

Report from k6 summary: req/s, p95 + p99 latency, error rate, and whether RAM stayed off swap. Run twice — once with sleep(1) (realistic) and once without (worst case).

Deliverables

  1. Verdict: can it handle 150 concurrent users, for each usage model. Yes/no with the limiting number (e.g. "pool=10 caps throughput at X req/s").
  2. Bottleneck table: ranked, with the measured evidence.
  3. Tuning patch: concrete diffs for
    • DB pool max and Postgres max_connections (sized together, not blindly raised — more connections = more RAM)
    • Per-container memory limits in the compose file (cap backend ~1 GiB, others smaller) so no container can OOM the host
    • Clustering/replicas if CPU-bound
  4. Before/after load-test numbers if changes are applied.

Guardrail to add regardless of verdict

Add memory limits in the compose file — currently none exist:

services:
  platform-backend:
    deploy:
      resources:
        limits:
          memory: 1g
  # smaller caps for nginx, postgres-app, postgres-db

Use --memory-swap = --memory semantics so a runaway container gets OOM-killed instead of dragging the whole VM into swap thrashing.

Constraints / notes for the agent

  • Do not assume cloud autoscaling — this is a single fixed 4 vCPU / 3.8 GiB VM.
  • Raising pool/connection counts costs RAM; every change must fit the ~1.8 GiB budget.
  • Verify findings with the load test; do not report a verdict from static reading alone.