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 <cursoragent@cursor.com>
This commit is contained in:
2026-10-12 13:12:23 +00:00
co-authored by Cursor
parent 4632a40770
commit a64afe05e3
2 changed files with 317 additions and 1 deletions
+3 -1
View File
@@ -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`;探针与对账仍跑。
+314
View File
@@ -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"