Files
zlareb1andClaude Sonnet 4.6 35e48faf43 keeper stress: add Keeper stress test framework
Adds a stress testing framework for ClickHouse Keeper under
`tests/stress/keeper/`, wired into CI (PR gate + nightly workflows).

Key components:
- Workloads: `keeper_bench.py` runner with YAML-defined workload profiles
  (`prod_mix.yaml`, `read_requests.yaml`, `write_requests.yaml`, etc.)
- Scenarios: `core_no_faults.yaml` and `core_faults.yaml` with netem-based
  network fault injection (latency, jitter, packet loss)
- Fault framework: `faults/network.py` (netem), `faults/base.py` (step
  dispatcher with `kind: parallel` support)
- Metrics: `MetricsSampler` collects keeper stats, `sink.py` writes JSONL,
  ingested into CIDB via `ingest_keeper_metrics.py` post-hook
- Gates: scenario pass/fail conditions based on RPS, p99 latency, error rate
- Grafana dashboards for run details, 1vs1 comparison, historical progression

CI integration:
- PR job: 3 no-fault scenarios (prod-mix, read-multi, write-multi), ARM_LARGE,
  15 min each, runs when `src/Coordination/` or stress test files change
- Nightly (no-faults): all no-fault scenarios, default + rocks backends, ARM_LARGE
- Nightly (faults): all fault scenarios, default + rocks backends, ARM_LARGE
- Single `keeper_stress_job` config; mode detected via `Info().pr_number` and
  `Info().workflow_name` inside the job script

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-03-21 16:08:00 +00:00

360 lines
13 KiB
Python

