who_need_help/docs/performance.md

25 KiB
Raw Blame History

Performance measurement

No production capacity, minimum resource requirement, SLO, alert threshold, pool size, or autoscaling threshold is known yet. The repository therefore contains a reproducible measurement profile, not a capacity claim or blocking resource preflight.

Kind port-table memory investigation

On 2026-07-20, the local kind control-plane container used 9.748 GiB while its four otherwise idle application beam.smp processes each had roughly 2.3 GiB RSS. Read-only runtime probes found an effective Erlang port limit of 134,217,727 and an allocated port table of 1,610,612,736 bytes in every kind BEAM VM. The nested runtime exposed nofile=2,147,483,584; OTP had derived the maximum port limit from that value.

The same release image in ordinary Compose used the normal 65,536 port limit and a 786,432-byte port table. A separate VM probe with ERL_ZFLAGS="+Q 65536" reproduced those smaller values inside the kind pod before any rollout. Compose and Helm now set that value explicitly, and the startup and rolling-verification scripts check the effective value in every running web and worker VM.

After the Helm rollout, the unchanged kind control-plane container measured 1.796 GiB, a reduction of 7.952 GiB (81.6%). Its four application pod cgroups measured 188.5215.6 MiB in the first post-change sample. A subsequent four-pod rolling replacement retained the 65,536 limit and 786,432-byte table; the replacement cgroups measured 194.3211.4 MiB, all four replacement pods were Ready, cross-node PubSub passed, 184 readiness samples had no failed sample after retry handling, and the application-table count diff was empty. Evidence is retained under output/memory/20260720-port-table-fix-005420 and output/resilience/beam-port-limit-final-20260720.

In the ordinary 2-web/2-worker Compose profile, the immediate post-recreation sample measured 190.1205.5 MiB per application container and 168.3 MiB for PostgreSQL. These local observations do not include a target server's OS and do not establish that a 1 GiB or 2 GiB machine is sufficient for production.

The profile uses a separate Compose project, generated independent secrets, and a separate PostgreSQL volume. Its Traefik instance is constrained to that exact Compose project; its router, service, middleware, and Host rule are unique, so the load profile cannot join the staging router. It exercises:

  • dynamic public home and Safety pages;
  • database-backed readiness queries;
  • Phoenix WebSocket upgrades and protocol heartbeats;
  • real password login and authenticated LiveView joins;
  • consent-driven tracking start, authenticated location writes, private chat writes, and explicit tracking stop/position deletion;
  • the configured number of web and worker replicas;
  • cross-node Phoenix PubSub after each run.

It does not render a real browser, retain a browser tracking session for an unknown real-world trip duration, or reproduce an unknown future production traffic mix. It also does not sample the k6 load-generator container itself. Its results only describe the recorded host, container versions, replica counts, database state, and experiment inputs.

Create the isolated profile

./scripts/ensure-local-load-env.sh
./scripts/load-stack-up.sh

The generated .env.load is ignored, restricted to mode 600, and contains independent PostgreSQL and application secrets. Edit its LOAD_* inputs to define a specific experiment. Values in .env.load.example are reproducible measurement points, not recommendations.

For a one-run duration that must not rewrite .env.load, pass an explicit experiment override:

LOAD_DURATION_OVERRIDE=10m ./scripts/load-run.sh local-soak-YYYYMMDD

The effective value and whether it came from .env.load or the override are recorded in that run's environment.txt.

Run and compare replica counts

./scripts/load-run.sh two-web
./scripts/load-stack-up.sh 3
# Set LOAD_WEB_REPLICAS=3 in .env.load so the recorded expected topology
# matches the running topology, then:
./scripts/load-run.sh three-web

Each run writes ignored evidence below output/performance/<label>/:

  • the exact inputs and Docker host observation;
  • a machine-readable k6 summary and complete console report;
  • periodic Docker CPU/memory/network/block-I/O/PID samples for every load stack container, plus a threshold-free CPU/RAM/PID JSON summary;
  • read-only PostgreSQL database size and pg_stat_database snapshots before and after k6, plus their machine-readable counter deltas;
  • pg_stat_statements rankings by total, mean, and maximum execution time, including calls, rows, buffer activity, temporary blocks, and WAL activity;
  • periodic pg_stat_activity connection samples with configured pool slots, observed connection headroom, transaction state, lock waits, and named active wait events;
  • per-web Ecto telemetry deltas separating database execution, pool queue, and result decode time;
  • before/after application table counts, exact authenticated-write evidence, fixture cleanup output, and a cleanup diff;
  • final Compose state, readiness response, application logs, and cross-node PubSub result.

