Files
qwen-code/.github/scripts/check-disk-floor.sh
T
a5fec03684 fix(ci): fail Java lanes before disk exhaustion (#16068)
* fix(ci): fail Java lanes before disk exhaustion

Co-authored-by: Qwen-Coder <[email protected]>

* fix(ci): gate the MySQL pool lane instead of a hosted job

The disk floor gate landed in hosted-harness-mysql, which runs on
ubuntu-latest, so `runner.environment == 'self-hosted'` is never true
there and the step was dead code. The job that actually shares the ECS
pool disk - mysql-integration, two Maven suites plus a per-leg MySQL
container - stayed ungated.

Move the step to mysql-integration, immediately after its checkout so
the sdk-java-workflow pin still holds. This also clears the three
Hosted real-process gates pins, which reject a conditioned step among
the setup steps that precede managed_dependencies in
hosted-harness-mysql, and reflow the new test to the repo's prettier
style.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmvya43o17i

* style(ci): drop the blank line left behind in hosted-harness-mysql

Moving the disk floor gate out of hosted-harness-mysql left a stray blank
line between its checkout and 'Set up Java'. Restoring it makes the whole
workflow diff additive on mysql-integration only, so 'hosted-only jobs are
unchanged' holds byte for byte and an A/B census of that job reads clean.

Co-authored-by: Qwen-Coder <[email protected]>

* ci(sdk-java): record the real workflow size after the disk-floor gates

The six #16003 disk-floor gate steps grow sdk-java.yml from 154074 to
155650 bytes. main had already drifted 3742 bytes past the recorded
150332 — inside the 4096 allowance, but it left only 354 bytes of ratchet
headroom, so this real growth tripped Lint & Static.

Record the file's actual size rather than shrinking the gates: each one is
a three-line step calling .github/scripts/check-disk-floor.sh, and the
workflow test pins all six gated jobs.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmw02ewzoa2

* fix(ci): derive the disk-floor corpus from runs-on and narrow the broker claim

Address the two blocking review findings on the Java disk-floor gate:

R1-1: the meta-test skipped any job without the gate and compared a closed
six-name list, so a newly pool-routed lane that forgot the gate stayed green,
and a neutered gate (continue-on-error) kept every assertion true. Derive the
corpus from each job's runs-on (same hazard as the setup-java corpus), require
gateIndex > -1 per pool-capable lane, and assert the gate carries no
continue-on-error. The derived set is the same six jobs today. Verified by
mutation: adding continue-on-error to a gate reddens it, and renaming a gate
(ungated pool job) reddens it with 'expected -1 to be greater than -1'.

R1-2: the step comment claimed RUNNER_TEMP covers the broker store, but the
absolute free-KiB floor cannot express the broker's used-ratio refusal (a
952 GiB volume at 94% used still has ample free bytes). Narrow the comment to
the gate's real scope (near-full filesystem / inode exhaustion, #10035) and
state the broker disk-threshold investigation stays out of scope, matching the
PR description. Bump .size-baseline for the comment change (155650 -> 155888).

sdk-java workflow meta-test 117 passed / 5 skipped; eslint exit 0, prettier
clean; check-workflow-size.sh passes.

* fix(ci): calibrate the Java disk floors and fail closed in the meta-test

Review round on this PR found the gate could not fire in the two shapes it
exists for, and that the meta-test could go green through the same drift it
was added to prevent.

R1-2: every lane ran the shared 2 GiB / 100k-inode default. The #16003
incident was a used-ratio refusal — the broker reported 0.94 while the
952 GiB volume still had tens of GiB free — so no absolute floor can express
that threshold, and 2 GiB is below what these lanes write: a host with 4 GiB
free passed the gate and then died mid-suite. Give the five heavy lanes the
repo's build-sized 8 GiB floor, leave flyway-migrations and the shared value
alone, and add an opt-in DISK_FLOOR_MAX_USED_PCT arm to check-disk-floor.sh
for the two broker lanes (90%).

