who_need_help/scripts/oban-burst-run.sh

321 lines
8.9 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
ENV_FILE="$ROOT/.env.load"
if [[ $# -ne 3 ]]; then
echo "Usage: $0 LABEL JOB_COUNT TIMEOUT_SECONDS" >&2
echo "All three values are recorded experiment inputs, not capacity thresholds." >&2
exit 2
fi
label=$1
job_count=$2
timeout_seconds=$3
if [[ ! "$label" =~ ^[A-Za-z0-9._-]+$ ]]; then
echo "LABEL may contain only letters, numbers, dot, underscore, and dash." >&2
exit 2
fi
for value_name in job_count timeout_seconds; do
value=${!value_name}
if [[ ! "$value" =~ ^[1-9][0-9]*$ ]]; then
echo "${value_name^^} must be a positive integer." >&2
exit 2
fi
done
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
for name in LOAD_PROJECT LOAD_WORKER_REPLICAS POSTGRES_DB; 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 Oban burst measurement must not use the ordinary Compose project." >&2
exit 1
fi
if [[ ! "$LOAD_WORKER_REPLICAS" =~ ^[1-9][0-9]*$ ]]; then
echo "LOAD_WORKER_REPLICAS must be a positive integer." >&2
exit 1
fi
compose=(
docker compose
--env-file "$ENV_FILE"
-p "$LOAD_PROJECT"
-f compose.yaml
-f compose.load.yaml
)
mapfile -t worker_containers < <("${compose[@]}" ps -q worker)
if [[ "${#worker_containers[@]}" -ne "$LOAD_WORKER_REPLICAS" ]]; then
echo "Expected $LOAD_WORKER_REPLICAS workers; observed ${#worker_containers[@]}." >&2
exit 1
fi
db_container=$("${compose[@]}" ps -q db)
if [[ -z "$db_container" ]]; then
echo "The isolated load database is not running." >&2
exit 1
fi
output_dir="$ROOT/output/performance/$label"
if [[ -e "$output_dir" ]]; then
echo "Output already exists: $output_dir" >&2
exit 1
fi
mkdir -p "$output_dir"
chmod 700 "$ROOT/output" "$ROOT/output/performance" "$output_dir"
run_id="${label}-$(date -u +%Y%m%dT%H%M%SZ)-$$"
worker_name="WhoNeedHelp.Workers.LocalBurstProbe"
sampler_pid=
sampling_marker="$output_dir/.sampling"
cleanup_required=false
database_psql() {
# The variables 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"'
}
exact_job_count() {
database_psql <<SQL
SELECT count(*)
FROM oban_jobs
WHERE worker = '$worker_name'
AND args->>'run_id' = '$run_id';
SQL
}
delete_exact_jobs() {
database_psql <<SQL
WITH deleted AS (
DELETE FROM oban_jobs
WHERE worker = '$worker_name'
AND args->>'run_id' = '$run_id'
RETURNING id
)
SELECT count(*) FROM deleted;
SQL
}
snapshot_domain_counts() {
database_psql <<'SQL'
SELECT jsonb_pretty(
jsonb_object_agg(table_name, row_count ORDER BY table_name)
)
FROM (
SELECT 'abuse_signals' AS table_name, count(*) AS row_count FROM abuse_signals
UNION ALL SELECT 'activities', count(*) FROM activities
UNION ALL SELECT 'activity_messages', count(*) FROM activity_messages
UNION ALL SELECT 'activity_participants', count(*) FROM activity_participants
UNION ALL SELECT 'audit_events', count(*) FROM audit_events
UNION ALL SELECT 'blocks', count(*) FROM blocks
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 'help_requests', count(*) FROM help_requests
UNION ALL SELECT 'messages', count(*) FROM messages
UNION ALL SELECT 'reports', count(*) FROM reports
UNION ALL SELECT 'reviews', count(*) FROM reviews
UNION ALL SELECT 'social_identities', count(*) FROM social_identities
UNION ALL SELECT 'tracking_positions', count(*) FROM tracking_positions
UNION ALL SELECT 'tracking_sessions', count(*) FROM tracking_sessions
UNION ALL SELECT 'users', count(*) FROM users
UNION ALL SELECT 'users_tokens', count(*) FROM users_tokens
) AS counts;
SQL
}
cleanup() {
trap - EXIT HUP INT TERM
rm -f "$sampling_marker"
if [[ -n "$sampler_pid" ]]; then
kill "$sampler_pid" >/dev/null 2>&1 || true
wait "$sampler_pid" >/dev/null 2>&1 || true
fi
if [[ "$cleanup_required" == true ]]; then
delete_exact_jobs >"$output_dir/job-cleanup-on-exit.txt" 2>&1 || true
cleanup_required=false
fi
}
trap cleanup EXIT HUP INT TERM
if [[ "$(exact_job_count)" != "0" ]]; then
echo "The exact burst run id already exists in oban_jobs." >&2
exit 1
fi
snapshot_domain_counts >"$output_dir/domain-counts-before.json"
{
printf 'label=%s\n' "$label"
printf 'run_id=%s\n' "$run_id"
printf 'job_count=%s\n' "$job_count"
printf 'timeout_seconds=%s\n' "$timeout_seconds"
printf 'started_at=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
printf 'load_project=%s\n' "$LOAD_PROJECT"
printf 'worker_replicas=%s\n' "$LOAD_WORKER_REPLICAS"
printf 'maintenance_concurrency_per_worker=%s\n' \
"${OBAN_MAINTENANCE_CONCURRENCY:-2}"
printf 'push_concurrency_per_worker=%s\n' "${OBAN_PUSH_CONCURRENCY:-1}"
} >"$output_dir/inputs.txt"
for container in "${worker_containers[@]}"; do
docker exec "$container" /app/bin/who_need_help rpc \
'IO.inspect(%{node: node(), queues: Oban.config().queues})' \
>>"$output_dir/worker-queue-config.txt"
done
measured_containers=("$db_container" "${worker_containers[@]}")
touch "$sampling_marker"
(
while [[ -e "$sampling_marker" ]]; do
captured_at=$(date -u +%Y-%m-%dT%H:%M:%S.%3NZ)
docker stats --no-stream --format json "${measured_containers[@]}" |
jq --compact-output --arg captured_at "$captured_at" \
'. + {captured_at: $captured_at}'
done
) >"$output_dir/docker-stats.jsonl" &
sampler_pid=$!
insert_started_ns=$(date +%s%N)
docker exec \
"${worker_containers[0]}" \
/app/bin/who_need_help rpc "
run_id = \"$run_id\"
count = $job_count
jobs =
1..count
|> Enum.map(fn sequence ->
WhoNeedHelp.Workers.LocalBurstProbe.new(%{
\"confirmation\" => \"isolated-local-oban-burst-probe\",
\"run_id\" => run_id,
\"sequence\" => sequence
})
end)
|> Oban.insert_all()
IO.puts(length(jobs))
" >"$output_dir/job-insert.txt"
cleanup_required=true
if [[ "$(tail -n 1 "$output_dir/job-insert.txt")" != "$job_count" ]]; then
echo "Oban.insert_all did not return the requested number of jobs." >&2
exit 1
fi
deadline=$((SECONDS + timeout_seconds))
while :; do
database_psql >"$output_dir/job-state-latest.json" <<SQL
SELECT jsonb_pretty(
jsonb_build_object(
'total', coalesce(sum(state_count), 0),
'states', coalesce(jsonb_object_agg(state, state_count), '{}'::jsonb)
)
)
FROM (
SELECT state::text AS state, count(*) AS state_count
FROM oban_jobs
WHERE worker = '$worker_name'
AND args->>'run_id' = '$run_id'
GROUP BY state
) AS grouped;
SQL
total=$(jq -r '.total // 0' "$output_dir/job-state-latest.json")
completed=$(jq -r '.states.completed // 0' "$output_dir/job-state-latest.json")
if [[ "$total" == "$job_count" && "$completed" == "$job_count" ]]; then
break
fi
if ((SECONDS >= deadline)); then
cp "$output_dir/job-state-latest.json" "$output_dir/job-state-timeout.json"
echo "The recorded experiment timeout elapsed before all jobs completed." >&2
exit 1
fi
sleep 0.1
done
completed_ns=$(date +%s%N)
cp "$output_dir/job-state-latest.json" "$output_dir/job-state-final.json"
rm -f "$sampling_marker"
wait "$sampler_pid" >/dev/null 2>&1 || true
sampler_pid=
elapsed_ms=$(((completed_ns - insert_started_ns) / 1000000))
printf '%s\n' "$elapsed_ms" >"$output_dir/elapsed-ms.txt"
snapshot_domain_counts >"$output_dir/domain-counts-after.json"
if ! cmp -s \
"$output_dir/domain-counts-before.json" \
"$output_dir/domain-counts-after.json"; then
echo "Domain row counts changed during the isolated burst measurement." >&2
exit 1
fi
deleted=$(delete_exact_jobs)
printf '%s\n' "$deleted" >"$output_dir/job-cleanup.txt"
if [[ "$deleted" != "$job_count" || "$(exact_job_count)" != "0" ]]; then
echo "The script did not remove exactly its own burst jobs." >&2
exit 1
fi
cleanup_required=false
jq -s '
group_by(.Name)
| map({
container: .[0].Name,
samples: length,
max_memory_bytes:
(map(.MemUsage | split(" / ")[0]) | map(
capture("(?<value>[0-9.]+)(?<unit>[KMG]iB)")
| (.value | tonumber) *
(if .unit == "KiB" then 1024
elif .unit == "MiB" then 1048576
else 1073741824 end)
) | max),
max_cpu_percent:
(map(.CPUPerc | rtrimstr("%") | tonumber) | max)
})
' "$output_dir/docker-stats.jsonl" >"$output_dir/resource-summary.json"
printf 'Completed %s isolated Oban jobs in %s ms. Evidence: %s\n' \
"$job_count" "$elapsed_ms" "$output_dir"