The load-only PostgreSQL command preloads pg_stat_statements and creates the extension only in the isolated load database. The application exposes cumulative Ecto measurements through its authenticated metrics endpoint; the runner snapshots every web replica before and after k6 and records the delta. Neither mechanism changes the ordinary public Compose database.

Before k6 starts, the production-compiled fixture task verifies the exact configured load database name and a deliberate confirmation marker. It creates isolated requester/helper/request/assignment pairs through the application contexts. The pair count equals the maximum simultaneous VU count across all scenarios, because k6 may reuse its global VU pool between parallel scenarios; mapping each global VU identifier directly to its own pair prevents concurrent users from sharing a tracking assignment. The random fixture password exists only in ignored mode-600 .env.load and is never written to the manifest or console.

On success and on trapped failure, cleanup deletes only the UUIDs recorded in the validated run manifest. A successful run additionally requires every tracked application-table count after cleanup to equal its pre-fixture count. In a separate negative check, replacing one helper UUID with another valid UUID made cleanup exit nonzero before mutation; all 2 users, 1 request, and 1 assignment remained. Restoring the original manifest then removed those exact rows and returned all three counts to zero.

The k6 image is pinned to version 2.1.0 by digest and runs locally with anonymous usage reporting disabled. No Grafana Cloud account, API token, OpenAI API, or usage-based service is involved.

Observed local comparison

Observed on 2026-07-18 with Docker Engine 29.6.2 on the recorded 32-CPU, 100289265664-byte Docker host. Neither application containers nor the load generator had CPU or memory limits. Both runs used 40 HTTP VUs, 40 WebSocket VUs, a 30-second duration, a 0.1-second HTTP think time, and five-second socket sessions. Each HTTP iteration requested the home page, Safety page, and database-backed readiness endpoint.

Observation 2 web replicas 3 web replicas
HTTP checks 35,160 / 35,160 passed 35,163 / 35,163 passed
HTTP failed rate 0% 0%
Observed HTTP rate 1,169.12 requests/s 1,169.42 requests/s
HTTP duration p95 2.87 ms 3.36 ms
WebSocket opened / heartbeat replies 240 / 240 240 / 240
WebSocket errors 0 0
WebSocket connect p95 46.65 ms 24.12 ms
Average CPU per web container 66.57%, 66.63% 50.25%, 49.77%, 49.18%
Maximum CPU per web container 79.80%, 79.21% 58.16%, 59.23%, 59.08%
Maximum observed memory per web container 268.3, 238.3 MiB 221.0, 223.2, 219.4 MiB

Each resource series contained 11 sequential docker stats --no-stream samples. Both before/after database diffs were empty, both cross-node PubSub probes passed, readiness remained healthy, and the captured proxy/web/worker logs contained no application error or warning. The public staging root and readiness endpoint also returned HTTP 200 while the isolated project was running.

An earlier harness smoke exposed that fixed Traefik router/service names are global to the Docker provider: starting a second Compose project temporarily made staging readiness return HTTP 404. The load project was stopped immediately and staging returned HTTP 200. The final Compose configuration parameterizes the project constraint, router/service name, Docker network, and router rule. Merged-config inspection found no shared router/service label key, the two running proxies showed different exact project constraints, and both origins returned HTTP 200 concurrently before the recorded final runs.

The nearly identical request rate is expected from the selected paced workload: 40 HTTP VUs each issue three requests and then wait 0.1 seconds, giving an approximate workload ceiling of 1,200 requests/s before response time is added. The result therefore does not show the saturation point of two replicas and does not justify an HPA threshold. The lower per-container CPU and lower WebSocket connect p95 with three replicas are observations for this run only; the slightly higher HTTP p95 also shows why one short comparison is not a production sizing study.

Ignored evidence:

  • output/performance/two-web-final-20260718/
  • output/performance/three-web-final-20260718/

Observed authenticated write run

Observed on 2026-07-19 on the same recorded Docker host with 3 web replicas, 2 worker replicas, 40 paced public HTTP VUs, 40 five-second Phoenix heartbeat VUs, and 8 authenticated mutual-aid VUs for 30 seconds. The fixture tool created 88 isolated pairs so every possible global VU had a unique account and assignment.

Observation Result
Public HTTP checks 35,316 / 35,316 passed
All HTTP failures 0 / 39,108
Phoenix heartbeat opened / replies 240 / 240
Authenticated logins 8
Authenticated pages / LiveView joins 1,888 / 1,888
Tracking starts / position writes / stops 1,888 / 1,888 / 1,888
Private messages written 1,888
Authenticated or WebSocket errors 0
HTTP request duration p95 5.29 ms
Authenticated HTTP duration p95 8.10 ms
WebSocket connect p95 3.16 ms