R2-1/R2-2: the corpus came from `runs-on` text and skipped jobs outside it,
so a lane routed through an expression or a job output shipped ungated while
the suite stayed green, and nothing asserted the hosted-only jobs carry no
gate. Fail closed: an explicit hosted-only allowlist, a gate required for
every other job, a total-count assertion, and a negative assertion on those
two jobs. Pin the per-lane floors so dropping one is a test failure.

Verified: sdk-java-workflow suite 117 passed, 5 skipped; it goes red when a
gate is deleted, when one is added to a hosted job, and when a floor is
removed. check-disk-floor suite 12 passed, including four new ratio cases.

---------

Co-authored-by: Qwen-Coder <[email protected]>
Co-authored-by: yiliang114 <[email protected]>
Co-authored-by: qwen-code patrol <[email protected]>
2026-11-04 17:57:04 +08:00

129 lines
5.6 KiB
Bash
Executable File

#!/usr/bin/env bash
# Pre-job disk floor gate for self-hosted Linux runners (#10035).
#
# ENOSPC failed `npm ci` mid-install on a saturated host while the workspace
# cleanup timer skipped busy runners, so the job died mid-suite and the host
# looked healthy by the time anyone ran `df`. Failing BEFORE the heavy step
# turns that into a clear, retryable gate failure instead of a corrupted
# half-run; a re-run lands on a host with headroom.
# Checkout populates this script first, so the gate cannot protect checkout.
#
# Usage: check-disk-floor.sh [DIR ...]
# With no directories, checks GITHUB_WORKSPACE and RUNNER_TEMP.
#
# The OS temp directory (TMPDIR, defaulting to /tmp) is ALWAYS checked in
# addition to the caller's directories (deduplicated): on 2026-10-31 the hz
# fleet's /tmp tmpfs hit 100% inodes while the workspace and RUNNER_TEMP sat
# on a data disk with millions free, so every gate was green and the jobs
# died on mkdtemp ENOSPC mid-suite. os.tmpdir() writes (node, surefire,
# tsx, git subprocess temp files) do not follow RUNNER_TEMP.
#
# Floors, checked per directory's filesystem (env-overridable):
# DISK_FLOOR_MIN_FREE_KB default 2097152 (2 GiB)
# DISK_FLOOR_MIN_FREE_INODES default 100000
# DISK_FLOOR_MAX_USED_PCT unset (disabled); 1-100 percent ceiling
#
# The percent ceiling exists for the RocketMQ broker lanes: the broker refuses
# writes on its own used-ratio rule (CL/CQ/INDEX 0.94 in the #16003 incident)
# while the volume still reports tens of GiB free, so absolute free bytes
# cannot express that threshold. It is opt-in and per job; the shared default
# stays absolute so one lane's calibration cannot move another lane's gate.
#
# Every check emits a DISKFLOOR sample tagged with runner and run ids so a
# breached gate can be correlated with the GitHub job that hit it.
set -u
MIN_FREE_KB="${DISK_FLOOR_MIN_FREE_KB:-2097152}"
MIN_FREE_INODES="${DISK_FLOOR_MIN_FREE_INODES:-100000}"
MAX_USED_PCT="${DISK_FLOOR_MAX_USED_PCT:-}"
validate_floor_override() {
local name="$1"
local value="$2"
if ! [[ "$value" =~ ^[0-9]{1,18}$ ]]; then
echo "::error::${name} must be a non-negative integer of at most 18 digits"
exit 1
fi
}
validate_floor_override DISK_FLOOR_MIN_FREE_KB "$MIN_FREE_KB"
validate_floor_override DISK_FLOOR_MIN_FREE_INODES "$MIN_FREE_INODES"
validate_percent_override() {
local name="$1"
local value="$2"
if [ -z "$value" ]; then
return 0
fi
if ! [[ "$value" =~ ^[0-9]{1,3}$ ]] || [ "$value" -lt 1 ] || [ "$value" -gt 100 ]; then
echo "::error::${name} must be a percentage between 1 and 100"
exit 1
fi
}
validate_percent_override DISK_FLOOR_MAX_USED_PCT "$MAX_USED_PCT"
TAG="runner[${RUNNER_NAME:-unknown}] run[${GITHUB_RUN_ID:-local}/${GITHUB_RUN_ATTEMPT:-1}] job[${GITHUB_JOB:-check-disk-floor}]"
if [ "$#" -eq 0 ]; then
set -- "${GITHUB_WORKSPACE:-.}" "${RUNNER_TEMP:-/tmp}"
fi
# Always include the OS temp directory: it is a distinct filesystem from the
# caller's directories on this fleet, and its exhaustion is exactly the
# failure class this gate exists to catch before the heavy step.
os_tmp="${TMPDIR:-/tmp}"
for existing in "$@"; do
if [ "$existing" = "$os_tmp" ]; then
os_tmp=''
break
fi
done
if [ -n "$os_tmp" ]; then
set -- "$@" "$os_tmp"
fi
status=0
for dir in "$@"; do
if [ ! -d "$dir" ]; then
echo "::warning::disk floor check skipped for missing directory: ${dir}"
continue
fi
# df -kP is POSIX-portable: one line per filesystem, sizes in KiB, mount
# point last. With -i the same layout reports inodes instead of blocks.
space_line="$(df -kP "$dir" 2>/dev/null | tail -n 1 || true)"
inode_line="$(df -kPi "$dir" 2>/dev/null | tail -n 1 || true)"
avail_kb="$(awk '{print $4}' <<< "$space_line")"
used_pct="$(awk '{print $5}' <<< "$space_line" | tr -d '%')"
mount="$(awk '{for (i = 6; i <= NF; i++) printf "%s%s", $i, (i < NF ? " " : ""); print ""}' <<< "$space_line")"
free_inodes="$(awk '{print $4}' <<< "$inode_line")"
case "$avail_kb" in '' | *[!0-9]*) avail_kb='' ;; esac
case "$free_inodes" in '' | *[!0-9]*) free_inodes='' ;; esac
case "$used_pct" in '' | *[!0-9]*) used_pct='' ;; esac
echo "DISKFLOOR $(date -u +%Y-%m-%dT%H:%M:%SZ) ${TAG} dir[${dir}] fs[${mount}] avail_kb[${avail_kb:-unknown}] floor_kb[${MIN_FREE_KB}] used_pct[${used_pct:-unknown}] max_used_pct[${MAX_USED_PCT:-unset}] free_inodes[${free_inodes:-unknown}] floor_inodes[${MIN_FREE_INODES}]"
if [ -z "$avail_kb" ] && [ -z "$free_inodes" ]; then
echo "::warning::could not read disk usage for ${dir}; letting the job proceed"
continue
fi
if [ -n "$avail_kb" ] && [ "$avail_kb" -lt "$MIN_FREE_KB" ]; then
echo "::error::Disk floor breached on ${RUNNER_NAME:-unknown}: ${dir} has ${avail_kb} KiB free, below the ${MIN_FREE_KB} KiB floor. Failing before the heavy step instead of dying on ENOSPC mid-run — re-run the job to land on a host with headroom."
status=1
fi
if [ -n "$MAX_USED_PCT" ] && [ -n "$used_pct" ] && [ "$used_pct" -ge "$MAX_USED_PCT" ]; then
echo "::error::Disk floor breached on ${RUNNER_NAME:-unknown}: ${dir} is ${used_pct}% used, at or above the ${MAX_USED_PCT}% ceiling. The RocketMQ broker refuses writes on its own used-ratio rule while free bytes still look healthy (#16003) — re-run the job to land on a host with headroom."
status=1
fi
if [ -n "$free_inodes" ] && [ "$free_inodes" -lt "$MIN_FREE_INODES" ]; then
echo "::error::Disk floor breached on ${RUNNER_NAME:-unknown}: ${dir} has ${free_inodes} free inodes, below the ${MIN_FREE_INODES} floor. Failing before the heavy step instead of dying on ENOSPC mid-run — re-run the job to land on a host with headroom."
status=1
fi
done
exit "$status"