Files
rustfs/scripts/run_conditional_read_abba.py
T
dd90bb7488 fix(s3): short-circuit conditional reads at metadata quorum (#8459)
* fix(s3): evaluate conditional reads before opening object bodies

Reuse locked metadata snapshots for conditional GETs and unify HEAD
preconditions and cache headers. Preserve body-cache hooks and validate
encryption metadata and key authorization before returning bodyless errors.

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

* refactor(s3): unify conditional reads at metadata quorum

Apply scoped metadata conditions to the existing reader pipeline instead
of maintaining parallel prepared-read branches. Preserve quorum checks,
early-stop, coalescing, and cache-hook behavior on condition misses.

Track metadata-only termination through selection and reject body access
or a changed decision. Keep concrete reader futures off request stacks.

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

* test(s3): strengthen conditional read acceptance evidence

Assert zero shard, decode, cache-fill, and remote-body calls on matching
conditions with live positive controls. Exercise GET and HEAD through
s3s and Hyper with conditional-header and precedence matrices.

Add reproducible paired application-read benchmarks with isolated
metadata controls and strict drift, artifact, and resource gates.

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

* refactor(s3): remove obsolete conditional comparison helper

Keep ETag comparison tests on the canonical read-condition evaluator,
remove unused date-trait imports, and name the HTTP case type.

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

* fix(s3): validate full conditional etag fields

Reject adjacent tags and malformed trailing list members before accepting a
match. Keep the existing request-scoped metadata read and authorization order,
while removing redundant validation wrappers and unused benchmark overrides.
Replace simulated phase coverage with real 304/412 cache-fill probes and extend
HTTP/1.1 and HTTP/2 ingress regressions.

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

* fix(ecstore): cache terminal conditional metadata safely

Preserve quorum-validated conditional metadata when early termination
cancels the remaining fanout. Restrict those entries to terminal GET
readers; body and full-layout consumers still obtain their own metadata.

Serialize cache publication per key so conditional entries cannot replace
complete layouts or renew their TTL. Retain generation fencing and the
final selected-metadata predicate check before any body access.

Cover real four-disk hedging, nonterminal body and range reads, consumer
isolation, generation races, atomic publication and actual cache expiry.

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

* fix(log-analyzer): align heal writer failure rule with current logs

Match the current structured all-targets failure, retain legacy log support, and reject partial or unrelated failures.

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-12 16:59:21 +08:00

206 lines
9.9 KiB
Python

#!/usr/bin/env python3
"""Run the identical application-read test harness in serial ABBA processes.
This measures local EC reads, not network/authentication or deployment capacity.
Use matching toolchain, features and profiles for both executable artifacts.
"""
import argparse
import hashlib
import json
import math
import os
from pathlib import Path
import shutil
import subprocess
import time
def digest(path):
result = hashlib.sha256()
with path.open("rb") as source:
for chunk in iter(lambda: source.read(1024 * 1024), b""):
result.update(chunk)
return result.hexdigest()
def parse_rows(output):
rows = {}
prefix = "CONDITIONAL_BENCH "
for line in output.splitlines():
if prefix in line:
row = json.loads(line.split(prefix, 1)[1])
key = (row["size"], row["kind"], row["cache"], row["slowtail_ms"])
if key in rows:
raise ValueError(f"duplicate benchmark row {key}")
rows[key] = row
if len(rows) != 6:
raise ValueError(f"expected six benchmark rows, received {len(rows)}")
if {(key[0], key[1]) for key in rows} != {
(size, kind) for size in (4096, 1_300_000) for kind in ("unconditional", "hit", "miss")
}:
raise ValueError("benchmark workload matrix is incomplete")
for row in rows.values():
for metric in ("ops_per_sec", "p95_ms", "p99_ms", "lock_p95_ms", "lock_p99_ms"):
if not math.isfinite(row[metric]) or row[metric] <= 0:
raise ValueError(f"invalid benchmark metric {metric}")
return rows
def check_resource_isolation(check_load=True):
commands = subprocess.check_output(["ps", "-A", "-o", "comm="], text=True)
if any(Path(command.strip()).name in ("cargo", "rustc") for command in commands.splitlines()):
raise RuntimeError("stop concurrent Cargo builds before measuring")
load = os.getloadavg()
if check_load and load[0] > (os.cpu_count() or 1) * 0.5:
raise RuntimeError(f"host load too high for paired measurements: {load[0]}")
return load
def run_case(executable, expected_digest, env, log_path=None):
if digest(executable) != expected_digest:
raise RuntimeError("benchmark executable changed before execution")
# Poll for concurrent builds without attributing this benchmark's own CPU
# use to external host load. Load gates run before and after the process.
with subprocess.Popen([str(executable.resolve()), "conditional_read_paired_benchmark",
"--ignored", "--nocapture", "--test-threads=1"],
env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) as run:
try:
while True:
try:
output, _ = run.communicate(timeout=1)
break
except subprocess.TimeoutExpired:
check_resource_isolation(check_load=False)
except BaseException:
run.kill()
output, _ = run.communicate()
if log_path is not None:
log_path.write_text(output)
raise
returncode = run.returncode
if log_path is not None:
log_path.write_text(output)
check_resource_isolation()
if digest(executable) != expected_digest:
raise RuntimeError("benchmark executable changed during execution")
return returncode, output
def drift_passes(first, last):
if abs(last["ops_per_sec"] / first["ops_per_sec"] - 1) > 0.10:
return False
return all(
abs(last[metric] - first[metric]) <= max(first[metric] * 0.15, 0.25)
for metric in ("p95_ms", "p99_ms", "lock_p95_ms", "lock_p99_ms")
)
def compare(rows):
if not all(rows[0].keys() == leg.keys() for leg in rows):
raise ValueError("ABBA legs have different workload matrices")
unstable = [key for key in rows[0] if not drift_passes(rows[0][key], rows[3][key])]
if unstable:
return {"accepted": False, "reason": "baseline_drift", "unstable_cases": unstable}
results = []
for key, first in rows[0].items():
baseline = {metric: math.sqrt(first[metric] * rows[3][key][metric]) for metric in
("ops_per_sec", "p95_ms", "p99_ms", "lock_p95_ms", "lock_p99_ms")}
candidate = {metric: math.sqrt(rows[1][key][metric] * rows[2][key][metric]) for metric in baseline}
# Protect ordinary reads and condition misses; 304 savings alone do not pass.
passed = candidate["ops_per_sec"] >= baseline["ops_per_sec"] * 0.95 and all(
candidate[metric] <= baseline[metric] + max(baseline[metric] * 0.10, 0.25)
for metric in ("p95_ms", "p99_ms", "lock_p95_ms", "lock_p99_ms")
)
results.append({"case": key, "baseline": baseline, "candidate": candidate,
"within_regression_budget": passed})
return {"accepted": all(row["within_regression_budget"] for row in results), "cases": results}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--baseline", type=Path, required=True)
parser.add_argument("--candidate", type=Path, required=True)
parser.add_argument("--baseline-revision", required=True)
parser.add_argument("--candidate-revision", required=True)
parser.add_argument("--harness", type=Path, required=True)
parser.add_argument("--profile", required=True)
parser.add_argument("--out-dir", type=Path, required=True)
parser.add_argument("--iterations", type=int, default=1000)
parser.add_argument("--concurrency", type=int, default=8)
parser.add_argument("--cooldown-seconds", type=float, default=5)
args = parser.parse_args()
if args.iterations < 100 or args.concurrency < 1 or args.cooldown_seconds < 0:
parser.error("iterations must be >=100, concurrency >0, cooldown >=0")
args.out_dir.mkdir(parents=True, exist_ok=False)
executables = {}
for name, source in (("baseline", args.baseline), ("candidate", args.candidate)):
original_digest = digest(source)
snapshot = args.out_dir / f"{name}.binary"
shutil.copyfile(source, snapshot)
snapshot.chmod(0o555)
if digest(snapshot) != original_digest:
raise RuntimeError("source executable changed while taking its snapshot")
executables[name] = snapshot
manifest = {
"baseline_revision": args.baseline_revision, "candidate_revision": args.candidate_revision,
"baseline_sha256": digest(executables["baseline"]), "candidate_sha256": digest(executables["candidate"]),
"harness_sha256": digest(args.harness), "profile": args.profile,
"iterations": args.iterations, "concurrency": args.concurrency,
"order": ["A1", "B1", "B2", "A2"], "slowtail_ms": [0, 20], "cache": [False, True],
"drift_budget": "throughput 10%; latency and lock percentiles 15% or 0.25ms",
"regression_budget": "throughput -5%; latency and lock percentiles +10% or 0.25ms",
"cpu_count": os.cpu_count(), "load_at_start": check_resource_isolation(),
}
(args.out_dir / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n")
legs = []
for name, artifact in (("A1", "baseline"), ("B1", "candidate"),
("B2", "candidate"), ("A2", "baseline")):
executable = executables[artifact]
cases = {}
for cache in (False, True):
for delay in (0, 20):
check_resource_isolation()
env = os.environ.copy()
# Keep ambient experiments and proxies out of localhost-sensitive runs.
for variable in tuple(env):
if variable.startswith("RUSTFS_") or variable.lower().endswith("_proxy"):
del env[variable]
env.update({
"NO_PROXY": "*", "no_proxy": "*", "RUSTFS_OBJECT_LOCK_DIAG_ENABLE": "true",
"RUSTFS_OBJECT_DATA_CACHE_ENABLE": str(cache).lower(),
"RUSTFS_OBJECT_DATA_CACHE_MODE": "fill_materialize_enabled",
"RUSTFS_OBJECT_DATA_CACHE_MAX_BYTES": "8388608",
"RUSTFS_OBJECT_DATA_CACHE_MAX_ENTRY_BYTES": "2097152",
"RUSTFS_OBJECT_DATA_CACHE_MIN_FREE_MEMORY_PERCENT": "0",
"RUSTFS_CONDITIONAL_BENCH_ITERATIONS": str(args.iterations),
"RUSTFS_CONDITIONAL_BENCH_CONCURRENCY": str(args.concurrency),
"RUSTFS_GET_METADATA_SLOWTAIL_FAULT_DELAY_MS": str(delay),
"RUSTFS_GET_METADATA_SLOWTAIL_FAULT_DISKS": "3",
"RUSTFS_GET_METADATA_SLOWTAIL_FAULT_BUCKET": "conditional-bench",
"RUSTFS_GET_METADATA_SLOWTAIL_FAULT_OBJECT_PREFIX": "bench-",
})
returncode, output = run_case(executable, manifest[f"{artifact}_sha256"], env,
args.out_dir / f"{name}-cache{int(cache)}-delay{delay}.log")
if returncode:
raise RuntimeError(f"{name} cache={cache} delay={delay} failed; see saved log")
for key, row in parse_rows(output).items():
if (row["cache"] != cache or row["slowtail_ms"] != str(delay)
or row["iterations"] != args.iterations or row["concurrency"] != args.concurrency
or row["metadata_cache_hits"] != 0 or not row["metadata_cache_bypassed"]
or row["lock_samples"] <= 0):
raise ValueError("benchmark did not observe requested environment")
cases[key] = row
print(f"{name}: cache={cache} slowtail={delay}ms complete", flush=True)
legs.append(cases)
time.sleep(args.cooldown_seconds)
result = compare(legs)
result["load_at_end"] = os.getloadavg()
(args.out_dir / "summary.json").write_text(json.dumps(result, indent=2) + "\n")
print(json.dumps(result, indent=2))
return 0 if result["accepted"] else 2
if __name__ == "__main__":
raise SystemExit(main())