feat: add isolated scaling measurements

This commit is contained in:
SimpleTest 2026-07-18 22:55:20 +03:00
parent 3a35af6782
commit 24e5e9ce82
15 changed files with 729 additions and 7 deletions

View File

@ -5,6 +5,12 @@ MAILPIT_PORT=8027
# Comma-separated proxy IP/CIDR values whose X-Forwarded-* headers Traefik
# accepts. Keep loopback locally; set the exact VPN proxy address for staging.
TRAEFIK_TRUSTED_IPS=127.0.0.1/32
# Docker-provider isolation and names. A second Compose project must use its
# own project constraint, router/service name, Docker network, and Host rule.
TRAEFIK_PROJECT_CONSTRAINT=who_need_help
TRAEFIK_APP_NAME=who-need-help
TRAEFIK_DOCKER_NETWORK=who_need_help_internal
TRAEFIK_ROUTER_RULE='PathPrefix(`/`)'
PHX_HOST=localhost
PHX_SCHEME=http
PHX_URL_PORT=4010

53
.env.load.example Normal file
View File

@ -0,0 +1,53 @@
# Copying is handled by scripts/ensure-local-load-env.sh, which replaces every
# GENERATE_* marker with an independent local secret and restricts the result
# to mode 600. This profile owns a separate Compose project and PostgreSQL
# volume; it must never use the staging project name.
LOAD_PROJECT=who_need_help_load
LOAD_HOST=load.local
HTTP_PORT=4110
MAILPIT_PORT=8127
PHX_HOST=load.local
PHX_SCHEME=https
PHX_URL_PORT=443
TRAEFIK_TRUSTED_IPS=127.0.0.1/32
TRAEFIK_PROJECT_CONSTRAINT=who_need_help_load
TRAEFIK_APP_NAME=who-need-help-load
TRAEFIK_DOCKER_NETWORK=who_need_help_load_internal
TRAEFIK_ROUTER_RULE='Host(`load.local`)'
POSTGRES_DB=who_need_help_load
POSTGRES_USER=wnh_load
POSTGRES_PASSWORD=GENERATE_POSTGRES_PASSWORD
DATABASE_URL=GENERATE_DATABASE_URL
POOL_SIZE=10
SECRET_KEY_BASE=GENERATE_SECRET_KEY_BASE
HANDOVER_SECRET=GENERATE_HANDOVER_SECRET
RELEASE_COOKIE=GENERATE_RELEASE_COOKIE
METRICS_TOKEN=GENERATE_METRICS_TOKEN
SMTP_RELAY=mailpit
SMTP_PORT=1025
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_AUTH=never
SMTP_TLS=never
SMTP_SSL=false
EMAIL_FROM_NAME="Who Need Help load profile"
EMAIL_FROM_ADDRESS=load-profile@example.invalid
CODEX_SESSION_ID=load-profile
RATE_LIMIT_POLICIES_JSON={}
MAP_TILE_URL=https://tile.openstreetmap.org/{z}/{x}/{y}.png
GITHUB_OAUTH_CLIENT_ID=
GITHUB_OAUTH_CLIENT_SECRET=
# These are reproducible experiment inputs, not capacity requirements,
# production traffic forecasts, alert thresholds, or recommended limits.
LOAD_WEB_REPLICAS=2
LOAD_WORKER_REPLICAS=2
LOAD_HTTP_VUS=40
LOAD_WS_VUS=40
LOAD_DURATION=30s
LOAD_WS_HOLD_MS=5000
LOAD_WS_CONNECT_TIMEOUT_MS=3000
LOAD_HTTP_THINK_SECONDS=0.1

1
.gitignore vendored
View File

@ -43,6 +43,7 @@ npm-debug.log
/.env
/.env.*
!/.env.example
!/.env.load.example
# Local browser automation state and generated verification artifacts.
/.playwright-cli/

View File