import json
import os
import shlex
import subprocess
from keeper.framework.core.settings import (
CLIENT_PORT,
KEEPER_CH_QUERY_TIMEOUT,
PROM_PORT,
)
from keeper.framework.core.util import _exec, sh
def four(node, cmd):
"""Execute 4LW command on keeper node. For ZooKeeper/RaftKeeper, runs keeper-client from host."""
if getattr(node, "is_raftkeeper", False):
bin_path = os.environ.get("CLICKHOUSE_BINARY", "clickhouse")
try:
proc = subprocess.run(
[
bin_path,
"keeper-client",
"--host",
str(node.ip_address),
"--port",
str(node.client_port),
"-q",
cmd,
],
capture_output=True,
text=True,
timeout=30,
)
return (proc.stdout or proc.stderr or "").strip()
except Exception as e:
print(f"[keeper][four] keeper-client failed for {node.name}: {e}. Skipping.")
return ""
if getattr(node, "is_zookeeper", False):
cluster = getattr(node, "_cluster", None)
if cluster:
# Use zk_name when set (ZKBackedNode: 4LW goes to ZK container, not CH)
zk_target = getattr(node, "zk_name", node.name)
try:
proc = subprocess.run(
[
cluster.server_bin_path,
"keeper-client",
"--host",
str(cluster.get_instance_ip(zk_target)),
"--port",
str(cluster.zookeeper_port),
"-q",
cmd,
],
capture_output=True,
text=True,
timeout=30,
)
return (proc.stdout or proc.stderr or "").strip()
except Exception as e:
print(f"[keeper][four] keeper-client failed for {node.name}: {e}. Skipping.")
return ""
methods = [
f"HOME=/tmp clickhouse keeper-client --host 127.0.0.1 --port {CLIENT_PORT} -q {shlex.quote(cmd)} 2>&1",
f"HOME=/tmp clickhouse keeper-client -p {CLIENT_PORT} -q {shlex.quote(cmd)} 2>&1",
f'bash -lc "exec 3<>/dev/tcp/127.0.0.1/{CLIENT_PORT}; printf \'%s\\n\' {shlex.quote(cmd)} >&3; cat <&3; exec 3<&-; exec 3>&-" 2>&1',
# Raw 4LW over TCP (works when keeper-client is missing or -q does not support 4LW)
f"printf '%s\\n' {shlex.quote(cmd)} | timeout 2 nc 127.0.0.1 {CLIENT_PORT} 2>&1",
]
for method in methods:
try:
out = _exec(node, method, nothrow=True, timeout=5)
if isinstance(out, str) and out.strip():
return out
except Exception as e:
print(f"[keeper][four] error executing command {cmd} for node {node.name}: {e}. Skipping.")
print(f"[keeper][four] error executing command {cmd} for node {node.name}: all methods returned empty. Skipping.")
return ""
def is_leader(node):
"""Check if node is the leader using multiple fallback methods."""
def _has_mode(text, mode):
return f"mode: {mode}" in str(text or "").lower()
# Try 'stat' first (most reliable)
stat_out = four(node, "stat")
if _has_mode(stat_out, "leader"):
return True
if _has_mode(stat_out, "follower") or _has_mode(stat_out, "standalone"):
return False
# Fallback to 'srvr'
srvr_out = four(node, "srvr")
if _has_mode(srvr_out, "leader"):
return True
# Final fallback: parse mntr
try:
return mntr(node).get("zk_server_state", "").strip().lower() == "leader"
except Exception:
return False
def count_leaders(nodes):
return sum(1 for n in nodes if is_leader(n))
def _get_current_leader(nodes):
"""Get current leader node, return None if not found."""
try:
return next(n for n in nodes if is_leader(n))
except Exception:
print(f"[keeper] no leader found in nodes: {nodes}")
return None
def ready(node):
try:
out = four(node, "ruok")
return "imok" in str(out or "").lower()
except Exception:
return False
def mntr(node):
kv = {}
out = four(node, "mntr")
if not out:
print(f"[keeper][mntr] error getting mntr for node {node.name}: (empty). Skipping.")
return {}
for line in out.splitlines():
line = line.strip()
if not line:
continue
if "\t" in line:
k, v = line.split("\t", 1)
kv[k.strip()] = v.strip()
else:
parts = line.split(None, 1)
if len(parts) == 2:
k, v = parts
k, v = k.strip(), v.strip()
if k:
kv[k] = v
return kv
def wchs_total(node):
out = four(node, "wchs")
for line in out.splitlines():
if line.lower().startswith("total watches"):
try:
return int(line.split(":")[1].strip())
except Exception:
pass
try:
return int(mntr(node).get("zk_watch_count", "0"))
except Exception:
return 0
def wchp_paths(node):
try:
out = four(node, "wchp")
counts = {}
for line in out.splitlines():
s = line.strip()
if not s or not s.startswith("/"):
continue
parts = s.split()
path = parts[0]
num = None
for tok in reversed(s.replace(",", " ").split()):
try:
num = int(tok)
break
except Exception:
continue
if num is None:
num = 1
counts[path] = counts.get(path, 0) + int(num)
return counts
except Exception:
return {}
def any_ephemerals(node):
return "Sessions with Ephemerals" in four(node, "dump")
def lgif(node):
out = four(node, "lgif")
kv = {}
for line in out.splitlines():
p = line.split()
if len(p) >= 2:
try:
kv[p[0]] = int(p[1])
except Exception:
pass
return kv
def srvr_kv(node):
out = four(node, "srvr")
keys = ("connections", "outstanding", "received", "sent")
kv = {}
for line in out.splitlines():
if ":" not in line:
continue
k, v = line.split(":", 1)
k_clean = k.strip().lower()
v_clean = v.strip().split()[0]
try:
val = float(v_clean)
except Exception:
continue
for key in keys:
if k_clean.startswith(key):
kv[key] = val
break
return kv
def prom_metrics(node):
"""Fetch Prometheus metrics from node using curl (guaranteed by preflight)."""
url = f"http://127.0.0.1:{PROM_PORT}/metrics"
# 2>&1: merge stderr so connection refused, timeouts, etc. are visible when stdout is empty
result = sh(node, f"curl -sf --max-time 2 {url} 2>&1", timeout=4)["out"]
if not result or not result.strip():
msg = (result or "").strip() or "empty response"
raise AssertionError(f"Failed to fetch prometheus metrics for node {node.name}: {msg}")
return result
def ch_metrics(node):
return _query_json_each_row(
node, "SELECT name, value FROM system.metrics FORMAT JSONEachRow"
)
def ch_async_metrics(node):
return _query_json_each_row(
node,
"SELECT name, value FROM system.asynchronous_metrics FORMAT JSONEachRow",
)
def _query_json_each_row(node, sql):
try:
txt = node.query(sql, timeout=KEEPER_CH_QUERY_TIMEOUT, ignore_error=True)
return [json.loads(l) for l in txt.strip().splitlines() if l.strip()]
except Exception:
return []
def ch_trace_log(node, limit_rows=500):
try:
q = f"SELECT * FROM system.trace_log ORDER BY event_time DESC LIMIT {int(limit_rows)} FORMAT JSONEachRow"
return node.query(q, timeout=KEEPER_CH_QUERY_TIMEOUT, ignore_error=True)
except Exception:
return ""
def dirs(node):
"""Return raw output of 4LW 'dirs' command (best-effort)."""
return four(node, "dirs")
def _exec_for_container_stats(node, cmd, nothrow=True, timeout=2):
"""Run cmd in the container we want to measure: ZK container for ZKBackedNode (workload hits ZK), else node's container."""
exec_zk = getattr(node, "exec_in_container_zk", None)
if callable(exec_zk):
args = ["bash", "-lc", cmd] if isinstance(cmd, str) else list(cmd)
return exec_zk(args, nothrow=nothrow, timeout=timeout)
return _exec(node, cmd, nothrow=nothrow, timeout=timeout)
def container_stats(node):
"""Return CPU and memory usage for the container (from cgroup, inside container).
For ZKBackedNode uses the Zookeeper container (zoo1/2/3) since workload is directed there.
Returns dict with container_memory_bytes, container_cpu_usage_usec (may be missing if unreadable),
and container_cpu_limit_cores when a CPU limit is set (cgroup quota); use with usage rate for CPU % of limit.
"""
out = {}
try:
# Memory: cgroup v2 memory.current (bytes) or v1 memory.usage_in_bytes
mem_cmd = (
"cat /sys/fs/cgroup/memory.current 2>/dev/null || "
"cat /sys/fs/cgroup/memory/memory.usage_in_bytes 2>/dev/null"
)
r = _exec_for_container_stats(node, mem_cmd, nothrow=True, timeout=2)
if r and str(r).strip().isdigit():
out["container_memory_bytes"] = int(str(r).strip())
except Exception as e:
print(f"[keeper][container_stats] error getting container memory for node {node.name}: {e}")
try:
# CPU: cgroup v2 cpu.stat (usage_usec) or v1 cpuacct.usage (nanoseconds).
# We always store microseconds so queries can use rate/1e6 = cores.
cpu_sources = [
("grep '^usage_usec' /sys/fs/cgroup/cpu.stat 2>/dev/null | awk '{print $2}'", "usec"),
("cat /sys/fs/cgroup/cpuacct/cpuacct.usage 2>/dev/null", "ns"),
("cat /sys/fs/cgroup/cpu,cpuacct/cpuacct.usage 2>/dev/null", "ns"),
]
val = None
for cmd, unit in cpu_sources:
r = _exec_for_container_stats(node, cmd, nothrow=True, timeout=2)
s = (r and str(r).strip()).strip() if r else ""
s = s.splitlines()[0].strip() if s else ""
if s and s.isdigit():
val = int(s)
if unit == "ns":
val = val // 1000 # ns -> usec
break
if val is not None:
out["container_cpu_usage_usec"] = val
except Exception as e:
print(f"[keeper][container_stats] error getting container cpu for node {node.name}: {e}")
try:
# CPU limit (effective cores): so one can compute CPU % of limit from usage rate.
# cgroup v2: cpu.max is "quota period" (usec) or "max"; effective_cores = quota/period.
# cgroup v1: cpu.cfs_quota_us, cpu.cfs_period_us; quota -1 = no limit; else effective_cores = quota/period.
limit_cores = None
r = _exec_for_container_stats(node, "cat /sys/fs/cgroup/cpu.max 2>/dev/null", nothrow=True, timeout=2)
if r:
parts = str(r).strip().split()
if len(parts) >= 2 and parts[0].isdigit() and parts[1].isdigit():
quota, period = int(parts[0]), int(parts[1])
if period > 0:
limit_cores = quota / period
if limit_cores is None:
r2 = _exec_for_container_stats(
node,
"quota=$(cat /sys/fs/cgroup/cpu/cpu.cfs_quota_us 2>/dev/null); period=$(cat /sys/fs/cgroup/cpu/cpu.cfs_period_us 2>/dev/null); echo $quota $period",
nothrow=True,
timeout=2,
)
if not r2:
r2 = _exec_for_container_stats(
node,
"quota=$(cat /sys/fs/cgroup/cpu,cpuacct/cpu.cfs_quota_us 2>/dev/null); period=$(cat /sys/fs/cgroup/cpu,cpuacct/cpu.cfs_period_us 2>/dev/null); echo $quota $period",
nothrow=True,
timeout=2,
)
if r2:
parts = str(r2).strip().split()
if len(parts) >= 2:
try:
quota, period = int(parts[0]), int(parts[1])
if period > 0 and quota > 0:
limit_cores = quota / period
except ValueError:
pass
if limit_cores is not None and limit_cores > 0:
out["container_cpu_limit_cores"] = limit_cores
except Exception as e:
print(f"[keeper][container_stats] error getting container cpu limit for node {node.name}: {e}")
return out