The team load test¶
This page is part of Team scale.
scripts/bench/team_load.py runs the real app on the fake provider (sandboxes are local directories; agents are
scripts that print an event every 0.5 s) and plays 6 engineers against it. Each has its own token and, at the
rates an MCP client and an open dashboard produce:
- starts 10 sessions over MCP, a second apart, and submits 2 jobs;
- polls each session with
get_sessionevery 5 s and sends the next message when a turn ends (2 turns each); - calls
list_sessions,list_sandboxesandlist_jobsevery 10 s, andexec(by sandbox name) every 4 s; - refreshes
/api/sessions,/api/sandboxesand/api/jobsevery 5 s, and creates and destroys a sandbox over REST every 20–60 s.
Before it starts, the database gets 20,000 settled sandboxes and 5,000 finished jobs: about six months of a team's
history. The server reports event-loop lag (how late a 50 ms timer fires), RSS, CPU time, and the time spent in
SQLite per store method, on the event loop and in threads. tests/test_team_scale.py::test_team_load_smoke runs a
small version on every CI run and fails if any request scans the sandbox table on the event loop.
uv run python scripts/bench/team_load.py --out bench/results/<date>-team-load.json
uv run python scripts/bench/report.py <before.json> <after.json>
Results on a shared 2-vCPU VM (bench/results/2026-10-14-team-load-*.json). The fake agents (and hangar's turn
driver, which on a real provider runs in the sandbox) run on the same two vCPUs as the server, and other work shared
the machine, so the after numbers are upper bounds; a second after run, under a host load of ~9, shows how they
degrade. The request rates are the same in every column; the after runs finish the same work 7–9× sooner.
| 6 × 10 sessions, 20k records of history | Before (main, host load ~2) | After (host load ~1–4) | After (host load ~9) |
|---|---|---|---|
| Event-loop lag p50 / p95 / max | 1,296 / 5,221 / 8,326 ms | 1.0 / 70 / 258 ms | 4 / 263 / 765 ms |
get_session (MCP) p50 / p95 |
3,364 / 5,914 ms | 42 / 305 ms | 214 / 807 ms |
list_sandboxes (MCP) p50 / p95 |
13,363 / 17,034 ms | 60 / 553 ms | 434 / 1,755 ms |
exec by name (MCP) p50 / p95 |
9,249 / 15,395 ms | 63 / 526 ms | 577 / 1,693 ms |
GET /api/sandboxes p50 / p95 |
2,369 / 5,633 ms | 68 / 247 ms | 159 / 438 ms |
| Create a sandbox (REST) p50 | 21,213 ms (n=23) | 32 ms (n=1) | 1,847 ms (n=2) |
| An 8 s agent turn, send to done, p50 / p95 | 44.0 / 65.8 s | 8.6 / 10.8 s | 9.5 / 11.5 s |
| Job queue wait p50 / p95 (12 jobs) | 83 / 261 s (4 slots) | 0.01 / 0.06 s (24 slots, set with --set job_concurrency=24; the default is now 8) |
0.02 / 0.11 s |
| SQLite time on the event loop | 310 s of a 354 s run | 4.8 s of 38 s | 8.6 s of 49 s |
| Server CPU / peak RSS | 330 CPU-s / 1,031 MB | 7.7 CPU-s / 258 MB | 8.3 CPU-s / 264 MB |
| Wall time for the same work | 354 s | 38 s | 49 s |
Before, the server spent 87% of the run parsing sandbox records on the event loop: fleet.list() behind every
listing, fleet.get(name) behind every MCP call that names a sandbox, two scans per create. Every other request
waited behind them.
Soak. 6 × 10 sessions with 20 s turns (4 each, all 60 running at once) and 24 jobs, while retention moved the
20,000 seeded records to the archive (2026-10-14-team-load-soak.json): event-loop lag p50 0.6 / p95 47 / max
528 ms, get_session p50 14 / p95 545 ms, every turn and job finished, peak RSS 267 MB, 22.5 CPU-s in 117 s. The
first soak, with retention moving 500 rows a transaction, had a loop lag p95 of 811 ms: one batch held the store's
lock for up to 0.9 s. Hence 100.
Months of job and session history¶
The job and session services used to load every job and session ever made at startup and keep them: ~30 KB for a
finished job with its timeline, so 18,000 jobs (six months at 100 a day) cost over half a GB, and every listing sorted
all of them. Now each holds what is live (unfinished jobs, jobs whose PR is still on its way to landed or with
auto-fix triggers waiting, live, suspending or busy sessions) and whatever was saved or asked for in the last
HANGAR_RESIDENT_MINUTES (60). Everything else is read back from the store when asked for, by id, by name, or
through the store's indexes (resident.py): the GitHub and Jira lookups (by PR number, branch, head commit,
ticket), idempotent retries, the inbox, the usage report and the daily caps. Every 5 minutes the retention loop lets
go of what nothing needs any more. A record read back stays one object while it's in use, so two callers never hold
copies that overwrite each other's changes.
Listings (list_jobs, list_sessions, GET /api/jobs, GET /api/sessions, the recent-work resources) are pages
from the store's indexes, newest first, filtered there by person: a member's listing is all of their own work, not
what of it was left after the team's newest 500. Each item carries a cursor; pass the last one's back for the next
page (REST also answers X-Next-Cursor when there is one). Finished jobs and settled sessions untouched for 30 days
move to jobs_archive and sessions_archive (below), which listings and lookups read too.
The team load test with that history seeded (6 people × 5 sessions, 12 jobs; 20,000 sandbox records, 18,000
finished jobs and 3,000 sessions over six months; --job-history 18000 --session-history 3000), two runs each on
the shared 2-vCPU VM, bench/results/2026-10-14-team-load-history-*.json:
| Before (build/ops) | After | |
|---|---|---|
| RSS at idle, history loaded | 735 / 736 MB | 213 / 213 MB |
| Peak RSS | 744 / 746 MB | 222 / 222 MB |
| Server CPU for the same work | 25.6 / 25.7 CPU-s | 14.5 / 16.5 CPU-s |
| Event-loop lag p95 / max | 69 / 881, 67 / 859 ms | 60 / 127, 61 / 132 ms |
list_jobs (MCP) p50 / p95 |
115 / 225, 115 / 265 ms | 87 / 191, 77 / 168 ms |
list_sessions (MCP) p50 / p95 |
139 / 245, 112 / 722 ms | 73 / 152, 63 / 134 ms |
GET /api/jobs p50 / p95 |
76 / 178, 65 / 223 ms | 65 / 133, 42 / 124 ms |
GET /api/sessions p50 / p95 |
74 / 126, 87 / 400 ms | 65 / 176, 65 / 134 ms |
| SQLite time on the event loop | 1.2 s | 6.5 s |
The loop now spends more time in SQLite (a page of jobs is read and decoded per listing, ~6 ms for 50) and less in
Python over every job in memory. Reading the pages in a worker thread was tried: decoding holds the GIL, so it saved
the loop nothing and raised the peak RSS by ~35 MB. The hangar_jobs and hangar_sessions gauges are counted by
SQLite in a worker thread on a connection of their own, at most a minute old.
What remains, by SQLite time on the loop: get of one sandbox by id (sessions check that theirs is alive, ~0.4 ms
each, 5k calls a run), put of sessions (every state change writes the whole record), and the token lookup in
auth.principal, which decodes every API token on every request (~1 ms; see follow-ups).
Retention and the export. Archiving keeps everything; what deletes data is deleting a session (its event log and
manifests went with it) and testresults' windows (every case after 3 days, runs after 14). With run capture on
(HANGAR_OUTBOX_URL, outbox.md) neither deletes what the outbox hasn't
exported yet: a deleted session's files wait in <data>/pending-delete/ until its runs are uploaded (or
HANGAR_EXPORT_GRACE_DAYS, 30, audited retention.unexported_deleted), and the test results of failed, abandoned
or failure-labelled jobs outlive the windows until exported. Months of history then live in the bucket, not on the
control plane's disk: hangar keeps what it needs to run, the sister tool what it needs to look back. Pending-delete
data counts toward the data volume; hangar doctor reports how much. With the outbox off, retention is as before.
Provider calls. At 60 sessions and 12–24 jobs the fake saw ~30–60 execs a second, plus 5–15 file reads and as many writes:
turn-status long-polls, the activity watch, checkpoints, setup. On GKE each of those is a pods/exec through the API
server unless hangar-envd answers it, so envd must be up at team scale (HANGAR_ENVD=auto, the default).