@ -232,6 +232,7 @@ and no fallback provider. Recommendations require a human moderator action.
- [Architecture](docs/architecture.md)
- [Trust and safety](docs/trust-safety.md)
- [Operations runbook](docs/operations.md)
- [Performance measurement](docs/performance.md)
- [Implementation verification and known limits](docs/verification.md)
- [Verified dependency baseline](docs/dependency-baseline.md)
- [PostgreSQL/PostGIS ADR](docs/decisions/0001-postgresql-postgis-over-spacetimedb.md)

19
compose.load.yaml Normal file
View File

@ -0,0 +1,19 @@
services:
proxy:
networks:
edge:
aliases:
- ${LOAD_HOST}
internal: {}
migrate:
image: who-need-help:load
web:
image: who-need-help:load
labels:
- traefik.http.routers.${TRAEFIK_APP_NAME}.middlewares=${TRAEFIK_APP_NAME}-forwarded
- traefik.http.middlewares.${TRAEFIK_APP_NAME}-forwarded.headers.customrequestheaders.X-Forwarded-Proto=https
worker:
image: who-need-help:load

View File

@ -35,6 +35,7 @@ services:
- --api.dashboard=false
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --providers.docker.constraints=Label(`com.docker.compose.project`,`${TRAEFIK_PROJECT_CONSTRAINT:-who_need_help}`)
- --entrypoints.web.address=:80
- --entrypoints.web.forwardedheaders.trustedips=${TRAEFIK_TRUSTED_IPS:-127.0.0.1/32}
ports:
@ -92,10 +93,11 @@ services:
condition: service_completed_successfully
labels:
- traefik.enable=true
- traefik.docker.network=who_need_help_internal
- traefik.http.routers.who-need-help.rule=PathPrefix(`/`)
- traefik.http.routers.who-need-help.entrypoints=web
- traefik.http.services.who-need-help.loadbalancer.server.port=4000
- traefik.docker.network=${TRAEFIK_DOCKER_NETWORK:-who_need_help_internal}
- traefik.http.routers.${TRAEFIK_APP_NAME:-who-need-help}.rule=${TRAEFIK_ROUTER_RULE:-PathPrefix(`/`)}
- traefik.http.routers.${TRAEFIK_APP_NAME:-who-need-help}.entrypoints=web
- traefik.http.routers.${TRAEFIK_APP_NAME:-who-need-help}.service=${TRAEFIK_APP_NAME:-who-need-help}
- traefik.http.services.${TRAEFIK_APP_NAME:-who-need-help}.loadbalancer.server.port=4000
healthcheck:
test: ["CMD", "curl", "--fail", "--silent", "http://localhost:4000/healthz/ready"]
interval: 10s

View File