The database evidence counted 1,888 chat rows, 1,888 tracking sessions with 1,888 total samples, 8 login tokens, and zero current positions after explicit stop. Cleanup removed the 176 fixture users, 88 requests, 88 assignments, 1,888 messages, 1,888 tracking sessions, and their audit rows. The post-cleanup application-table diff was empty. Readiness and the five-node web/worker PubSub probe passed, and the run-scoped proxy/web/worker logs contained no application error, warning, HTTP 4xx/5xx, or database deadlock.

Eleven sequential resource samples observed average CPU of 135.63%, 140.01%, and 136.88% for the three web containers, with maximum memory of 273.4, 271.2, and 272.9 MiB respectively. PostgreSQL averaged 37.55% CPU and reached 249.2 MiB; Traefik averaged 47.11% CPU and reached 79.25 MiB. These are short local observations without container resource limits, not minimums, production capacity, or autoscaling inputs.

Ignored evidence:

  • output/performance/final-load-20260719a/

Observed SQL and connection-pool run

Observed on 2026-07-20 with 3 web replicas, 2 worker replicas, PostgreSQL 18.4, 40 paced public HTTP VUs, 40 Phoenix heartbeat VUs, and 8 authenticated mutual-aid VUs for 30 seconds.

Observation Result
HTTP requests / failures 38,429 / 0
Public checks 35,163 / 35,163 passed
HTTP duration average / p95 / maximum 2.42 / 7.74 / 187.11 ms
Authenticated chat/tracking chains 1,664
Phoenix heartbeat opened / replies 240 / 240
Configured Ecto Repo pool slots 50
Peak PostgreSQL client backends 55
Minimum observed non-reserved connection headroom 42
Idle-in-transaction / lock-waiting backends 0 / 0
Ecto average execution / queue / decode time 308.58 / 31.15 / 0.27 µs
PostgreSQL rollback / deadlock / conflict / temp files 0 / 0 / 0 / 0

The connection sampler observed active PostgreSQL waits named Client:ClientRead and IO:WalSync; it observed no lock wait or idle-in-transaction backend. The Ecto queue measurement is the application-side evidence for time waiting to check out a Repo connection. It averaged 31.15 microseconds over 166,459 queries across the three web replicas. This short run did not produce a connection timeout or establish a maximum connection capacity.

The SQL ranking exposed an unbounded preload of every message attached to a request even though the LiveView immediately loaded a separate 50-message cursor page. In the before run that redundant statement executed 3,318 times, returned 342,378 rows in aggregate, and consumed 392.378 ms of PostgreSQL execution time. The request preload was removed and a regression test now requires the chat association to remain unloaded. In the repeat run the unbounded statement was absent; the bounded cursor statement retained its LIMIT, executed 3,328 times, returned 148,512 rows, and averaged 0.122 ms.

No normalized statement in the repeat run used temporary blocks. The largest mean statement time was 0.481 ms for six session-token inserts; the highest-call request lookup averaged 0.031 ms over 39,936 calls. These are observations against the small generated load dataset, not proof that every query remains fast at arbitrary data size. The separate 50,000-row database profile below checks the indexed list and cursor queries at a larger cardinality.

Across the seven measured application/database/proxy containers, simultaneous memory samples totalled 1,501.601,509.86 MiB during this run. This excludes the host OS, the k6 generator, Mailpit, and optional observability services. Therefore it is not a claim that this 3-web/2-worker topology fits a 1 GiB or 2 GiB server. It does show that the measured workload used about 1.47 GiB for those seven containers rather than the former kind port-table failure's roughly 10 GiB.

The run created exactly 1,664 messages, tracking sessions, and tracking samples, plus 8 session tokens; explicit tracking stop left zero current positions. Cleanup restored every tracked application-table count, readiness and the five-node PubSub probe passed, and the captured application log had no database timeout, Postgrex error, deadlock, ERROR, FATAL, or PANIC match.

Ignored before/after evidence:

  • output/performance/pool-sql-final-20260720/
  • output/performance/pool-sql-chat-fixed-canonical-20260720/

Observed 10-minute authenticated soak

Observed on 2026-07-19 with the same 3-web/2-worker topology and the same 40 public HTTP, 40 five-second Phoenix heartbeat, and 8 authenticated mutual-aid VUs. The duration came from the recorded LOAD_DURATION_OVERRIDE=10m; it is an experiment input, not a recommended soak duration or capacity target.

