who_need_help/scripts/load-run.sh

205 lines
6.1 KiB
Bash
Executable File

#!/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"