perf(bomet): live-deployment validation run + deployment-configurable harness - #1848
perf(bomet): live-deployment validation run + deployment-configurable harness#1848dhruv-1001 wants to merge 18 commits into
Conversation
…ent-configurable The existing results under performance/results/ were all produced in March 2026 against dedicated test machines with a synthetic statea.citya dataset. This adds the first run against a real installation: Bomet County's live production deployment (16 vCPU / 30 GB), carrying ~2,225 existing complaints and 20-100 real complaints/day. Results (ramp-2vu / ramp-10vu / ramp-50vu): 100% transaction success and zero failed requests across all 4,561 requests. Throughput scales near-linearly (95% then 91% efficiency per 5x VU step) and p95 latency moves only +21% across a 25x load increase. Extrapolated capacity ~288,000 lifecycles/day, roughly 28x the 10,000 txn/day design target. Host load peaked at 28.0 on 16 vCPU while available memory barely moved, so the ceiling above 50 VU is CPU-bound, not memory-bound. Tests ran 00:47-01:15 EAT to minimise overlap with real users, and all 2,954 test complaints were removed afterwards, verified back to the exact pre-test row count. Pointing the harness at a non-seed deployment required values it previously hardcoded, so these are now overridable via environments.js: - locality/city: `JLC477` and `City A` exist only in full-dump.sql. PGR validates locality against the boundary service, so every CREATE fails on a real deployment. createComplaint() now takes locality and city, and the lifecycle rotates across a configurable list to spread writes across wards. - citizenPhone/citizenName: allows filing as one pre-existing citizen instead of fabricating 100 user records, for shared or live environments. - serviceCodes: brings this vendored copy in line with the upstream load-tests repo, which already had the override. Note on reading the latency figures: k6 ran from a remote control machine over the public internet rather than on-host, so every http_req_duration includes ~185ms of network RTT. Throughput and error rates are unaffected. k6-output.json is omitted for the ramp-50vu run only (27 MB); its metrics.csv and summary.json are included. console.log files are excluded repo-wide by .gitignore, consistent with the existing March runs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Team Run ID: Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
| **VU ceilings:** Dev ~250, Prod ~300. Failures at the ceiling are caused by connection exhaustion and PgBouncer timeouts, not CPU. | ||
|
|
||
| ## Live Deployment Validation (Bomet, August 2026) | ||
|
|
There was a problem hiding this comment.
@dhruv-1001 Can we add the hardware provisioned on this machine?
There was a problem hiding this comment.
Added a Machine section with the full spec:
| CPU | AMD EPYC-Rome, 16 vCPU |
| Memory | 30 GiB |
| Disk | 305 GB SSD (non-rotational) |
| OS / runtime | Ubuntu 24.04.4 LTS, Docker 29.4.0 |
| Virtualisation | KVM guest |
| Services | 59 containers (full DIGIT stack) |
Two corrections while checking this, both now fixed in the doc:
- I had described bomet as bare metal — it isn't.
systemd-detect-virtreportskvm, so it's a VM sharing 16 vCPU across the whole stack. - I had called it a live production deployment. It's a shared dev environment that happens to carry real data and real daily usage (~2,250 complaints, 20–100/day), so I've described it that way instead.
I also recorded the idle baseline — load ~5.5–6.9 with 4–7 GB free before any test load. That matters for reading the rest: this box isn't quiet at rest, so every figure sits on top of existing load.
| **Bomet County's live production deployment** to confirm the findings hold on a real | ||
| installation carrying real data and real daily traffic. | ||
|
|
||
| ### Methodology Differences |
There was a problem hiding this comment.
What's the max number of concurrent users? How long did we sustain the test? What was the ramp up period? @dhruv-1001
There was a problem hiding this comment.
Added a Test Shape section. Every ramp uses warmup → ramp-up → sustained hold → ramp-down, and only the main scenario is measured (warmup is excluded from all thresholds and figures):
| Scenario | Warmup | Ramp-up | Sustained at peak | Ramp-down | Peak VU |
|---|---|---|---|---|---|
| ramp-2vu | 1 VU / 2m | 2m | 5m | 1m | 2 |
| ramp-10vu | 2 VU / 2m | 2m | 5m | 1m | 10 |
| ramp-50vu | 5 VU / 2m | 3m | 5m | 2m | 50 |
| ramp-nvu (75–150) | VU/10 / 2m | 3m | 5m | 2m | 75–150 |
Direct answers:
- Max concurrent users tested: 150 VU (up from 50 — I added the 75/100/125/150 steps to answer your other comment).
- Sustained: 5 minutes at each peak.
- Ramp-up: 2 minutes to 10 VU, 3 minutes above that.
One caveat now recorded in the doc: the 2/10/50 VU runs were taken overnight (00:47–01:15 EAT, idle load ~5.5) while the 75–150 VU ceiling runs ran during working hours (12:47–13:40 EAT, idle load ~6.9, less free RAM). The ceiling figures are therefore slightly conservative.
| | Test | Transactions | Throughput | p95 Latency* | Success | Failures | | ||
| |------|-------------|-----------|-------------|---------|----------| | ||
| | ramp-2vu | 94 | 0.154/s | 423ms | 100% | 0% | | ||
| | ramp-10vu | 438 | 0.730/s | 407ms | 100% | 0% | |
There was a problem hiding this comment.
0.7/s seems too small a throughput no? @dhruv-1001
There was a problem hiding this comment.
Good catch — you were right, and it was two separate things.
1. The number was genuinely understated. Those were run averages, which dilute the peak with the warmup, ramp-up and ramp-down phases. Recomputed as steady-state over the 5-minute hold at peak:
| Test | Was (run-avg) | Steady-state | API req/s |
|---|---|---|---|
| ramp-2vu | 0.154/s | 0.214/s | 0.86 |
| ramp-10vu | 0.730/s | 1.054/s | 4.20 |
| ramp-50vu | 3.332/s | 5.165/s | 20.66 |
So the real figure was 40–55% higher than published. The doc now reports steady-state and adds an API req/s column, since one lifecycle = 4 API calls.
2. Even corrected, it's small on purpose — it's think-time-bound, not server-bound. Each lifecycle carries ~8s of scripted sleep (4 random 1–3s pauses modelling a human) plus ~1.6s of requests, so one VU can do at most one lifecycle per ~9.5s. Theoretical max is VU / 9.5:
| Peak VU | Theoretical | Measured | Ratio |
|---|---|---|---|
| 10 | 1.05/s | 1.054/s | 100% |
| 50 | 5.26/s | 5.165/s | 98% |
| 75 | 7.89/s | 7.947/s | 101% |
| 100 | 10.53/s | 9.798/s | 93% |
| 150 | 15.79/s | 10.851/s | 69% |
Up to 75 VU throughput tracks the client-side think time to within 1–2%, which means the server imposed no measurable throughput limit at all below that. The divergence from 100 VU onward is where the server actually becomes the constraint — that's the real signal, and it was missing from the original section.
| |------|-------------|-----------|-------------|---------|----------| | ||
| | ramp-2vu | 94 | 0.154/s | 423ms | 100% | 0% | | ||
| | ramp-10vu | 438 | 0.730/s | 407ms | 100% | 0% | | ||
| | ramp-50vu | 2,421 | 3.332/s | 511ms | 100% | 0% | |
There was a problem hiding this comment.
What's our upper limit? HOw many TPS can we handle before things start to break? @dhruv-1001
There was a problem hiding this comment.
This one needed new tests — nothing broke at 50 VU, so the original PR genuinely didn't know. I've now stepped 75 → 100 → 125 → 150 VU until a threshold tripped.
Answer:
| Max sustainable concurrent users | 125 VU |
| Max sustained throughput | ~43 API req/s (~10.8 lifecycles/s) |
| Breaking point | 150 VU — end-to-end p95 hits 16.11s vs the 15s threshold |
| Failure mode | Latency, not errors |
Full curve (steady-state over the 5-minute hold):
| Peak VU | Lifecycles/s | API req/s | http p95* | txn p95 | Success | HTTP fail | Verdict |
|---|---|---|---|---|---|---|---|
| 50 | 5.165 | 20.66 | 510ms | 11.70s | 100% | 0% | pass |
| 75 | 7.947 | 31.78 | 446ms | 11.31s | 100% | 0% | pass |
| 100 | 9.798 | 39.20 | 754ms | 11.99s | 100% | 0% | pass |
| 125 | 10.785 | 43.15 | 1316ms | 13.50s | 100% | 0% | last clean level |
| 150 | 10.851 | 43.37 | 2312ms | 16.11s | 100% | 0% | BREACH |
* includes ~185ms network RTT.
Three things worth flagging:
- Throughput plateaus at ~43 req/s. Going 125 → 150 VU bought +0.5% throughput while p95 latency grew 76%. That's a saturated system, not a cliff.
- Nothing ever failed.
http_req_failedstayed at 0.000% andtransaction_successat 100% at every level including the breach — roughly 77,000 requests in the ceiling runs alone. Under saturation this stack queues rather than shedding load or erroring. The breach is purely on end-to-end duration;http_req_durationp95 (2312ms) was still well inside its own 5000ms threshold. - CPU is the binding constraint. Idle time hit 2–3% from 100 VU up, while memory never dropped below 1.3 GB of 30 GB and no container was OOM-killed. Adding CPU should move this ceiling; adding RAM won't.
For context, ~43 req/s sustains roughly 932,000 lifecycles/day — about 93× the 10,000 txn/day design target, against Bomet's actual 20–100/day.
Implementation note: I added ramp-nvu.js (parameterised by __ENV.VUS) rather than using burst.js, because burst.js declares no thresholds and tags its scenario default, so the {scenario:main}-scoped thresholds never evaluate — it can't fail, which makes it useless for ceiling work.
All 19,235 test complaints were removed afterwards via a gated transaction that preserved every non-test row, including ones filed by real users mid-test.
…, address review Addresses the four review comments on egovernments#1848 and fixes a regression the PR itself introduced. Regression (found during review triage): createComplaint() gained trailing `locality`/`city` params, but seed-1m.js, seed-calibrate.js and variable-throughput.js duplicate the lifecycle instead of importing pgrLifecycle and still called it with 7 args, so they would have written complaints with city/locality undefined. Both halves fixed: the params now default to the full-dump.sql seed values so any un-updated caller keeps its old behaviour, and all three scenarios are made env-aware (serviceCodes, localities, city, citizenPhone/citizenName) like pgr-lifecycle.js. Also adds a .gitignore rule for performance/k6/config/environments.js. That file is created by copying the .example and holds real deployment credentials; it was ignored in the upstream load-tests repo but not in this vendored copy, so it could have been committed by accident. No leak occurred — only the .example is tracked. Review comment 1 (hardware): adds a machine table. Corrects the previous description of bomet as "bare metal" — systemd-detect-virt reports kvm. AMD EPYC-Rome 16 vCPU / 30 GiB / 305 GB SSD, Ubuntu 24.04.4, Docker 29.4.0, 59 containers, idle baseline load ~5.5-6.9. Also re-describes the deployment as a shared dev environment carrying live usage rather than "live production". Review comment 2 (test shape): documents warmup, ramp-up, sustained hold and ramp-down per scenario. Peak concurrency 150 VU, each peak sustained 5 minutes. Review comment 3 (throughput looks small): the reviewer was right. The published figures were run averages that dilute the peak with warmup and ramp phases, understating capacity by 40-55%. Replaced with steady-state measured over the 5-minute hold, plus an API req/s column. Adds the explanation: below ~75 VU the workload is think-time-bound by design, not server-bound — measured throughput tracks VU/9.5s to within 1-2%, so the server imposed no measurable limit there. Review comment 4 (upper limit): new ramp-nvu.js scenario, parameterised by __ENV.VUS, used to step 75/100/125/150 VU. burst.js was unsuitable — it declares no thresholds and tags its scenario `default`, so the {scenario:main}-scoped thresholds never evaluate. Max sustainable concurrent users : 125 VU Max sustained throughput : ~43 API req/s (~10.8 lifecycles/s) Breaking point : 150 VU, transaction_duration p95 16.11s vs 15s Failure mode : latency, not errors Throughput plateaus between 125 and 150 VU (+0.5% for +25 VU) while p95 grows 76%. CPU is the binding constraint: idle hits 2-3% from 100 VU while memory never drops below 1.3 GB of 30 GB and no container was OOM-killed. Notably http_req_failed stayed at 0.000% and transaction_success at 100% at every level including the breach — the system queues under saturation rather than shedding load. All ~19,235 test complaints were removed afterwards via a gated transaction that preserved every non-test row, including complaints filed by real users mid-test. metrics.csv is gzipped for the ceiling runs (78 MB -> 4 MB, 5% ratio); console.log remains excluded by .gitignore as with the existing runs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@subhashini-egov all four comments addressed in 87d7611 — replies inline. Summary:
Two things you didn't ask for but should know about, both in the same commit: 1. This PR had a regression, now fixed. My earlier change added trailing 2. Credential-leak risk in the repo, unrelated to this PR. On test data: all 19,235 complaints created during the ceiling runs were removed via a gated transaction that verified non-test rows were preserved exactly, including complaints filed by real users while the tests ran. Server ended healthy — 59/59 containers up, no OOM kills, no restarts. One caveat on the ceiling numbers: they were measured during Kenyan working hours on a box with an existing idle load of ~6.9 and ~4 GB free, so they're slightly conservative versus an idle box. |
…tefacts The Bomet validation was carried as ~156K lines of raw k6 output (metrics.csv, k6-output.json, gzipped CSVs) plus a 153-line section appended to the shared findings.md. Neither is reviewable in a diff. Move the run to performance/docs/run-24-08-26/, following the structure the existing docs already use — index / executive-summary / findings / recommendations-transition-plan — and report the numbers through the same framework as the March 2026 results: steady-state throughput over the 5-minute hold, lifecycles x 4 for API TPS, lifecycles x 86,400 for daily capacity, and the site's existing 1 VU = 20-30 real users conversion. Headline: 125 VU sustained, 43.3 API req/s, ~935K lifecycles/day, zero failed requests across all seven levels; the 150 VU breach is on end-to-end latency (16.14s p95 against a 15s budget) while server latency stayed at less than half its own budget. Three things the write-up now states that the previous version did not: - Bomet is running without any of the three PR egovernments#248 database fixes. Verified against this repo's deploy path: no idx_eg_pgr_address_v2_parentid, no composite workflow indexes, no GIN trigram index, no jit = off, and EGOV_WF_FUZZYSEARCH_ISFUZZYENABLED unset. The March tier figures assume all three are applied, so Bomet sits outside that assumption. - Bomet's 125 VU and March's ~300 VU are not the same measurement. March marks where errors appear; Bomet marks where a latency budget breaks with zero errors. Bomet's error ceiling was never probed and is strictly higher. - The host metric sample logs were not retained, so that table is carried forward from the run-time analysis and cannot be re-derived. Raw artefacts are kept out of the repo and gitignored going forward. The March 2026 runs stay tracked; the ignore rule only affects new files. Every table states how it was computed, so the figures can be regenerated from a rerun. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Restructured — the raw k6 output is out, the run is now a docs setThis PR was 156,514 insertions across 56 files, almost all of it raw k6 output. It is now 718 insertions across 14 files. What changedRaw artefacts removed. The seven The 153-line section appended to The run is published as its own docs set at
Numbers are mapped through the framework this site already uses: steady-state throughput over the 5-minute hold, Headline125 VU sustained · 43.3 API req/s · ~935K lifecycles/day · zero failed requests at every level. The 150 VU breach is on end-to-end Three things the write-up now says that it didn't before
Every table states how it was computed, so the figures can be regenerated from a rerun. |
…ommentary Rewrite the run docs to mirror executive-summary.md / findings.md / recommendations-transition-plan.md section for section, with this run's numbers in place of March's. Same headings, same tables, same tone. Removed: "Three Things This Run Surfaced", "What To Do", "Methodology Notes", "When Bomet Would Need to Scale", the "three terms" preamble, the tier reconciliation section, and the warning/tip callouts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…p profile
apply-cpu-profile.py shelled out to `docker compose -f docker-compose.yml ps`,
which requires that exact filename in the working directory. Deployments that
spread the stack across several compose files plus a per-tenant overlay have no
such file, so the lookup errored, get_running_containers() returned {}, every
service fell into "skipped (not running)", and both code paths returned 0.
Two consequences, both silent:
- A profile matrix applies nothing. cpu-2/4/8/16 all run unthrottled and all
report success, yielding four identical result sets under four labels.
- `--remove` removes nothing while reporting success, so a stack throttled to
cpu-2 stays that way after the "restore" step.
Resolve by the com.docker.compose.service label instead, which is independent of
how compose was invoked, and refuse to exit 0 when zero containers resolve or
zero services match. --remove now also verifies NanoCpus actually returned to 0
rather than trusting the exit code.
Verified against a live compose deployment with no docker-compose.yml: the old
path errors to an empty map, the new one resolves 54 services including all nine
the profiles touch.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…erify cgroup Two defects found by running the profile matrix against a live Docker 29 host. `docker update --cpus 0` is a silent no-op: the daemon ignores a zero NanoCPUs and leaves the cgroup quota untouched, so every container stayed throttled while docker reported success. On a matrix run that means the restore step cannot restore — the stack is left capped at whatever the last profile applied. Clear cpu-quota/cpu-period instead, which does reset cpu.max to "max". The verification added in the previous commit read HostConfig.NanoCpus, which is also wrong: that metadata cannot be cleared by `docker update` and stays stale until the container is recreated. It reports throttling that is no longer enforced, and would report success while throttling is still enforced. Read /sys/fs/cgroup/cpu.max instead — the value the kernel actually applies. The zero-match guard did its job: the broken remove exited 1 rather than claiming success, which is how the problem surfaced at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The 24 Aug run measured a single unthrottled configuration and defined its ceiling by latency. This campaign goes back to the original March method: a CPU-profile matrix (cpu-2/4/8/16 x 2/10/50 VU) plus a burst ladder whose ceiling is defined by error rate, which populates the same sections the top-level docs already carry. Headline: 2.204 lifecycles/s, 8.80 API req/s, 190,426 txn/day at the 16 vCPU profile with 100% success; VU ceiling 80 under 1% errors. Below 8 vCPU the cgroup cap binds, not the machine -- host CPU idle stayed 60-90% while the stack collapsed. Also records that a profile is not a machine of that size: cpu-16 at 50 VU gives 2.204 LC/s against ~5.4 LC/s unthrottled on the same host, so the profile figures are conservative. Renames run-24-08-26 to run-28-08-26 and repoints the sidebar and the findings pointer section. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The run-28 docs led with cpu-16 at 50 VU (2.204 lifecycles/s, 8.80 API req/s, 190,426 txn/day). That is a throttled per-service CPU profile, not the machine as deployed, and it understated the deployment roughly 5x. A CPU profile pins each service to a fixed slice of the budget; an unthrottled machine lets services burst into each other's idle headroom. Same host, same ramp-50vu scenario, ~30 minutes apart: cpu-16 profile 1,299 iterations 7.26 API req/s p95 6,621ms unthrottled 2,446 iterations 13.50 API req/s p95 360ms Headline is now the unthrottled ladder: 125 VU sustainable, 10.785 lifecycles/s, 43.15 API req/s, 931,824 txn/day, 0.000% HTTP failures at every level from 2 to 150 VU. All figures recomputed as steady state over the 5-minute hold (vus >= 0.95 x peak) from the raw run data. The CPU matrix keeps all 12 cells and the burst ladder under a clearly labelled "Constrained CPU Profiles" section, with Tier 1 and Tier 2 in the capacity plan marked as conservative floors rather than machine sizes.
The ramp tests stop on a latency budget and record 0.000% HTTP failures at every level, so they never locate the point at which the deployment fails. A burst ladder at 20/40/80/160/320 VU, unthrottled, puts the error-based ceiling at 160 VU (0.670% HTTP failures) and peak throughput at 51.24 API req/s. At 320 VU the stack collapses to 41.12% failures and 0% success. Below 160 VU the deployment is think-time-bound rather than server-bound: throughput tracks VU/9.68s almost exactly and server p95 moves only 108ms across a fourfold concurrency increase. Records that ABAC scoping landed on PGR search between the ramp tests and this ladder, so the SEARCH step is not measured under identical conditions. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RnTVKdxpDRiTKWLyAXtvac
…M/10M per day The capacity guide still led with the 125 VU ramp figure and had no error-based ceiling, no zero-error number to quote commercially, and no sizing beyond a single machine. Rewrites it around complaints/day as the planning unit: - Comfortable ceiling 694,138/day at 80 VU with 0.000% failures, the number to put in an SLA; absolute maximum 1,076,803/day at 160 VU with 0.67% failures; collapse beyond that. - Throttled vs unthrottled stated in commercial terms: the same 16 vCPU host gives 6x the throughput at 1/34th the latency when services share the machine rather than each holding a fixed slice. - Test users vs real people kept as a rule of thumb, with complaints/day as the figure to commit to. - Sizing for 1M/day (2 app nodes, separate database, archiving mandatory) and 10M/day (~15 nodes, but queue repartitioning and data partitioning are the real work). Marked as arithmetic projection, not measurement. - Records the single-partition complaint event topic as a hard ceiling no amount of application hardware can lift, and notification fan-out as a separate cost line at volume. Also documents in the harness config example what this run cost us to learn: PGR validates serviceCode against ComplaintHierarchy rather than ServiceDefs, and ABAC-scoped search returns zero rows with HTTP 200 when the employee's departments and jurisdictions do not match the complaints being filed, which corrupts throughput figures without surfacing an error.
…ler tiers The stack holds 26.8 GB of memory at rest on the live deployment, measured with no load applied: 26787 MB used of 31337 MB, 4549 MB available, no swap configured, 57 containers. Nothing below 32 GiB can hold it. Running there would require swap, and a set of JVM services that swaps thrashes rather than slows, because the garbage collector touches memory the OS has paged out. That is a hard floor, not a tuning parameter. Withdraw every sizing recommendation below it: - Replace the Tier 1 (4 vCPU / 8 GB) and Tier 2 (8 vCPU / 16 GB) tiers in both capacity-planning docs with a single floor plus a scale-out ladder. A pilot and a large city are now provisioned identically; the difference between deployments is stored data and operations, not machine size. - Explain why the smaller tiers were valid in March 2026 and are not now: that stack was ~30 containers and fitted into 16 GB, the current one is 57-59 and its resident footprint has roughly doubled. - Rewrite both decision flowcharts to start every branch at the floor. - Update setup.md to provision a single 16 vCPU / 32 GiB target rather than a dev/prod pair, and say explicitly not to enable swap to fit on less. - Note on the historical test-machine tables in findings.md, architecture.md and executive-summary.md that they record rigs, not sizing advice. The March measurements are kept - they are the evidence for the floor, not an alternative to it. Scale upward from the floor rather than growing one machine. At the collapse point the host was 64% CPU idle at load average 32.6, so it was queue- and connection-bound, not processor-bound; extra cores on one host do not lift that ceiling. Add nodes of the floor spec instead: ~2 for 1M complaints/day, ~15 for 10M/day, both projections from the single measured machine. 32 vCPU / 64 GiB remains reasonable for headroom to run other DIGIT modules or a co-located database, but buys little throughput. Also record that 32 GiB covers the application stack only - move the database to its own host past ~500K stored complaints, or grow memory with the data. Constrained CPU profiles (cpu-2/4/8) are now explicitly labelled as non-deployable: they cap CPU on a host that still has all 30 GiB of memory, so they locate where CPU binds and are not smaller machine sizes. The tested machine had 30.6 GiB, marginally under the floor it establishes, which makes its throughput figures conservative rather than optimistic. Verified: vitepress build clean, cross-doc anchors resolve, and a setext heading introduced by an unspaced rule was caught in the rendered HTML and fixed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014nKwNs2gAVkHXCrB43LJEF
…capacity Investigating a suspected ASSIGN -> RESOLVE workflow race turned up the real cause of the burst ladder's top two levels, and it is not capacity. pgr-services runs with JAVA_OPTS=-Xmx384m: a fixed 384 MB heap on a 30.6 GiB host with no container memory limit. During the 160 VU level the JVM exhausted it, throwing java.lang.OutOfMemoryError 421 times between 01:55:38 and 02:00:01 UTC, with none in the preceding 16 hours of container uptime. The OutOfMemoryError stopped Spring's KafkaMessageListenerContainer and took the Kafka producer's sender thread with it. CustomKafkaTemplate.send waits on CompletableFuture.get() with no timeout, so with no sender thread no send ever completes. A dump taken six hours later showed 130 of 130 Tomcat workers WAITING on that get - 113 in PGRService.create, 17 in update - at 0.15% CPU, zero kafka-producer-network-thread, and a healthcheck that had failed 512 consecutive times. The OOM killed a listener thread rather than main, so the JVM never exited and Docker never restarted it. The service could not recover on its own and was still wedged when found. Consequences for the published figures: - 320 VU ran against a service whose Kafka producer was already dead. Its 372 CREATE 504s measure a broken service, not a saturated one. Withdrawn. - 160 VU carries 12 RESOLVE INVALID ACTION rejections logged 50 seconds before the first OOM - the same heap exhaustion stalling workflow commits past the harness think time, not an independent limit. Withdrawn as a ceiling. - 20, 40 and 80 VU all completed before the first OOM at 0.000% failures and stand unchanged. 80 VU / 694,138 per day is now the headline everywhere. - The machine's actual ceiling above 80 VU is unmeasured. The software gave out before the hardware did, and the ladder needs re-running with a realistic heap before any higher figure is quoted. Corrects a factual error in findings.md, which stated there were no OOM kills during the campaign. The host never ran out of memory and the kernel OOM killer never fired, but the JVM exhausted its own capped heap; the two are now distinguished. Also removes the "failure edge is sharp" claim, which described this fault rather than a degradation curve. Adds scenarios/race-probe.js, the diagnostic written to test the race hypothesis. The hypothesis did not survive it: 70 consecutive zero-delay ASSIGN -> RESOLVE cycles passed, 50 remote and 20 from localhost at ~2ms RTT, with no INVALID ACTION. Measured commit lag was under 35ms and every poll caught the transition on the first attempt. Kept because it is the tool that falsified the theory and measures the window directly. Two defects to raise separately: the 384 MB cap, and the untimed CompletableFuture.get() that turns any producer failure into permanent, self-sustaining unavailability. The OOM was survivable; the untimed wait is what made it terminal. Verified: vitepress build clean, cross-doc anchors resolve, probe complaints removed under a gated transaction (ke back to baseline + 1 unrelated real complaint left untouched). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014nKwNs2gAVkHXCrB43LJEF
… slice
Two questions came out of reading the matrix table, and neither was answerable
from the docs as written.
**"Why don't the percentages add up to 100?"** Because Success and HTTP fail
count different populations over different denominators, which the column
names did nothing to convey. Renamed across all seven tables to Lifecycle
success and Request fail, and added a "Reading the two percentage columns"
section that works the arithmetic through:
cpu-4 at 50 VU, whole-run counters: 225 of 672 requests failed (33.5%),
which killed 108 of 163 lifecycles, leaving 33.7% success. Sum 67%, with
nothing unaccounted for.
One failed request anywhere in the chain fails the whole lifecycle, so the
lifecycle rate runs several times the request rate. A lifecycle can also fail
with no failed request at all - all four calls return 200 but the final SEARCH
does not report RESOLVED, or the run ends mid-lifecycle. The denominators
drift further apart the worse things get, because dying lifecycles bail early
and never issue their remaining calls: cpu-2 at 50 VU averaged 1.66 requests
per lifecycle against 4.04 when healthy. The section says plainly that the
worked example uses whole-run counters while the tables report the hold
window, so the figures are not expected to match the rows.
**"Why is cpu-16 different from unthrottled?"** Because cpu-16 never gave the
stack 16 vCPU. It divides a 16 vCPU budget across 31 named services and pins
each one, and pgr-services - the service that files and updates complaints -
gets 0.80 of a core. The profile table now carries that slice as a column
(0.10 / 0.20 / 0.40 / 0.80), and a new section explains the three mechanisms:
- a request chain is sequential, so one service works while the rest idle;
pinning forbids the borrowing that makes an unthrottled machine fast
- CFS quota is bursty - 80ms per 100ms period, then a hard stop - so latency
degrades far more than throughput: at 50 VU throughput differs 2.3x while
server p95 differs 14x
- the partition is partial: 30 of 57 running containers were capped
Also corrects the container count, which read "~20 of 59" against an applied
limit set of 30 of 57.
Verified: vitepress build clean, both new anchors resolve, five inbound links
to them check out.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014nKwNs2gAVkHXCrB43LJEF
…etraction Self-review of the PR turned up four defects, all introduced or left behind by the earlier retraction of the 160/320 VU results. 1. findings.md still asserted the withdrawn claim in prose: "the unthrottled ladder below reaches 160 VU before errors appear". Replaced with the corrected reading. 2. The "Degradation Points by Profile" table listed cpu-16 as crossing 5% errors at "160 VU (burst)". That contradicted the prose two sections above it, which correctly says the 5% rate is crossed at 320 VU - and the burst table itself records 3.34% at 160 VU, below the threshold. Corrected to 320 VU. This one was wrong before the OOM work, not because of it. 3. findings.md carried its own summary block headlining 931,824 txn/day at 125 VU with "HTTP failures, all levels 0.000%", while index.md and the executive summary now headline 694,138/day at 80 VU. Both are true - they come from different ladders with different scenarios, stopping rules and access-control conditions - but nothing said so, leaving two unreconciled headline numbers in one PR. The block now labels every row with its ladder, carries the burst figure alongside, and states plainly that 694,138/day is the number to plan against because it was measured with zero failures under the stricter test. 4. "Below 160 VU the deployment is bound by client think time" implied 160 VU was still a meaningful boundary. Reworded to "Up to 80 VU", the highest level actually verified. Also qualified the "the unthrottled ladder never crosses the failure budget" observation, which was true of the ramp ladder only. Verified: vitepress build clean, stale-claim sweep now returns only the burst table's own data rows, which carry an explicit do-not-plan-against note. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014nKwNs2gAVkHXCrB43LJEF
A numeric audit against the raw run artefacts found three places where the docs presented figures that could not be reconciled from what was on the page. 1. The cpu-16 vs unthrottled comparison box quoted whole-run figures (1,299 iterations, 7.26 req/s, 6,621ms p95) while every table on the page quotes steady-state over the hold window (8.80 req/s, 7.31s p95 for the same cell). Nothing said so, so the two disagreed for no visible reason. The box is now labelled whole-run, explains why that basis is used for a head-to- head, names the 29-minute gap and the provenance of the unthrottled arm - a run whose CPU limits a failsafe had already cleared, which invalidated it as a cpu-16 measurement and left a clean unthrottled one on the same host. Added a Lifecycles/s row so both arms can be read directly. 2. The executive summary compared "2.204 lifecycles/sec at 6,621ms" against "5.165 lifecycles/sec at 517ms". That mixed a steady-state throughput with a whole-run latency for the throttled arm, and took the unthrottled arm from a different run four days earlier under different access-control conditions. Rewritten to use one basis for both arms: 1.798 against 3.367 lifecycles/sec, 6,621ms against 360ms - 1.9x the throughput at one eighteenth of the latency. 3. The Host Behaviour table's "Min CPU idle" column held "60-90%" for cpu-2 and cpu-4. A minimum cannot be a range. Column renamed to what the figures actually are. Also documents why API req/s is not exactly four times Lifecycles/s, which an audit of every result row raised: healthy rows sit at 3.98-4.11 but degraded ones reach 5-21. Two causes, both verified against the raw counters rather than assumed - one login per VU outside the four-call lifecycle (the cpu-16 burst at 80 VU issued 240 x 4 + 80 = 1,040 requests, exactly), and lifecycles still in flight when a run ends, which dominates where a single lifecycle takes a large fraction of the run. No retries or auth failures were involved; both were checked and are zero. Verified: every "per day" figure reconciles to rate x 86400, vitepress build clean, no markdown regressions. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014nKwNs2gAVkHXCrB43LJEF
|
@dhruv-1001 Two things before merging:
|
…o one project Both from @Lokendra-egov's review on egovernments#1848. 1. race-probe.js threw on the first iteration against dev or prod. It read env.serviceCodes and env.localities directly, while every other scenario resolves them through a fallback. Neither key exists outside the bomet block, so svcCodes was undefined and svcCodes.length threw before a single request went out. SERVICE_CODES and LOCALITIES are now exported from pgr-lifecycle.js and imported here, so the probe uses the same resolved values as everything else instead of duplicating the seed defaults. The same scenario had a third gap in the same place: env.citizenPhone and env.citizenName were passed straight through with no default, and createComplaint has no default for either, so a dev run would have filed complaints with an undefined mobile number rather than failing. Defaulted to the generated identities pgr-lifecycle.js already uses. 2. remove_limits() cleared CPU quotas on every compose-managed container on the host. Resolving containers by the com.docker.compose.service label - which the previous commit adopted deliberately, because `docker compose ps` needs a compose filename that multi-file deployments do not have - matches across every project on the box. Fine on a dedicated host, destructive on a shared one, where --remove would reset quotas another stack had set. Every action is now scoped to one compose project, taken from --project, else $COMPOSE_PROJECT_NAME, else auto-detected when exactly one project is running. With several running and none named it refuses rather than guessing, which keeps the single-host case a no-op change while making the shared-host case safe. remove_limits() also skips containers already at cpu.max, so it only touches what a profile actually limited. playbook-profile.yml passes an optional `compose_project` through. Verified: both scenarios pass node --check; the script compiles; the five resolve_project paths (explicit, env var, single auto-detect, several refused, none refused) each behave correctly; --remove against a non-existent project exits 1 having touched nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014nKwNs2gAVkHXCrB43LJEF
|
@Lokendra-egov Both correct, both fixed in 1.
|
…ribution
The 28 August run had three method gaps. All three are now closed on the same
deployment, against a fixed dataset of 2,525 complaints restored by a gated
cleanup between every run.
**Variance.** Every figure in the original ladder came from a single run and
carried no error bar. Three repeats at each of two levels, on an identical
database:
40 VU throughput CV 1.33% p95 CV 0.98%
120 VU throughput CV 2.24% p95 CV 19.19%
Latency variance grows nineteenfold between the two levels while throughput
barely moves. A single p95 near saturation is worth about +/-20%, so latency
differences below roughly 40% at those levels cannot be read from one run. The
Kubernetes campaign measured 20.1% at its own saturation point on unrelated
infrastructure - two stacks, same answer. Recorded honestly: both bomet sets
are monotonic, so part of that spread is warm-up rather than noise, and
separating them needs a discard-first-run protocol that was not used.
**Open-loop.** Every 28 August figure came from a closed-loop test, which
cannot overload a system because the load slows down when the server does.
Re-run with a ramping arrival rate at a near-identical request rate:
closed-loop 120 VU 45.64 req/s 0.000% fail 100.00% lifecycles
open-loop 43.41 req/s 19.570% fail 50.23% lifecycles
24% of work never started
Half of all complaints fail and a quarter never start, on a system that looked
flawless minutes earlier at the same throughput. 1,686 of those failures were
INVALID ACTION - a RESOLVE refused because the preceding ASSIGN had not
committed - with zero OutOfMemoryErrors and zero restarts. That settles a
question this run left open: the failure is arrival pressure on an asynchronous
write path, not a symptom of the heap exhaustion documented in "When the heap
gave out".
For that run the heap was temporarily raised to 1 GB with
-XX:+ExitOnOutOfMemoryError and the restart policy set to unless-stopped, all
reverted afterwards. The combination matters - a wedged JVM does not exit, so a
restart policy alone would never recover it. Under those settings the
deployment absorbed a threefold overload without wedging, where the shipped
configuration collapsed for six hours at lower load on 31 August.
**Attribution.** Postgres slow-query logging at 100ms for a full load run,
reverted afterwards. Five statements exceeded 100ms and not one was a PGR
write-path query; the two slowest were periodic dashboard view refreshes at
~7s each, unrelated to load. At 2,525 records the database is not the
constraint. The Kubernetes campaign reached the same conclusion independently
(5-27% CPU, 48 of 402 connections, slowest query 3.27ms). Commercially this
matters: a bigger server or database will not raise these numbers.
The business document gains three plain-language sections - what realistic
traffic does, how much a single measurement can be trusted, and what the limit
is not - plus a caveat that the daily capacity figures are an upper bound under
evenly-paced load rather than a planning target.
Also carries the harness fix that made the k8s run possible: `authTenant`, for
deployments that authenticate at one tenant and file at another. Falls back to
`tenant`, so bomet is unaffected.
Verified: vitepress build clean, both new anchors resolve, all 14 harness files
pass node --check. Every change made to bomet for this campaign has been
reverted and independently confirmed - ke back to 2,525, HRMS grant removed,
heap and restart policy restored, slow-query logging off.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014nKwNs2gAVkHXCrB43LJEF
closes #1854