Observation Result
Completed / interrupted iterations 271,072 / 0
Public checks 703,995 / 703,995 passed
HTTP requests / failures 767,225 / 0
HTTP duration average / p95 / maximum 2.34 / 5.71 / 175.96 ms
Phoenix heartbeat opened / replies 4,800 / 4,800
Authenticated logins 8
Authenticated pages / LiveView joins 31,607 / 31,607
Tracking starts / position writes / stops 31,607 / 31,607 / 31,607
Private messages written 31,607
Authenticated or WebSocket errors 0

The database evidence counted exactly those 31,607 messages, tracking sessions, and samples, plus 8 login tokens and zero current positions after explicit stop. Its read-only pg_stat_database delta recorded 2,921,283 commits, zero rollbacks, 12 block reads, 33,967,340 block hits, zero temporary files/bytes, zero deadlocks, and zero conflicts. Database size increased by 16,678,912 bytes during the write workload. The physical database size was recorded but was not treated as a cleanup assertion; the post-cleanup count diff across all tracked application tables was empty. The five-node PubSub probe passed. All seven measured app/database/proxy containers remained running, had zero observed restarts, and were not OOM-killed. The 75,058,636 byte run-scoped log had zero matches for the recorded error, warning, exception, crash, HTTP 4xx/5xx, and deadlock search.

Each container has 199 sequential resource samples. No container resource limit or pass/fail resource threshold was applied.

Container Average / max CPU First 10 / last 10 memory average Max memory
PostgreSQL 61.44% / 85.30% 236.15 / 261.81 MiB 263.60 MiB
Traefik 57.25% / 73.14% 93.44 / 103.98 MiB 108.50 MiB
web 16 198.67% / 281.58% 264.24 / 376.33 MiB 397.10 MiB
web 17 195.66% / 247.46% 259.30 / 373.79 MiB 406.10 MiB
web 18 195.36% / 266.74% 265.06 / 388.06 MiB 410.80 MiB
worker 10 1.47% / 5.09% 201.63 / 202.64 MiB 203.80 MiB
worker 11 2.21% / 5.56% 199.30 / 201.05 MiB 201.80 MiB

The web RSS increased across most of the loaded interval, so this run does not establish a steady-state memory plateau. A separate read-only observation at 2026-07-19T16:06:21Z, 5 minutes 23 seconds after the k6 summary timestamp, measured web RSS at 278.2, 269.8, and 285.6 MiB. At that point the BEAM runtime reported 142,250,344, 147,808,440, and 147,403,872 total bytes with 613, 612, and 612 processes. This observed post-load reduction shows that a substantial part of the loaded RSS was released after the connections closed; it does not prove that the application is free of a longer-term memory leak. A production-like duration and traffic distribution remain unknown.

Ignored evidence:

  • output/performance/authenticated-soak-10m-20260719/

Observed local resilience drills

The canonical Compose drill on 2026-07-19 used the isolated 3-web/2-worker profile. One web and one worker BEAM process exited with status 1 and each container's observed restart count increased to 1. All original replicas were then replaced sequentially. The route returned 758 successful readiness responses with zero final failure and responses from all three web nodes. After replacement, the observed cluster contained all five BEAM nodes and the cross-node PubSub probe passed.

The local Oban probe completed with state completed, attempt 2, max_attempts=2, and exactly one recorded first-attempt error. Its exact row was removed afterward. The load database then contained zero probe jobs, users, requests, messages, and tracking sessions. Run-scoped available logs contained no unexpected application error, warning, HTTP 4xx/5xx, or database deadlock.

The canonical kind drill rolled both 2-replica Deployments from revision 19 to 20 with the chart's observed maxUnavailable=0 and maxSurge=1. All four pod UIDs changed, all replacements were Ready with zero container restart, the four-node BEAM cluster and PubSub probe passed, and the application-table count diff was empty. Of 362 readiness samples, all ultimately returned HTTP 200. Two samples needed one transport retry each while kind's single-node NodePort reset connections during endpoint changes. Those retries are retained in evidence rather than reported as uninterrupted raw TCP connections. This is a local kind observation, not a production availability guarantee.

Ignored evidence:

  • output/resilience/final-resilience-20260719a/
  • output/resilience/final-kind-20260719a/

Observed local monitoring drill

The canonical observability run on 2026-07-19 generated one direct Prometheus target for each of the three current load web containers. The exact expected and observed instance lists matched, and all three targets were up before the induced failure. Grafana reported a healthy Prometheus datasource and served the tracked provisioned dashboard with four panels.