@ -37,6 +37,7 @@ package checksums are in `mix.lock` and `assets/package-lock.json`.
| PostGIS | 3.6.4 |
| Traefik | 3.7.8 |
| Mailpit | 1.30.4 |
| k6 load generator | 2.1.0 |
| Debian builder/runner snapshot | trixie-20260713-slim |
Every external Compose/kind service image and every Dockerfile base image is
@ -74,6 +75,7 @@ because the official SDK channel identifies it as a QPR beta.
- [PostGIS release notes](https://postgis.net/docs/en/release_notes.html)
- [Traefik releases](https://github.com/traefik/traefik/releases)
- [Mailpit releases](https://github.com/axllent/mailpit/releases)
- [k6 releases](https://github.com/grafana/k6/releases)
- [Android Gradle Plugin 9.3.0 release notes](https://developer.android.com/build/releases/agp-9-3-0-release-notes)
- [Android 17 SDK setup](https://developer.android.com/about/versions/17/setup-sdk)
- [Gradle release notes](https://docs.gradle.org/current/release-notes.html)

121
docs/performance.md Normal file
View File

@ -0,0 +1,121 @@
# 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.
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;
- the configured number of web and worker replicas;
- cross-node Phoenix PubSub after each run.
It does not authenticate users, create requests, send chat messages, update
locations, render a real browser, 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.
## 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;
- before/after application table counts and their diff;
- final Compose state, readiness response, application logs, and cross-node
PubSub result.
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/`
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.

View File

@ -135,8 +135,11 @@ for that reverse lookup selected the index. The table contained no block rows,
so this confirms query shape, not production performance.
Exact production capacity, minimum CPU/RAM, and scaling thresholds are unknown:
there is no representative load dataset or target-environment measurement.
The Helm chart therefore does not invent resource limits or an HPA policy.
there is no representative load dataset or target-environment measurement. An
isolated 40 HTTP VU / 40 WebSocket VU local comparison is recorded in
`docs/performance.md`; it tested a paced public/readiness/heartbeat workload
with 2 and 3 web replicas but did not find a saturation point. The Helm chart
therefore does not invent resource limits or an HPA policy.
## Public staging observation

View File

@ -0,0 +1,154 @@
import { check, sleep } from "k6";
import http from "k6/http";
import { Counter } from "k6/metrics";
import { WebSocket } from "k6/websockets";
function required(name) {
const value = __ENV[name];
if (value === undefined || value === "") {
throw new Error(`${name} is required`);
}
return value;
}
function positiveInteger(name) {
const value = Number(required(name));
if (!Number.isInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive integer`);
}
return value;
}
function nonNegativeNumber(name) {
const value = Number(required(name));
if (!Number.isFinite(value) || value < 0) {
throw new Error(`${name} must be a non-negative number`);
}
return value;
}
const baseUrl = required("BASE_URL").replace(/\/+$/, "");
if (!/^https?:\/\/[^/?#]+$/.test(baseUrl)) {
throw new Error("BASE_URL must be an HTTP(S) origin without a path");
}
const websocketUrl =
baseUrl.replace(/^http/, "ws") + "/live/websocket?vsn=2.0.0";
const websocketHoldMs = positiveInteger("WS_HOLD_MS");
const websocketConnectTimeoutMs = positiveInteger("WS_CONNECT_TIMEOUT_MS");
const httpThinkSeconds = nonNegativeNumber("HTTP_THINK_SECONDS");
const websocketOpened = new Counter("wnh_websocket_opened");
const websocketErrors = new Counter("wnh_websocket_errors");
const heartbeatReplies = new Counter("wnh_websocket_heartbeat_replies");
export const options = {
discardResponseBodies: true,
scenarios: {
public_http: {
executor: "constant-vus",
exec: "publicHttp",
vus: positiveInteger("HTTP_VUS"),
duration: required("DURATION"),
},
phoenix_websocket: {
executor: "constant-vus",
exec: "phoenixWebsocket",
vus: positiveInteger("WS_VUS"),
duration: required("DURATION"),
},
},
};
export function publicHttp() {
const responses = http.batch([
{
method: "GET",
url: `${baseUrl}/`,
tags: { endpoint: "home" },
},
{
method: "GET",
url: `${baseUrl}/safety`,
tags: { endpoint: "safety" },
},
{
method: "GET",
url: `${baseUrl}/healthz/ready`,
tags: { endpoint: "readiness" },
},
]);
check(responses[0], { "home returned 200": (response) => response.status === 200 });
check(responses[1], { "safety returned 200": (response) => response.status === 200 });
check(responses[2], {
"readiness returned 200": (response) => response.status === 200,
});
sleep(httpThinkSeconds);
}
export function phoenixWebsocket() {
const socket = new WebSocket(websocketUrl, [], {
tags: { endpoint: "phoenix_live_socket" },
});
const ref = `${__VU}-${__ITER}`;
let opened = false;
let closeTimer;
const connectTimer = setTimeout(() => {
if (!opened) {
websocketErrors.add(1);
socket.close();
}
}, websocketConnectTimeoutMs);
socket.addEventListener("open", () => {
opened = true;
clearTimeout(connectTimer);
websocketOpened.add(1);
socket.send(JSON.stringify([null, ref, "phoenix", "heartbeat", {}]));
closeTimer = setTimeout(() => socket.close(), websocketHoldMs);
});
socket.addEventListener("message", (event) => {
let frame;
try {
frame = JSON.parse(event.data);
} catch (_error) {
return;
}
if (
Array.isArray(frame) &&
frame[1] === ref &&
frame[2] === "phoenix" &&
frame[3] === "phx_reply" &&
frame[4] &&
frame[4].status === "ok"
) {
heartbeatReplies.add(1);
}
});
socket.addEventListener("error", () => {
websocketErrors.add(1);
clearTimeout(connectTimer);
socket.close();
});
socket.addEventListener("close", () => {
clearTimeout(connectTimer);
if (closeTimer !== undefined) {
clearTimeout(closeTimer);
}
});
}

View File

@ -0,0 +1,61 @@
#!/bin/sh
set -eu
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
TEMPLATE="$ROOT/.env.load.example"
ENV_FILE="$ROOT/.env.load"
if [ -f "$ENV_FILE" ]; then
chmod 600 "$ENV_FILE"
echo ".env.load already exists; no secret or experiment input was changed."
exit 0
fi
for command in openssl perl; do
if ! command -v "$command" >/dev/null 2>&1; then
echo "Required command is unavailable: $command" >&2
exit 1
fi
done
if [ ! -f "$TEMPLATE" ]; then
echo "Missing tracked template: $TEMPLATE" >&2
exit 1
fi
umask 077
postgres_password=$(openssl rand -hex 32)
secret_key_base=$(openssl rand -hex 64)
handover_secret=$(openssl rand -hex 64)
release_cookie=$(openssl rand -hex 64)
metrics_token=$(openssl rand -hex 32)
database_url="ecto://wnh_load:${postgres_password}@db/who_need_help_load"
temporary=$(mktemp "${ENV_FILE}.XXXXXX")
trap 'rm -f "$temporary"' EXIT HUP INT TERM
POSTGRES_PASSWORD_VALUE=$postgres_password \
DATABASE_URL_VALUE=$database_url \
SECRET_KEY_BASE_VALUE=$secret_key_base \
HANDOVER_SECRET_VALUE=$handover_secret \
RELEASE_COOKIE_VALUE=$release_cookie \
METRICS_TOKEN_VALUE=$metrics_token \
perl -0pe '
s/GENERATE_POSTGRES_PASSWORD/$ENV{POSTGRES_PASSWORD_VALUE}/g;
s/GENERATE_DATABASE_URL/$ENV{DATABASE_URL_VALUE}/g;
s/GENERATE_SECRET_KEY_BASE/$ENV{SECRET_KEY_BASE_VALUE}/g;
s/GENERATE_HANDOVER_SECRET/$ENV{HANDOVER_SECRET_VALUE}/g;
s/GENERATE_RELEASE_COOKIE/$ENV{RELEASE_COOKIE_VALUE}/g;
s/GENERATE_METRICS_TOKEN/$ENV{METRICS_TOKEN_VALUE}/g;
' "$TEMPLATE" >"$temporary"
if grep -Eq '^[A-Z0-9_]+=GENERATE_' "$temporary"; then
echo "A secret marker was not replaced; refusing to publish .env.load." >&2
exit 1
fi
chmod 600 "$temporary"
mv "$temporary" "$ENV_FILE"
trap - EXIT HUP INT TERM
unset postgres_password secret_key_base handover_secret release_cookie metrics_token database_url
echo "Generated independent load-profile secrets in ignored .env.load."

204
scripts/load-run.sh Executable file
View File

@ -0,0 +1,204 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
ENV_FILE="$ROOT/.env.load"
K6_IMAGE="grafana/k6@sha256:65c920dc067d5e2e00befbf982af6ad6ad0117034e8b1c65817c7975c52d4669"
LABEL=${1:-"run-$(date -u +%Y%m%dT%H%M%SZ)"}
if [[ ! -f "$ENV_FILE" ]]; then
echo "Missing $ENV_FILE. Run scripts/ensure-local-load-env.sh first." >&2
exit 1
fi
set -a
. "$ENV_FILE"
set +a
for name in LOAD_PROJECT LOAD_HOST LOAD_WEB_REPLICAS LOAD_HTTP_VUS LOAD_WS_VUS \
LOAD_DURATION LOAD_WS_HOLD_MS LOAD_WS_CONNECT_TIMEOUT_MS \
LOAD_HTTP_THINK_SECONDS; do
if [[ -z "${!name:-}" ]]; then
echo "$name is missing from .env.load" >&2
exit 1
fi
done
if [[ "$LOAD_PROJECT" == "who_need_help" ]]; then
echo "The load profile must not use the staging Compose project." >&2
exit 1
fi
if [[ ! "$LABEL" =~ ^[A-Za-z0-9._-]+$ ]]; then
echo "Run label may contain only letters, numbers, dot, underscore, and dash." >&2
exit 1
fi
compose=(
docker compose
--env-file "$ENV_FILE"
-p "$LOAD_PROJECT"
-f compose.yaml
-f compose.load.yaml
)
mapfile -t web_containers < <("${compose[@]}" ps -q web)
if [[ "${#web_containers[@]}" -ne "$LOAD_WEB_REPLICAS" ]]; then
echo "Expected $LOAD_WEB_REPLICAS running web replicas; observed ${#web_containers[@]}." >&2
exit 1
fi
network_id=$(
docker network ls \
--filter "label=com.docker.compose.project=$LOAD_PROJECT" \
--filter "label=com.docker.compose.network=edge" \
--quiet |
head -n 1
)
if [[ -z "$network_id" ]]; then
echo "The isolated load-profile edge network was not found." >&2
exit 1
fi
mapfile -t measured_containers < <(
"${compose[@]}" ps -q web worker db proxy
)
if [[ "${#measured_containers[@]}" -lt 4 ]]; then
echo "The isolated load stack is incomplete." >&2
exit 1
fi
output_dir="$ROOT/output/performance/$LABEL"
mkdir -p "$output_dir"
chmod 700 "$ROOT/output" "$ROOT/output/performance" "$output_dir"
running_marker="$output_dir/.sampling"
resource_log="$output_dir/docker-stats.jsonl"
touch "$running_marker"
snapshot_database() {
local destination=$1
"${compose[@]}" exec -T db sh -c \
'psql --no-psqlrc --set ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB"' \
>"$destination" <<'SQL'
BEGIN READ ONLY;
SELECT 'users' AS table_name, count(*) AS row_count FROM users
UNION ALL SELECT 'help_requests', count(*) FROM help_requests
UNION ALL SELECT 'messages', count(*) FROM messages
UNION ALL SELECT 'categories', count(*) FROM categories
UNION ALL SELECT 'help_assignments', count(*) FROM help_assignments
UNION ALL SELECT 'activities', count(*) FROM activities
UNION ALL SELECT 'reports', count(*) FROM reports
UNION ALL SELECT 'social_identities', count(*) FROM social_identities
UNION ALL SELECT 'tracking_sessions', count(*) FROM tracking_sessions
ORDER BY table_name;
COMMIT;
SQL
}
sample_resources() {
while [[ -e "$running_marker" ]]; do
observed_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)
docker stats --no-stream --format '{{json .}}' "${measured_containers[@]}" |
jq -c --arg observed_at "$observed_at" '. + {ObservedAt: $observed_at}' \
>>"$resource_log"
sleep 1
done
}
cleanup_sampler() {
rm -f "$running_marker"
if [[ -n "${sampler_pid:-}" ]]; then
wait "$sampler_pid" 2>/dev/null || true
fi
}
trap cleanup_sampler EXIT HUP INT TERM
{
printf 'observed_at=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
printf 'k6_image=%s\n' "$K6_IMAGE"
printf 'load_project=%s\n' "$LOAD_PROJECT"
printf 'web_replicas=%s\n' "$LOAD_WEB_REPLICAS"
printf 'http_vus=%s\n' "$LOAD_HTTP_VUS"
printf 'websocket_vus=%s\n' "$LOAD_WS_VUS"
printf 'duration=%s\n' "$LOAD_DURATION"
printf 'websocket_hold_ms=%s\n' "$LOAD_WS_HOLD_MS"
printf 'websocket_connect_timeout_ms=%s\n' "$LOAD_WS_CONNECT_TIMEOUT_MS"
printf 'http_think_seconds=%s\n' "$LOAD_HTTP_THINK_SECONDS"
docker info --format 'docker_cpus={{.NCPU}} docker_memory_bytes={{.MemTotal}} docker_server={{.ServerVersion}}'
docker compose version
uname -a
} >"$output_dir/environment.txt"
snapshot_database "$output_dir/database-before.txt"
sample_resources &
sampler_pid=$!
set +e
docker run --rm \
--user "$(id -u):$(id -g)" \
--network "$network_id" \
--volume "$ROOT/load/k6:/scripts:ro" \
--volume "$output_dir:/output" \
--env "BASE_URL=http://$LOAD_HOST" \
--env "HTTP_VUS=$LOAD_HTTP_VUS" \
--env "WS_VUS=$LOAD_WS_VUS" \
--env "DURATION=$LOAD_DURATION" \
--env "WS_HOLD_MS=$LOAD_WS_HOLD_MS" \
--env "WS_CONNECT_TIMEOUT_MS=$LOAD_WS_CONNECT_TIMEOUT_MS" \
--env "HTTP_THINK_SECONDS=$LOAD_HTTP_THINK_SECONDS" \
"$K6_IMAGE" run \
--no-usage-report \
--summary-mode=full \
--summary-export=/output/k6-summary.json \
--new-machine-readable-summary \
/scripts/public-and-websocket.js 2>&1 |
tee "$output_dir/k6.log"
k6_status=${PIPESTATUS[0]}
set -e
cleanup_sampler
sampler_pid=
trap - EXIT HUP INT TERM
snapshot_database "$output_dir/database-after.txt"
diff -u "$output_dir/database-before.txt" "$output_dir/database-after.txt" \
>"$output_dir/database-diff.txt" || true
COMPOSE_PROJECT_NAME=$LOAD_PROJECT "$ROOT/scripts/verify-realtime-cluster.sh" compose \
>"$output_dir/pubsub-probe.txt"
curl --fail --silent --show-error \
--header "Host: $LOAD_HOST" \
"http://localhost:$HTTP_PORT/healthz/ready" \
>"$output_dir/readiness-after.json"
"${compose[@]}" ps -a >"$output_dir/compose-after.txt"
"${compose[@]}" logs --since 10m proxy web worker >"$output_dir/application.log" 2>&1
if ! jq -e '
def metric($name):
([.results.metrics[] | select(.name == $name) | .values][0] // {});
(.results.checks.metrics[] |
select(.name == "checks_failed") |
.values.matches) == 0 and
metric("http_req_failed").matches == 0 and
metric("wnh_websocket_opened").count > 0 and
metric("wnh_websocket_opened").count ==
metric("wnh_websocket_heartbeat_replies").count and
(metric("wnh_websocket_errors").count // 0) == 0
' "$output_dir/k6-summary.json" >/dev/null; then
echo "Functional load checks failed; evidence is in $output_dir." >&2
exit 1
fi
if [[ "$k6_status" -ne 0 ]]; then
echo "k6 exited with status $k6_status; evidence is in $output_dir." >&2
exit "$k6_status"
fi
printf 'Load evidence: %s\n' "$output_dir"

31
scripts/load-stack-stop.sh Executable file
View File

@ -0,0 +1,31 @@
#!/bin/sh
set -eu
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
ENV_FILE="$ROOT/.env.load"
if [ ! -f "$ENV_FILE" ]; then
echo "Missing $ENV_FILE; no load-profile project was selected." >&2
exit 1
fi
set -a
. "$ENV_FILE"
set +a
: "${LOAD_PROJECT:?LOAD_PROJECT is missing from .env.load}"
if [ "$LOAD_PROJECT" = who_need_help ]; then
echo "Refusing to stop the staging Compose project." >&2
exit 1
fi
cd "$ROOT"
docker compose \
--env-file "$ENV_FILE" \
-p "$LOAD_PROJECT" \
-f compose.yaml \
-f compose.load.yaml \
stop
echo "Stopped the isolated load-profile containers; its database volume remains."

63
scripts/load-stack-up.sh Executable file
View File

@ -0,0 +1,63 @@
#!/bin/sh
set -eu
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
ENV_FILE="$ROOT/.env.load"
REPLICAS=${1:-}
"$ROOT/scripts/ensure-local-load-env.sh"
set -a
. "$ENV_FILE"
set +a
: "${LOAD_PROJECT:?LOAD_PROJECT is missing from .env.load}"
: "${LOAD_HOST:?LOAD_HOST is missing from .env.load}"
: "${LOAD_WEB_REPLICAS:?LOAD_WEB_REPLICAS is missing from .env.load}"
: "${LOAD_WORKER_REPLICAS:?LOAD_WORKER_REPLICAS is missing from .env.load}"
if [ "$LOAD_PROJECT" = who_need_help ]; then
echo "The load profile must not use the staging Compose project." >&2
exit 1
fi
if [ -n "$REPLICAS" ]; then
LOAD_WEB_REPLICAS=$REPLICAS
fi
case "$LOAD_WEB_REPLICAS:$LOAD_WORKER_REPLICAS" in
*[!0-9:]* | 0:* | *:0 | :* | *:)
echo "Web and worker replica counts must be positive integers." >&2
exit 1
;;
esac
cd "$ROOT"
docker compose \
--env-file "$ENV_FILE" \
-p "$LOAD_PROJECT" \
-f compose.yaml \
-f compose.load.yaml \
up -d --build --wait \
--scale "web=$LOAD_WEB_REPLICAS" \
--scale "worker=$LOAD_WORKER_REPLICAS"
COMPOSE_PROJECT_NAME=$LOAD_PROJECT "$ROOT/scripts/verify-realtime-cluster.sh" compose
status=$(
curl --silent --show-error \
--header "Host: $LOAD_HOST" \
--output /dev/null \
--write-out '%{http_code}' \
"http://localhost:$HTTP_PORT/healthz/ready"
)
if [ "$status" != 200 ]; then
echo "Load-profile readiness returned HTTP $status instead of 200." >&2
exit 1
fi
curl --fail --silent --show-error \
--header "Host: $LOAD_HOST" \
"http://localhost:$HTTP_PORT/healthz/ready"
printf '\nLoad profile is ready with %s web and %s worker replicas.\n' \
"$LOAD_WEB_REPLICAS" "$LOAD_WORKER_REPLICAS"

View File

@ -3,10 +3,11 @@ set -eu
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
MODE=${1:-compose}
COMPOSE_PROJECT_NAME=${COMPOSE_PROJECT_NAME:-who_need_help}
case "$MODE" in
compose)
TARGET=$(docker compose -p who_need_help ps -q web | head -n 1)
TARGET=$(docker compose -p "$COMPOSE_PROJECT_NAME" ps -q web | head -n 1)
if [ -z "$TARGET" ]; then
echo "No running Compose web replica was found." >&2
exit 1