Files
rustfs/scripts/validate_object_data_cache_cold_stampede.sh
houseme 21049401fa fix(ilm): harden tier transition failure boundaries (#5031)
* fix(tier): fence generation-scoped operations

Refs rustfs/backlog#1354

Co-Authored-By: heihutu <heihutu@gmail.com>

* fix(ilm): verify transition upload streams

Refs rustfs/backlog#1353

Co-Authored-By: heihutu <heihutu@gmail.com>

* test(ecstore): expand transition fault matrix

Refs rustfs/backlog#1355

Co-Authored-By: heihutu <heihutu@gmail.com>

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-19 10:48:32 +00:00

787 lines
34 KiB
Bash
Executable File

#!/usr/bin/env bash
# Manual/nightly validation for cold object-data-cache stampedes.
#
# This gate deliberately requires a dedicated RustFS instance, an exact 28 GiB
# cgroup v2 limit, pre-provisioned objects, and authoritative Prometheus queries.
# It never substitutes process I/O counters or synthetic values for server
# metrics. With no --run flag (or with missing prerequisites outside --strict),
# it reports SKIP and exits successfully.
set -euo pipefail
readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
readonly REQUESTS=2000
readonly KEY_MATRIX="1 4 32"
readonly REQUIRED_MEMORY_BYTES=$((28 * 1024 * 1024 * 1024))
readonly OBJECT_SIZE=$((384 * 1024 * 1024))
readonly READER_BYTES_METRIC='rustfs_io_get_object_reader_bytes_total'
readonly FOLLOWER_PERMIT_METRIC='rustfs_object_data_cache_cold_fill_follower_disk_permits'
RUN=0
SELF_TEST=0
STRICT=0
ACK_ISOLATED=0
ROUNDS=3
ENDPOINT=""
BUCKET=""
KEY_PREFIX=""
EXPECTED_SHA256=""
REGION="us-east-1"
PROMETHEUS_QUERY_URL=""
PROMETHEUS_SCRAPE_SECONDS=""
RESET_COMMAND=""
CACHE_OFF_COMMAND=""
CACHE_ON_COMMAND=""
SERVER_PID=""
SERVER_PID_COMMAND=""
CGROUP_PATH=""
SAMPLE_INTERVAL="0.25"
METRICS_SETTLE_SECONDS="5"
LOAD_TIMEOUT_SECONDS="1800"
OUT_DIR=""
usage() {
cat <<'USAGE'
Usage:
validate_object_data_cache_cold_stampede.sh --run --ack-isolated \
--endpoint URL --bucket NAME --key-prefix PREFIX \
--expected-sha256 HEX --prometheus-query-url URL \
--prometheus-scrape-seconds SECONDS \
--reset-command COMMAND --cache-off-command COMMAND \
--cache-on-command COMMAND \
(--server-pid PID | --server-pid-command COMMAND) [--cgroup-path PATH] [options]
Required run contract:
* N is fixed at 2000; K is fixed at 1, 4, and 32; each K runs >= 3 rounds.
* PREFIX/object-00 through PREFIX/object-31 must already exist, each exactly
384 MiB with identical SHA-256 content.
* COMMAND must synchronously make the selected objects cold before returning.
* The RustFS process must be isolated from unrelated traffic and run in a
dedicated cgroup v2 with memory.max exactly 28 GiB.
* The built-in first-party reader-byte counter and follower-permit gauge must
each return one value vector and one raw-scrape timestamp vector.
* Cache switch commands must return only after the mode is effective. A dynamic
PID resolver may observe a restarted RustFS process, but every process must
remain inside the same dedicated cgroup.
* Every response must match SHA-256, ETag, Content-Length, and its per-key
stable header contract. Date, x-amz-id-2, x-amz-request-id,
x-minio-request-id, x-rustfs-request-id, Connection, Keep-Alive, and
Transfer-Encoding are explicitly excluded as volatile/transport headers.
Built-in PromQL observes each metric value together with max(timestamp(metric));
the HTTP API evaluation timestamp is never treated as a scrape timestamp.
Options:
--self-test Validate the follower sample append/check chain locally
--rounds N Rounds per K (default: 3; minimum: 3)
--region REGION SigV4 region (default: us-east-1)
--sample-interval SECONDS Follower gauge polling interval (default: 0.25)
--prometheus-scrape-seconds N Configured scrape interval; required
--metrics-settle-seconds N Wait for final Prometheus scrape (default: 5)
--load-timeout-seconds N Per-round load timeout (default: 1800)
--server-pid-command COMMAND Resolve the live RustFS PID after every operator command
--out-dir PATH Artifact directory (default: temporary)
--strict Missing prerequisite is FAIL instead of SKIP
-h, --help Show this help
Credentials are read only from AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and
optional AWS_SESSION_TOKEN. They are never written to the result artifacts.
USAGE
}
skip_or_fail() {
local message=$1
if ((STRICT)); then
printf 'FAIL: %s\n' "$message" >&2
exit 1
fi
printf 'SKIP: %s\n' "$message"
exit 0
}
fail() {
printf 'FAIL: %s\n' "$1" >&2
exit 1
}
need_value() {
(($# >= 2)) || fail "option $1 requires a value"
}
append_follower_sample() {
local samples_file=$1
local sample=$2
local sample_epoch sample_value trailing
read -r sample_epoch sample_value trailing <<<"$sample"
[[ -n $sample_epoch && -n $sample_value && -z $trailing ]] || return 1
printf '%s %s\n' "$sample_epoch" "$sample_value" >>"$samples_file"
}
follower_samples_check() {
local samples_file=$1
local ready_file=$2
local scrape_seconds=${3:-$PROMETHEUS_SCRAPE_SECONDS}
python3 "$SCRIPT_DIR/check_object_data_cache_follower_samples.py" \
--samples "$samples_file" --ready "$ready_file" \
--scrape-seconds "$scrape_seconds"
}
run_operator_command() {
local mode=$1
local round=$2
local key_count=$3
local command=$4
env -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY -u AWS_SESSION_TOKEN \
RUSTFS_STAMPEDE_MODE="$mode" RUSTFS_STAMPEDE_ROUND="$round" RUSTFS_STAMPEDE_K="$key_count" \
bash -c "$command"
}
parse_server_pid() {
local output=$1
[[ $output =~ ^[[:space:]]*([0-9]+)[[:space:]]*$ ]] || return 1
printf '%s\n' "${BASH_REMATCH[1]}"
}
resolve_server_pid() {
local output
if [[ -n $SERVER_PID_COMMAND ]]; then
output=$(env -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY -u AWS_SESSION_TOKEN bash -c "$SERVER_PID_COMMAND") || return 1
parse_server_pid "$output"
else
parse_server_pid "$SERVER_PID"
fi
}
parse_prometheus_observation() {
python3 -c '
import json, math, sys
doc = json.load(sys.stdin)
if doc.get("status") != "success":
raise SystemExit("Prometheus query was not successful")
result = doc.get("data", {}).get("result", [])
if len(result) != 2:
raise SystemExit(f"expected value and sample_timestamp vectors, got {len(result)}")
observations = {}
for item in result:
kind = item.get("metric", {}).get("__rustfs_probe_kind")
pair = item.get("value")
if kind not in ("value", "sample_timestamp") or not isinstance(pair, list) or len(pair) != 2:
raise SystemExit("invalid Prometheus observation shape")
evaluation_timestamp = float(pair[0])
observed_value = float(pair[1])
if not math.isfinite(evaluation_timestamp) or not math.isfinite(observed_value):
raise SystemExit("Prometheus observation is not finite")
if kind in observations:
raise SystemExit(f"duplicate Prometheus observation kind: {kind}")
observations[kind] = observed_value
if set(observations) != {"value", "sample_timestamp"}:
raise SystemExit("Prometheus observation is incomplete")
if observations["value"] < 0 or observations["sample_timestamp"] <= 0:
raise SystemExit("Prometheus observation is outside the accepted range")
print(format(observations["sample_timestamp"], ".17g"), format(observations["value"], ".17g"))
'
}
follower_samples_self_test() (
command -v python3 >/dev/null 2>&1 || fail "python3 is required for --self-test"
local temp_dir samples_file ready_file result sample stale_response fresh_response
temp_dir=$(mktemp -d "${TMPDIR:-/tmp}/rustfs-follower-samples.XXXXXX")
trap 'rm -rf -- "$temp_dir"' EXIT
samples_file="$temp_dir/follower.samples"
ready_file="$temp_dir/ready"
printf '999\n' >"$ready_file"
stale_response='{"status":"success","data":{"result":[{"metric":{"__rustfs_probe_kind":"value"},"value":[2000,"0"]},{"metric":{"__rustfs_probe_kind":"sample_timestamp"},"value":[2000,"900"]}]}}'
sample=$(parse_prometheus_observation <<<"$stale_response") || fail "self-test could not parse stale Prometheus observation"
[[ $sample == '900 0' ]] || fail "Prometheus evaluation timestamp was mistaken for scrape timestamp: $sample"
fresh_response='{"status":"success","data":{"result":[{"metric":{"__rustfs_probe_kind":"sample_timestamp"},"value":[2000,"1000"]},{"metric":{"__rustfs_probe_kind":"value"},"value":[2000,"0"]}]}}'
sample=$(parse_prometheus_observation <<<"$fresh_response") || fail "self-test could not parse fresh Prometheus observation"
[[ $sample == '1000 0' ]] || fail "Prometheus scrape timestamp was not preserved: $sample"
[[ $(parse_server_pid '101') == 101 ]] || fail "single-line PID output was not accepted"
if parse_server_pid '101 102' >/dev/null 2>&1; then
fail "multi-value PID output unexpectedly passed"
fi
if parse_server_pid $'101\n102' >/dev/null 2>&1; then
fail "multi-line PID output unexpectedly passed"
fi
: >"$samples_file"
for sample in '1000 0' '1000 0' '1000 0'; do
append_follower_sample "$samples_file" "$sample" || fail "self-test could not append duplicate follower sample"
done
[[ $(<"$samples_file") == $'1000 0\n1000 0\n1000 0' ]] || fail "duplicate Prometheus timestamps were not preserved by the append chain"
if follower_samples_check "$samples_file" "$ready_file" 5 >/dev/null 2>&1; then
fail "duplicate Prometheus timestamps unexpectedly passed the follower sample checker"
fi
: >"$samples_file"
for sample in '1000 0' '1000 1' '1005 0' '1010 0'; do
append_follower_sample "$samples_file" "$sample" || fail "self-test could not append conflicting follower sample"
done
if follower_samples_check "$samples_file" "$ready_file" 5 >/dev/null 2>&1; then
fail "conflicting values for one Prometheus timestamp unexpectedly passed the follower sample checker"
fi
: >"$samples_file"
for sample in '1000 0' '1005 0' '1010 0'; do
append_follower_sample "$samples_file" "$sample" || fail "self-test could not append fresh follower sample"
done
[[ $(<"$samples_file") == $'1000 0\n1005 0\n1010 0' ]] || fail "fresh Prometheus timestamps were not preserved by the append chain"
result=$(follower_samples_check "$samples_file" "$ready_file" 5) || fail "fresh Prometheus timestamps unexpectedly failed the follower sample checker"
[[ $result == '3,0' ]] || fail "fresh Prometheus timestamps returned unexpected result: $result"
result=$(
AWS_ACCESS_KEY_ID=self-test-access \
AWS_SECRET_ACCESS_KEY=self-test-secret \
AWS_SESSION_TOKEN=self-test-session \
run_operator_command self-test 7 11 \
'printf "%s|%s|%s|%s|%s|%s" "$RUSTFS_STAMPEDE_MODE" "$RUSTFS_STAMPEDE_ROUND" "$RUSTFS_STAMPEDE_K" "${AWS_ACCESS_KEY_ID-unset}" "${AWS_SECRET_ACCESS_KEY-unset}" "${AWS_SESSION_TOKEN-unset}"'
) || fail "operator command self-test failed"
[[ $result == 'self-test|7|11|unset|unset|unset' ]] || fail "operator command inherited AWS credentials: $result"
printf 'PASS: cold stampede script self-test\n'
)
while (($#)); do
case "$1" in
--run) RUN=1 ;;
--self-test) SELF_TEST=1 ;;
--strict) STRICT=1 ;;
--ack-isolated) ACK_ISOLATED=1 ;;
--endpoint) need_value "$@"; ENDPOINT=$2; shift ;;
--bucket) need_value "$@"; BUCKET=$2; shift ;;
--key-prefix) need_value "$@"; KEY_PREFIX=$2; shift ;;
--expected-sha256) need_value "$@"; EXPECTED_SHA256=$2; shift ;;
--region) need_value "$@"; REGION=$2; shift ;;
--prometheus-query-url) need_value "$@"; PROMETHEUS_QUERY_URL=$2; shift ;;
--prometheus-scrape-seconds) need_value "$@"; PROMETHEUS_SCRAPE_SECONDS=$2; shift ;;
--reset-command) need_value "$@"; RESET_COMMAND=$2; shift ;;
--cache-off-command) need_value "$@"; CACHE_OFF_COMMAND=$2; shift ;;
--cache-on-command) need_value "$@"; CACHE_ON_COMMAND=$2; shift ;;
--server-pid) need_value "$@"; SERVER_PID=$2; shift ;;
--server-pid-command) need_value "$@"; SERVER_PID_COMMAND=$2; shift ;;
--cgroup-path) need_value "$@"; CGROUP_PATH=$2; shift ;;
--rounds) need_value "$@"; ROUNDS=$2; shift ;;
--sample-interval) need_value "$@"; SAMPLE_INTERVAL=$2; shift ;;
--metrics-settle-seconds) need_value "$@"; METRICS_SETTLE_SECONDS=$2; shift ;;
--load-timeout-seconds) need_value "$@"; LOAD_TIMEOUT_SECONDS=$2; shift ;;
--out-dir) need_value "$@"; OUT_DIR=$2; shift ;;
-h|--help) usage; exit 0 ;;
*) fail "unknown option: $1" ;;
esac
shift
done
if ((SELF_TEST)); then
follower_samples_self_test
exit 0
fi
((RUN)) || skip_or_fail "not started; pass --run after reading --help"
((ACK_ISOLATED)) || skip_or_fail "--ack-isolated is required because global counters must have no unrelated traffic"
[[ $(uname -s) == Linux ]] || skip_or_fail "Linux with cgroup v2 is required"
for command in bash curl python3; do
command -v "$command" >/dev/null 2>&1 || skip_or_fail "required command is unavailable: $command"
done
[[ -n $ENDPOINT ]] || skip_or_fail "--endpoint is required"
[[ -n $BUCKET ]] || skip_or_fail "--bucket is required"
[[ -n $KEY_PREFIX ]] || skip_or_fail "--key-prefix is required"
[[ $EXPECTED_SHA256 =~ ^[[:xdigit:]]{64}$ ]] || skip_or_fail "--expected-sha256 must be exactly 64 hexadecimal characters"
[[ -n $PROMETHEUS_QUERY_URL ]] || skip_or_fail "--prometheus-query-url is required"
[[ -n $PROMETHEUS_SCRAPE_SECONDS ]] || skip_or_fail "--prometheus-scrape-seconds is required"
[[ -n $RESET_COMMAND ]] || skip_or_fail "--reset-command is required to prove each round starts cold"
[[ -n $CACHE_OFF_COMMAND ]] || skip_or_fail "--cache-off-command is required"
[[ -n $CACHE_ON_COMMAND ]] || skip_or_fail "--cache-on-command is required"
[[ -n ${AWS_ACCESS_KEY_ID:-} ]] || skip_or_fail "AWS_ACCESS_KEY_ID is required"
[[ -n ${AWS_SECRET_ACCESS_KEY:-} ]] || skip_or_fail "AWS_SECRET_ACCESS_KEY is required"
if ! [[ $ROUNDS =~ ^[0-9]+$ ]] || ((ROUNDS < 3)); then
fail "--rounds must be an integer >= 3"
fi
[[ $METRICS_SETTLE_SECONDS =~ ^[0-9]+$ ]] || fail "--metrics-settle-seconds must be a non-negative integer"
if ! [[ $LOAD_TIMEOUT_SECONDS =~ ^[0-9]+$ ]] || ((LOAD_TIMEOUT_SECONDS == 0)); then
fail "--load-timeout-seconds must be positive"
fi
python3 - "$SAMPLE_INTERVAL" "$PROMETHEUS_SCRAPE_SECONDS" <<'PY' || fail "sample and scrape intervals must be finite positive numbers"
import math, sys
values = [float(value) for value in sys.argv[1:]]
raise SystemExit(0 if all(math.isfinite(value) and value > 0 for value in values) else 1)
PY
python3 - "$METRICS_SETTLE_SECONDS" "$PROMETHEUS_SCRAPE_SECONDS" <<'PY' \
|| fail "--metrics-settle-seconds must be at least one configured Prometheus scrape interval"
import sys
raise SystemExit(0 if float(sys.argv[1]) >= float(sys.argv[2]) else 1)
PY
if [[ -n $SERVER_PID && -n $SERVER_PID_COMMAND ]]; then
fail "--server-pid and --server-pid-command are mutually exclusive"
fi
if [[ -z $SERVER_PID && -z $SERVER_PID_COMMAND ]]; then
skip_or_fail "--server-pid or --server-pid-command is required to bind OOM evidence to RustFS"
fi
SERVER_PID=$(resolve_server_pid) || skip_or_fail "could not resolve exactly one RustFS PID"
[[ -r /proc/$SERVER_PID/cgroup ]] || skip_or_fail "cannot read /proc/$SERVER_PID/cgroup"
if [[ -z $CGROUP_PATH ]]; then
cgroup_relative=$(awk -F: '$1 == "0" { print $3; exit }' "/proc/$SERVER_PID/cgroup")
[[ -n $cgroup_relative ]] || skip_or_fail "server process is not in a cgroup v2 hierarchy"
CGROUP_PATH="/sys/fs/cgroup${cgroup_relative}"
fi
[[ -r $CGROUP_PATH/memory.max ]] || skip_or_fail "cannot read $CGROUP_PATH/memory.max"
[[ -r $CGROUP_PATH/memory.events ]] || skip_or_fail "cannot read $CGROUP_PATH/memory.events"
[[ -r $CGROUP_PATH/cgroup.procs ]] || skip_or_fail "cannot read $CGROUP_PATH/cgroup.procs"
memory_max=$(<"$CGROUP_PATH/memory.max")
[[ $memory_max =~ ^[0-9]+$ ]] || skip_or_fail "memory.max must be finite, not '$memory_max'"
((memory_max == REQUIRED_MEMORY_BYTES)) || skip_or_fail "memory.max must equal 28 GiB ($REQUIRED_MEMORY_BYTES), got $memory_max"
server_in_cgroup() {
awk -v pid="$SERVER_PID" '$1 == pid { found = 1; exit } END { exit !found }' \
"$CGROUP_PATH/cgroup.procs"
}
server_in_cgroup || skip_or_fail "RustFS PID $SERVER_PID is not in $CGROUP_PATH"
refresh_server_pid() {
local previous_pid=$SERVER_PID
local resolved
resolved=$(resolve_server_pid) || fail "could not resolve exactly one live RustFS PID"
[[ -r /proc/$resolved/cgroup ]] || fail "cannot read /proc/$resolved/cgroup"
SERVER_PID=$resolved
server_in_cgroup || fail "RustFS PID $SERVER_PID is not in $CGROUP_PATH"
if [[ $previous_pid != "$SERVER_PID" ]] && grep -Fxq -- "$previous_pid" "$CGROUP_PATH/cgroup.procs"; then
fail "replaced RustFS PID $previous_pid is still running in $CGROUP_PATH"
fi
}
nofile_limit=$(ulimit -n)
[[ $nofile_limit =~ ^[0-9]+$ ]] || skip_or_fail "unable to determine the open-file limit"
((nofile_limit >= REQUESTS + 256)) || skip_or_fail "open-file limit must be at least $((REQUESTS + 256)), got $nofile_limit"
if [[ -z $OUT_DIR ]]; then
OUT_DIR=$(mktemp -d "${TMPDIR:-/tmp}/rustfs-cold-stampede.XXXXXX")
else
mkdir -p "$OUT_DIR"
fi
readonly OUT_DIR
readonly RESULTS_CSV="$OUT_DIR/results.csv"
LOAD_PID=""
cleanup_load() {
if [[ -n $LOAD_PID ]] && kill -0 "$LOAD_PID" 2>/dev/null; then
kill "$LOAD_PID" 2>/dev/null || true
wait "$LOAD_PID" 2>/dev/null || true
fi
}
trap cleanup_load EXIT
prometheus_sample() {
local metric=$1
local query
local response
query="label_replace(sum($metric), \"__rustfs_probe_kind\", \"value\", \"__name__\", \".*\") or label_replace(max(timestamp($metric)), \"__rustfs_probe_kind\", \"sample_timestamp\", \"__name__\", \".*\")"
response=$(curl --fail --silent --show-error --get \
--data-urlencode "query=$query" "$PROMETHEUS_QUERY_URL") || return 1
parse_prometheus_observation <<<"$response"
}
prometheus_value_after() {
local metric=$1
local minimum_epoch=$2
local timestamp value
local attempts
attempts=$(python3 - "$METRICS_SETTLE_SECONDS" "$PROMETHEUS_SCRAPE_SECONDS" "$SAMPLE_INTERVAL" <<'PY'
import math, sys
settle, scrape, poll = map(float, sys.argv[1:])
print(max(1, math.ceil((settle + 2 * scrape) / poll)))
PY
) || return 1
for ((attempt = 0; attempt < attempts; attempt++)); do
if read -r timestamp value < <(prometheus_sample "$metric") \
&& python3 - "$timestamp" "$minimum_epoch" <<'PY'
import sys
raise SystemExit(0 if float(sys.argv[1]) >= float(sys.argv[2]) else 1)
PY
then
printf '%s\n' "$value"
return 0
fi
sleep "$SAMPLE_INTERVAL"
done
return 1
}
cgroup_event() {
local name=$1
awk -v name="$name" '$1 == name { print $2; found = 1; exit } END { if (!found) exit 1 }' \
"$CGROUP_PATH/memory.events"
}
numeric_delta_check() {
local before=$1
local after=$2
local expected_min=$3
local expected_max=$4
python3 - "$before" "$after" "$expected_min" "$expected_max" <<'PY'
import math, sys
before, after, lower, upper = map(float, sys.argv[1:])
delta = after - before
if not math.isfinite(delta) or delta < lower or delta > upper:
raise SystemExit(f"reader byte delta {delta:.0f} outside [{lower:.0f}, {upper:.0f}]")
print(f"{delta:.0f}")
PY
}
numeric_positive_delta() {
local before=$1
local after=$2
python3 - "$before" "$after" <<'PY'
import math, sys
before, after = map(float, sys.argv[1:])
delta = after - before
if not math.isfinite(delta) or delta <= 0:
raise SystemExit(f"reader byte baseline delta must be positive, got {delta:.0f}")
print(f"{delta:.0f}")
PY
}
run_load() {
local key_count=$1
local request_count=$2
local summary_file=$3
local ready_file=$4
local reference_file=${5:-}
python3 - "$ENDPOINT" "$BUCKET" "$KEY_PREFIX" "$EXPECTED_SHA256" "$REGION" \
"$OBJECT_SIZE" "$request_count" "$key_count" "$LOAD_TIMEOUT_SECONDS" "$ready_file" "$reference_file" \
>"$summary_file" <<'PY'
import asyncio
import datetime
import hashlib
import hmac
import json
import os
import ssl
import sys
import time
import urllib.parse
(
endpoint, bucket, key_prefix, expected_sha, region, object_size,
request_count, key_count, timeout_seconds, ready_file, reference_file,
) = sys.argv[1:]
object_size = int(object_size)
request_count = int(request_count)
key_count = int(key_count)
timeout_seconds = int(timeout_seconds)
expected_sha = expected_sha.lower()
access_key = os.environ["AWS_ACCESS_KEY_ID"]
secret_key = os.environ["AWS_SECRET_ACCESS_KEY"]
session_token = os.environ.get("AWS_SESSION_TOKEN")
parsed = urllib.parse.urlsplit(endpoint)
if parsed.scheme not in ("http", "https") or not parsed.hostname:
raise SystemExit("endpoint must be an absolute http(s) URL")
port = parsed.port or (443 if parsed.scheme == "https" else 80)
host_header = parsed.netloc
base_path = parsed.path.rstrip("/")
payload_hash = hashlib.sha256(b"").hexdigest()
tls_context = ssl.create_default_context() if parsed.scheme == "https" else None
volatile_headers = {
"connection",
"date",
"keep-alive",
"transfer-encoding",
"x-amz-id-2",
"x-amz-request-id",
"x-minio-request-id",
"x-rustfs-request-id",
}
def sign_headers(method, canonical_uri):
now = datetime.datetime.now(datetime.timezone.utc)
amz_date = now.strftime("%Y%m%dT%H%M%SZ")
date_stamp = now.strftime("%Y%m%d")
headers = {
"host": host_header,
"x-amz-content-sha256": payload_hash,
"x-amz-date": amz_date,
}
if session_token:
headers["x-amz-security-token"] = session_token
signed_headers = ";".join(sorted(headers))
canonical_headers = "".join(f"{name}:{headers[name]}\n" for name in sorted(headers))
canonical_request = "\n".join((
method, canonical_uri, "", canonical_headers, signed_headers, payload_hash,
))
scope = f"{date_stamp}/{region}/s3/aws4_request"
string_to_sign = "\n".join((
"AWS4-HMAC-SHA256", amz_date, scope,
hashlib.sha256(canonical_request.encode()).hexdigest(),
))
def digest(key, message):
return hmac.new(key, message.encode(), hashlib.sha256).digest()
signing_key = digest(digest(digest(digest(
("AWS4" + secret_key).encode(), date_stamp), region), "s3"), "aws4_request")
signature = hmac.new(signing_key, string_to_sign.encode(), hashlib.sha256).hexdigest()
headers["authorization"] = (
f"AWS4-HMAC-SHA256 Credential={access_key}/{scope}, "
f"SignedHeaders={signed_headers}, Signature={signature}"
)
return headers
async def read_one(index, release, connected, connected_lock, all_connected):
key = f"{key_prefix.rstrip('/')}/object-{index % key_count:02d}"
raw_path = f"{base_path}/{bucket}/{key}"
canonical_uri = urllib.parse.quote(raw_path, safe="/-_.~")
reader, writer = await asyncio.wait_for(
asyncio.open_connection(parsed.hostname, port, ssl=tls_context), timeout=60,
)
async with connected_lock:
connected[0] += 1
if connected[0] == request_count:
all_connected.set()
await release.wait()
headers = sign_headers("GET", canonical_uri)
request = [f"GET {canonical_uri} HTTP/1.1", "Connection: close"]
request.extend(f"{name}: {value}" for name, value in headers.items())
writer.write(("\r\n".join(request) + "\r\n\r\n").encode())
await writer.drain()
status = (await reader.readline()).decode("latin1").rstrip("\r\n")
parts = status.split(" ", 2)
if len(parts) < 2 or parts[1] != "200":
raise RuntimeError(f"request {index}: unexpected status {status!r}")
response_headers = {}
while True:
line = await reader.readline()
if line in (b"\r\n", b"\n", b""):
break
name, value = line.decode("latin1").split(":", 1)
response_headers[name.strip().lower()] = value.strip()
if "transfer-encoding" in response_headers:
raise RuntimeError(f"request {index}: chunked responses are not accepted")
length = int(response_headers.get("content-length", "-1"))
if length != object_size:
raise RuntimeError(f"request {index}: content-length {length}, expected {object_size}")
digest = hashlib.sha256()
remaining = length
while remaining:
chunk = await reader.read(min(64 * 1024, remaining))
if not chunk:
raise RuntimeError(f"request {index}: body ended with {remaining} bytes missing")
digest.update(chunk)
remaining -= len(chunk)
writer.close()
await writer.wait_closed()
actual = digest.hexdigest()
if actual != expected_sha:
raise RuntimeError(f"request {index}: SHA-256 {actual}, expected {expected_sha}")
etag = response_headers.get("etag")
if not etag:
raise RuntimeError(f"request {index}: missing ETag")
semantic_headers = {
name: value
for name, value in response_headers.items()
if name not in volatile_headers and name not in {"content-length", "etag"}
}
return {
"bytes": length,
"etag": etag,
"key_index": index % key_count,
"semantic_headers": semantic_headers,
"sha256": actual,
}
async def main():
release = asyncio.Event()
all_connected = asyncio.Event()
connected = [0]
connected_lock = asyncio.Lock()
tasks = [asyncio.create_task(read_one(
index, release, connected, connected_lock, all_connected,
)) for index in range(request_count)]
started = time.monotonic()
await asyncio.wait_for(all_connected.wait(), timeout=300)
with open(ready_file, "x", encoding="utf-8") as handle:
handle.write(str(time.time()))
release.set()
responses = await asyncio.wait_for(asyncio.gather(*tasks), timeout=timeout_seconds)
reference_contracts = {}
if reference_file:
with open(reference_file, encoding="utf-8") as handle:
reference_contracts = json.load(handle).get("contracts", {})
contracts = {}
for index, response in enumerate(responses):
key = str(response["key_index"])
contract = {
"etag": response["etag"],
"semantic_headers": response["semantic_headers"],
}
expected = reference_contracts.get(key) or contracts.setdefault(key, contract)
if contract != expected:
raise RuntimeError(
f"request {index}: stable response contract differs: actual={contract!r}, expected={expected!r}"
)
print(json.dumps({
"requests": len(responses),
"keys": key_count,
"bytes": sum(response["bytes"] for response in responses),
"contracts": contracts or reference_contracts,
"sha256_failures": 0,
"duration_seconds": time.monotonic() - started,
}, sort_keys=True))
asyncio.run(main())
PY
}
summary_fields() {
local summary_file=$1
python3 - "$summary_file" <<'PY'
import json, sys
with open(sys.argv[1], encoding="utf-8") as handle:
doc = json.load(handle)
if doc.get("sha256_failures") != 0:
raise SystemExit("SHA-256 failures were reported")
if not doc.get("contracts"):
raise SystemExit("no ETag/stable-header response contract was recorded")
print(doc["requests"], doc["duration_seconds"])
PY
}
switch_cache_mode() {
local mode=$1
local command=$2
local round=$3
local key_count=$4
run_operator_command "$mode" "$round" "$key_count" "$command" \
|| fail "cache-$mode command failed for round=$round K=$key_count"
if [[ -n $SERVER_PID_COMMAND ]]; then
refresh_server_pid
fi
server_in_cgroup || fail "cache-$mode command moved RustFS PID $SERVER_PID out of $CGROUP_PATH"
}
reset_cache() {
local mode=$1
local round=$2
local key_count=$3
run_operator_command "$mode" "$round" "$key_count" "$RESET_COMMAND" \
|| fail "reset command failed for mode=$mode round=$round K=$key_count"
if [[ -n $SERVER_PID_COMMAND ]]; then
refresh_server_pid
fi
server_in_cgroup || fail "reset command moved RustFS PID $SERVER_PID out of $CGROUP_PATH"
}
printf 'mode,k,round,requests,object_size,baseline_reader_bytes,reader_bytes_delta,reader_bytes_limit,follower_samples,follower_max,oom_delta,oom_kill_delta,memory_peak_before,memory_peak_after,duration_seconds\n' >"$RESULTS_CSV"
overall_oom_before=$(cgroup_event oom) || fail "memory.events has no oom field"
overall_oom_kill_before=$(cgroup_event oom_kill) || fail "memory.events has no oom_kill field"
printf 'INFO: artifacts: %s\n' "$OUT_DIR"
printf 'INFO: fixed matrix N=%d, K={1,4,32}, rounds=%d, object_size=%d\n' \
"$REQUESTS" "$ROUNDS" "$OBJECT_SIZE"
for ((round = 1; round <= ROUNDS; round++)); do
for key_count in $KEY_MATRIX; do
baseline_prefix="cache-off-k${key_count}-round${round}"
baseline_summary="$OUT_DIR/$baseline_prefix-load.json"
baseline_ready="$OUT_DIR/$baseline_prefix-ready"
printf 'INFO: K=%d round=%d: switching cache off for one request per key baseline\n' "$key_count" "$round"
switch_cache_mode off "$CACHE_OFF_COMMAND" "$round" "$key_count"
reset_cache off "$round" "$key_count"
baseline_start_epoch=$(python3 -c 'import time; print(format(time.time(), ".17g"))')
baseline_reader_before=$(prometheus_value_after "$READER_BYTES_METRIC" "$baseline_start_epoch") || fail "fresh reader query failed before cache-off K=$key_count round=$round"
baseline_oom_before=$(cgroup_event oom) || fail "cannot read oom before baseline"
baseline_oom_kill_before=$(cgroup_event oom_kill) || fail "cannot read oom_kill before baseline"
baseline_peak_before=$(cat "$CGROUP_PATH/memory.peak" 2>/dev/null || printf 'NA')
run_load "$key_count" "$key_count" "$baseline_summary" "$baseline_ready"
baseline_finished_epoch=$(python3 -c 'import time; print(format(time.time(), ".17g"))')
baseline_reader_after=$(prometheus_value_after "$READER_BYTES_METRIC" "$baseline_finished_epoch") || fail "fresh reader query failed after cache-off K=$key_count round=$round"
baseline_reader_delta=$(numeric_positive_delta "$baseline_reader_before" "$baseline_reader_after") \
|| fail "cache-off reader baseline is invalid for K=$key_count round=$round"
baseline_oom_after=$(cgroup_event oom) || fail "cannot read oom after baseline"
baseline_oom_kill_after=$(cgroup_event oom_kill) || fail "cannot read oom_kill after baseline"
baseline_oom_delta=$((baseline_oom_after - baseline_oom_before))
baseline_oom_kill_delta=$((baseline_oom_kill_after - baseline_oom_kill_before))
((baseline_oom_delta == 0 && baseline_oom_kill_delta == 0)) \
|| fail "OOM event delta is non-zero for cache-off K=$key_count round=$round"
baseline_peak_after=$(cat "$CGROUP_PATH/memory.peak" 2>/dev/null || printf 'NA')
read -r baseline_requests baseline_duration < <(summary_fields "$baseline_summary")
((baseline_requests == key_count)) \
|| fail "cache-off baseline completed $baseline_requests requests, expected $key_count"
printf 'cache_off,%d,%d,%d,%d,%s,%s,NA,NA,NA,%d,%d,%s,%s,%s\n' \
"$key_count" "$round" "$key_count" "$OBJECT_SIZE" "$baseline_reader_delta" "$baseline_reader_delta" \
"$baseline_oom_delta" "$baseline_oom_kill_delta" "$baseline_peak_before" "$baseline_peak_after" "$baseline_duration" \
>>"$RESULTS_CSV"
prefix="k${key_count}-round${round}"
summary_file="$OUT_DIR/$prefix-load.json"
ready_file="$OUT_DIR/$prefix-ready"
follower_file="$OUT_DIR/$prefix-follower.samples"
: >"$follower_file"
printf 'INFO: K=%d round=%d: switching cache on and resetting\n' "$key_count" "$round"
switch_cache_mode on "$CACHE_ON_COMMAND" "$round" "$key_count"
reset_cache on "$round" "$key_count"
round_start_epoch=$(python3 -c 'import time; print(format(time.time(), ".17g"))')
reader_before=$(prometheus_value_after "$READER_BYTES_METRIC" "$round_start_epoch") || fail "fresh reader query failed before K=$key_count round=$round"
oom_before=$(cgroup_event oom) || fail "cannot read oom before round"
oom_kill_before=$(cgroup_event oom_kill) || fail "cannot read oom_kill before round"
memory_peak_before=$(cat "$CGROUP_PATH/memory.peak" 2>/dev/null || printf 'NA')
run_load "$key_count" "$REQUESTS" "$summary_file" "$ready_file" "$baseline_summary" &
load_pid=$!
LOAD_PID=$load_pid
while kill -0 "$load_pid" 2>/dev/null; do
if [[ -e $ready_file ]]; then
if sample=$(prometheus_sample "$FOLLOWER_PERMIT_METRIC" 2>/dev/null); then
append_follower_sample "$follower_file" "$sample" || fail "invalid follower sample for K=$key_count round=$round"
fi
fi
sleep "$SAMPLE_INTERVAL"
done
wait "$load_pid" || fail "load or SHA-256 validation failed for K=$key_count round=$round (see $summary_file)"
LOAD_PID=""
round_finished_epoch=$(python3 -c 'import time; print(format(time.time(), ".17g"))')
reader_after=$(prometheus_value_after "$READER_BYTES_METRIC" "$round_finished_epoch") || fail "fresh reader query failed after K=$key_count round=$round"
expected_reader_bytes=$(((baseline_reader_delta * 90) / 100))
reader_limit=$(((baseline_reader_delta * 110 + 99) / 100))
reader_delta=$(numeric_delta_check "$reader_before" "$reader_after" \
"$expected_reader_bytes" "$reader_limit") || fail "reader byte invariant failed for K=$key_count round=$round"
follower_result=$(follower_samples_check "$follower_file" "$ready_file") || fail "follower permit invariant failed for K=$key_count round=$round"
IFS=, read -r follower_samples follower_max <<<"$follower_result"
oom_after=$(cgroup_event oom) || fail "cannot read oom after round"
oom_kill_after=$(cgroup_event oom_kill) || fail "cannot read oom_kill after round"
server_in_cgroup || fail "RustFS PID $SERVER_PID left $CGROUP_PATH during the run"
oom_delta=$((oom_after - oom_before))
oom_kill_delta=$((oom_kill_after - oom_kill_before))
((oom_delta == 0 && oom_kill_delta == 0)) || fail "OOM event delta is non-zero for K=$key_count round=$round"
memory_peak_after=$(cat "$CGROUP_PATH/memory.peak" 2>/dev/null || printf 'NA')
read -r completed_requests duration_seconds < <(summary_fields "$summary_file")
((completed_requests == REQUESTS)) || fail "completed request count is $completed_requests, expected $REQUESTS"
printf 'cache_on,%d,%d,%d,%d,%s,%s,%d,%s,%s,%d,%d,%s,%s,%s\n' \
"$key_count" "$round" "$completed_requests" "$OBJECT_SIZE" "$baseline_reader_delta" "$reader_delta" \
"$reader_limit" "$follower_samples" "$follower_max" "$oom_delta" \
"$oom_kill_delta" "$memory_peak_before" "$memory_peak_after" "$duration_seconds" \
>>"$RESULTS_CSV"
printf 'PASS: K=%d round=%d SHA-256=%s reader_delta=%s follower_max=0 OOM_delta=0\n' \
"$key_count" "$round" "$EXPECTED_SHA256" "$reader_delta"
done
done
overall_oom_after=$(cgroup_event oom) || fail "cannot read final oom count"
overall_oom_kill_after=$(cgroup_event oom_kill) || fail "cannot read final oom_kill count"
server_in_cgroup || fail "RustFS PID $SERVER_PID left $CGROUP_PATH during the run"
((overall_oom_after == overall_oom_before)) || fail "overall oom delta is non-zero"
((overall_oom_kill_after == overall_oom_kill_before)) || fail "overall oom_kill delta is non-zero"
printf 'PASS: cold stampede matrix completed; results: %s\n' "$RESULTS_CSV"