The script then stopped the exact scoped container who_need_help_load-web-16. Prometheus fired WhoNeedHelpWebReplicaUnavailable, and Alertmanager delivered a webhook whose labels identified that instance and the run. After the same container became healthy again, all three direct targets returned to up, the alert cleared, and a resolved webhook with the same instance/run labels arrived. The application-table count diff was empty. These are observed local protocol results, not a production monitoring or notification guarantee.

Ignored evidence:

  • output/observability/final-observability-20260719a/

Stop the isolated containers without deleting their database volume:

./scripts/load-stack-stop.sh

The stop script reads the actual LOAD_PROJECT, refuses the staging project name, and does not delete the volume. Volume deletion is intentionally not automated.

Isolated database scale and cursor plans

Run the database-only before/after measurement with:

./scripts/db-scale-benchmark.sh

The script builds the test image and creates a uniquely named Compose project, random one-run PostgreSQL credentials, and a separate PostGIS volume. It migrates only through 20260719004249, seeds the isolated baseline, records machine-readable PostgreSQL EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) plans, applies the generated 20260719013320_add_keyset_pagination_indexes migration, and records the same queries again. It also verifies that two 25-row keyset pages exactly equal the first 50 ordered rows without duplicates or gaps. The exact project, network, volume, and image are removed by its trap.

DB_SCALE_ROWS controls the configured sample size and defaults to 50,000. That default is a reproducible experiment input, not a minimum database size, capacity claim, latency target, or production traffic model. The structural gate checks the canonical sample's chosen cursor indexes and query correctness; it does not fail on an arbitrary millisecond threshold.

Observed locally on 2026-07-19 with PostgreSQL 18.4, the canonical run created 50,000 rows in each of users, requests, assignments, messages, reviews, reports, abuse signals, proposals, activities, and activity messages; it created 50,500 activity participants and 49,998 block rows. The isolated database occupied 231,241,407 bytes after the cursor migration.

Query Baseline observed Cursor migration observed Selected cursor index
Urgent-help discovery 8.010 ms 0.110 ms help_requests_discovery_cursor_index
Requester history 7.969 ms 0.036 ms help_requests_requester_cursor_index
Activity discovery 8.137 ms 0.051 ms activities_discovery_cursor_index
Visible reviews 8.264 ms 0.052 ms reviews_visible_cursor_index
All reports 8.021 ms 0.025 ms reports_cursor_index
All category proposals 6.501 ms 0.025 ms category_proposals_cursor_index
Moderation users 5.367 ms 0.030 ms users_moderation_cursor_index
Blocks 5.165 ms 0.036 ms blocks_blocker_cursor_index

These are measurements of one warm local run and must not be interpreted as an SLO or portable speedup. The measurement also exposed redundant candidate indexes and an OR-based Activity membership query. The final migration keeps the indexes PostgreSQL selected, uses partial discovery indexes for open, non-hidden records, and the application joins the existing participant membership invariant directly. Personal reputation and the leaderboard now aggregate in PostgreSQL instead of loading all completed assignments into the BEAM; the leaderboard itself uses composite keyset pagination.

Ignored evidence for the recorded run:

  • output/db-scale/20260719054559-2339191/

Current final replay

The same threshold-free profiles were replayed on 2026-07-20 against application commit b96d443.

  • The 30-second 3-web/2-worker load run used 40 public HTTP, 40 heartbeat WebSocket, and 8 authenticated VUs. It completed 13,765 iterations and 38,826 HTTP requests; all 35,280 checks passed and HTTP failures were 0. The authenticated scenario completed 1,765 page/LiveView/chat/tracking start-update-stop chains, and the heartbeat scenario opened and answered 240 sockets. PostgreSQL recorded no rollback, deadlock, conflict, or temporary file. The run-specific fixtures were removed and the cleanup count diff was empty. Evidence is output/performance/final-load-20260720.
  • The database benchmark again used 50,000 configured rows per large table on PostgreSQL 18.4. After migration, the asserted list queries selected their cursor indexes; for example, observed execution time changed from 8.139 ms to 0.127 ms for urgent-help discovery and from 8.035 ms to 0.050 ms for Activity discovery. These are single local observations, not thresholds or portable performance claims. Evidence is output/db-scale/20260719233526-97476.
  • The corrected kind rolling run replaced all four application pod UIDs, retained four Ready zero-restart replacements, passed the four-node BEAM and PubSub probes, and produced an empty database diff. All 395 readiness samples ultimately returned HTTP 200; two required one retry during local NodePort endpoint replacement. Evidence is output/resilience/final-kind-rollout-fixed-20260720.