#!/usr/bin/env bash set -euo pipefail ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) ENV_FILE="$ROOT/.env.load" K6_IMAGE="grafana/k6@sha256:65c920dc067d5e2e00befbf982af6ad6ad0117034e8b1c65817c7975c52d4669" PYTHON_IMAGE="python:3.14.6-alpine3.23@sha256:b165067c5afc37fa5608a3c05609cc3d51aafd808a30fbfd822ee594fef55ad4" LABEL=${1:-"run-$(date -u +%Y%m%dT%H%M%SZ)"} duration_override=${LOAD_DURATION_OVERRIDE:-} if [[ ! -f "$ENV_FILE" ]]; then echo "Missing $ENV_FILE. Run scripts/ensure-local-load-env.sh first." >&2 exit 1 fi set -a # shellcheck source=/dev/null . "$ENV_FILE" set +a WEB_POOL_SIZE=${WEB_POOL_SIZE:-${POOL_SIZE:-}} WORKER_POOL_SIZE=${WORKER_POOL_SIZE:-${POOL_SIZE:-}} duration_source=".env.load" if [[ -n "$duration_override" ]]; then LOAD_DURATION=$duration_override duration_source="LOAD_DURATION_OVERRIDE" fi for name in LOAD_PROJECT LOAD_HOST LOAD_WEB_REPLICAS LOAD_WORKER_REPLICAS \ LOAD_HTTP_VUS LOAD_WS_VUS \ LOAD_DURATION LOAD_WS_HOLD_MS LOAD_WS_CONNECT_TIMEOUT_MS \ LOAD_HTTP_THINK_SECONDS LOAD_AUTH_VUS LOAD_AUTH_WS_TIMEOUT_MS \ LOAD_AUTH_THINK_SECONDS LOAD_FIXTURE_PASSWORD POSTGRES_DB HTTP_PORT \ WEB_POOL_SIZE WORKER_POOL_SIZE; do if [[ -z "${!name:-}" ]]; then echo "$name is missing from .env.load" >&2 exit 1 fi done for name in LOAD_WEB_REPLICAS LOAD_WORKER_REPLICAS LOAD_HTTP_VUS LOAD_WS_VUS \ LOAD_AUTH_VUS WEB_POOL_SIZE WORKER_POOL_SIZE; do if [[ ! "${!name}" =~ ^[1-9][0-9]*$ ]]; then echo "$name must be a positive integer." >&2 exit 1 fi done fixture_count=$((LOAD_HTTP_VUS + LOAD_WS_VUS + LOAD_AUTH_VUS)) 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) mapfile -t worker_containers < <("${compose[@]}" ps -q worker) if [[ "${#web_containers[@]}" -ne "$LOAD_WEB_REPLICAS" ]]; then echo "Expected $LOAD_WEB_REPLICAS running web replicas; observed ${#web_containers[@]}." >&2 exit 1 fi if [[ "${#worker_containers[@]}" -ne "$LOAD_WORKER_REPLICAS" ]]; then echo "Expected $LOAD_WORKER_REPLICAS running worker replicas; observed ${#worker_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 internal_network_id=$( docker network ls \ --filter "label=com.docker.compose.project=$LOAD_PROJECT" \ --filter "label=com.docker.compose.network=internal" \ --quiet | head -n 1 ) if [[ -z "$internal_network_id" ]]; then echo "The isolated load-profile internal network was not found." >&2 exit 1 fi if ! docker image inspect who-need-help:load-tools >/dev/null 2>&1; then echo "The isolated load-tools image is missing. Run scripts/load-stack-up.sh first." >&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" run_started_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) running_marker="$output_dir/.sampling" resource_log="$output_dir/docker-stats.jsonl" connection_log="$output_dir/database-connections.jsonl" touch "$running_marker" database_psql() { # The variables below are intentionally expanded inside the database container. # shellcheck disable=SC2016 "${compose[@]}" exec -T db sh -c \ 'psql --no-psqlrc --quiet --tuples-only --no-align --set ON_ERROR_STOP=1 \ --username "$POSTGRES_USER" --dbname "$POSTGRES_DB"' } prepare_statement_stats() { local preloaded preloaded=$(database_psql <<'SQL' SELECT current_setting('shared_preload_libraries') ~ '(^|,)pg_stat_statements(,|$)'; SQL ) if [[ "$preloaded" != "t" ]]; then echo "pg_stat_statements is not present in shared_preload_libraries." >&2 exit 1 fi database_psql >"$output_dir/statement-statistics-setup.txt" <<'SQL' SELECT current_setting('shared_preload_libraries'); CREATE EXTENSION IF NOT EXISTS pg_stat_statements; SQL } reset_statement_stats() { database_psql >"$output_dir/statement-statistics-reset.txt" <<'SQL' SELECT pg_stat_statements_reset(); SQL } snapshot_statement_stats() { local destination=$1 database_psql >"$destination" <<'SQL' WITH statements AS ( SELECT queryid::text AS query_id, regexp_replace(query, '[[:space:]]+', ' ', 'g') AS query, calls, rows, round(total_exec_time::numeric, 3) AS total_exec_time_ms, round(mean_exec_time::numeric, 3) AS mean_exec_time_ms, round(max_exec_time::numeric, 3) AS max_exec_time_ms, shared_blks_hit, shared_blks_read, shared_blks_dirtied, shared_blks_written, temp_blks_read, temp_blks_written, wal_records, wal_fpi, wal_bytes FROM pg_stat_statements WHERE dbid = (SELECT oid FROM pg_database WHERE datname = current_database()) AND query NOT LIKE '%pg_stat_statements%' AND query NOT LIKE '%pg_stat_activity%' AND query NOT LIKE '%pg_stat_database%' ), top_total AS ( SELECT * FROM statements ORDER BY total_exec_time_ms DESC, query_id LIMIT 25 ), top_mean AS ( SELECT * FROM statements ORDER BY mean_exec_time_ms DESC, query_id LIMIT 25 ), top_max AS ( SELECT * FROM statements ORDER BY max_exec_time_ms DESC, query_id LIMIT 25 ) SELECT jsonb_pretty( jsonb_build_object( 'captured_at', clock_timestamp(), 'measurement', 'pg_stat_statements since the pre-load reset', 'statement_count', (SELECT count(*) FROM statements), 'statistics_info', (SELECT to_jsonb(info) FROM pg_stat_statements_info AS info), 'top_by_total_execution_time', (SELECT coalesce( jsonb_agg( to_jsonb(item) ORDER BY total_exec_time_ms DESC, query_id ), '[]'::jsonb ) FROM top_total AS item), 'top_by_mean_execution_time', (SELECT coalesce( jsonb_agg( to_jsonb(item) ORDER BY mean_exec_time_ms DESC, query_id ), '[]'::jsonb ) FROM top_mean AS item), 'top_by_max_execution_time', (SELECT coalesce( jsonb_agg( to_jsonb(item) ORDER BY max_exec_time_ms DESC, query_id ), '[]'::jsonb ) FROM top_max AS item) ) ); SQL } snapshot_database() { local destination=$1 # The variables below are intentionally expanded inside the database container. # shellcheck disable=SC2016 "${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 'users_tokens', count(*) FROM users_tokens 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 'category_proposals', count(*) FROM category_proposals UNION ALL SELECT 'category_votes', count(*) FROM category_votes UNION ALL SELECT 'help_assignments', count(*) FROM help_assignments UNION ALL SELECT 'activities', count(*) FROM activities UNION ALL SELECT 'activity_participants', count(*) FROM activity_participants UNION ALL SELECT 'activity_messages', count(*) FROM activity_messages UNION ALL SELECT 'reports', count(*) FROM reports UNION ALL SELECT 'reviews', count(*) FROM reviews UNION ALL SELECT 'blocks', count(*) FROM blocks UNION ALL SELECT 'audit_events', count(*) FROM audit_events UNION ALL SELECT 'abuse_signals', count(*) FROM abuse_signals UNION ALL SELECT 'rate_limit_buckets', count(*) FROM rate_limit_buckets UNION ALL SELECT 'social_identities', count(*) FROM social_identities UNION ALL SELECT 'tracking_sessions', count(*) FROM tracking_sessions UNION ALL SELECT 'tracking_positions', count(*) FROM tracking_positions ORDER BY table_name; COMMIT; SQL } snapshot_database_metrics() { local destination=$1 # The variables below are intentionally expanded inside the database container. # shellcheck disable=SC2016 "${compose[@]}" exec -T db sh -c \ 'psql --no-psqlrc --quiet --tuples-only --no-align --set ON_ERROR_STOP=1 \ --username "$POSTGRES_USER" --dbname "$POSTGRES_DB"' \ >"$destination" <<'SQL' BEGIN READ ONLY; SELECT json_build_object( 'captured_at', clock_timestamp(), 'database_size_bytes', pg_database_size(current_database()), 'stats_reset', stats_reset, 'numbackends', numbackends, 'xact_commit', xact_commit, 'xact_rollback', xact_rollback, 'blks_read', blks_read, 'blks_hit', blks_hit, 'tup_returned', tup_returned, 'tup_fetched', tup_fetched, 'tup_inserted', tup_inserted, 'tup_updated', tup_updated, 'tup_deleted', tup_deleted, 'temp_files', temp_files, 'temp_bytes', temp_bytes, 'deadlocks', deadlocks, 'conflicts', conflicts ) FROM pg_stat_database WHERE datname = current_database(); COMMIT; SQL } summarize_database_metrics() { local before=$1 local after=$2 local destination=$3 jq -n \ --slurpfile before "$before" \ --slurpfile after "$after" ' def counters: [ "database_size_bytes", "xact_commit", "xact_rollback", "blks_read", "blks_hit", "tup_returned", "tup_fetched", "tup_inserted", "tup_updated", "tup_deleted", "temp_files", "temp_bytes", "deadlocks", "conflicts" ]; ($before[0]) as $before | ($after[0]) as $after | { schema_version: 1, measurement: "read-only pg_stat_database snapshots around the k6 run", thresholds_applied: false, stats_reset_before: $before.stats_reset, stats_reset_after: $after.stats_reset, before: $before, after: $after, delta: reduce counters[] as $key ({}; .[$key] = ($after[$key] - $before[$key])) } ' >"$destination" } summarize_resources() { docker run --rm \ --user "$(id -u):$(id -g)" \ --volume "$ROOT/scripts/summarize-docker-stats.py:/scripts/summarize-docker-stats.py:ro" \ --volume "$output_dir:/output" \ "$PYTHON_IMAGE" \ python /scripts/summarize-docker-stats.py \ /output/docker-stats.jsonl \ /output/resource-summary.json } sample_database_connections() { database_psql >>"$connection_log" <<'SQL' WITH activity AS ( SELECT * FROM pg_stat_activity WHERE backend_type = 'client backend' AND pid <> pg_backend_pid() ), active_wait_events AS ( SELECT concat_ws(':', wait_event_type, wait_event) AS event, count(*) AS backends FROM activity WHERE state = 'active' AND wait_event IS NOT NULL GROUP BY wait_event_type, wait_event ) SELECT json_build_object( 'captured_at', clock_timestamp(), 'max_connections', current_setting('max_connections')::integer, 'reserved_connections', current_setting('reserved_connections')::integer, 'superuser_reserved_connections', current_setting('superuser_reserved_connections')::integer, 'client_backends', count(*), 'active_backends', count(*) FILTER (WHERE state = 'active'), 'idle_backends', count(*) FILTER (WHERE state = 'idle'), 'idle_in_transaction_backends', count(*) FILTER (WHERE state = 'idle in transaction'), 'active_waiting_backends', count(*) FILTER ( WHERE state = 'active' AND wait_event IS NOT NULL ), 'lock_waiting_backends', count(*) FILTER (WHERE wait_event_type = 'Lock'), 'active_wait_events', ( SELECT coalesce(json_object_agg(event, backends), '{}'::json) FROM active_wait_events ), 'longest_idle_in_transaction_ms', coalesce( max( extract(epoch FROM clock_timestamp() - xact_start) * 1000 ) FILTER ( WHERE state = 'idle in transaction' ), 0 ), 'longest_active_query_ms', coalesce( max( extract(epoch FROM clock_timestamp() - query_start) * 1000 ) FILTER ( WHERE state = 'active' ), 0 ) ) FROM activity; SQL } summarize_database_connections() { jq -s \ --argjson web_pool_size "$WEB_POOL_SIZE" \ --argjson worker_pool_size "$WORKER_POOL_SIZE" \ --argjson web_replicas "$LOAD_WEB_REPLICAS" \ --argjson worker_replicas "$LOAD_WORKER_REPLICAS" ' . as $samples | { schema_version: 1, measurement: "read-only pg_stat_activity samples during the k6 run", thresholds_applied: false, sample_count: length, configured_web_replicas: $web_replicas, configured_worker_replicas: $worker_replicas, configured_web_pool_size_per_replica: $web_pool_size, configured_worker_pool_size_per_replica: $worker_pool_size, configured_repo_pool_slots: (($web_replicas * $web_pool_size) + ($worker_replicas * $worker_pool_size)), max_connections: (map(.max_connections) | max), reserved_connections: (map(.reserved_connections) | max), superuser_reserved_connections: (map(.superuser_reserved_connections) | max), peak_client_backends: (map(.client_backends) | max), peak_active_backends: (map(.active_backends) | max), peak_idle_backends: (map(.idle_backends) | max), peak_idle_in_transaction_backends: (map(.idle_in_transaction_backends) | max), peak_active_waiting_backends: (map(.active_waiting_backends) | max), peak_lock_waiting_backends: (map(.lock_waiting_backends) | max), observed_active_wait_events: ([ $samples[] | .active_wait_events | to_entries[] ] | group_by(.key) | map({ event: .[0].key, peak_backends: (map(.value) | max), observed_samples: length })), longest_observed_idle_in_transaction_ms: (map(.longest_idle_in_transaction_ms | tonumber) | max), longest_observed_active_query_ms: (map(.longest_active_query_ms | tonumber) | max), minimum_observed_connection_headroom: ((map( .max_connections - .reserved_connections - .superuser_reserved_connections ) | min) - (map(.client_backends) | max)) } ' "$connection_log" >"$output_dir/database-connections-summary.json" } prometheus_value() { local metric=$1 awk -v metric="$metric" '$1 == metric { print $2; found = 1 } END { if (!found) print 0 }' } snapshot_web_metrics() { local destination=$1 local temporary temporary=$(mktemp "${TMPDIR:-/tmp}/wnh-load-metrics.XXXXXX") : >"$temporary" for container in "${web_containers[@]}"; do local scrape node queries total execution queue decode scrape=$( docker exec "$container" sh -c \ 'curl --fail --silent --show-error \ --header "Authorization: Bearer $METRICS_TOKEN" \ --header "X-Forwarded-Proto: https" \ --header "Host: $PHX_HOST" \ http://127.0.0.1:4000/metrics' ) node=$( docker exec "$container" /app/bin/who_need_help rpc \ 'IO.puts(JSON.encode!(%{node: to_string(node())}))' | tail -n 1 | jq -r '.node' ) queries=$( printf '%s\n' "$scrape" | prometheus_value who_need_help_database_queries_total ) total=$( printf '%s\n' "$scrape" | prometheus_value who_need_help_database_query_duration_microseconds_total ) execution=$( printf '%s\n' "$scrape" | prometheus_value \ who_need_help_database_query_execution_duration_microseconds_total ) queue=$( printf '%s\n' "$scrape" | prometheus_value \ who_need_help_database_query_queue_duration_microseconds_total ) decode=$( printf '%s\n' "$scrape" | prometheus_value \ who_need_help_database_query_decode_duration_microseconds_total ) jq -cn \ --arg container "$container" \ --arg node "$node" \ --argjson queries "$queries" \ --argjson total_duration_us "$total" \ --argjson execution_duration_us "$execution" \ --argjson queue_duration_us "$queue" \ --argjson decode_duration_us "$decode" \ '{ container: $container, node: $node, queries: $queries, total_duration_us: $total_duration_us, execution_duration_us: $execution_duration_us, queue_duration_us: $queue_duration_us, decode_duration_us: $decode_duration_us }' >>"$temporary" done jq -s 'sort_by(.node)' "$temporary" >"$destination" rm -f "$temporary" } summarize_web_metrics() { local before=$1 local after=$2 jq -n \ --slurpfile before "$before" \ --slurpfile after "$after" ' [ $after[0][] as $after_row | ($before[0][] | select(.node == $after_row.node)) as $before_row | { node: $after_row.node, queries: ($after_row.queries - $before_row.queries), total_duration_us: ($after_row.total_duration_us - $before_row.total_duration_us), execution_duration_us: ($after_row.execution_duration_us - $before_row.execution_duration_us), queue_duration_us: ($after_row.queue_duration_us - $before_row.queue_duration_us), decode_duration_us: ($after_row.decode_duration_us - $before_row.decode_duration_us) } | . + { average_total_duration_us: (if .queries > 0 then .total_duration_us / .queries else 0 end), average_execution_duration_us: (if .queries > 0 then .execution_duration_us / .queries else 0 end), average_queue_duration_us: (if .queries > 0 then .queue_duration_us / .queries else 0 end), average_decode_duration_us: (if .queries > 0 then .decode_duration_us / .queries else 0 end) } ] as $nodes | { schema_version: 1, measurement: "per-web Ecto telemetry counter deltas around the k6 run", thresholds_applied: false, nodes: $nodes, cluster: ( reduce $nodes[] as $node ({ queries: 0, total_duration_us: 0, execution_duration_us: 0, queue_duration_us: 0, decode_duration_us: 0 }; .queries += $node.queries | .total_duration_us += $node.total_duration_us | .execution_duration_us += $node.execution_duration_us | .queue_duration_us += $node.queue_duration_us | .decode_duration_us += $node.decode_duration_us) | . + { average_total_duration_us: (if .queries > 0 then .total_duration_us / .queries else 0 end), average_execution_duration_us: (if .queries > 0 then .execution_duration_us / .queries else 0 end), average_queue_duration_us: (if .queries > 0 then .queue_duration_us / .queries else 0 end), average_decode_duration_us: (if .queries > 0 then .decode_duration_us / .queries else 0 end) } ) } ' >"$output_dir/ecto-query-metrics-summary.json" } run_fixture_tool() { local action=$1 docker run --rm \ --network "$internal_network_id" \ --env-file "$ENV_FILE" \ --env APP_ROLE=migrate \ --env "WNH_LOAD_EXPECTED_DATABASE=$POSTGRES_DB" \ --env WNH_LOAD_FIXTURE_CONFIRM=isolated-load-fixtures \ --env "WNH_LOAD_FIXTURE_RUN_ID=$LABEL" \ --env "WNH_LOAD_FIXTURE_COUNT=$fixture_count" \ --env "WNH_LOAD_FIXTURE_PATH=/output/fixtures.json" \ --volume "$output_dir:/output" \ who-need-help:load-tools \ mix wnh.load_fixtures "$action" } validate_authenticated_writes() { # The run label was restricted to a conservative character set above and is # passed as a psql variable, not interpolated into SQL syntax. # shellcheck disable=SC2016 "${compose[@]}" exec -T db sh -c \ 'psql --no-psqlrc --tuples-only --no-align --set ON_ERROR_STOP=1 \ --set run_id="$1" --username "$POSTGRES_USER" --dbname "$POSTGRES_DB"' \ sh "$LABEL" >"$output_dir/authenticated-writes.json" <<'SQL' SELECT json_build_object( 'messages', (SELECT count(*) FROM messages WHERE body LIKE 'load:' || :'run_id' || ':%'), 'tracking_samples', (SELECT coalesce(sum(session.sample_count), 0) FROM tracking_sessions AS session JOIN users AS actor ON actor.id = session.user_id WHERE left(actor.email, length('wnh-load-' || lower(:'run_id') || '-')) = 'wnh-load-' || lower(:'run_id') || '-'), 'tracking_sessions', (SELECT count(*) FROM tracking_sessions AS session JOIN users AS actor ON actor.id = session.user_id WHERE left(actor.email, length('wnh-load-' || lower(:'run_id') || '-')) = 'wnh-load-' || lower(:'run_id') || '-'), 'tracking_positions', (SELECT count(*) FROM tracking_positions AS position JOIN tracking_sessions AS session ON session.id = position.tracking_session_id JOIN users AS actor ON actor.id = session.user_id WHERE left(actor.email, length('wnh-load-' || lower(:'run_id') || '-')) = 'wnh-load-' || lower(:'run_id') || '-'), 'session_tokens', (SELECT count(*) FROM users_tokens AS token JOIN users AS actor ON actor.id = token.user_id WHERE left(actor.email, length('wnh-load-' || lower(:'run_id') || '-')) = 'wnh-load-' || lower(:'run_id') || '-') ); SQL jq -e ' .messages > 0 and .tracking_samples > 0 and .tracking_sessions > 0 and .tracking_positions == 0 and .session_tokens > 0 ' "$output_dir/authenticated-writes.json" >/dev/null } 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" sample_database_connections sleep 1 done } cleanup_sampler() { rm -f "$running_marker" if [[ -n "${sampler_pid:-}" ]]; then wait "$sampler_pid" 2>/dev/null || true fi } fixtures_prepared=false cleanup_on_exit() { local status=$? local cleanup_status=0 trap - EXIT HUP INT TERM cleanup_sampler if [[ "$fixtures_prepared" == true ]]; then set +e run_fixture_tool cleanup >>"$output_dir/fixture-cleanup.log" 2>&1 cleanup_status=$? set -e fi if [[ "$cleanup_status" -ne 0 ]]; then echo "Fixture cleanup failed; inspect $output_dir/fixture-cleanup.log." >&2 status=1 fi exit "$status" } trap cleanup_on_exit EXIT HUP INT TERM { printf 'observed_at=%s\n' "$run_started_at" printf 'k6_image=%s\n' "$K6_IMAGE" printf 'resource_summarizer_image=%s\n' "$PYTHON_IMAGE" printf 'load_project=%s\n' "$LOAD_PROJECT" printf 'web_replicas=%s\n' "$LOAD_WEB_REPLICAS" printf 'worker_replicas=%s\n' "$LOAD_WORKER_REPLICAS" printf 'http_vus=%s\n' "$LOAD_HTTP_VUS" printf 'websocket_vus=%s\n' "$LOAD_WS_VUS" printf 'duration=%s\n' "$LOAD_DURATION" printf 'duration_source=%s\n' "$duration_source" 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" printf 'authenticated_vus=%s\n' "$LOAD_AUTH_VUS" printf 'fixture_count=%s\n' "$fixture_count" printf 'authenticated_websocket_timeout_ms=%s\n' "$LOAD_AUTH_WS_TIMEOUT_MS" printf 'authenticated_think_seconds=%s\n' "$LOAD_AUTH_THINK_SECONDS" docker info --format 'docker_cpus={{.NCPU}} docker_memory_bytes={{.MemTotal}} docker_server={{.ServerVersion}}' docker compose version uname -a } >"$output_dir/environment.txt" prepare_statement_stats snapshot_database "$output_dir/database-pre-fixtures.txt" run_fixture_tool prepare >"$output_dir/fixture-prepare.log" 2>&1 fixtures_prepared=true snapshot_database "$output_dir/database-before-load.txt" snapshot_database_metrics "$output_dir/database-metrics-before-load.json" reset_statement_stats snapshot_web_metrics "$output_dir/ecto-query-metrics-before.json" 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:/fixtures:ro" \ --volume "$output_dir:/output" \ --env "BASE_URL=https://$LOAD_HOST" \ --env "PUBLIC_ORIGIN=https://$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" \ --env "AUTH_VUS=$LOAD_AUTH_VUS" \ --env "AUTH_WS_TIMEOUT_MS=$LOAD_AUTH_WS_TIMEOUT_MS" \ --env "AUTH_THINK_SECONDS=$LOAD_AUTH_THINK_SECONDS" \ --env "FIXTURE_PASSWORD=$LOAD_FIXTURE_PASSWORD" \ --env "FIXTURE_RUN_ID=$LABEL" \ "$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= summarize_resources summarize_database_connections snapshot_web_metrics "$output_dir/ecto-query-metrics-after.json" summarize_web_metrics \ "$output_dir/ecto-query-metrics-before.json" \ "$output_dir/ecto-query-metrics-after.json" snapshot_statement_stats "$output_dir/statement-statistics.json" snapshot_database "$output_dir/database-after-load.txt" snapshot_database_metrics "$output_dir/database-metrics-after-load.json" summarize_database_metrics \ "$output_dir/database-metrics-before-load.json" \ "$output_dir/database-metrics-after-load.json" \ "$output_dir/database-metrics-summary.json" diff -u "$output_dir/database-before-load.txt" "$output_dir/database-after-load.txt" \ >"$output_dir/database-load-diff.txt" || true validate_authenticated_writes 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 "$run_started_at" 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 and metric("wnh_authenticated_logins").count > 0 and metric("wnh_authenticated_pages").count > 0 and metric("wnh_tracking_updates").count > 0 and metric("wnh_liveview_joins").count > 0 and metric("wnh_liveview_joins").count == metric("wnh_liveview_tracking_starts").count and metric("wnh_liveview_joins").count == metric("wnh_tracking_updates").count and metric("wnh_liveview_joins").count == metric("wnh_liveview_messages").count and metric("wnh_liveview_joins").count == metric("wnh_liveview_tracking_stops").count and (metric("wnh_authenticated_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 run_fixture_tool cleanup >"$output_dir/fixture-cleanup.log" 2>&1 fixtures_prepared=false snapshot_database "$output_dir/database-after-cleanup.txt" snapshot_database_metrics "$output_dir/database-metrics-after-cleanup.json" if ! diff -u "$output_dir/database-pre-fixtures.txt" \ "$output_dir/database-after-cleanup.txt" >"$output_dir/database-cleanup-diff.txt"; then echo "Fixture cleanup did not restore the pre-run table counts; evidence is in $output_dir." >&2 exit 1 fi trap - EXIT HUP INT TERM printf 'Load evidence: %s\n' "$output_dir"