Files
pulse/scripts/run-release-backend-tests.sh
T
2026-08-29 01:15:59 +01:00

355 lines
12 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: run-release-backend-tests.sh [options]
Options:
--data-root DIR Isolated test-data root (required)
--api-shards VALUE auto or a positive integer (default: auto)
--batch-size VALUE Top-level tests per API test-binary invocation (default: 10000)
--max-regex-bytes VALUE
Maximum encoded -test.run regex bytes (default: 120000)
--memory-wait-seconds VALUE
Bounded wait for multi-shard memory admission (default: 120)
--api-shard-timeout VALUE
Cumulative watchdog for each API shard (default: 45m)
EOF
}
DATA_ROOT=""
API_SHARDS="${PULSE_BACKEND_TEST_SHARDS:-auto}"
BATCH_SIZE=10000
MAX_REGEX_BYTES="${PULSE_BACKEND_TEST_MAX_REGEX_BYTES:-120000}"
MEMORY_WAIT_SECONDS="${PULSE_BACKEND_TEST_MEMORY_WAIT_SECONDS:-120}"
API_SHARD_TIMEOUT="${PULSE_BACKEND_API_SHARD_TIMEOUT:-45m}"
SHARD_WEIGHTS="${PULSE_BACKEND_TEST_SHARD_WEIGHTS:-}"
SHARD_BOUNDARIES="${PULSE_BACKEND_TEST_SHARD_BOUNDARIES:-}"
while [ "$#" -gt 0 ]; do
case "$1" in
--data-root)
DATA_ROOT="${2:-}"
shift 2
;;
--api-shards)
API_SHARDS="${2:-}"
shift 2
;;
--batch-size)
BATCH_SIZE="${2:-}"
shift 2
;;
--max-regex-bytes)
MAX_REGEX_BYTES="${2:-}"
shift 2
;;
--memory-wait-seconds)
MEMORY_WAIT_SECONDS="${2:-}"
shift 2
;;
--api-shard-timeout)
API_SHARD_TIMEOUT="${2:-}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Error: unknown option: $1" >&2
usage >&2
exit 2
;;
esac
done
if [ -z "$DATA_ROOT" ]; then
echo "Error: --data-root is required." >&2
exit 2
fi
if [[ ! "$BATCH_SIZE" =~ ^[1-9][0-9]*$ ]]; then
echo "Error: --batch-size must be a positive integer." >&2
exit 2
fi
if [[ ! "$MAX_REGEX_BYTES" =~ ^[1-9][0-9]*$ ]]; then
echo "Error: --max-regex-bytes must be a positive integer." >&2
exit 2
fi
if [ "$MAX_REGEX_BYTES" -gt 120000 ]; then
echo "Error: --max-regex-bytes cannot exceed the safe 120000-byte per-argument ceiling." >&2
exit 2
fi
if [[ ! "$MEMORY_WAIT_SECONDS" =~ ^[0-9]+$ ]]; then
echo "Error: --memory-wait-seconds must be a non-negative integer." >&2
exit 2
fi
if [[ ! "$API_SHARD_TIMEOUT" =~ ^[1-9][0-9]*(ms|s|m|h)$ ]]; then
echo "Error: --api-shard-timeout must be a positive Go duration using ms, s, m, or h." >&2
exit 2
fi
for command_name in go python3 getconf awk pgrep; do
if ! command -v "$command_name" >/dev/null 2>&1; then
echo "Error: required backend-test command is missing: $command_name" >&2
exit 3
fi
done
REPOSITORY_ROOT="$(git rev-parse --show-toplevel)"
cd "$REPOSITORY_ROOT"
# This is the canonical release gate even when exercised directly on the
# worker. Preserve hosted-CI security and isolation semantics in both paths.
export GITHUB_ACTIONS=true
export CI=true
RUN_ROOT="$DATA_ROOT/release-backend-run"
PLAN_DIR="$RUN_ROOT/api-plan"
API_BINARY="$RUN_ROOT/internal-api.test"
TEST_NAMES_FILE="$RUN_ROOT/internal-api-tests.txt"
rm -rf "$RUN_ROOT"
mkdir -p "$PLAN_DIR" "$RUN_ROOT/data/other"
VCPUS="$(getconf _NPROCESSORS_ONLN)"
read_available_kib() {
awk '/^MemAvailable:/ { print $2; exit }' /proc/meminfo
}
AVAILABLE_KIB="$(read_available_kib)"
if [ -z "$AVAILABLE_KIB" ]; then AVAILABLE_KIB=0; fi
if [ "$API_SHARDS" = auto ]; then
# Stable releases run on GitHub's four-vCPU ubuntu-24.04 worker, while
# prereleases use the eight-vCPU PVE worker. Stable v6.4.0 run 33220050114
# proved that splitting the API package into two equal-count ranges is not
# cost-balanced: one range passed in 1,797s while the other was still making
# progress when its 2,700s watchdog fired. Use the measured three-way
# boundaries on both release worker classes; memory admission below still
# degrades safely on smaller hosts.
if [ "$VCPUS" -ge 4 ]; then
cpu_shards=3
elif [ "$VCPUS" -ge 2 ]; then
cpu_shards=2
else
cpu_shards=1
fi
# A release starts the public and private credential-free compilers beside
# this lane on the same worker VM. Their short peak must not permanently
# collapse an otherwise capable 8-vCPU worker to a long API shard. Wait only
# while those useful jobs finish, then admit the widest shard count the
# measured headroom supports instead of failing the release at admission.
# A 2026-08-21 direct probe on the 8-vCPU/18-GiB PVE worker measured a
# ~7.5 GiB gate footprint for three race shards plus the concurrent non-API
# package graph (8.9 GiB MemAvailable floor from a 16.4 GiB idle start, zero
# swap), so these thresholds keep >2x margin while remaining reachable
# beside the compile peak (~14.1 GiB observed mid-release). The previous
# 16 GiB requirement exceeded the worker's own idle availability (16.1 GiB)
# and would have hard-failed the next release at admission.
shard_admission_required_kib() {
case "$1" in
2) echo $((8 * 1024 * 1024)) ;;
*) echo $((10 * 1024 * 1024)) ;;
esac
}
if [ "$cpu_shards" -gt 1 ]; then
required_kib="$(shard_admission_required_kib "$cpu_shards")"
waited_seconds=0
while [ "$AVAILABLE_KIB" -lt "$required_kib" ] && [ "$waited_seconds" -lt "$MEMORY_WAIT_SECONDS" ]; do
remaining_seconds=$((MEMORY_WAIT_SECONDS - waited_seconds))
wait_seconds=5
if [ "$remaining_seconds" -lt "$wait_seconds" ]; then wait_seconds="$remaining_seconds"; fi
echo "Waiting ${wait_seconds}s for backend shard admission: $((AVAILABLE_KIB / 1024)) MiB available, $((required_kib / 1024)) MiB required."
sleep "$wait_seconds"
waited_seconds=$((waited_seconds + wait_seconds))
AVAILABLE_KIB="$(read_available_kib)"
if [ -z "$AVAILABLE_KIB" ]; then AVAILABLE_KIB=0; fi
done
while [ "$cpu_shards" -gt 1 ] && [ "$AVAILABLE_KIB" -lt "$(shard_admission_required_kib "$cpu_shards")" ]; do
cpu_shards=$((cpu_shards - 1))
echo "Degrading to $cpu_shards API shard(s): $((AVAILABLE_KIB / 1024)) MiB available after the ${MEMORY_WAIT_SECONDS}s bounded wait."
done
fi
API_SHARDS="$cpu_shards"
elif [[ ! "$API_SHARDS" =~ ^[1-9][0-9]*$ ]]; then
echo "Error: --api-shards must be auto or a positive integer." >&2
exit 2
fi
if [ "$API_SHARDS" -gt "$VCPUS" ]; then
echo "Error: API shard count ($API_SHARDS) cannot exceed available vCPUs ($VCPUS)." >&2
exit 2
fi
if [ -n "$SHARD_WEIGHTS" ] && [ -n "$SHARD_BOUNDARIES" ]; then
echo "Error: configure shard weights or shard boundaries, not both." >&2
exit 2
fi
if [ -z "$SHARD_WEIGHTS" ] && [ -z "$SHARD_BOUNDARIES" ] && [ "$API_SHARDS" -eq 3 ]; then
# RC.6 exact-SHA bisection found the repeated integration-server cost in the
# final 31 tests. Keep the fast prefix together, then divide that hot tail
# across the remaining two processes at stable top-level test boundaries.
SHARD_BOUNDARIES="TestWebSocketOriginAllowsTrustedForwardedHostedOriginIPv6Loopback,TestServerInfoEndpointMethodNotAllowed"
fi
echo "Release backend test plan"
echo " vCPUs: $VCPUS"
echo " Available MiB: $((AVAILABLE_KIB / 1024))"
echo " API shards: $API_SHARDS"
echo " Shard weights: ${SHARD_WEIGHTS:-none}"
echo " Shard boundaries: ${SHARD_BOUNDARIES:-automatic}"
echo " Memory wait: ${MEMORY_WAIT_SECONDS}s max"
echo " API watchdog: $API_SHARD_TIMEOUT per shard"
echo " Batch size: $BATCH_SIZE"
echo " Regex bytes: $MAX_REGEX_BYTES max"
./scripts/ensure_test_assets.sh
echo "Compiling the race-enabled internal/api test binary once..."
go test -c -race -o "$API_BINARY" ./internal/api
(
cd internal/api
"$API_BINARY" -test.list '^Test'
) | awk '/^Test[A-Za-z0-9_]+$/ { print }' > "$TEST_NAMES_FILE"
plan_args=(
--tests-file "$TEST_NAMES_FILE"
--shards "$API_SHARDS"
--batch-size "$BATCH_SIZE"
--max-regex-bytes "$MAX_REGEX_BYTES"
--output-dir "$PLAN_DIR"
)
if [ -n "$SHARD_WEIGHTS" ]; then
plan_args+=(--shard-weights "$SHARD_WEIGHTS")
fi
if [ -n "$SHARD_BOUNDARIES" ]; then
plan_args+=(--shard-boundaries "$SHARD_BOUNDARIES")
fi
python3 scripts/shard_go_tests.py "${plan_args[@]}"
# Partition the worker CPU budget between the API shards and the concurrently
# running non-API package graph. Top-level API tests execute serially inside
# one test-binary process, so a shard's wall time tracks the serial duration
# of its range, not its CPU width — but the runtime, GC, and race detector
# behind the canonical prefix shard's thousands of unit tests still benefit
# from width the ~15-test wait-bound tail shards cannot use.
#
# Direct 2026-08-21 worker probes measured the prefix shard at 569s with 2
# procs and 484s with 4. RC.10 exposed that allocating all eight worker CPUs
# to API shards while also launching the non-API graph left that graph's Go
# package concurrency unbounded. Reserve two package slots where possible,
# cap each non-API test binary to one CPU, and keep the combined declared
# width at the worker's vCPU count.
mapfile -t CPU_PLAN < <(python3 - "$PLAN_DIR/manifest.json" "$VCPUS" <<'PY'
import json
import sys
sys.path.insert(0, "scripts")
from shard_go_tests import allocate_cpu_plan
manifest = json.load(open(sys.argv[1]))
vcpus = int(sys.argv[2])
counts = [shard["test_count"] for shard in manifest["shards"]]
widths, reserved_other = allocate_cpu_plan(counts, vcpus)
print(reserved_other)
print("\n".join(str(width) for width in widths))
PY
)
RESERVED_OTHER_PACKAGE_PROCS="${CPU_PLAN[0]}"
OTHER_PACKAGE_PROCS="$RESERVED_OTHER_PACKAGE_PROCS"
if [ "$OTHER_PACKAGE_PROCS" -eq 0 ]; then OTHER_PACKAGE_PROCS=1; fi
SHARD_GOMAXPROCS=("${CPU_PLAN[@]:1}")
if [ "${#SHARD_GOMAXPROCS[@]}" -ne "$API_SHARDS" ]; then
echo "Error: shard GOMAXPROCS plan produced ${#SHARD_GOMAXPROCS[@]} entries for $API_SHARDS shards." >&2
exit 4
fi
echo "API shard GOMAXPROCS: ${SHARD_GOMAXPROCS[*]}"
echo "Non-API package workers: $OTHER_PACKAGE_PROCS (GOMAXPROCS=1 each)"
API_IMPORT_PATH="$(go list -f '{{.ImportPath}}' ./internal/api)"
mapfile -t OTHER_PACKAGES < <(
go list ./cmd/... ./internal/... ./pkg/... ./scripts/... ./tests/... \
| awk -v api="$API_IMPORT_PATH" '$0 != api'
)
if [ "${#OTHER_PACKAGES[@]}" -eq 0 ]; then
echo "Error: no non-API backend test packages were discovered." >&2
exit 4
fi
run_other_packages() {
echo "Running ${#OTHER_PACKAGES[@]} non-API packages..."
env GOMAXPROCS=1 PULSE_DATA_DIR="$RUN_ROOT/data/other" \
go test -race -p "$OTHER_PACKAGE_PROCS" -timeout 30m "${OTHER_PACKAGES[@]}"
}
run_api_shard() {
local shard_index="$1"
local shard_procs="$2"
local shard_dir="$RUN_ROOT/data/api-shard-$shard_index"
local regex_file regex batch_index=0
local shard_started_seconds="$SECONDS"
mkdir -p "$shard_dir"
shopt -s nullglob
local regex_files=("$PLAN_DIR"/shard-"$shard_index"-batch-*.regex)
if [ "${#regex_files[@]}" -eq 0 ]; then
echo "Error: API shard $shard_index has no batches." >&2
return 4
fi
for regex_file in "${regex_files[@]}"; do
batch_index=$((batch_index + 1))
mkdir -p "$shard_dir/batch-$batch_index"
regex="$(tr -d '\r\n' < "$regex_file")"
echo "Running internal/api shard $shard_index batch $batch_index/${#regex_files[@]}..."
(
cd internal/api
env \
GOMAXPROCS="$shard_procs" \
PULSE_DATA_DIR="$shard_dir/batch-$batch_index" \
"$API_BINARY" -test.run "$regex" -test.timeout "$API_SHARD_TIMEOUT"
)
done
echo "Completed internal/api shard $shard_index in $((SECONDS - shard_started_seconds))s."
}
pids=()
# A one-vCPU host cannot reserve a concurrent package slot. Complete the
# non-API graph first there; release workers have enough width for concurrent
# execution through the normal path below.
if [ "$RESERVED_OTHER_PACKAGE_PROCS" -eq 0 ]; then
run_other_packages
else
run_other_packages &
pids+=("$!")
fi
for shard_number in $(seq 1 "$API_SHARDS"); do
shard_index="$(printf '%02d' "$shard_number")"
run_api_shard "$shard_index" "${SHARD_GOMAXPROCS[$((shard_number - 1))]}" &
pids+=("$!")
done
remaining="${#pids[@]}"
terminate_tree() {
local parent_pid="$1"
local child_pid
while IFS= read -r child_pid; do
if [ -n "$child_pid" ]; then
terminate_tree "$child_pid"
fi
done < <(pgrep -P "$parent_pid" || true)
kill -TERM "$parent_pid" >/dev/null 2>&1 || true
}
while [ "$remaining" -gt 0 ]; do
if wait -n; then
remaining=$((remaining - 1))
else
status=$?
for pid in "${pids[@]}"; do
terminate_tree "$pid"
done
wait "${pids[@]}" >/dev/null 2>&1 || true
exit "$status"
fi
done
echo "Race-enabled backend release tests passed with $API_SHARDS API shard(s)."