From a64afe05e39b7343ecb3002519fba447f6546f48 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 12 Oct 2026 13:12:23 +0000 Subject: [PATCH] Add an isolated pgbench entry for the U7 database baseline Why: Checklist B6 needs a repeatable database-only baseline that cannot reach production ports or instances. Evidence: run_pgbench.sh --self-check passed (help, unknown arg, foreign name, busy port, SIGINT cleanup). Timed runs are recorded separately. Impact: New load-test entry and README note. No product or release-gate change. Refs: docs/release-multi-instance.md B6 Co-authored-by: Cursor --- tests/load/collaboration/README.md | 4 +- tests/load/collaboration/run_pgbench.sh | 314 ++++++++++++++++++++++++ 2 files changed, 317 insertions(+), 1 deletion(-) create mode 100755 tests/load/collaboration/run_pgbench.sh diff --git a/tests/load/collaboration/README.md b/tests/load/collaboration/README.md index 23d1f72d..abee7ba9 100644 --- a/tests/load/collaboration/README.md +++ b/tests/load/collaboration/README.md @@ -80,5 +80,7 @@ P95≤300ms 阈值约束——「互相不拖累」的可机判口径。SSE 探 - 正式 60min/2h 窗口、双主机(C3)、`contract` 满量压测窗口需用户指定资源与时间; 本仓提供可复核入口,不预填容量结论。 -- `pgbench` 只测数据库能力,不替代站点业务验收(方案 §5 U7)。 +- `pgbench` 只测数据库能力,不替代站点业务验收(方案 §5 U7)。入口是 + `run_pgbench.sh`(检查单 B6):隔离实例 `u7bench`、端口 18995、scale 100, + select-only 与 TPC-B 各跑 10/30/100 客户端。没有业务阈值;结果是参考基线。 - k6 缺失时跳过 k6 段并写 `k6-skipped.txt`;探针与对账仍跑。 diff --git a/tests/load/collaboration/run_pgbench.sh b/tests/load/collaboration/run_pgbench.sh new file mode 100755 index 00000000..07fcd670 --- /dev/null +++ b/tests/load/collaboration/run_pgbench.sh @@ -0,0 +1,314 @@ +#!/usr/bin/env bash +# 检查单 B6:隔离 PostgreSQL 上的 pgbench 参考基线。 +# 只创建并回收名称以 u7bench 开头、端口落在 18980–18999 的实例。 +# 不打生产服务,不把连接串或口令写进输出。 +# +# 用法: +# tests/load/collaboration/run_pgbench.sh [--name u7bench] [--port 18995] +# [--scale 100] [--duration 60] [--clients 10,30,100] [--help] +# tests/load/collaboration/run_pgbench.sh --self-check +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +NAME=u7bench +PORT=18995 +SCALE=100 +DURATION=60 +CLIENTS="10,30,100" +SELF_CHECK=0 +HOLD_AFTER_START=0 + +usage() { + sed -n '2,9p' "$0" +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --name) NAME="$2"; shift 2 ;; + --port) PORT="$2"; shift 2 ;; + --scale) SCALE="$2"; shift 2 ;; + --duration) DURATION="$2"; shift 2 ;; + --clients) CLIENTS="$2"; shift 2 ;; + --self-check) SELF_CHECK=1; shift ;; + --hold-after-start) HOLD_AFTER_START=1; shift ;; + -h|--help) usage; exit 0 ;; + *) echo "[pgbench] unknown arg: $1" >&2; usage >&2; exit 2 ;; + esac +done + +die() { echo "[pgbench] $*" >&2; exit 2; } + +case "$NAME" in + u7bench|u7bench-*) ;; + *) die "--name must start with u7bench (refusing $NAME)" ;; +esac +[[ "$NAME" =~ ^[a-z][a-z0-9-]{0,15}$ ]] || die "illegal instance name: $NAME" +[[ "$PORT" =~ ^[0-9]+$ ]] || die "--port must be an integer" +if (( PORT < 18980 || PORT > 18999 )); then + die "--port must be inside 18980-18999 (refusing $PORT)" +fi +[[ "$SCALE" =~ ^[0-9]+$ && "$DURATION" =~ ^[0-9]+$ ]] || die "--scale and --duration must be integers" + +port_listening() { + local p="$1" + if command -v ss >/dev/null 2>&1; then + ss -ltn 2>/dev/null | awk -v p=":$p" '$4 ~ p"$" { found=1 } END { exit !found }' && return 0 + fi + (exec 3<>"/dev/tcp/127.0.0.1/$p") 2>/dev/null && return 0 + return 1 +} + +# ---- 自检:正常 / 拒绝 / 中断回收。在创建任何实例之前跑完拒绝项。 ---- +if [[ "$SELF_CHECK" -eq 1 ]]; then + echo "[pgbench] self-check: --help" + "$0" --help >/dev/null + echo "[pgbench] self-check: unknown arg exits 2" + set +e + "$0" --not-a-flag >/dev/null 2>&1 + ec=$? + set -e + [[ "$ec" -eq 2 ]] || die "expected exit 2 for unknown arg, got $ec" + echo "[pgbench] self-check: name outside the prefix is refused" + set +e + "$0" --name prod --port 18995 >/dev/null 2>&1 + ec=$? + set -e + [[ "$ec" -eq 2 ]] || die "expected exit 2 for --name prod, got $ec" + echo "[pgbench] self-check: busy port is refused before any instance exists" + busy=18994 + python3 -m http.server "$busy" --bind 127.0.0.1 >/dev/null 2>&1 & + busy_pid=$! + for _ in $(seq 1 20); do port_listening "$busy" && break; sleep 0.1; done + set +e + "$0" --name u7bench --port "$busy" --duration 5 >/dev/null 2>&1 + ec=$? + set -e + kill "$busy_pid" 2>/dev/null || true + wait "$busy_pid" 2>/dev/null || true + [[ "$ec" -eq 2 ]] || die "expected exit 2 for busy port, got $ec" + [[ ! -e "$ROOT/runtime/u7bench" ]] || die "busy-port refusal created runtime/u7bench" + echo "[pgbench] self-check: SIGINT removes the instance and the port" + # 非交互脚本里后台任务会忽略 SIGINT。打开监视模式后,子进程能接到 SIGINT。 + set -m + "$0" --name u7bench --port 18995 --hold-after-start >/tmp/u7bench-hold.txt 2>&1 & + hold_pid=$! + set +m + ready=0 + for _ in $(seq 1 90); do + if [[ -f "$ROOT/runtime/u7bench/hold.ready" ]]; then + ready=1 + break + fi + if ! kill -0 "$hold_pid" 2>/dev/null; then + break + fi + sleep 0.5 + done + [[ "$ready" -eq 1 ]] || { + echo "[pgbench] hold output:" >&2 + cat /tmp/u7bench-hold.txt >&2 || true + die "hold-after-start did not become ready" + } + kill -INT -- "-$hold_pid" 2>/dev/null || kill -INT "$hold_pid" 2>/dev/null || true + set +e + wait "$hold_pid" + set -e + if port_listening 18995 || [[ -e "$ROOT/runtime/u7bench" ]]; then + die "SIGINT left port 18995 or runtime/u7bench behind" + fi + echo "[pgbench] self-check passed" + exit 0 +fi + +if port_listening "$PORT"; then + die "port $PORT is already in use; refusing before creating $NAME" +fi +if [[ -e "$ROOT/runtime/$NAME" ]]; then + die "runtime/$NAME already exists; refusing to touch it" +fi + +command -v pgbench >/dev/null 2>&1 || die "pgbench is not installed" + +# 计时窗口才受负载门槛约束。生命周期自检(--hold-after-start)不测量吞吐。 +resource_gate() { + local avail_kb load1 + avail_kb="$(df -Pk "$ROOT" | awk 'NR==2 { print $4 }')" + load1="$(awk '{ print $1 }' /proc/loadavg)" + if (( avail_kb < 100 * 1024 * 1024 )); then + echo "[pgbench] disk free ${avail_kb}KB is below 100GB; pausing" >&2 + exit 3 + fi + awk -v load1="$load1" 'BEGIN { if (load1+0 > 16) exit 1 }' || { + echo "[pgbench] load average $load1 is above 16; pausing" >&2 + exit 3 + } +} + +OWNED=0 +cleanup() { + local ec=$? + trap - EXIT INT TERM + if [[ "$OWNED" -eq 1 ]]; then + "$ROOT/scripts/svc.sh" "$NAME" stop >/dev/null 2>&1 || true + "$ROOT/scripts/svc.sh" rm "$NAME" --purge >/dev/null 2>&1 || true + fi + exit "$ec" +} +trap cleanup EXIT INT TERM + +TS="$(date +%Y%m%d-%H%M%S)" +RESULTS="$HERE/results/pgbench-$TS" +mkdir -p "$RESULTS" +echo "[pgbench] results $RESULTS" + +{ + echo "dev_head=$(git -C "$ROOT" rev-parse HEAD)" + echo "backend=$(git -C "$ROOT" rev-parse HEAD:modules/backend)" + echo "when=$(date -u +%Y-%m-%dT%H:%M:%SZ)" + echo "nproc=$(nproc)" + echo "loadavg=$(cat /proc/loadavg)" + free -h + df -h "$ROOT" + pgbench --version + echo "--- heavy processes (comm only) ---" + ps -eo pcpu,pmem,comm --sort=-pcpu | head -12 +} >"$RESULTS/host.txt" 2>&1 + +echo "[pgbench] creating isolated instance $NAME on 127.0.0.1:$PORT" +"$ROOT/scripts/svc.sh" db create "$NAME" --port "$PORT" >"$RESULTS/svc-create.txt" 2>&1 +OWNED=1 +addr="$(sed -n 's/^addr=//p' "$ROOT/runtime/$NAME/svc.env" | head -1)" +[[ "$addr" == "127.0.0.1:$PORT" ]] || { + echo "[pgbench] instance addr is $addr, expected 127.0.0.1:$PORT; aborting" >&2 + exit 1 +} +"$ROOT/scripts/svc.sh" "$NAME" start >"$RESULTS/svc-start.txt" 2>&1 + +if [[ "$HOLD_AFTER_START" -eq 1 ]]; then + echo "[pgbench] holding after start for signal test" + : >"$ROOT/runtime/$NAME/hold.ready" + sleep 3600 +fi + +resource_gate + +DSN="$("$ROOT/scripts/svc.sh" db dsn "$NAME" --reveal)" + +psql "$DSN" -v ON_ERROR_STOP=1 -c "SELECT version()" >"$RESULTS/pg-version.txt" +psql "$DSN" -v ON_ERROR_STOP=1 -c \ + "SELECT name, setting, unit FROM pg_settings WHERE name IN ('max_connections','superuser_reserved_connections','shared_buffers','work_mem','effective_cache_size','wal_level','listen_addresses')" \ + >"$RESULTS/pg-settings.txt" + +max_conn="$(psql "$DSN" -tAc "SHOW max_connections")" +reserved="$(psql "$DSN" -tAc "SHOW superuser_reserved_connections")" +headroom=$(( max_conn - reserved - 2 )) +(( headroom >= 1 )) || die "no connection headroom (max=$max_conn reserved=$reserved)" + +echo "[pgbench] init scale=$SCALE" +pgbench -i -s "$SCALE" -d "$DSN" >"$RESULTS/init.txt" 2>&1 +psql "$DSN" -v ON_ERROR_STOP=1 -c "VACUUM ANALYZE" >"$RESULTS/vacuum.txt" + +runs_tsv="$RESULTS/runs.tsv" +: >"$runs_tsv" +IFS=',' read -r -a client_list <<<"$CLIENTS" +for raw in "${client_list[@]}"; do + [[ "$raw" =~ ^[0-9]+$ ]] || die "bad client count: $raw" + clients="$raw" + capped=0 + if (( clients > headroom )); then + echo "[pgbench] capping clients $clients -> $headroom (max_connections=$max_conn reserved=$reserved)" + clients="$headroom" + capped=1 + fi + jobs="$clients" + ncpu="$(nproc)" + if (( jobs > ncpu )); then + jobs="$ncpu" + fi + for mode in select-only tpcb; do + echo "[pgbench] mode=$mode clients=$clients jobs=$jobs duration=${DURATION}s" + prefix="$RESULTS/${mode}-${clients}" + extra=() + if [[ "$mode" == "select-only" ]]; then + extra+=(-S) + fi + pgbench -n -c "$clients" -j "$jobs" -T "$DURATION" -M prepared -P 10 \ + --sampling-rate=0.01 -l --log-prefix="$prefix" \ + "${extra[@]}" -d "$DSN" | tee "$RESULTS/${mode}-${clients}.txt" + failed="$(awk '/number of failed transactions:/ { print $5; exit }' "$RESULTS/${mode}-${clients}.txt")" + tps="$(awk '/^tps = / { print $3; exit }' "$RESULTS/${mode}-${clients}.txt")" + avg="$(awk '/^latency average = / { print $4; exit }' "$RESULTS/${mode}-${clients}.txt")" + [[ -n "$failed" && "$failed" == "0" ]] || { + echo "[pgbench] failed transactions = ${failed:-missing} for $mode/$clients" >&2 + exit 1 + } + read -r p50 p95 p99 samples <<<"$(python3 - "$prefix" <<'PY' +import glob, sys +prefix = sys.argv[1] +vals = [] +for path in sorted(glob.glob(prefix + ".*")): + with open(path) as fh: + for line in fh: + parts = line.split() + if len(parts) >= 3: + vals.append(int(parts[2])) +vals.sort() +def pct(p): + if not vals: + return "nan" + idx = int(round((p / 100.0) * (len(vals) - 1))) + return f"{vals[idx] / 1000.0:.3f}" +print(pct(50), pct(95), pct(99), len(vals)) +PY +)" + awk -v p50="$p50" 'BEGIN { if (p50+0 > 60000) exit 1 }' || { + echo "[pgbench] latency sample looks unparsed (p50=${p50}ms)" >&2 + exit 1 + } + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$mode" "$clients" "$jobs" "$tps" "$failed" "$avg" "$p50" "$p95" "$p99" "$samples" \ + >>"$runs_tsv" + echo "[pgbench] $mode clients=$clients tps=$tps avg=${avg}ms p50=${p50}ms p95=${p95}ms p99=${p99}ms samples=$samples capped=$capped" + load1="$(awk '{ print $1 }' /proc/loadavg)" + echo "after ${mode}-${clients} loadavg=$load1" >>"$RESULTS/host.txt" + done +done + +python3 - "$RESULTS" "$runs_tsv" "$max_conn" "$reserved" "$headroom" "$SCALE" "$DURATION" <<'PY' +import json, pathlib, sys +results, tsv, max_conn, reserved, headroom, scale, duration = sys.argv[1:] +host = pathlib.Path(results, "host.txt").read_text() +def grab(key): + for line in host.splitlines(): + if line.startswith(key + "="): + return line.split("=", 1)[1] + return "" +runs = [] +for line in pathlib.Path(tsv).read_text().splitlines(): + mode, clients, jobs, tps, failed, avg, p50, p95, p99, samples = line.split("\t") + runs.append({ + "mode": mode, "clients": int(clients), "jobs": int(jobs), + "tps": float(tps), "failed": int(failed), "latency_avg_ms": float(avg), + "p50_ms": float(p50), "p95_ms": float(p95), "p99_ms": float(p99), + "samples": int(samples), + }) +doc = { + "source": "SELF", + "kind": "pgbench-reference", + "note": "Database capacity only. Not a business acceptance threshold.", + "dev_head": grab("dev_head"), + "backend": grab("backend"), + "scale": int(scale), + "duration_s": int(duration), + "max_connections": int(max_conn), + "superuser_reserved_connections": int(reserved), + "client_headroom": int(headroom), + "runs": runs, +} +path = pathlib.Path(results, "pgbench-summary.json") +path.write_text(json.dumps(doc, indent=2) + "\n") +print(json.dumps(doc, indent=2)) +PY + +echo "[pgbench] done. summary $RESULTS/pgbench-summary.json"