who_need_help/docs/performance.md

625 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.
On a host with more CPUs than the intended server, the optional
`compose.cpu-replay.yaml` override can constrain the CPUs visible to each
long-running BEAM container:
```sh
CPU_REPLAY_WEB_CPUS=1 CPU_REPLAY_WORKER_CPUS=1 \
CPU_REPLAY_WEB_SCHEDULERS=1 CPU_REPLAY_WORKER_SCHEDULERS=1 \
docker compose -f compose.yaml -f compose.cpu-replay.yaml up -d --wait
```
All four values are explicit experiment inputs. The override does not set a memory
limit or represent a whole-server CPU budget. The scheduler inputs make total
and online BEAM scheduler counts match the intended replay instead of leaving
offline scheduler threads sized from the development host. None of these
inputs is a production sizing recommendation. Running ordinary
`docker compose up` without the override removes the replay constraints.
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
```sh
./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:
```sh
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
```sh
./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 role-specific connection pools
On 2026-07-20 the same 3-web/2-worker, 40 public HTTP VU, 40 Phoenix
heartbeat VU, and 8 authenticated mutual-aid VU profile was repeated with
four Repo connections per web replica and two per worker replica. The migrate
role also started and completed with a two-connection pool. These are measured
development defaults, not production minimums.
The 16 configured long-running Repo slots produced 38,678 HTTP requests with
zero failures, 1,760 complete authenticated page/chat/tracking chains, and 240
Phoenix heartbeat sessions. The application recorded 131,409 web-side queries
with 34.82 microseconds average pool queue time, compared with 31.15
microseconds in the earlier 10-connections-per-replica run. PostgreSQL observed
21 peak client backends, 6 peak active backends, 76 minimum non-reserved
connection headroom, no idle transaction, no lock wait, no rollback, no
deadlock, no conflict, and no temporary file.
The run-scoped application log had no match for DBConnection/Postgrex
connection errors, pool timeouts, exceptions, deadlocks, `FATAL`, or `PANIC`.
All 1,760 messages, tracking sessions, and tracking samples were recorded;
explicit stop left zero current positions, and fixture cleanup restored the
tracked table counts exactly. The result supports using web=4, worker=2, and
migrate=2 as the repository's current measured starting profile. It does not
establish saturation, an SLO, or a production capacity limit.
Ignored evidence:
- `output/performance/role-pools-4-2-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:
```sh
./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:
```sh
./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`.
## Runtime hardening and queue replay on 2026-07-20
The ordinary and isolated load releases were rebuilt after separating
internal cluster, ingress, and outbound networks. All web and worker node names
used internal `172.16.52.x` or `172.16.55.x` addresses respectively, every
node observed the expected peers, and cross-node PubSub passed. The
worker runtime no longer starts the unused `default` queue. Both measured load
workers reported exactly `maintenance: 2` and `push: 1`; their BEAM process
counts were 574 after the change, compared with the earlier observation of
577/578. This three-to-four-process difference is an observation, not a memory
or capacity guarantee.
The isolated Oban burst experiment inserted 1,000 side-effect-free jobs in one
batch. With two workers and configured maintenance concurrency two per worker,
all 1,000 reached `completed` in an observed 4,516 ms. Three resource samples
per measured container recorded maxima of 80.80 MiB for PostgreSQL and
218.80/186.90 MiB for the two worker cgroups. No error matched the run-scoped
logs; the database reported zero deadlocks, temporary files, or conflicts.
The script removed exactly 1,000 probe rows, verified zero remaining probe
jobs, and found byte-identical before/after row-count documents for all domain
tables. These short local samples do not establish a production throughput,
steady-state memory plateau, or minimum server size. Evidence is
`output/performance/oban-burst-1000-final-20260720`.
The database-scale harness was repaired to build and pass its own hardened
PostGIS image rather than relying on an image variable owned by another
script. Its fresh PostgreSQL 18.4 replay again used 50,000 configured rows per
large table and identical before/after table counts. Every asserted list plan
selected its intended cursor index. Observed examples were 8.806 to 0.123 ms
for urgent-help discovery, 8.348 to 0.061 ms for Activity discovery, and 9.084
to 0.048 ms for visible reviews. The aggregate leaderboard and single hot
helper reputation plans still scanned their 50,000-row completed-assignment
working sets and took 53.949 and 41.247 ms in this deliberately concentrated
fixture. That is a measured future optimization target, not evidence of a
current failure or a portable latency. Evidence is
`output/db-scale/20260720035817-2416501`.
## Final queue/runtime load replay
After the queue and network changes, the same isolated 3-web/2-worker profile
was replayed on 2026-07-20 with 40 paced public HTTP VUs, 40 Phoenix heartbeat
VUs, and 8 authenticated mutual-aid VUs for 30 seconds.
| Observation | Result |
| --- | ---: |
| HTTP requests / failures | 38,962 / 0 |
| Public checks | 35,280 / 35,280 passed |
| Authenticated page/chat/tracking chains | 1,833 |
| Phoenix heartbeat opened / replies | 240 / 240 |
| HTTP duration average / p95 / maximum | 1.91 / 4.90 / 179.46 ms |
| Authenticated HTTP duration p95 | 7.77 ms |
| Configured long-running Repo pool slots | 16 |
| Peak PostgreSQL client / active backends | 21 / 4 |
| Minimum observed non-reserved connection headroom | 76 |
| Ecto average execution / queue / decode time | 313.90 / 29.75 / 0.14 µs |
| PostgreSQL rollback / deadlock / conflict / temp files | 0 / 0 / 0 / 0 |
The load generated exactly 1,833 chat rows, tracking sessions, and tracking
samples; explicit stop left zero current positions. Cleanup removed the 176
fixture users, 88 requests, 88 assignments, 1,833 messages, 1,833 tracking
sessions, and their audit rows. The before/after application-table count diff
was empty, all five BEAM nodes participated in the PubSub probe, and every load
service remained running.
Ten sequential resource samples recorded maxima of 268.5, 260.4, and
284.3 MiB for the three web cgroups; 207.2 and 198.7 MiB for the workers;
119.9 MiB for PostgreSQL; and 94.85 MiB for Traefik. These short samples had
no memory limit and do not establish a steady-state plateau or server minimum.
No captured query used temporary blocks. The highest mean SQL statement was a
single 5.249 ms tracking-session maintenance update; the highest-total
statement was the indexed session-token lookup at 0.081 ms mean across 7,332
calls. No measured query result justifies another schema or pool change at
this data size.
Ignored evidence:
- `output/performance/final-after-queue-20260720/`