Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 7s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 13s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m15s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m10s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 6s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m12s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 13m47s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 19m12s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
The test-job memory instrument (#411) reported memory.peak — the high-water mark of memory.current, which charges reclaimable page cache to the cgroup. A build does heavy NuGet/npm/obj/bin/coverage I/O, so cache can dominate the peak, and page cache is reclaimed under a tighter cap rather than OOM-killed. Sizing a per-job cap (server-management#604) off memory.peak therefore inverts the decision. The OOM-forcing quantity is peak anon, which the kernel exposes no counter for and which the end-of-job split misses (a job that peaks mid-dotnet-test then frees reports a low anon). New scripts/ci-peak-anon.sh: a `start` step (before Build/Test/Coverage) launches a detached background sampler tracking the high-water mark of cgroup anon; a `report` step (last) stops it and prints the sampled peak anon as the headline, keeping memory.peak + end-of-job split as a cache-inflated ceiling and reference. Both continue-on-error + fail-open so they never redden a build. Validated on bumblebee: survives step-boundary re-execs, catches a transient 2.5 GiB anon spike the snapshot reports as 0, stops cleanly on kill, degrades gracefully. Compiler-server A/B (swap-off, sampled peak-anon, n=2 interleaved): OFF (CI config) ~5.84 GiB consistent; ON (defaults) 6.3-7.6 GiB, always higher, + a ~3 GiB resident VBCSCompiler. Disabling the servers is worth it, but OFF sits right at 6 GiB for the build phase alone and the test job adds test+coverage, so #406's "budget loosens well under 6 GiB" premise is not supported. Size the cap off the live test-job sampler. Docs: ci-cd.md instrument section rewritten (peak-anon headline + A/B table + premise verdict); decisions.md entry added. No .cs touched. fixes #412
115 lines
5.3 KiB
Bash
Executable File
115 lines
5.3 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# scripts/ci-peak-anon.sh — true peak-anon memory sampler for the CI `test` job (ersatztv#412).
|
|
#
|
|
# WHY. server-management#604 sizes the runners' per-job memory caps on what CI actually uses. The
|
|
# obvious number, cgroup v2 `memory.peak`, is the WRONG one: it is the high-water mark of
|
|
# `memory.current`, which charges reclaimable **page cache** to the cgroup alongside anonymous
|
|
# memory. A build job does heavy NuGet/npm/obj/bin/coverage I/O, so cache can dominate the peak —
|
|
# and page cache is *reclaimed* under a tighter cap, not OOM-killed. Sizing a cap off `memory.peak`
|
|
# therefore INVERTS the decision (ersatztv#406/#411): a big, mostly-`file` peak reads like "the cap
|
|
# must stay high" when it is nothing of the sort. The number that actually forces an OOM is peak
|
|
# **anon**. The kernel exposes `memory.peak` (peak of anon+cache) but has no peak-anon counter, so
|
|
# we must sample it.
|
|
#
|
|
# HOW. `start` launches a detached background poller that tracks the high-water mark of the cgroup's
|
|
# `anon` (from `memory.stat`) into a file; `report` (the job's last step) stops it and prints the
|
|
# sampled peak-anon alongside `memory.peak` (kept as the cache-inflated ceiling) and the end-of-job
|
|
# split (kept for reference — but note it is the composition *then*, not at the peak instant, which
|
|
# is exactly why the sampler exists: a job that peaks mid-`dotnet test` then frees reports a
|
|
# misleadingly low end-of-job anon).
|
|
#
|
|
# Robustness (validated on bumblebee, ersatztv#412):
|
|
# - The detached sampler survives step-boundary re-execs (reparents to the container's PID 1) and
|
|
# is reaped at container teardown, so it cannot outlive the job.
|
|
# - A TERM trap + `sleep & wait` makes `report`'s `kill` stop it at once (not after the sleep).
|
|
# - Fail-OPEN: if the cgroup files are unreadable it disables sampling and returns 0 — this runs
|
|
# `continue-on-error` in the workflow and must never redden a green build.
|
|
set -uo pipefail
|
|
|
|
OUT="${RUNNER_TEMP:-/tmp}/etv-peak-anon"
|
|
PIDF="${RUNNER_TEMP:-/tmp}/etv-peak-anon.pid"
|
|
STAT="${ETV_CGROUP_MEMSTAT:-/sys/fs/cgroup/memory.stat}" # ETV_CGROUP_MEMSTAT overrides for tests
|
|
PEAKFILES=("${ETV_CGROUP_MEMPEAK:-/sys/fs/cgroup/memory.peak}" /sys/fs/cgroup/memory/memory.max_usage_in_bytes)
|
|
|
|
mib() { echo "$(( ${1:-0} / 1048576 ))"; }
|
|
|
|
anon_bytes() { awk '/^anon /{print $2}' "$STAT" 2>/dev/null; }
|
|
|
|
# Internal: the sampler loop itself (run detached via `start`).
|
|
sample_loop() {
|
|
trap 'exit 0' TERM INT
|
|
local max=0 a
|
|
while :; do
|
|
a=$(anon_bytes)
|
|
# Atomic update (write-temp + rename) so a concurrent `report` read never catches a truncated
|
|
# $OUT mid-write and mistakes real data for "not sampled" (cold-review nit, ersatztv#412).
|
|
if [ -n "$a" ] && [ "$a" -gt "$max" ]; then max=$a; echo "$max" > "$OUT.tmp" && mv -f "$OUT.tmp" "$OUT"; fi
|
|
sleep "${ETV_PEAK_ANON_INTERVAL:-2}" & wait $!
|
|
done
|
|
}
|
|
|
|
start() {
|
|
if [ ! -r "$STAT" ] || [ -z "$(anon_bytes)" ]; then
|
|
echo "peak-anon: cgroup 'anon' not readable at $STAT -- sampling disabled (non-fatal)."
|
|
return 0
|
|
fi
|
|
echo 0 > "$OUT"
|
|
# Re-exec THIS script in sampler mode (absolute path, so a later CWD change can't strand it).
|
|
local self
|
|
self="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")"
|
|
nohup "$self" __sample >/dev/null 2>&1 &
|
|
echo $! > "$PIDF"
|
|
echo "peak-anon: sampler started (pid $(cat "$PIDF"), ${ETV_PEAK_ANON_INTERVAL:-2}s interval)"
|
|
}
|
|
|
|
report() {
|
|
# Stop the sampler (best-effort; container teardown reaps it regardless).
|
|
[ -r "$PIDF" ] && kill "$(cat "$PIDF")" 2>/dev/null || true
|
|
|
|
local peakanon peak src f anon file
|
|
peakanon=$(cat "$OUT" 2>/dev/null || echo "")
|
|
peak=""; src=""
|
|
for f in "${PEAKFILES[@]}"; do
|
|
if [ -r "$f" ]; then peak=$(cat "$f" 2>/dev/null || echo ""); src="$f"; break; fi
|
|
done
|
|
anon=""; file=""
|
|
if [ -r "$STAT" ]; then
|
|
anon=$(anon_bytes)
|
|
file=$(awk '/^file /{print $2}' "$STAT" 2>/dev/null || echo "")
|
|
fi
|
|
|
|
echo "::group::Container memory (ersatztv#406/#412, server-management#604)"
|
|
if [ -n "$peakanon" ] && [ "$peakanon" != "0" ]; then
|
|
printf 'PEAK ANON: %s MiB [%s bytes, sampled] <- size caps on THIS (the part that forces an OOM)\n' \
|
|
"$(mib "$peakanon")" "$peakanon"
|
|
else
|
|
echo "PEAK ANON: not sampled (sampler disabled or no data) -- use peak + end-of-job split below."
|
|
fi
|
|
[ -n "$peak" ] && printf 'peak incl. page cache (cache-inflated ceiling, do NOT size on this): %s MiB [%s]\n' \
|
|
"$(mib "$peak")" "$src"
|
|
[ -n "$anon" ] && printf 'end-of-job anon: %s MiB end-of-job file (reclaimable cache): %s MiB\n' \
|
|
"$(mib "$anon")" "$(mib "${file:-0}")"
|
|
echo "::endgroup::"
|
|
|
|
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
|
|
{
|
|
printf '**Container memory (test job):** '
|
|
if [ -n "$peakanon" ] && [ "$peakanon" != "0" ]; then
|
|
printf 'peak anon **%s MiB** *(size caps on this)*' "$(mib "$peakanon")"
|
|
else
|
|
printf 'peak anon *(not sampled)*'
|
|
fi
|
|
[ -n "$peak" ] && printf ' · peak incl. cache %s MiB *(inflated ceiling)*' "$(mib "$peak")"
|
|
[ -n "$anon" ] && printf ' · end-of-job anon %s MiB · file %s MiB' "$(mib "$anon")" "$(mib "${file:-0}")"
|
|
printf '\n'
|
|
} >> "$GITHUB_STEP_SUMMARY" || true
|
|
fi
|
|
}
|
|
|
|
case "${1:-}" in
|
|
start) start ;;
|
|
report) report ;;
|
|
__sample) sample_loop ;; # internal: detached sampler process
|
|
*) echo "usage: $0 {start|report}" >&2; exit 2 ;;
|
|
esac
|