Files
pulse/scripts/run-release-backend-tests.sh
T
rcourtman 9c28f097b3 Warm the pulse-agent build before the pinned release API shards
The first beta's release workflow failed twice in backend_tests:
TestHostAgentCredentialFileHandoff, added on 2026-10-11 and so new to
every release job, compiles pulse-agent itself and was killed at exactly
180s with "build agent from this source: signal: killed".

The release runner starts each API shard with a small pinned GOMAXPROCS,
and the test's child compiler inherits it. A cold agent build is about
250 CPU-seconds, so under the pin it serializes past the test's fixed
three-minute limit. The release job also has no Go build cache, and the
race-enabled test binary the runner compiles does not warm this plain
build. Pull request jobs keep a warm cache, which is why the test passed
there.

Reproduced on pulse-dev with the shard's conditions, a prebuilt test
binary, GOMAXPROCS=1 and an empty cache: the test fails at 180.02s with
the identical message. With the agent built once beforehand, unpinned, in
99s, the same pinned run passes in 16s.

Build the agent once in the runner, after compiling the test binary and
before any shard starts, with the full CPU count. The runner test pins
that order so the warm-up cannot drift after the shards, and the contract
records why. Against the previous runner the pin fails with the expected
message. The release preflight and policy suites pass.

Contract-Neutral: Release test runner ordering only; no published artifact, tag, signing, install or customer contract changes.
2026-10-12 16:28:14 +01:00

364 lines
13 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
# TestHostAgentCredentialFileHandoff compiles pulse-agent itself, with a fixed three-minute
# limit. Every API shard runs with a small pinned GOMAXPROCS that the test's child compiler
# inherits, so a cold build of roughly 250 CPU-seconds is serialized past that limit: run
# alone with GOMAXPROCS=1 and a cold cache it fails at 180s with "signal: killed", while a
# cache warmed beforehand makes it a 16s test. The race-enabled test binary does not warm
# this plain build, so compile the agent once here, before any shard starts and unpinned.
echo "Warming the pulse-agent build cache for tests that compile the agent..."
go build -o /dev/null ./cmd/pulse-agent
(
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)."