The mesh harness ran nodes with the build host's resources, so a daemon that pins a small machine's CPU while idle never showed up. `up` and `run` take --cpus and --memory (docker cpus/mem_limit per node). The new `idle` command samples docker stats with no client traffic, writes idle.csv and idle.json, optionally captures top -H of the hottest node, and exits 5 when a node's mean CPU exceeds --max-cpu. Local/manual tooling; nothing runs in CI. Signed-off-by: trvon <me@trevon.dev>
890 lines
41 KiB
Python
890 lines
41 KiB
Python
#!/usr/bin/env python3
|
|
"""N-node YAMS direct-p2p memory-sync mesh in Docker (local/manual use only, not CI).
|
|
|
|
Commands: up, seed, converge, status, chaos, run, down. See README.md.
|
|
Python stdlib only; shells out to the `docker` CLI (compose v2 plugin).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import uuid
|
|
from pathlib import Path
|
|
|
|
HERE = Path(__file__).resolve().parent
|
|
REPO = HERE.parents[2]
|
|
DEFAULT_WORK = REPO / "build" / "p2p_mesh"
|
|
PROJECT_PREFIX = "yamsmesh"
|
|
CORPUS_ID = "mesh-memory"
|
|
LISTEN_PORT = 9721
|
|
|
|
FAILURE_PATTERNS = [
|
|
"memory_sync content apply failed",
|
|
"memory_sync metadata apply failed",
|
|
"memory_sync vector apply failed",
|
|
"memory_sync topology apply failed",
|
|
"replicated embedding content prerequisite is missing",
|
|
"replicated metadata content prerequisite is missing",
|
|
"topology edge references a missing replicated node",
|
|
]
|
|
|
|
TOPICS = [
|
|
("compaction", "log-structured merge tree compaction schedules write amplification and sstable levels"),
|
|
("tls", "mutual tls certificate pinning handshake authenticates peers without a certificate authority"),
|
|
("vectors", "approximate nearest neighbor search over dense embeddings using hierarchical small world graphs"),
|
|
("backup", "incremental backup snapshots deduplicate content addressed chunks across retention windows"),
|
|
("tracing", "distributed tracing spans propagate context across services to explain tail latency"),
|
|
("scheduler", "work stealing scheduler balances coroutine tasks across worker threads under contention"),
|
|
("crdt", "conflict free replicated data types merge concurrent edits with commutative operations"),
|
|
("parsing", "incremental parsers reuse syntax trees so editors can highlight code while typing"),
|
|
]
|
|
|
|
|
|
def log(msg: str) -> None:
|
|
print(f"[mesh] {msg}", flush=True)
|
|
|
|
|
|
def die(msg: str) -> "None":
|
|
print(f"mesh: error: {msg}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
def run(cmd, *, check=True, capture=True, timeout=None, input_text=None, env=None):
|
|
proc = subprocess.run(
|
|
cmd,
|
|
check=False,
|
|
text=True,
|
|
capture_output=capture,
|
|
timeout=timeout,
|
|
input=input_text,
|
|
env=env,
|
|
)
|
|
if check and proc.returncode != 0:
|
|
out = (proc.stdout or "") + (proc.stderr or "")
|
|
die(f"command failed ({proc.returncode}): {' '.join(map(str, cmd))}\n{out.strip()}")
|
|
return proc
|
|
|
|
|
|
class Mesh:
|
|
def __init__(self, work: Path, project: str | None = None):
|
|
self.work = work
|
|
# Each `up` gets its own compose project (containers, network, volumes, image tag), so
|
|
# concurrent meshes on one host never collide. The name is persisted in state.json.
|
|
self.project = project or f"{PROJECT_PREFIX}-{uuid.uuid4().hex[:8]}"
|
|
self.state_path = work / "state.json"
|
|
self.compose_path = work / "docker-compose.yml"
|
|
self.state: dict = {}
|
|
if self.state_path.exists():
|
|
self.state = json.loads(self.state_path.read_text())
|
|
self.project = self.state.get("project", self.project)
|
|
|
|
# -- state ---------------------------------------------------------
|
|
def save(self) -> None:
|
|
self.work.mkdir(parents=True, exist_ok=True)
|
|
self.state_path.write_text(json.dumps(self.state, indent=2))
|
|
|
|
def need_state(self) -> None:
|
|
if not self.state.get("nodes"):
|
|
die(f"no mesh state in {self.work}; run `mesh.py up` first")
|
|
|
|
@property
|
|
def nodes(self) -> list[dict]:
|
|
return self.state["nodes"]
|
|
|
|
def ctr(self, node: dict) -> str:
|
|
return f"{self.project}-{node['name']}"
|
|
|
|
# -- docker helpers -------------------------------------------------
|
|
def compose(self, *args, **kw):
|
|
return run(
|
|
["docker", "compose", "-p", self.project, "-f", str(self.compose_path), *args], **kw
|
|
)
|
|
|
|
def exec(self, node: dict, *argv, user=None, check=True, timeout=120, input_text=None):
|
|
cmd = ["docker", "exec"]
|
|
if input_text is not None:
|
|
cmd.append("-i")
|
|
if user:
|
|
cmd += ["-u", user]
|
|
cmd += [self.ctr(node), *argv]
|
|
return run(cmd, check=check, timeout=timeout, input_text=input_text)
|
|
|
|
def put(self, node: dict, path: str, content: str, mode: str = "644") -> None:
|
|
self.exec(
|
|
node,
|
|
"sh",
|
|
"-c",
|
|
f"umask 077 && cat > '{path}' && chmod {mode} '{path}'",
|
|
input_text=content,
|
|
)
|
|
|
|
def yams(self, node: dict, *args, check=False, timeout=60):
|
|
return self.exec(node, "yams", *args, check=check, timeout=timeout)
|
|
|
|
def p2p_json(self, node: dict, sub: str, timeout=30):
|
|
proc = self.yams(node, "p2p", sub, "--json", timeout=timeout)
|
|
if proc.returncode != 0:
|
|
return None
|
|
try:
|
|
return json.loads(proc.stdout)
|
|
except json.JSONDecodeError:
|
|
return None
|
|
|
|
# -- up -------------------------------------------------------------
|
|
def stage_payload(self, build_dir: Path | None, deb: Path | None) -> Path:
|
|
payload = self.work / "context" / "payload"
|
|
if payload.exists():
|
|
shutil.rmtree(payload)
|
|
payload.mkdir(parents=True)
|
|
if deb:
|
|
if not deb.is_file():
|
|
die(f"--deb {deb} not found")
|
|
shutil.copy2(deb, payload / deb.name)
|
|
return payload.parent
|
|
assert build_dir is not None
|
|
found = {}
|
|
for name, aliases in (("yams-cli", ("yams-cli", "yams")), ("yams-daemon", ("yams-daemon",))):
|
|
for cand in build_dir.rglob("*"):
|
|
if (
|
|
cand.name in aliases
|
|
and cand.is_file()
|
|
and os.access(cand, os.X_OK)
|
|
and "subprojects" not in cand.parts
|
|
and not cand.is_symlink()
|
|
):
|
|
found[name] = cand
|
|
break
|
|
if name not in found:
|
|
die(f"no {name} binary under {build_dir}")
|
|
bindir = payload / "bin"
|
|
libdir = payload / "lib"
|
|
bindir.mkdir()
|
|
libdir.mkdir()
|
|
for name, src in found.items():
|
|
shutil.copy2(src, bindir / name)
|
|
if shutil.which("strip"):
|
|
run(["strip", "--strip-unneeded", str(bindir / name)], check=False)
|
|
(bindir / "yams").symlink_to("yams-cli")
|
|
# Bundle shared libraries that resolve into the build tree or the conan cache.
|
|
libs: set[Path] = set()
|
|
for src in found.values():
|
|
ldd = run(["ldd", str(src)], check=False).stdout
|
|
for m in re.finditer(r"=>\s+(/\S+)", ldd):
|
|
p = Path(m.group(1))
|
|
if str(p).startswith(str(build_dir.resolve())) or ".conan" in str(p):
|
|
libs.add(p)
|
|
for lib in sorted(libs):
|
|
shutil.copy2(lib, libdir / lib.name)
|
|
log(f"payload: {', '.join(p.name for p in found.values())} + {len(libs)} bundled libs")
|
|
return payload.parent
|
|
|
|
def write_compose(self, nodes: list[dict], image: str, log_level: str,
|
|
cpus: float = 0.0, memory: str = "") -> None:
|
|
# cpus/memory are per-node cgroup limits (docker `cpus:` / `mem_limit:`). They model a
|
|
# small host; 0 / "" means unlimited.
|
|
env = {
|
|
"HOME": "/node/home",
|
|
"XDG_CONFIG_HOME": "/node/xdg/config",
|
|
"XDG_DATA_HOME": "/node/xdg/data",
|
|
"XDG_STATE_HOME": "/node/xdg/state",
|
|
"XDG_RUNTIME_DIR": "/node/run",
|
|
"YAMS_DATA_DIR": "/node/data",
|
|
"YAMS_DAEMON_SOCKET": "/node/run/yams.sock",
|
|
"YAMS_NON_INTERACTIVE": "1",
|
|
"LD_LIBRARY_PATH": "/opt/yams/lib",
|
|
"MESH_LOG_LEVEL": log_level,
|
|
}
|
|
lines = ["name: " + self.project, "services:"]
|
|
for n in nodes:
|
|
lines += [
|
|
f" {n['name']}:",
|
|
f" image: {image}",
|
|
f" container_name: {self.ctr(n)}",
|
|
f" hostname: {n['name']}",
|
|
" init: true",
|
|
" cap_add: [NET_ADMIN]",
|
|
" stop_grace_period: 20s",
|
|
*([f" cpus: {cpus}"] if cpus > 0 else []),
|
|
*([f" mem_limit: {memory}"] if memory else []),
|
|
" networks: [mesh]",
|
|
" environment:",
|
|
*[f" {k}: \"{v}\"" for k, v in env.items()],
|
|
" volumes:",
|
|
f" - {n['name']}-data:/node",
|
|
]
|
|
lines += ["networks:", " mesh:", " driver: bridge", "volumes:"]
|
|
lines += [f" {n['name']}-data:" for n in nodes]
|
|
self.compose_path.write_text("\n".join(lines) + "\n")
|
|
|
|
def cmd_up(self, a) -> None:
|
|
if self.state.get("nodes") and not a.force:
|
|
die("mesh state exists; run `mesh.py down` first (or pass --force)")
|
|
if bool(a.build_dir) == bool(a.deb):
|
|
die("pass exactly one of --build-dir or --deb")
|
|
if a.nodes < 2:
|
|
die("--nodes must be >= 2")
|
|
if self.state.get("nodes"):
|
|
# --force: remove the previous mesh first. Recreated services would otherwise reuse
|
|
# its named /node volumes (stale /node/ready, keys, config), and services of a
|
|
# larger previous mesh would be left running as orphans.
|
|
log(f"tearing down previous mesh {self.project}")
|
|
self.cmd_down(argparse.Namespace(keep_image=False, purge=False))
|
|
self.project = f"{PROJECT_PREFIX}-{uuid.uuid4().hex[:8]}"
|
|
self.work.mkdir(parents=True, exist_ok=True)
|
|
if a.project:
|
|
self.project = a.project
|
|
log(f"compose project {self.project}")
|
|
ctx = self.stage_payload(Path(a.build_dir).resolve() if a.build_dir else None,
|
|
Path(a.deb).resolve() if a.deb else None)
|
|
shutil.copy2(HERE / "Dockerfile", ctx / "Dockerfile")
|
|
shutil.copy2(HERE / "node.sh", ctx / "node.sh")
|
|
image = f"{self.project}:{int(time.time())}"
|
|
base = a.base or ("ubuntu:24.04" if a.deb else "debian:testing-slim")
|
|
log(f"building image {image} (base {base})")
|
|
run(["docker", "build", "--build-arg", f"BASE={base}", "-t", image, str(ctx)],
|
|
capture=False)
|
|
|
|
nodes = [
|
|
{"name": f"n{i + 1}", "node_id": str(uuid.uuid4()), "key_id": f"n{i + 1}-v1"}
|
|
for i in range(a.nodes)
|
|
]
|
|
self.state = {
|
|
"project": self.project,
|
|
"image": image,
|
|
"corpus_id": CORPUS_ID,
|
|
"corpus_epoch": 1,
|
|
"nodes": nodes,
|
|
"docs": [],
|
|
"extra_config": a.config or [],
|
|
"cpus": a.cpus,
|
|
"memory": a.memory,
|
|
}
|
|
self.write_compose(nodes, image, a.log_level, a.cpus, a.memory)
|
|
self.save()
|
|
self.compose("up", "-d", "--no-build", capture=False)
|
|
|
|
log("waiting for containers")
|
|
for n in nodes:
|
|
self.wait_for(lambda n=n: self.exec(n, "test", "-d", "/node/home", check=False).returncode == 0,
|
|
30, f"{n['name']} container")
|
|
for n in nodes:
|
|
self.bootstrap_node(n)
|
|
self.finish_bootstrap()
|
|
for n in nodes:
|
|
self.exec(n, "touch", "/node/ready")
|
|
for n in nodes:
|
|
self.wait_for(lambda n=n: self.p2p_json(n, "identity") is not None, 90,
|
|
f"{n['name']} daemon")
|
|
ident = self.p2p_json(n, "identity")
|
|
n["spki_pin"] = ident["spki_pin"]
|
|
if ident["node_id"] != n["node_id"]:
|
|
die(f"{n['name']} reports node_id {ident['node_id']} != configured {n['node_id']}")
|
|
self.save()
|
|
self.wire_mesh(a.topology, a.connect)
|
|
log(f"mesh of {a.nodes} nodes is up")
|
|
self.print_status_line()
|
|
|
|
def bootstrap_node(self, n: dict) -> None:
|
|
"""Keys + config for one node: writer key, manifest listing every writer, config.toml."""
|
|
self.exec(n, "sh", "-c", "mkdir -p /node/keys /node/xdg/config/yams && chmod 700 /node/keys")
|
|
init = self.yams(n, "init", "--auto", "--no-keygen", timeout=120)
|
|
if init.returncode != 0:
|
|
die(f"yams init failed on {n['name']}: {init.stdout}{init.stderr}")
|
|
self.exec(n, "openssl", "genpkey", "-algorithm", "ED25519", "-out", "/node/keys/writer-private.pem")
|
|
self.exec(n, "chmod", "600", "/node/keys/writer-private.pem")
|
|
pub = self.exec(n, "openssl", "pkey", "-in", "/node/keys/writer-private.pem", "-pubout").stdout
|
|
n["writer_public"] = pub
|
|
self.exec(n, "chmod", "go-w", "/node/keys")
|
|
self.save()
|
|
|
|
def finish_bootstrap(self) -> None:
|
|
for n in self.nodes:
|
|
trusted = []
|
|
for peer in self.nodes:
|
|
path = f"/node/keys/{peer['name']}-public.pem"
|
|
self.put(n, path, peer["writer_public"], "644")
|
|
trusted.append(
|
|
{"writer_id": peer["node_id"], "key_id": peer["key_id"], "public_key_path": path}
|
|
)
|
|
manifest = {
|
|
"schema_version": 1,
|
|
"corpus_id": self.state["corpus_id"],
|
|
"corpus_epoch": self.state["corpus_epoch"],
|
|
"local_key": {
|
|
"writer_id": n["node_id"],
|
|
"key_id": n["key_id"],
|
|
"private_key_path": "/node/keys/writer-private.pem",
|
|
},
|
|
"trusted_writers": trusted,
|
|
}
|
|
self.put(n, "/node/keys/writers.json", json.dumps(manifest, indent=2), "644")
|
|
toml = [
|
|
"[memory_sync]",
|
|
"enabled = true",
|
|
'transport = "direct"',
|
|
f'listen = "0.0.0.0:{LISTEN_PORT}"',
|
|
f'node_id = "{n["node_id"]}"',
|
|
f'corpus_id = "{self.state["corpus_id"]}"',
|
|
f'corpus_epoch = {self.state["corpus_epoch"]}',
|
|
'corpus_scope = "shared"',
|
|
'mode = "persistent"',
|
|
"allow_first_contact = false",
|
|
"writer_auth_required = true",
|
|
'writer_auth_manifest = "/node/keys/writers.json"',
|
|
]
|
|
toml += [f"{kv}" for kv in self.state.get("extra_config", [])]
|
|
# Start from the same config `yams init` gives a fresh install (simeon embeddings,
|
|
# vector DB, topology defaults), then append the sync section the way an operator does.
|
|
self.put(n, "/node/memory-sync.toml", "\n".join(toml) + "\n")
|
|
self.exec(n, "sh", "-c",
|
|
"printf '\\n' >> /node/xdg/config/yams/config.toml && "
|
|
"cat /node/memory-sync.toml >> /node/xdg/config/yams/config.toml")
|
|
|
|
def wire_mesh(self, topology: str, connect: str) -> None:
|
|
nodes = self.nodes
|
|
for n in nodes:
|
|
for peer in nodes:
|
|
if peer is n:
|
|
continue
|
|
proc = self.yams(n, "p2p", "enroll", peer["node_id"], peer["spki_pin"])
|
|
if proc.returncode != 0:
|
|
die(f"enroll {peer['name']} on {n['name']} failed: {proc.stdout}{proc.stderr}")
|
|
if topology == "none":
|
|
return
|
|
for i, n in enumerate(nodes):
|
|
for j, peer in enumerate(nodes):
|
|
if i == j or (connect == "once" and j < i):
|
|
continue
|
|
conn = f"{peer['name']}:{LISTEN_PORT}?pin={peer['spki_pin']}&remember=true"
|
|
proc = self.yams(n, "p2p", "connect", conn, timeout=90)
|
|
tag = "ok" if proc.returncode == 0 else "FAILED"
|
|
log(f"connect {n['name']} -> {peer['name']}: {tag}")
|
|
if proc.returncode != 0:
|
|
log((proc.stdout + proc.stderr).strip())
|
|
|
|
def wait_for(self, fn, timeout, what):
|
|
deadline = time.time() + timeout
|
|
while time.time() < deadline:
|
|
try:
|
|
if fn():
|
|
return
|
|
except subprocess.TimeoutExpired:
|
|
pass
|
|
time.sleep(1)
|
|
die(f"timed out waiting for {what}")
|
|
|
|
# -- status ---------------------------------------------------------
|
|
def node_status(self, n: dict) -> dict:
|
|
data = self.p2p_json(n, "status") or {}
|
|
return data
|
|
|
|
def print_status_line(self) -> None:
|
|
for n in self.nodes:
|
|
proc = self.yams(n, "p2p", "status")
|
|
line = (proc.stdout or proc.stderr).strip().splitlines()
|
|
print(f" {n['name']}: {line[0] if line else '<no output>'}")
|
|
|
|
def count_failures(self, n: dict) -> dict[str, int]:
|
|
logs = self.exec(n, "sh", "-c", "cat /node/log/daemon.log 2>/dev/null", check=False).stdout
|
|
return {p: logs.count(p) for p in FAILURE_PATTERNS if logs.count(p)}
|
|
|
|
def cmd_status(self, a) -> None:
|
|
self.need_state()
|
|
out = Path(a.out) if a.out else self.work / "artifacts" / time.strftime("%Y%m%d-%H%M%S")
|
|
self.collect(out)
|
|
log(f"artifacts: {out}")
|
|
|
|
def collect(self, out: Path) -> dict:
|
|
out.mkdir(parents=True, exist_ok=True)
|
|
summary = {}
|
|
for n in self.nodes:
|
|
nd = out / n["name"]
|
|
nd.mkdir(exist_ok=True)
|
|
entry = {"status": self.node_status(n), "peers": self.p2p_json(n, "peers")}
|
|
(nd / "p2p-status.json").write_text(json.dumps(entry["status"], indent=2))
|
|
(nd / "p2p-peers.json").write_text(json.dumps(entry["peers"], indent=2))
|
|
text = self.yams(n, "p2p", "status").stdout
|
|
(nd / "p2p-status.txt").write_text(text)
|
|
logs = self.exec(n, "sh", "-c", "cat /node/log/daemon.log 2>/dev/null", check=False).stdout
|
|
(nd / "daemon.log").write_text(logs)
|
|
(nd / "container.log").write_text(
|
|
run(["docker", "logs", self.ctr(n)], check=False).stdout or ""
|
|
)
|
|
entry["failures"] = {p: logs.count(p) for p in FAILURE_PATTERNS if logs.count(p)}
|
|
entry["status_line"] = text.strip().splitlines()[0] if text.strip() else ""
|
|
entry["apply"] = (entry["status"] or {}).get("apply")
|
|
summary[n["name"]] = entry
|
|
(out / "summary.json").write_text(json.dumps(summary, indent=2))
|
|
for name, e in summary.items():
|
|
print(f" {name}: {e['status_line']}")
|
|
for p, c in e["failures"].items():
|
|
print(f" {c:5d} {p}")
|
|
if e.get("apply"):
|
|
ap = e["apply"]
|
|
print(f" apply: cycles={ap.get('cycles')} failed={ap.get('failed_cycles')} "
|
|
f"deferred={ap.get('deferred')} publish_skipped={ap.get('publish_skipped_cycles')}")
|
|
return summary
|
|
|
|
# -- idle -----------------------------------------------------------
|
|
def cmd_idle(self, a) -> int:
|
|
"""Sample per-node CPU% and RSS while no client talks to the mesh.
|
|
|
|
Writes idle.csv (one row per sample per node) and idle.json (mean/max per node) to
|
|
the artifacts dir. Exit 5 when a node's mean CPU% is above --max-cpu. The daemon is
|
|
the only process of note in a node, so `docker stats` is the node's daemon.
|
|
"""
|
|
self.need_state()
|
|
if a.seconds < a.interval or a.interval < 1:
|
|
die("--seconds must be >= --interval >= 1")
|
|
out = Path(a.out) if a.out else self.work / "artifacts" / time.strftime("%Y%m%d-%H%M%S")
|
|
out.mkdir(parents=True, exist_ok=True)
|
|
names = {self.ctr(n): n["name"] for n in self.nodes}
|
|
rows: list[dict] = []
|
|
hottest: dict[str, tuple[float, str]] = {}
|
|
deadline = time.monotonic() + a.seconds
|
|
fmt = "{{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}"
|
|
log(f"sampling {len(names)} nodes for {a.seconds}s every {a.interval}s"
|
|
+ (f" (limits: cpus={self.state.get('cpus')} memory={self.state.get('memory')})"
|
|
if self.state.get("cpus") or self.state.get("memory") else ""))
|
|
while time.monotonic() < deadline:
|
|
t0 = time.monotonic()
|
|
res = run(["docker", "stats", "--no-stream", "--format", fmt, *names], check=False)
|
|
stamp = time.time()
|
|
for line in (res.stdout or "").splitlines():
|
|
parts = line.split("\t")
|
|
if len(parts) != 3 or parts[0] not in names:
|
|
continue
|
|
cpu = float(parts[1].rstrip("%") or 0.0)
|
|
mem = parts[2].split("/")[0].strip()
|
|
rows.append({"t": stamp, "node": names[parts[0]], "cpu_pct": cpu, "mem": mem})
|
|
prev = hottest.get(names[parts[0]], (-1.0, ""))
|
|
if cpu > prev[0]:
|
|
hottest[names[parts[0]]] = (cpu, parts[0])
|
|
time.sleep(max(0.0, a.interval - (time.monotonic() - t0)))
|
|
with (out / "idle.csv").open("w") as f:
|
|
f.write("t,node,cpu_pct,mem\n")
|
|
for r in rows:
|
|
f.write(f"{r['t']:.0f},{r['node']},{r['cpu_pct']:.2f},{r['mem']}\n")
|
|
summary: dict[str, dict] = {}
|
|
for n in self.nodes:
|
|
cpus = [r["cpu_pct"] for r in rows if r["node"] == n["name"]]
|
|
if not cpus:
|
|
continue
|
|
summary[n["name"]] = {
|
|
"samples": len(cpus),
|
|
"cpu_mean": round(sum(cpus) / len(cpus), 2),
|
|
"cpu_max": round(max(cpus), 2),
|
|
"mem_last": next(r["mem"] for r in reversed(rows) if r["node"] == n["name"]),
|
|
}
|
|
# Per-thread view of the hottest node so the pinning thread is named, not guessed.
|
|
if a.threads and hottest:
|
|
node = max(hottest, key=lambda k: hottest[k][0])
|
|
n = next(x for x in self.nodes if x["name"] == node)
|
|
top = self.exec(n, "sh", "-c", "top -H -b -n 1 -o %CPU 2>/dev/null | head -40",
|
|
check=False).stdout or ""
|
|
(out / f"{node}-threads.txt").write_text(top)
|
|
summary[node]["threads_file"] = f"{node}-threads.txt"
|
|
(out / "idle.json").write_text(json.dumps(summary, indent=2))
|
|
rc = 0
|
|
for name, s in summary.items():
|
|
flag = ""
|
|
if a.max_cpu is not None and s["cpu_mean"] > a.max_cpu:
|
|
flag = f" OVER --max-cpu {a.max_cpu}"
|
|
rc = 5
|
|
print(f" {name}: cpu mean {s['cpu_mean']}% max {s['cpu_max']}% "
|
|
f"mem {s['mem_last']} ({s['samples']} samples){flag}")
|
|
log(f"artifacts: {out}")
|
|
return rc
|
|
|
|
# -- seed -----------------------------------------------------------
|
|
def cmd_seed(self, a) -> None:
|
|
self.need_state()
|
|
if a.docs_per_node < 1:
|
|
die("--docs-per-node must be >= 1")
|
|
if not 1 <= a.topics <= len(TOPICS):
|
|
die(f"--topics must be between 1 and {len(TOPICS)}")
|
|
topics = TOPICS[: a.topics]
|
|
added = []
|
|
# Exactly --docs-per-node documents per node: full rounds over every topic, then a
|
|
# truncated final round.
|
|
rounds = -(-a.docs_per_node // len(topics))
|
|
for r in range(rounds):
|
|
in_round = min(len(topics), a.docs_per_node - r * len(topics))
|
|
for n in self.nodes:
|
|
for t, (topic, blurb) in enumerate(topics[:in_round]):
|
|
# Same topic everywhere (so semantic-neighbour edges cross nodes), but every
|
|
# document body is unique to its author node and round.
|
|
body = (
|
|
f"Note {r} on {topic} by {n['name']}: {blurb}. "
|
|
f"This variant was written on {n['name']} (writer {n['node_id']}), "
|
|
f"round {r}, topic index {t}. "
|
|
+ " ".join(f"{topic}-{n['name']}-{r}-{k}" for k in range(a.words))
|
|
+ "\n"
|
|
)
|
|
fname = f"/node/seed/{topic}-{n['name']}-{r}.md"
|
|
self.exec(n, "mkdir", "-p", "/node/seed")
|
|
self.put(n, fname, body)
|
|
proc = self.yams(n, "add", fname, "--tags", f"mesh,{topic}", timeout=120)
|
|
if proc.returncode != 0:
|
|
die(f"add on {n['name']} failed: {proc.stdout}{proc.stderr}")
|
|
h = hashlib.sha256(body.encode()).hexdigest()
|
|
added.append({"node": n["name"], "hash": h, "topic": topic, "round": r})
|
|
if a.stagger:
|
|
time.sleep(a.stagger)
|
|
self.state["docs"] = self.state.get("docs", []) + added
|
|
self.save()
|
|
log(f"seeded {len(added)} documents ({a.docs_per_node} per node over {len(topics)} topics, "
|
|
f"{len(self.nodes)} nodes)")
|
|
if a.settle:
|
|
log(f"waiting {a.settle}s for local post-ingest (embeddings/topology)")
|
|
time.sleep(a.settle)
|
|
|
|
# -- converge -------------------------------------------------------
|
|
@staticmethod
|
|
def is_zero(value) -> bool:
|
|
"""An explicit numeric zero; a missing field or failed status read is not zero."""
|
|
return isinstance(value, int) and not isinstance(value, bool) and value == 0
|
|
|
|
def listed_hashes(self, n: dict) -> set[str] | None:
|
|
"""All document hashes the node lists (one CLI call)."""
|
|
proc = self.yams(n, "list", "--format", "json", "--limit", "1000000", timeout=120)
|
|
if proc.returncode != 0:
|
|
return None
|
|
try:
|
|
docs = json.loads(proc.stdout).get("documents", [])
|
|
except json.JSONDecodeError:
|
|
return None
|
|
return {d["hash"] for d in docs if d.get("hash")}
|
|
|
|
def readable(self, n: dict, h: str) -> bool:
|
|
proc = self.yams(n, "get", "--hash", h, "--raw", "--max-bytes", "1", timeout=30)
|
|
return proc.returncode == 0
|
|
|
|
def apply_summary(self, n: dict) -> dict:
|
|
"""Compact view of status JSON (the `apply` block exists only on builds that report it)."""
|
|
st = self.node_status(n)
|
|
ap = st.get("apply") or {}
|
|
return {
|
|
"records": st.get("records"),
|
|
"peers": st.get("peer_count"),
|
|
"failed_cycles": st.get("failed_cycles"),
|
|
"quarantined": st.get("quarantined"),
|
|
"quarantined_writers": st.get("quarantined_writers"),
|
|
"apply": ap or None,
|
|
"deferred": (ap.get("deferred") or {}).get("total") if ap else None,
|
|
}
|
|
|
|
def cmd_converge(self, a) -> int:
|
|
self.need_state()
|
|
docs = self.state.get("docs", [])
|
|
if not docs:
|
|
die("no seeded documents; run `mesh.py seed` first")
|
|
want = {d["hash"] for d in docs}
|
|
start = time.time()
|
|
deadline = start + a.timeout
|
|
trace = []
|
|
it = 0
|
|
converged = False
|
|
bad = None
|
|
while True:
|
|
it += 1
|
|
bad = None
|
|
missing = {}
|
|
summaries = {}
|
|
for n in self.nodes:
|
|
have = self.listed_hashes(n)
|
|
missing[n["name"]] = sorted(want - have) if have is not None else sorted(want)
|
|
summaries[n["name"]] = self.apply_summary(n)
|
|
total = len(want)
|
|
elapsed = round(time.time() - start, 1)
|
|
line = " ".join(
|
|
f"{k}:{total - len(v)}/{total}(rec={summaries[k]['records']}"
|
|
+ (f",def={summaries[k]['deferred']}" if summaries[k]["deferred"] is not None else "")
|
|
+ ")"
|
|
for k, v in missing.items()
|
|
)
|
|
print(f"[converge #{it} t={elapsed}s] {line}", flush=True)
|
|
trace.append({"t": elapsed, "have": {k: total - len(v) for k, v in missing.items()},
|
|
"nodes": summaries})
|
|
if all(not v for v in missing.values()):
|
|
# Final proof with the real read path: `yams get` by hash on every node.
|
|
if not a.fast:
|
|
bad = {n["name"]: [h for h in sorted(want) if not self.readable(n, h)]
|
|
for n in self.nodes}
|
|
if bad is None or all(not v for v in bad.values()):
|
|
converged = True
|
|
break
|
|
if time.time() >= deadline:
|
|
break
|
|
time.sleep(a.interval)
|
|
# When every hash was listed but `yams get` still failed for some, the read-path result
|
|
# is the truth: report those hashes as unreadable rather than the empty listing gap.
|
|
unreadable = bad if bad is not None else missing
|
|
result = {
|
|
"converged": converged,
|
|
"seconds": round(time.time() - start, 1),
|
|
"documents": len(want),
|
|
"readable": {k: len(want) - len(v) for k, v in unreadable.items()},
|
|
"unreadable_via_get": bad,
|
|
"final": summaries,
|
|
"trace": trace,
|
|
}
|
|
if converged and any(s["deferred"] is not None for s in summaries.values()):
|
|
# Deferred records must drain once every document is readable; give the apply
|
|
# loop a few cycles to retire the tail.
|
|
drain_deadline = time.time() + a.drain_timeout
|
|
while True:
|
|
summaries = {n["name"]: self.apply_summary(n) for n in self.nodes}
|
|
if all(self.is_zero(s["deferred"]) for s in summaries.values()):
|
|
result["deferred_drained"] = True
|
|
break
|
|
if time.time() >= drain_deadline:
|
|
result["deferred_drained"] = False
|
|
break
|
|
time.sleep(3)
|
|
result["final"] = summaries
|
|
self.state["last_converge"] = result
|
|
self.save()
|
|
self.work.mkdir(parents=True, exist_ok=True)
|
|
(self.work / "converge.json").write_text(json.dumps(result, indent=2))
|
|
# Builds without the field report it on no node; once any node reports it, every node
|
|
# must report an explicit zero (a failed status read does not pass).
|
|
quarantined = {}
|
|
if any(s.get("quarantined_writers") is not None for s in result["final"].values()):
|
|
quarantined = {k: s.get("quarantined_writers") for k, s in result["final"].items()
|
|
if not self.is_zero(s.get("quarantined_writers"))}
|
|
result["quarantined_writers"] = quarantined
|
|
(self.work / "converge.json").write_text(json.dumps(result, indent=2))
|
|
if converged:
|
|
log(f"CONVERGED in {result['seconds']}s: every document hash is readable on every node")
|
|
if quarantined:
|
|
# An honest mesh never quarantines a writer; a durable quarantine means a node
|
|
# rejected a peer's history and will diverge from it from now on.
|
|
log(f"FAILED: quarantined_writers is not 0 (None: status unreadable): {quarantined}")
|
|
return 4
|
|
if result.get("deferred_drained") is False:
|
|
log("WARNING: deferred records did not drain to 0 after convergence")
|
|
return 3
|
|
return 0
|
|
log(f"NOT CONVERGED after {a.timeout}s: readable {result['readable']} of {len(want)}")
|
|
return 2
|
|
|
|
# -- chaos ----------------------------------------------------------
|
|
def node_by_name(self, name: str) -> dict:
|
|
for n in self.nodes:
|
|
if n["name"] == name:
|
|
return n
|
|
die(f"unknown node {name}")
|
|
|
|
def cmd_chaos(self, a) -> None:
|
|
self.need_state()
|
|
n = self.node_by_name(a.node)
|
|
net = f"{self.project}_mesh"
|
|
act = a.action
|
|
if act == "restart":
|
|
run(["docker", "restart", self.ctr(n)], capture=False)
|
|
elif act == "kill":
|
|
run(["docker", "kill", self.ctr(n)], capture=False)
|
|
elif act == "start":
|
|
run(["docker", "start", self.ctr(n)], capture=False)
|
|
elif act == "pause":
|
|
run(["docker", "pause", self.ctr(n)], capture=False)
|
|
elif act == "unpause":
|
|
run(["docker", "unpause", self.ctr(n)], capture=False)
|
|
elif act == "partition":
|
|
run(["docker", "network", "disconnect", net, self.ctr(n)], capture=False)
|
|
elif act == "heal":
|
|
run(["docker", "network", "connect", "--alias", n["name"], net, self.ctr(n)], capture=False)
|
|
elif act == "netem":
|
|
args = ["tc", "qdisc", "replace", "dev", "eth0", "root", "netem"]
|
|
if a.delay_ms:
|
|
args += ["delay", f"{a.delay_ms}ms"]
|
|
if a.loss:
|
|
args += ["loss", f"{a.loss}%"]
|
|
proc = self.exec(n, *args, user="root", check=False)
|
|
if proc.returncode != 0:
|
|
die(f"tc failed (needs NET_ADMIN and iproute2): {proc.stderr.strip()}")
|
|
elif act == "netem-clear":
|
|
self.exec(n, "tc", "qdisc", "del", "dev", "eth0", "root", user="root", check=False)
|
|
log(f"{act} {n['name']}: done")
|
|
|
|
# -- run (scenario) -------------------------------------------------
|
|
def cmd_run(self, a) -> int:
|
|
"""up + seed + converge + status + REPORT.md, then down unless --keep."""
|
|
label = a.label or f"{a.nodes}node"
|
|
out = Path(a.out) if a.out else self.work / "artifacts" / label
|
|
out.mkdir(parents=True, exist_ok=True)
|
|
self.cmd_up(a)
|
|
self.cmd_seed(a)
|
|
rc = self.cmd_converge(a)
|
|
summary = self.collect(out)
|
|
conv = json.loads((self.work / "converge.json").read_text())
|
|
(out / "converge.json").write_text(json.dumps(conv, indent=2))
|
|
lines = [
|
|
f"# Scenario {label}",
|
|
"",
|
|
f"- build: {a.build_dir or a.deb}",
|
|
f"- nodes: {a.nodes}, documents seeded: {conv['documents']}",
|
|
f"- converged: {conv['converged']} in {conv['seconds']}s (timeout {a.timeout}s)",
|
|
f"- deferred drained: {conv.get('deferred_drained', 'n/a (build has no apply status)')}",
|
|
"",
|
|
"| node | readable | records | failed_cycles | peers | quarantined | quarantined_writers "
|
|
"| deferred | vector fails | topology fails |",
|
|
"|---|---|---|---|---|---|---|---|---|---|",
|
|
]
|
|
for name, e in summary.items():
|
|
fin = conv["final"][name]
|
|
lines.append(
|
|
f"| {name} | {conv['readable'][name]}/{conv['documents']} | {fin['records']} | "
|
|
f"{fin['failed_cycles']} | {fin['peers']} | {fin['quarantined']} | "
|
|
f"{fin.get('quarantined_writers')} | "
|
|
f"{fin['deferred'] if fin['deferred'] is not None else 'n/a'} | "
|
|
f"{e['failures'].get('memory_sync vector apply failed', 0)} | "
|
|
f"{e['failures'].get('memory_sync topology apply failed', 0)} |"
|
|
)
|
|
lines += ["", "Status lines:", ""]
|
|
lines += [f" {n}: {e['status_line']}" for n, e in summary.items()]
|
|
(out / "REPORT.md").write_text("\n".join(lines) + "\n")
|
|
log(f"report: {out / 'REPORT.md'}")
|
|
if not a.keep:
|
|
self.cmd_down(argparse.Namespace(keep_image=False, purge=False))
|
|
return rc
|
|
|
|
# -- down -----------------------------------------------------------
|
|
def cmd_down(self, a) -> None:
|
|
if self.compose_path.exists():
|
|
self.compose("down", "-v", "--remove-orphans", check=False, capture=False)
|
|
image = self.state.get("image")
|
|
if image and not a.keep_image:
|
|
run(["docker", "rmi", image], check=False)
|
|
if a.purge and self.work.exists():
|
|
shutil.rmtree(self.work / "context", ignore_errors=True)
|
|
self.state = {}
|
|
if self.state_path.exists():
|
|
self.state_path.unlink()
|
|
log("down")
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
p.add_argument("--work", default=os.environ.get("MESH_WORK", str(DEFAULT_WORK)),
|
|
help="state/artifact directory (default: build/p2p_mesh)")
|
|
sub = p.add_subparsers(dest="cmd", required=True)
|
|
|
|
up = sub.add_parser("up", help="build image, start N nodes, enroll and connect a full mesh")
|
|
up.add_argument("--nodes", type=int, default=3)
|
|
up.add_argument("--build-dir", help="host build dir containing yams-cli and yams-daemon")
|
|
up.add_argument("--deb", help="released .deb to install instead of a build dir")
|
|
up.add_argument("--base", help="base image (default debian:testing-slim; ubuntu:24.04 for --deb)")
|
|
up.add_argument("--topology", choices=["full", "none"], default="full",
|
|
help="none: enroll only, no connect")
|
|
up.add_argument("--connect", choices=["once", "both"], default="both",
|
|
help="connect each pair from one side or from both sides")
|
|
up.add_argument("--config", action="append",
|
|
help="extra raw line appended to [memory_sync] (repeatable)")
|
|
up.add_argument("--log-level", default="info")
|
|
up.add_argument("--force", action="store_true")
|
|
up.add_argument("--project", help="compose project name (default: unique per run)")
|
|
up.add_argument("--cpus", type=float, default=0.0,
|
|
help="per-node CPU limit, e.g. 2 (default: unlimited)")
|
|
up.add_argument("--memory", default="",
|
|
help="per-node memory limit, e.g. 2g (default: unlimited)")
|
|
|
|
seed = sub.add_parser("seed", help="add documents on every node")
|
|
seed.add_argument("--docs-per-node", type=int, default=16)
|
|
seed.add_argument("--topics", type=int, default=4, help="shared topics (max %d)" % len(TOPICS))
|
|
seed.add_argument("--words", type=int, default=40, help="unique filler tokens per document")
|
|
seed.add_argument("--stagger", type=float, default=0.0, help="seconds between node rounds")
|
|
seed.add_argument("--settle", type=int, default=0, help="seconds to wait after adding")
|
|
|
|
cv = sub.add_parser("converge", help="poll until every hash is readable on every node")
|
|
cv.add_argument("--timeout", type=int, default=300)
|
|
cv.add_argument("--interval", type=int, default=10)
|
|
cv.add_argument("--drain-timeout", type=int, default=60,
|
|
help="after convergence, wait this long for apply.deferred to reach 0")
|
|
cv.add_argument("--fast", action="store_true",
|
|
help="trust `yams list` instead of verifying every hash with `yams get`")
|
|
|
|
st = sub.add_parser("status", help="collect p2p status and daemon logs into an artifacts dir")
|
|
st.add_argument("--out")
|
|
|
|
ch = sub.add_parser("chaos", help="restart/kill/pause/partition/netem a node")
|
|
ch.add_argument("action", choices=["restart", "kill", "start", "pause", "unpause", "partition",
|
|
"heal", "netem", "netem-clear"])
|
|
ch.add_argument("node")
|
|
ch.add_argument("--delay-ms", type=int, default=0)
|
|
ch.add_argument("--loss", type=float, default=0.0)
|
|
|
|
rn = sub.add_parser("run", help="scenario: up + seed + converge + status + report (+ down)")
|
|
rn.add_argument("--nodes", type=int, default=3)
|
|
rn.add_argument("--build-dir")
|
|
rn.add_argument("--deb")
|
|
rn.add_argument("--base")
|
|
rn.add_argument("--topology", choices=["full", "none"], default="full")
|
|
rn.add_argument("--connect", choices=["once", "both"], default="both")
|
|
rn.add_argument("--config", action="append")
|
|
rn.add_argument("--log-level", default="info")
|
|
rn.add_argument("--force", action="store_true")
|
|
rn.add_argument("--project", help="compose project name (default: unique per run)")
|
|
rn.add_argument("--cpus", type=float, default=0.0)
|
|
rn.add_argument("--memory", default="")
|
|
rn.add_argument("--docs-per-node", type=int, default=40)
|
|
rn.add_argument("--topics", type=int, default=8)
|
|
rn.add_argument("--words", type=int, default=40)
|
|
rn.add_argument("--stagger", type=float, default=0.0)
|
|
rn.add_argument("--settle", type=int, default=30)
|
|
rn.add_argument("--timeout", type=int, default=300)
|
|
rn.add_argument("--interval", type=int, default=15)
|
|
rn.add_argument("--drain-timeout", type=int, default=60)
|
|
rn.add_argument("--fast", action="store_true")
|
|
rn.add_argument("--label")
|
|
rn.add_argument("--out")
|
|
rn.add_argument("--keep", action="store_true", help="leave the mesh up afterwards")
|
|
|
|
idl = sub.add_parser("idle", help="sample idle CPU and RSS per node; exit 5 over --max-cpu")
|
|
idl.add_argument("--seconds", type=int, default=300)
|
|
idl.add_argument("--interval", type=int, default=5)
|
|
idl.add_argument("--max-cpu", type=float, default=None,
|
|
help="fail when a node's mean CPU%% exceeds this (percent of one core)")
|
|
idl.add_argument("--threads", action="store_true",
|
|
help="also capture `top -H` of the hottest node")
|
|
idl.add_argument("--out")
|
|
|
|
dn = sub.add_parser("down", help="remove containers, network, volumes")
|
|
dn.add_argument("--keep-image", action="store_true", help="keep the built image")
|
|
dn.add_argument("--purge", action="store_true", help="also delete the staged build context")
|
|
return p
|
|
|
|
|
|
def main() -> int:
|
|
args = build_parser().parse_args()
|
|
mesh = Mesh(Path(args.work))
|
|
if args.cmd == "up":
|
|
mesh.cmd_up(args)
|
|
elif args.cmd == "seed":
|
|
mesh.cmd_seed(args)
|
|
elif args.cmd == "converge":
|
|
return mesh.cmd_converge(args)
|
|
elif args.cmd == "status":
|
|
mesh.cmd_status(args)
|
|
elif args.cmd == "chaos":
|
|
mesh.cmd_chaos(args)
|
|
elif args.cmd == "run":
|
|
return mesh.cmd_run(args)
|
|
elif args.cmd == "idle":
|
|
return mesh.cmd_idle(args)
|
|
elif args.cmd == "down":
|
|
mesh.cmd_down(args)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|