523 lines
24 KiB
Bash
Executable file
523 lines
24 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
# Long-context admission dev capacity smoke.
|
|
#
|
|
# Milestone: model-group-long-context-admission, SDD S09 (capacity-smoke).
|
|
# Verifies, against the live dev provider pool (GX10 vLLM, OneXPlayer Lemonade,
|
|
# mac-mlx-vllm), that:
|
|
# normal-10 - normal capacity+1 concurrency reaches total in_flight and
|
|
# queues at least one request, then recovers to zero.
|
|
# mixed - long requests hold long slots while normal requests still
|
|
# dispatch without head-of-line blocking, then recover.
|
|
# all-long-slot-full - more long requests than total long slots queue, then
|
|
# dispatch as long slots free up, then recover to zero.
|
|
#
|
|
# This is a live-dependency auxiliary smoke. It does not stand up any process; it
|
|
# drives an already-running dev Edge OpenAI-compatible endpoint and reads the
|
|
# Control Plane status snapshot. Every reachability failure is recorded as an
|
|
# exact failed command (a verification blocker), never turned into a user-review
|
|
# request.
|
|
#
|
|
# Secrets: the bearer token is read from IOP_LONG_SMOKE_TOKEN only and is never
|
|
# printed to stdout/stderr or written to the out-dir.
|
|
#
|
|
# Options:
|
|
# --preflight run source/config/endpoint/identity/snapshot checks only
|
|
# --scenario <name> normal-10 | mixed | all-long-slot-full
|
|
# --config <path> edge config for `edge config check` (default configs/edge.yaml)
|
|
# --out-dir <dir> evidence output dir (default /tmp/iop-long-admission-smoke)
|
|
# -h | --help usage
|
|
#
|
|
# Environment (defaults target the dev-runtime inventory; override per host):
|
|
# IOP_LONG_SMOKE_BASE_URL Edge OpenAI-compatible base URL (default http://toki-labs.com:18083/v1)
|
|
# IOP_LONG_SMOKE_STATUS_URL Control Plane status URL (default runner-local edge status)
|
|
# IOP_LONG_SMOKE_STATUS_SSH optional ssh target used to curl STATUS_URL from a host
|
|
# that cannot reach it directly (e.g. toki@toki-labs.com)
|
|
# IOP_LONG_SMOKE_TOKEN optional bearer token for the OpenAI-compatible endpoint
|
|
# IOP_LONG_SMOKE_MODEL model alias (default qwen3.6:35b)
|
|
# IOP_LONG_SMOKE_LONG_CHARS synthetic long-prompt size in chars (default 720000 ~ 200k tokens)
|
|
# IOP_LONG_SMOKE_MAX_TOKENS completion max_tokens per request (default 64)
|
|
# IOP_LONG_SMOKE_POLL_SECS hard ceiling seconds for the status poll loop; the
|
|
# loop otherwise runs until requests finish (default 3600)
|
|
# IOP_LONG_SMOKE_EDGE_BIN optional prebuilt edge binary used for `config check`
|
|
# instead of `go run` (for runners without a go toolchain)
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
# REPO_ROOT is where `edge config check` runs. Defaults to the script's parent
|
|
# repo, but can be overridden (e.g. when the script is copied to /tmp on a runner
|
|
# whose checkout lives elsewhere).
|
|
REPO_ROOT="${IOP_LONG_SMOKE_REPO:-$(cd "$SCRIPT_DIR/.." && pwd)}"
|
|
|
|
BASE_URL="${IOP_LONG_SMOKE_BASE_URL:-http://toki-labs.com:18083/v1}"
|
|
STATUS_URL="${IOP_LONG_SMOKE_STATUS_URL:-http://127.0.0.1:18001/edges/edge-toki-labs-dev/status}"
|
|
STATUS_SSH="${IOP_LONG_SMOKE_STATUS_SSH:-}"
|
|
MODEL="${IOP_LONG_SMOKE_MODEL:-qwen3.6:35b}"
|
|
LONG_CHARS="${IOP_LONG_SMOKE_LONG_CHARS:-720000}"
|
|
MAX_TOKENS="${IOP_LONG_SMOKE_MAX_TOKENS:-64}"
|
|
POLL_SECS="${IOP_LONG_SMOKE_POLL_SECS:-3600}"
|
|
EDGE_BIN="${IOP_LONG_SMOKE_EDGE_BIN:-}"
|
|
|
|
CONFIG="configs/edge.yaml"
|
|
OUT_DIR="/tmp/iop-long-admission-smoke"
|
|
SCENARIO=""
|
|
DO_PREFLIGHT=0
|
|
|
|
# dev pool baseline (agent-test/dev/inventory.yaml).
|
|
PROVIDER_IDS=(gx10-vllm onexplayer-lemonade mac-mlx-vllm)
|
|
NORMAL_CAPACITY_TOTAL=9 # gx10=4 + onexplayer=3 + mac=2
|
|
LONG_SLOT_TOTAL=4 # gx10=1 + onexplayer=2 + mac=1
|
|
|
|
usage() {
|
|
sed -n '3,54p' "$0"
|
|
}
|
|
|
|
log() { echo "[long-admission-smoke] $*"; }
|
|
|
|
die() {
|
|
log "ERROR: $*"
|
|
exit 1
|
|
}
|
|
|
|
# ── argument parsing ─────────────────────────────────────────────────────────
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--preflight) DO_PREFLIGHT=1; shift ;;
|
|
--scenario) SCENARIO="${2:?--scenario requires a value}"; shift 2 ;;
|
|
--config) CONFIG="${2:?--config requires a value}"; shift 2 ;;
|
|
--out-dir) OUT_DIR="${2:?--out-dir requires a value}"; shift 2 ;;
|
|
-h|--help) usage; exit 0 ;;
|
|
*) die "unknown argument: $1 (see --help)" ;;
|
|
esac
|
|
done
|
|
|
|
if [ "$DO_PREFLIGHT" -eq 0 ] && [ -z "$SCENARIO" ]; then
|
|
die "nothing to do: pass --preflight and/or --scenario <name>"
|
|
fi
|
|
|
|
command -v curl >/dev/null 2>&1 || die "curl is required"
|
|
command -v jq >/dev/null 2>&1 || die "jq is required"
|
|
|
|
mkdir -p "$OUT_DIR"
|
|
RUN_STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
|
|
log "out-dir=$OUT_DIR run=$RUN_STAMP base_url=$BASE_URL"
|
|
|
|
# ── helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
# curl auth args, built once. Expanded with the ${arr[@]+...} idiom so an empty
|
|
# array survives `set -u` on the dev runner's macOS bash 3.2, which also lacks
|
|
# `mapfile` (do not reintroduce bash-4-only builtins here).
|
|
AUTH_ARGS=()
|
|
if [ -n "${IOP_LONG_SMOKE_TOKEN:-}" ]; then
|
|
AUTH_ARGS=(-H "Authorization: Bearer ${IOP_LONG_SMOKE_TOKEN}")
|
|
fi
|
|
|
|
# fetch_status <out-file>: capture the Control Plane status JSON.
|
|
fetch_status() {
|
|
local out="$1"
|
|
if [ -n "$STATUS_SSH" ]; then
|
|
ssh -o BatchMode=yes -o ConnectTimeout=10 "$STATUS_SSH" \
|
|
"curl -fsS --connect-timeout 10 '$STATUS_URL'" >"$out" 2>"$out.err"
|
|
else
|
|
curl -fsS --connect-timeout 10 "$STATUS_URL" >"$out" 2>"$out.err"
|
|
fi
|
|
}
|
|
|
|
# provider_snapshots_jq: normalize provider snapshots from either the edge status
|
|
# response shape or a fleet node list into a flat array.
|
|
SNAP_JQ='[.. | objects | select(has("provider_snapshots")) | .provider_snapshots[]?]'
|
|
|
|
# summarize_snapshot <status-json> -> prints a compact per-provider line and totals.
|
|
summarize_snapshot() {
|
|
local file="$1"
|
|
jq -r "
|
|
${SNAP_JQ} as \$s
|
|
| (\$s | map(.in_flight // 0) | add // 0) as \$inflight
|
|
| (\$s | map(.queued // 0) | add // 0) as \$queued
|
|
| (\$s | map(.long_in_flight // 0) | add // 0) as \$loninflight
|
|
| (\$s | map(.long_queued // 0) | add // 0) as \$lonqueued
|
|
| (\$s | map(has(\"long_in_flight\")) | any) as \$haslong
|
|
| (\$s[] | \" provider=\(.id // .adapter // \"?\") capacity=\(.capacity // 0) in_flight=\(.in_flight // 0) queued=\(.queued // 0) long_in_flight=\(.long_in_flight // \"n/a\") long_queued=\(.long_queued // \"n/a\") health=\(.health // \"?\")\"),
|
|
\" TOTAL in_flight=\(\$inflight) queued=\(\$queued) long_in_flight=\(if \$haslong then \$loninflight else \"n/a\" end) long_queued=\(if \$haslong then \$lonqueued else \"n/a\" end)\"
|
|
" "$file" 2>/dev/null || echo " (unparseable status json)"
|
|
}
|
|
|
|
# totals_from_snapshot <status-json> <key> -> integer sum, or empty when key absent everywhere.
|
|
totals_from_snapshot() {
|
|
local file="$1" key="$2"
|
|
jq -r "${SNAP_JQ} as \$s
|
|
| if (\$s | map(has(\"${key}\")) | any) then (\$s | map(.${key} // 0) | add // 0) else \"\" end" \
|
|
"$file" 2>/dev/null
|
|
}
|
|
|
|
# build the synthetic long prompt file once (kept in out-dir, not repo).
|
|
LONG_PROMPT_FILE="$OUT_DIR/long_prompt_${RUN_STAMP}.txt"
|
|
ensure_long_prompt() {
|
|
[ -f "$LONG_PROMPT_FILE" ] && return 0
|
|
# Repeat a filler sentence to LONG_CHARS. Synthetic text is enough; a
|
|
# tokenizer-perfect fixture is intentionally out of scope.
|
|
local unit="Long context admission smoke synthetic filler segment. "
|
|
local buf=""
|
|
while [ "${#buf}" -lt "$LONG_CHARS" ]; do
|
|
buf="${buf}${unit}"
|
|
done
|
|
printf '%s' "${buf:0:$LONG_CHARS}" >"$LONG_PROMPT_FILE"
|
|
log "synthetic long prompt: ${LONG_CHARS} chars -> $LONG_PROMPT_FILE"
|
|
}
|
|
|
|
# build a chat completion request body file for a given kind (normal|long).
|
|
# $1=kind $2=idx $3=out-body-file
|
|
build_body() {
|
|
local kind="$1" idx="$2" out="$3" content
|
|
if [ "$kind" = "long" ]; then
|
|
ensure_long_prompt
|
|
content="$(cat "$LONG_PROMPT_FILE")
|
|
Summarize the above in one short sentence. request_idx=${idx}"
|
|
else
|
|
content="Reply with the single word OK. request_idx=${idx}"
|
|
fi
|
|
jq -n --arg model "$MODEL" --arg content "$content" --argjson maxt "$MAX_TOKENS" \
|
|
'{model:$model, max_tokens:$maxt, stream:false, messages:[{role:"user", content:$content}]}' \
|
|
>"$out"
|
|
}
|
|
|
|
# fire one request in the background; write HTTP code + body to out prefix.
|
|
# $1=kind $2=idx $3=out-prefix
|
|
fire_request() {
|
|
local kind="$1" idx="$2" prefix="$3"
|
|
local body="$prefix.req.json"
|
|
build_body "$kind" "$idx" "$body"
|
|
curl -sS -o "$prefix.resp.json" -w '%{http_code}' \
|
|
--connect-timeout 15 --max-time 1800 \
|
|
-H "Content-Type: application/json" \
|
|
${AUTH_ARGS[@]+"${AUTH_ARGS[@]}"} \
|
|
-X POST "$BASE_URL/chat/completions" \
|
|
--data-binary "@$body" >"$prefix.code" 2>"$prefix.curl.err" || true
|
|
}
|
|
|
|
# poll status repeatedly into a peaks file while a scenario runs. The poll loop
|
|
# runs until the caller creates "$OUT_DIR/<label>.polling_done" (so peaks are
|
|
# captured for the full request duration, however slow), bounded by POLL_SECS as
|
|
# a hard safety ceiling.
|
|
# $1=label $2=peaks-out-file
|
|
poll_peaks() {
|
|
local label="$1" peaks="$2"
|
|
local deadline=$((SECONDS + POLL_SECS))
|
|
local doneflag="$OUT_DIR/${label}.polling_done"
|
|
local snap="$OUT_DIR/${label}_poll_${RUN_STAMP}.json"
|
|
local peak_inflight=0 peak_queued=0 peak_loninflight=0 peak_lonqueued=0 samples=0
|
|
local long_visible=0
|
|
rm -f "$doneflag"
|
|
while (( SECONDS < deadline )) && [ ! -f "$doneflag" ]; do
|
|
if fetch_status "$snap"; then
|
|
samples=$((samples + 1))
|
|
local inflight queued lon lonq
|
|
inflight="$(totals_from_snapshot "$snap" in_flight)"; inflight="${inflight:-0}"
|
|
queued="$(totals_from_snapshot "$snap" queued)"; queued="${queued:-0}"
|
|
lon="$(totals_from_snapshot "$snap" long_in_flight)"
|
|
lonq="$(totals_from_snapshot "$snap" long_queued)"
|
|
[ -n "$lon" ] && long_visible=1
|
|
(( inflight > peak_inflight )) && peak_inflight=$inflight
|
|
(( queued > peak_queued )) && peak_queued=$queued
|
|
[ -n "$lon" ] && (( lon > peak_loninflight )) && peak_loninflight=$lon
|
|
[ -n "$lonq" ] && (( lonq > peak_lonqueued )) && peak_lonqueued=$lonq
|
|
fi
|
|
sleep 3
|
|
done
|
|
{
|
|
echo "label=$label samples=$samples"
|
|
echo "peak_in_flight=$peak_inflight"
|
|
echo "peak_queued=$peak_queued"
|
|
if [ "$long_visible" -eq 1 ]; then
|
|
echo "peak_long_in_flight=$peak_loninflight"
|
|
echo "peak_long_queued=$peak_lonqueued"
|
|
else
|
|
echo "peak_long_in_flight=n/a (Control Plane status view does not expose long fields)"
|
|
echo "peak_long_queued=n/a (Control Plane status view does not expose long fields)"
|
|
fi
|
|
} >"$peaks"
|
|
cat "$peaks"
|
|
}
|
|
|
|
# capture a final snapshot and assert recovery to zero.
|
|
# $1=label -> returns non-zero when recovery assertion fails.
|
|
assert_recovery() {
|
|
local label="$1"
|
|
local snap="$OUT_DIR/${label}_final_${RUN_STAMP}.json"
|
|
local ok=1
|
|
if ! fetch_status "$snap"; then
|
|
log "$label: FAILED to fetch final status (see $snap.err)"
|
|
return 1
|
|
fi
|
|
log "$label final snapshot:"
|
|
summarize_snapshot "$snap"
|
|
local inflight queued lon lonq
|
|
inflight="$(totals_from_snapshot "$snap" in_flight)"; inflight="${inflight:-0}"
|
|
queued="$(totals_from_snapshot "$snap" queued)"; queued="${queued:-0}"
|
|
lon="$(totals_from_snapshot "$snap" long_in_flight)"
|
|
lonq="$(totals_from_snapshot "$snap" long_queued)"
|
|
[ "$inflight" = "0" ] || { log "$label: in_flight not recovered ($inflight)"; ok=0; }
|
|
[ "$queued" = "0" ] || { log "$label: queued not recovered ($queued)"; ok=0; }
|
|
if [ -n "$lon" ]; then
|
|
[ "$lon" = "0" ] || { log "$label: long_in_flight not recovered ($lon)"; ok=0; }
|
|
[ "${lonq:-0}" = "0" ] || { log "$label: long_queued not recovered ($lonq)"; ok=0; }
|
|
else
|
|
log "$label: long_in_flight/long_queued not exposed by status view; verify recovery via Edge dispatch logs (context_class/queue_reason)."
|
|
fi
|
|
[ "$ok" -eq 1 ] || return 1
|
|
log "$label: recovery OK (in_flight=0, queued=0)"
|
|
return 0
|
|
}
|
|
|
|
# read_kv <kv-file> <key>: print the value after "key=" for the first matching
|
|
# line. The value may be a number or an "n/a ..." note.
|
|
read_kv() {
|
|
local file="$1" key="$2"
|
|
sed -n "s/^${key}=//p" "$file" 2>/dev/null | head -1
|
|
}
|
|
|
|
# assert_peak <peaks-file> <key> <op> <bound> <label>: assert a numeric peak
|
|
# field satisfies a comparison. <op> is a test(1) integer operator (-ge/-le/...).
|
|
# Returns non-zero when the field is missing, non-numeric, or fails the bound.
|
|
assert_peak() {
|
|
local file="$1" key="$2" op="$3" bound="$4" label="$5" val
|
|
val="$(read_kv "$file" "$key")"
|
|
if ! [[ "$val" =~ ^[0-9]+$ ]]; then
|
|
log "$label: FAIL peak $key not a number ('${val:-<absent>}'); cannot assert $key $op $bound"
|
|
return 1
|
|
fi
|
|
if [ "$val" "$op" "$bound" ]; then
|
|
log "$label: peak $key=$val ($op $bound) OK"
|
|
return 0
|
|
fi
|
|
log "$label: FAIL peak $key=$val (require $key $op $bound)"
|
|
return 1
|
|
}
|
|
|
|
# fire N requests of a kind, wait for all, print success/http summary.
|
|
# $1=kind $2=count $3=label
|
|
fire_and_wait() {
|
|
local kind="$1" count="$2" label="$3"
|
|
local pids=() i
|
|
log "$label: firing $count $kind request(s) to $BASE_URL/chat/completions"
|
|
for ((i = 1; i <= count; i++)); do
|
|
fire_request "$kind" "$i" "$OUT_DIR/${label}_${kind}_${i}_${RUN_STAMP}" &
|
|
pids+=("$!")
|
|
done
|
|
# poll while they run.
|
|
poll_peaks "$label" "$OUT_DIR/${label}_peaks_${RUN_STAMP}.txt" &
|
|
local poll_pid=$!
|
|
for p in "${pids[@]}"; do wait "$p" 2>/dev/null || true; done
|
|
touch "$OUT_DIR/${label}.polling_done"
|
|
wait "$poll_pid" 2>/dev/null || true
|
|
local ok=0 total=0
|
|
for ((i = 1; i <= count; i++)); do
|
|
total=$((total + 1))
|
|
local code
|
|
code="$(cat "$OUT_DIR/${label}_${kind}_${i}_${RUN_STAMP}.code" 2>/dev/null || echo "000")"
|
|
[ "$code" = "200" ] && ok=$((ok + 1))
|
|
done
|
|
log "$label: $kind http_200=$ok/$total"
|
|
if [ "$ok" -ne "$total" ]; then
|
|
log "$label: FAIL $kind http_200=$ok/$total (require $total/$total)"
|
|
return 1
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
# ── preflight ────────────────────────────────────────────────────────────────
|
|
run_preflight() {
|
|
log "=== PREFLIGHT ==="
|
|
local pre="$OUT_DIR/preflight_${RUN_STAMP}.txt"
|
|
local ok=1
|
|
|
|
# Run `edge config check` in its own capture so its exit status gates
|
|
# preflight instead of being swallowed by the tee pipeline. A prebuilt edge
|
|
# binary (IOP_LONG_SMOKE_EDGE_BIN) replaces `go run` on runners without go.
|
|
local -a cfg_cmd
|
|
if [ -n "$EDGE_BIN" ]; then
|
|
cfg_cmd=("$EDGE_BIN" config check --config "$CONFIG")
|
|
else
|
|
cfg_cmd=(go run ./apps/edge/cmd/edge config check --config "$CONFIG")
|
|
fi
|
|
local cfg_out cfg_rc=0
|
|
if [ -n "$EDGE_BIN" ] && [ ! -x "$EDGE_BIN" ]; then
|
|
cfg_out="edge binary not executable: $EDGE_BIN"
|
|
cfg_rc=1
|
|
else
|
|
cfg_out="$(cd "$REPO_ROOT" && "${cfg_cmd[@]}" 2>&1)" || cfg_rc=$?
|
|
fi
|
|
{
|
|
echo "run=$RUN_STAMP"
|
|
echo "workdir=$REPO_ROOT"
|
|
echo "base_url=$BASE_URL"
|
|
echo "status_url=$STATUS_URL status_ssh=${STATUS_SSH:-<direct>}"
|
|
echo "config=$CONFIG"
|
|
echo
|
|
echo "## source state"
|
|
echo "\$ git -C $REPO_ROOT rev-parse HEAD"
|
|
git -C "$REPO_ROOT" rev-parse HEAD 2>&1 || true
|
|
echo "\$ git -C $REPO_ROOT status --short"
|
|
git -C "$REPO_ROOT" status --short 2>&1 || true
|
|
echo
|
|
echo "## config check"
|
|
echo "\$ ${cfg_cmd[*]}"
|
|
echo "$cfg_out"
|
|
if [ "$cfg_rc" -eq 0 ]; then echo "config check OK"; else echo "config check FAILED (rc=$cfg_rc)"; fi
|
|
} | tee "$pre"
|
|
|
|
if [ "$cfg_rc" -ne 0 ]; then
|
|
log " BLOCKER: edge config check failed (rc=$cfg_rc, config=$CONFIG)"
|
|
ok=0
|
|
fi
|
|
|
|
log "endpoint reachability: $BASE_URL/models"
|
|
if curl -fsS --connect-timeout 10 ${AUTH_ARGS[@]+"${AUTH_ARGS[@]}"} "$BASE_URL/models" \
|
|
>"$OUT_DIR/models_${RUN_STAMP}.json" 2>"$OUT_DIR/models_${RUN_STAMP}.err"; then
|
|
log " /models OK"
|
|
jq -r '.data[]?.id' "$OUT_DIR/models_${RUN_STAMP}.json" 2>/dev/null | sed 's/^/ model=/' || true
|
|
else
|
|
log " BLOCKER: /models unreachable. exact command:"
|
|
log " curl -fsS --connect-timeout 10 $BASE_URL/models"
|
|
ok=0
|
|
fi
|
|
|
|
log "status reachability: $STATUS_URL"
|
|
local snap="$OUT_DIR/preflight_status_${RUN_STAMP}.json"
|
|
if fetch_status "$snap"; then
|
|
log " status OK -> $snap"
|
|
summarize_snapshot "$snap"
|
|
# every expected provider identity must be present and healthy.
|
|
local pid phealth
|
|
for pid in "${PROVIDER_IDS[@]}"; do
|
|
if ! jq -e "${SNAP_JQ} | map(.id // .adapter) | index(\"$pid\")" "$snap" >/dev/null 2>&1; then
|
|
log " BLOCKER: provider identity not found in status snapshot: $pid"
|
|
ok=0
|
|
continue
|
|
fi
|
|
phealth="$(jq -r "${SNAP_JQ} | map(select((.id // .adapter) == \"$pid\")) | (.[0].health // \"?\")" "$snap" 2>/dev/null || echo "?")"
|
|
if [ "$phealth" = "healthy" ]; then
|
|
log " provider present+healthy: $pid"
|
|
else
|
|
log " BLOCKER: provider not healthy: $pid (health=$phealth)"
|
|
ok=0
|
|
fi
|
|
done
|
|
# summed provider capacity must match the documented normal baseline.
|
|
local cap_total
|
|
cap_total="$(totals_from_snapshot "$snap" capacity)"
|
|
if [ -n "$cap_total" ] && [ "$cap_total" = "$NORMAL_CAPACITY_TOTAL" ]; then
|
|
log " capacity total OK: $cap_total (== $NORMAL_CAPACITY_TOTAL)"
|
|
else
|
|
log " BLOCKER: capacity total '${cap_total:-<absent>}' != expected $NORMAL_CAPACITY_TOTAL"
|
|
ok=0
|
|
fi
|
|
if [ -z "$(totals_from_snapshot "$snap" long_in_flight)" ]; then
|
|
log " NOTE: status view does not expose long_in_flight/long_queued; long-slot recovery must be read from Edge dispatch logs (context_class/queue_reason) or a follow-up that adds long fields to the Control Plane status view."
|
|
fi
|
|
else
|
|
log " BLOCKER: status unreachable. exact command:"
|
|
if [ -n "$STATUS_SSH" ]; then
|
|
log " ssh $STATUS_SSH curl -fsS --connect-timeout 10 $STATUS_URL"
|
|
else
|
|
log " curl -fsS --connect-timeout 10 $STATUS_URL"
|
|
fi
|
|
ok=0
|
|
fi
|
|
|
|
log "expected baseline: normal_capacity_total=$NORMAL_CAPACITY_TOTAL long_slot_total=$LONG_SLOT_TOTAL"
|
|
if [ "$ok" -eq 1 ]; then
|
|
log "=== PREFLIGHT OK ==="
|
|
return 0
|
|
fi
|
|
log "=== PREFLIGHT BLOCKED (see out-dir; blockers are verification blockers, not user-review) ==="
|
|
return 3
|
|
}
|
|
|
|
# ── scenarios ────────────────────────────────────────────────────────────────
|
|
run_scenario() {
|
|
local name="$1"
|
|
local rc=0
|
|
case "$name" in
|
|
normal-10)
|
|
log "=== SCENARIO normal-10 (expect peak in_flight>=$NORMAL_CAPACITY_TOTAL, queued>=1) ==="
|
|
local peaks="$OUT_DIR/normal-10_peaks_${RUN_STAMP}.txt"
|
|
fire_and_wait normal 10 "normal-10" || rc=1
|
|
assert_peak "$peaks" peak_in_flight -ge "$NORMAL_CAPACITY_TOTAL" "normal-10" || rc=1
|
|
assert_peak "$peaks" peak_queued -ge 1 "normal-10" || rc=1
|
|
assert_recovery "normal-10" || rc=1
|
|
;;
|
|
mixed)
|
|
log "=== SCENARIO mixed (long slots held; normal must not head-of-line block) ==="
|
|
local peaks="$OUT_DIR/mixed_peaks_${RUN_STAMP}.txt"
|
|
# Fill all long slots, and pile normal requests behind them.
|
|
local i pids=()
|
|
for ((i = 1; i <= LONG_SLOT_TOTAL; i++)); do
|
|
fire_request long "$i" "$OUT_DIR/mixed_long_${i}_${RUN_STAMP}" &
|
|
pids+=("$!")
|
|
done
|
|
for ((i = 1; i <= 6; i++)); do
|
|
fire_request normal "$i" "$OUT_DIR/mixed_normal_${i}_${RUN_STAMP}" &
|
|
pids+=("$!")
|
|
done
|
|
poll_peaks "mixed" "$peaks" &
|
|
local poll_pid=$!
|
|
for p in "${pids[@]}"; do wait "$p" 2>/dev/null || true; done
|
|
touch "$OUT_DIR/mixed.polling_done"
|
|
wait "$poll_pid" 2>/dev/null || true
|
|
local lok=0 nok=0
|
|
for ((i = 1; i <= LONG_SLOT_TOTAL; i++)); do
|
|
[ "$(cat "$OUT_DIR/mixed_long_${i}_${RUN_STAMP}.code" 2>/dev/null || echo 000)" = "200" ] && lok=$((lok + 1))
|
|
done
|
|
for ((i = 1; i <= 6; i++)); do
|
|
[ "$(cat "$OUT_DIR/mixed_normal_${i}_${RUN_STAMP}.code" 2>/dev/null || echo 000)" = "200" ] && nok=$((nok + 1))
|
|
done
|
|
log "mixed: long http_200=$lok/$LONG_SLOT_TOTAL normal http_200=$nok/6 (head-of-line blocking would show normal stalling behind full long slots)"
|
|
[ "$lok" -eq "$LONG_SLOT_TOTAL" ] || { log "mixed: FAIL long http_200=$lok/$LONG_SLOT_TOTAL"; rc=1; }
|
|
[ "$nok" -eq 6 ] || { log "mixed: FAIL normal http_200=$nok/6"; rc=1; }
|
|
# long_in_flight is optional in the Control Plane status view: assert
|
|
# only when visible, otherwise defer to Edge dispatch log evidence.
|
|
if [[ "$(read_kv "$peaks" peak_long_in_flight)" =~ ^[0-9]+$ ]]; then
|
|
assert_peak "$peaks" peak_long_in_flight -ge "$LONG_SLOT_TOTAL" "mixed" || rc=1
|
|
else
|
|
log "mixed: long_in_flight not exposed by status view; capture Edge dispatch log (context_class) as review evidence."
|
|
fi
|
|
assert_recovery "mixed" || rc=1
|
|
;;
|
|
all-long-slot-full)
|
|
local n=$((LONG_SLOT_TOTAL + 2))
|
|
log "=== SCENARIO all-long-slot-full (fire $n long > $LONG_SLOT_TOTAL slots; expect queue then dispatch) ==="
|
|
local peaks="$OUT_DIR/all-long-slot-full_peaks_${RUN_STAMP}.txt"
|
|
fire_and_wait long "$n" "all-long-slot-full" || rc=1
|
|
# only long requests are in flight, so total in_flight must not exceed
|
|
# the long-slot total; at least one long request must have queued.
|
|
assert_peak "$peaks" peak_in_flight -le "$LONG_SLOT_TOTAL" "all-long-slot-full" || rc=1
|
|
assert_peak "$peaks" peak_queued -ge 1 "all-long-slot-full" || rc=1
|
|
if [[ "$(read_kv "$peaks" peak_long_queued)" =~ ^[0-9]+$ ]]; then
|
|
assert_peak "$peaks" peak_long_queued -ge 1 "all-long-slot-full" || rc=1
|
|
else
|
|
log "all-long-slot-full: long_queued not exposed by status view; capture Edge dispatch log (queue_reason=long_context_capacity_full) as review evidence."
|
|
fi
|
|
assert_recovery "all-long-slot-full" || rc=1
|
|
;;
|
|
*)
|
|
die "unknown scenario: $name (normal-10 | mixed | all-long-slot-full)"
|
|
;;
|
|
esac
|
|
return "$rc"
|
|
}
|
|
|
|
# ── main ─────────────────────────────────────────────────────────────────────
|
|
rc=0
|
|
if [ "$DO_PREFLIGHT" -eq 1 ]; then
|
|
run_preflight || rc=$?
|
|
fi
|
|
if [ -n "$SCENARIO" ]; then
|
|
if [ "$rc" -ne 0 ]; then
|
|
die "preflight blocked (rc=$rc); not running scenario '$SCENARIO'"
|
|
fi
|
|
run_scenario "$SCENARIO" || rc=$?
|
|
fi
|
|
|
|
log "done rc=$rc evidence=$OUT_DIR"
|
|
exit "$rc"
|