Skip to content

perf(bomet): live-deployment validation run + deployment-configurable harness - #1848

Open
dhruv-1001 wants to merge 18 commits into
egovernments:masterfrom
dhruv-1001:perf/bomet-live-deployment-results
Open

perf(bomet): live-deployment validation run + deployment-configurable harness#1848
dhruv-1001 wants to merge 18 commits into
egovernments:masterfrom
dhruv-1001:perf/bomet-live-deployment-results

Conversation

@dhruv-1001

@dhruv-1001 dhruv-1001 commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

closes #1854

…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>

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@coderabbitai

coderabbitai Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Team

Run ID: 2c431cd2-95d9-4f36-a3d3-de25ce880f95


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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

**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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dhruv-1001 Can we add the hardware provisioned on this machine?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. I had described bomet as bare metal — it isn't. systemd-detect-virt reports kvm, so it's a VM sharing 16 vCPU across the whole stack.
  2. 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.

Comment thread performance/docs/findings.md Outdated
**Bomet County's live production deployment** to confirm the findings hold on a real
installation carrying real data and real daily traffic.

### Methodology Differences

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the max number of concurrent users? How long did we sustain the test? What was the ramp up period? @dhruv-1001

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread performance/docs/findings.md Outdated
| 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% |

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0.7/s seems too small a throughput no? @dhruv-1001

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread performance/docs/findings.md Outdated
|------|-------------|-----------|-------------|---------|----------|
| 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% |

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's our upper limit? HOw many TPS can we handle before things start to break? @dhruv-1001

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_failed stayed at 0.000% and transaction_success at 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_duration p95 (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>
@dhruv-1001

Copy link
Copy Markdown
Collaborator Author

@subhashini-egov all four comments addressed in 87d7611 — replies inline. Summary:

# Ask Resolution
1 Hardware Machine table added. Corrected: it's a KVM guest, not bare metal, and a shared dev env rather than "live production" as I'd written
2 Concurrency / duration / ramp Test-shape table added. 150 VU max, 5 min sustained at each peak
3 "0.7/s seems too small" You were right — those were run averages. Steady-state is 40–55% higher; also explained it's think-time-bound below ~75 VU
4 Upper limit / TPS New tests. Ceiling measured: 125 VU / ~43 API req/s; breaks at 150 VU on latency, not errors

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 locality/city params to createComplaint(), but seed-1m.js, seed-calibrate.js and variable-throughput.js duplicate the lifecycle instead of importing pgrLifecycle, so they still called it with 7 args and would have written complaints with city/locality undefined. Fixed both ways — the params now default to the seed values so any un-updated caller is safe, and those three scenarios are made env-aware like pgr-lifecycle.js.

2. Credential-leak risk in the repo, unrelated to this PR. performance/k6/config/environments.js is created by copying the .example and holds real deployment credentials. It's gitignored in the upstream load-tests repo but was not ignored here, so a git add -A would have committed it. Added the rule. No leak has occurred — only the .example is tracked — but worth knowing it was possible.

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>
@dhruv-1001

Copy link
Copy Markdown
Collaborator Author

Restructured — the raw k6 output is out, the run is now a docs set

This PR was 156,514 insertions across 56 files, almost all of it raw k6 output. It is now 718 insertions across 14 files.

What changed

Raw artefacts removed. The seven performance/results/20260824-* run directories (metrics.csv, k6-output.json, gzipped CSVs) and the seven results/baseline/bomet-*.json summaries are no longer committed — ~22 MB that could not be reviewed in a diff. Nothing in the repo reads them. A gitignore rule keeps future run artefacts out; the March 2026 runs stay tracked, since ignore rules don't affect already-tracked files.

The 153-line section appended to performance/docs/findings.md is now 19 lines — a summary and a pointer.

The run is published as its own docs set at performance/docs/run-24-08-26/, mirroring the structure already in performance/docs/:

index.md Overview and what makes this run different
executive-summary.md Headline numbers
findings.md Machine, methodology, capacity curve, degradation
recommendations-transition-plan.md Sizing and scaling in business terms

Numbers are mapped through the framework this site already uses: steady-state throughput over the 5-minute hold, lifecycles × 4 for API TPS, lifecycles × 86,400 for daily capacity, and the existing 1 VU ≈ 20–30 real users conversion. VitePress builds clean, so the new nav has no dead links.

Headline

125 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 transaction_duration (16.14s p95 vs a 15s budget), which includes ~8s of scripted think time. http_req_duration — actual server latency — was 2,326ms against its own 5,000ms budget, so by the metric that measures the server directly nothing had broken at 150 VU.

Three things the write-up now says that it didn't before

  1. Bomet is running without any of the three PR perf: add missing PGR address index + disable workflow fuzzy search #248 database fixes. Verified against this repo's deploy path: no idx_eg_pgr_address_v2_parentid (only a locality index), no composite workflow indexes, no GIN trigram index, no jit = off, and EGOV_WF_FUZZYSEARCH_ISFUZZYENABLED unset. The March tier figures explicitly assume all three are applied — so Bomet sits outside that assumption. Its database is small (~2,250 complaints) so this costs little today, but the March data shows an unfixed deployment degrading ~9.4× by 100K records.

  2. 125 VU and the March ~300 VU are not the same measurement. March marks where errors appear (connection exhaustion, PgBouncer timeouts). Bomet's 125 marks where a latency budget breaks with zero errors. Bomet's error ceiling was never probed — deliberately, on a live deployment — and is strictly higher. The earlier version of this section left the two numbers side by side without reconciling them.

  3. The host metric sample logs were not retained. The load/CPU/memory table is carried forward from the run-time analysis and cannot be re-derived from what's in the repo. Said so explicitly rather than presenting it as reproducible.

Every table states how it was computed, so the figures can be regenerated from a rerun.

dhruv-1001 and others added 7 commits August 27, 2026 09:26
…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.
dhruv-1001 and others added 3 commits August 31, 2026 12:40
…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
dhruv-1001 and others added 2 commits August 31, 2026 14:41
…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
@Lokendra-egov

Lokendra-egov commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

@dhruv-1001 Two things before merging:

  1. race-probe.js will crash on dev/prod targets. env.serviceCodes/env.localities have no fallback there (unlike every other scenario file in this PR), so a plain k6 run scenarios/race-probe.js throws on iteration one.

  2. apply-cpu-profile.py's new docker ps lookup isn't scoped to a compose project anymore. remove_limits() now resets CPU quotas on every labeled container on the host, not just this stack's, risky on a shared box.

…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
@dhruv-1001

Copy link
Copy Markdown
Collaborator Author

@Lokendra-egov Both correct, both fixed in 47359f63.

1. race-probe.js on dev/prod

Confirmed exactly as you described — it read the keys raw where every other scenario resolves them through a fallback:

const svcCodes = env.serviceCodes;                  // undefined outside the bomet block
const serviceCode = svcCodes[i % svcCodes.length];  // throws before the first request

SERVICE_CODES and LOCALITIES are now exported from pgr-lifecycle.js and imported here, so the probe uses the same already-resolved values rather than duplicating the 33 seed codes.

There was a third gap in the same three lines, which I'd rather flag than leave for someone to hit: env.citizenPhone and env.citizenName were also passed straight through, and createComplaint has no default for either. That one wouldn't have thrown — it would have quietly filed complaints with an undefined mobile number, which is worse than crashing because nothing surfaces it. Both now default to the generated identities pgr-lifecycle.js already uses.

2. apply-cpu-profile.py scoping

Also confirmed. get_running_containers() ran docker ps with no --filter, so it matched every compose-managed container on the host and remove_limits() cleared quotas across all of them.

The constraint worth naming: resolving by the com.docker.compose.service label was a deliberate earlier fix, because docker compose ps requires a compose filename that multi-file deployments don't have. So the fix had to keep label resolution and add scoping on top:

  • Project comes from --project, else $COMPOSE_PROJECT_NAME, else auto-detected when exactly one compose project is running.
  • With several running and none named, it refuses rather than guessing. Single-host behaviour is unchanged; the shared-host case is the one that's now safe.
  • remove_limits() additionally skips containers already at cpu.max, so it only touches what a profile actually limited.
  • playbook-profile.yml threads an optional compose_project through.

Verification

  • All five resolve_project paths exercised: explicit, env var, single auto-detect, several → refused, none → refused
  • --remove --project no-such-project exits 1 having touched nothing
  • All 14 k6 harness files pass node --check; the script compiles

One thing your review exposed beyond the two bugs: my pre-push gate was node --check on every scenario, which proves syntax and nothing else. Neither of these would ever have failed it — the first needs a target whose config lacks optional keys, the second needs a second compose project on the host. Both are runtime-shape problems that a parse check cannot see. Worth a smoke run per target in CI rather than a syntax gate; I haven't added that here.

…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Perf] Ansible load testing

3 participants