Files
wehub-resource-sync 979fb22d7c
CI / test (20, macos-latest) (push) Waiting to run
CI / test (20, ubuntu-latest) (push) Waiting to run
CI / test (22, macos-latest) (push) Waiting to run
CI / test (22, ubuntu-latest) (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:01:18 +08:00

529 lines
15 KiB
TypeScript

/**
* Load harness — seeds N synthetic memories against a local agentmemory
* daemon, then drives a matrix of (N, concurrency, endpoint) cells and
* records p50 / p90 / p99 latency + throughput per cell.
*
* Spec: GitHub issue #346.
*
* Runs against an already-running daemon at `http://localhost:3111` by
* default. Set `AGENTMEMORY_BENCH_AUTOSTART=1` to spawn one via
* `node dist/cli.js start` for the duration of the run.
*
* Env knobs:
* AGENTMEMORY_BENCH_AUTOSTART "1" to spawn the daemon (default: assume up)
* AGENTMEMORY_URL base URL of the daemon (default: http://localhost:3111)
* BENCH_N comma-separated N sizes (default: 1000,10000,100000)
* BENCH_C comma-separated concurrency levels (default: 1,10,100)
* BENCH_OPS ops per cell during measurement (default: 200)
* BENCH_SEED seed for the mulberry32 RNG (default: 0xC0FFEE)
* BENCH_OUT_DIR results dir (default: benchmark/results)
*
* The harness writes one JSON file per run named
* `load-100k-<short-git-sha>.json`. The git sha is best-effort — falls
* back to a timestamp when run outside a checkout.
*/
import { spawn, type ChildProcess } from "node:child_process";
import { execFileSync } from "node:child_process";
import { mkdirSync, writeFileSync, existsSync } from "node:fs";
import { join, resolve } from "node:path";
import { performance } from "node:perf_hooks";
import { pXX } from "./lib/percentiles.js";
/** Seedable PRNG. Mulberry32 — 32-bit state, uniform output in [0, 1). */
function mulberry32(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const NOUNS = [
"cache", "queue", "router", "stream", "shard", "lock", "buffer", "worker",
"engine", "trigger", "function", "memory", "index", "graph", "vector",
"session", "observation", "summary", "embedding", "tokenizer", "scheduler",
"consumer", "producer", "channel", "actor", "pipeline", "watcher", "pool",
];
const VERBS = [
"flushes", "rotates", "compacts", "rebalances", "drains", "warms",
"expires", "deduplicates", "snapshots", "replays", "promotes", "demotes",
"merges", "splits", "indexes", "scans", "compresses", "uploads",
];
const CONCEPTS = [
"throughput", "latency", "backpressure", "consistency", "isolation",
"durability", "idempotency", "fan-out", "cardinality", "skew",
"hot-path", "cold-start", "tail-latency", "saturation", "quiescence",
];
function buildContent(rng: () => number, i: number): string {
const n = NOUNS[Math.floor(rng() * NOUNS.length)]!;
const v = VERBS[Math.floor(rng() * VERBS.length)]!;
const c1 = CONCEPTS[Math.floor(rng() * CONCEPTS.length)]!;
const c2 = CONCEPTS[Math.floor(rng() * CONCEPTS.length)]!;
const k = Math.floor(rng() * 9999);
return `seed-${i} the ${n} ${v} ${c1} under ${c2} pressure (k=${k})`;
}
interface RunConfig {
baseUrl: string;
Ns: number[];
Cs: number[];
opsPerCell: number;
seed: number;
outDir: string;
autoStart: boolean;
}
function parseIntList(raw: string | undefined, fallback: number[]): number[] {
if (!raw) return fallback;
const out = raw
.split(",")
.map((s) => parseInt(s.trim(), 10))
.filter((n) => Number.isFinite(n) && n > 0);
return out.length > 0 ? out : fallback;
}
function loadConfig(): RunConfig {
return {
baseUrl: (process.env["AGENTMEMORY_URL"] || "http://localhost:3111").replace(
/\/+$/,
"",
),
Ns: parseIntList(process.env["BENCH_N"], [1000, 10000, 100000]),
Cs: parseIntList(process.env["BENCH_C"], [1, 10, 100]),
opsPerCell: parseInt(process.env["BENCH_OPS"] || "200", 10) || 200,
seed: parseInt(process.env["BENCH_SEED"] || "12648430", 10) || 12648430,
outDir:
process.env["BENCH_OUT_DIR"] ||
resolve(process.cwd(), "benchmark", "results"),
autoStart: process.env["AGENTMEMORY_BENCH_AUTOSTART"] === "1",
};
}
async function waitForLivez(baseUrl: string, timeoutMs: number): Promise<void> {
const start = Date.now();
let lastErr: unknown = null;
while (Date.now() - start < timeoutMs) {
try {
const res = await fetch(`${baseUrl}/agentmemory/livez`, {
signal: AbortSignal.timeout(2000),
});
if (res.ok) return;
lastErr = new Error(`livez HTTP ${res.status}`);
} catch (err) {
lastErr = err;
}
await new Promise((r) => setTimeout(r, 500));
}
const reason =
lastErr instanceof Error ? lastErr.message : String(lastErr ?? "unknown");
throw new Error(
`daemon at ${baseUrl} did not become ready within ${timeoutMs} ms: ${reason}`,
);
}
function maybeStartDaemon(): ChildProcess | null {
const candidates = ["dist/cli.mjs", "dist/cli.js"].map((p) =>
resolve(process.cwd(), p),
);
const cliPath = candidates.find((p) => existsSync(p));
if (!cliPath) {
throw new Error(
`AGENTMEMORY_BENCH_AUTOSTART=1 but neither ${candidates.join(" nor ")} exists — run \`npm run build\` first`,
);
}
const child = spawn(process.execPath, [cliPath, "start"], {
stdio: ["ignore", "pipe", "pipe"],
detached: false,
});
child.stdout?.on("data", () => {
/* drain */
});
child.stderr?.on("data", () => {
/* drain */
});
return child;
}
function shortGitSha(): string {
try {
const sha = execFileSync("git", ["rev-parse", "--short", "HEAD"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
if (sha) return sha;
} catch {
/* no git */
}
return `nogit-${Date.now().toString(36)}`;
}
interface CellResult {
endpoint: string;
N: number;
C: number;
ops: number;
errors: number;
wall_ms: number;
throughput_per_sec: number;
p50_ms: number;
p90_ms: number;
p99_ms: number;
min_ms: number;
max_ms: number;
}
/**
* Issue `total` requests against `fetcher`, capped at `concurrency`
* in-flight at any moment. Records per-request latency in ms.
*/
async function driveLoad(
concurrency: number,
total: number,
fetcher: (i: number) => Promise<void>,
): Promise<{ latencies: number[]; errors: number; wallMs: number }> {
const latencies: number[] = [];
let errors = 0;
let issued = 0;
const wallStart = performance.now();
async function worker(): Promise<void> {
while (true) {
const i = issued++;
if (i >= total) return;
const t0 = performance.now();
try {
await fetcher(i);
latencies.push(performance.now() - t0);
} catch {
errors++;
}
}
}
const workers = Array.from({ length: Math.max(1, concurrency) }, () =>
worker(),
);
await Promise.allSettled(workers);
const wallMs = performance.now() - wallStart;
return { latencies, errors, wallMs };
}
function summarize(
endpoint: string,
N: number,
C: number,
latencies: number[],
errors: number,
wallMs: number,
): CellResult {
const sorted = latencies.slice().sort((a, b) => a - b);
const ops = sorted.length;
return {
endpoint,
N,
C,
ops,
errors,
wall_ms: Math.round(wallMs * 1000) / 1000,
throughput_per_sec:
wallMs > 0 ? Math.round((ops / (wallMs / 1000)) * 100) / 100 : 0,
p50_ms: Math.round(pXX(sorted, 50) * 1000) / 1000,
p90_ms: Math.round(pXX(sorted, 90) * 1000) / 1000,
p99_ms: Math.round(pXX(sorted, 99) * 1000) / 1000,
min_ms: ops > 0 ? Math.round(sorted[0]! * 1000) / 1000 : NaN,
max_ms: ops > 0 ? Math.round(sorted[ops - 1]! * 1000) / 1000 : NaN,
};
}
async function seedMemories(
baseUrl: string,
count: number,
rng: () => number,
seedConcurrency = 32,
): Promise<{ seeded: number; errors: number; wallMs: number }> {
let issued = 0;
let seeded = 0;
let errors = 0;
const t0 = performance.now();
async function worker(): Promise<void> {
while (true) {
const i = issued++;
if (i >= count) return;
const body = JSON.stringify({
content: buildContent(rng, i),
type: "observation",
});
try {
const res = await fetch(`${baseUrl}/agentmemory/remember`, {
method: "POST",
headers: { "content-type": "application/json" },
body,
signal: AbortSignal.timeout(30_000),
});
if (res.ok) {
seeded++;
} else {
errors++;
}
// drain body to free the socket
await res.text().catch(() => "");
} catch {
errors++;
}
}
}
await Promise.allSettled(
Array.from({ length: seedConcurrency }, () => worker()),
);
return { seeded, errors, wallMs: performance.now() - t0 };
}
async function measureRemember(
baseUrl: string,
rng: () => number,
N: number,
C: number,
ops: number,
): Promise<CellResult> {
const { latencies, errors, wallMs } = await driveLoad(C, ops, async (i) => {
const body = JSON.stringify({
content: buildContent(rng, N + i),
type: "observation",
});
const res = await fetch(`${baseUrl}/agentmemory/remember`, {
method: "POST",
headers: { "content-type": "application/json" },
body,
signal: AbortSignal.timeout(30_000),
});
await res.text().catch(() => "");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
});
return summarize("POST /agentmemory/remember", N, C, latencies, errors, wallMs);
}
async function measureSmartSearch(
baseUrl: string,
rng: () => number,
N: number,
C: number,
ops: number,
): Promise<CellResult> {
const queries = Array.from({ length: 32 }, (_, i) => buildContent(rng, i));
const { latencies, errors, wallMs } = await driveLoad(C, ops, async (i) => {
const body = JSON.stringify({
query: queries[i % queries.length],
limit: 5,
});
const res = await fetch(`${baseUrl}/agentmemory/smart-search`, {
method: "POST",
headers: { "content-type": "application/json" },
body,
signal: AbortSignal.timeout(30_000),
});
await res.text().catch(() => "");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
});
return summarize(
"POST /agentmemory/smart-search",
N,
C,
latencies,
errors,
wallMs,
);
}
async function measureMemoriesLatest(
baseUrl: string,
N: number,
C: number,
ops: number,
): Promise<CellResult> {
const { latencies, errors, wallMs } = await driveLoad(C, ops, async () => {
const res = await fetch(`${baseUrl}/agentmemory/memories?latest=true`, {
method: "GET",
signal: AbortSignal.timeout(30_000),
});
await res.text().catch(() => "");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
});
return summarize(
"GET /agentmemory/memories?latest=true",
N,
C,
latencies,
errors,
wallMs,
);
}
interface RunReport {
schema_version: 1;
generated_at: string;
git_sha: string;
base_url: string;
seed: number;
matrix: { N: number[]; C: number[] };
ops_per_cell: number;
cells: CellResult[];
notes: string;
}
async function main(): Promise<void> {
const cfg = loadConfig();
console.log(
`[load-100k] base=${cfg.baseUrl} N=${cfg.Ns.join(",")} C=${cfg.Cs.join(",")} ops/cell=${cfg.opsPerCell} seed=${cfg.seed}`,
);
let daemon: ChildProcess | null = null;
if (cfg.autoStart) {
console.log("[load-100k] AGENTMEMORY_BENCH_AUTOSTART=1 — spawning daemon");
daemon = maybeStartDaemon();
}
try {
console.log("[load-100k] waiting for /agentmemory/livez (timeout 30s)");
await waitForLivez(cfg.baseUrl, 30_000);
const cells: CellResult[] = [];
// N sorted ascending so each cell builds on the previous seed work.
const sortedNs = cfg.Ns.slice().sort((a, b) => a - b);
let seededSoFar = 0;
for (const N of sortedNs) {
const delta = N - seededSoFar;
if (delta > 0) {
console.log(
`[load-100k] seeding ${delta} memories (target N=${N})`,
);
const rng = mulberry32(cfg.seed + seededSoFar);
const seedRes = await seedMemories(cfg.baseUrl, delta, rng);
seededSoFar += seedRes.seeded;
console.log(
`[load-100k] seeded=${seedRes.seeded} errors=${seedRes.errors} wall=${(
seedRes.wallMs / 1000
).toFixed(2)}s`,
);
if (seedRes.errors > 0 && seedRes.seeded === 0) {
throw new Error(
`seeding produced 0 successes and ${seedRes.errors} errors — daemon misconfigured`,
);
}
}
for (const C of cfg.Cs) {
const probeRng = mulberry32(cfg.seed ^ (N * 0x9e3779b1) ^ C);
console.log(`[load-100k] cell N=${N} C=${C} remember`);
const remember = await measureRemember(
cfg.baseUrl,
probeRng,
N,
C,
cfg.opsPerCell,
);
cells.push(remember);
console.log(`[load-100k] cell N=${N} C=${C} smart-search`);
const search = await measureSmartSearch(
cfg.baseUrl,
mulberry32(cfg.seed ^ (N * 0x85ebca77) ^ C),
N,
C,
cfg.opsPerCell,
);
cells.push(search);
console.log(`[load-100k] cell N=${N} C=${C} memories?latest=true`);
const memories = await measureMemoriesLatest(
cfg.baseUrl,
N,
C,
cfg.opsPerCell,
);
cells.push(memories);
}
}
const report: RunReport = {
schema_version: 1,
generated_at: new Date().toISOString(),
git_sha: shortGitSha(),
base_url: cfg.baseUrl,
seed: cfg.seed,
matrix: { N: sortedNs, C: cfg.Cs.slice() },
ops_per_cell: cfg.opsPerCell,
cells,
notes:
"Single-process load harness. Latency in milliseconds. " +
"Throughput is wall-clock ops/sec for the cell (concurrent in-flight = C).",
};
mkdirSync(cfg.outDir, { recursive: true });
const outPath = join(cfg.outDir, `load-100k-${report.git_sha}.json`);
writeFileSync(outPath, JSON.stringify(report, null, 2) + "\n", "utf8");
console.log(`[load-100k] wrote ${outPath} (${cells.length} cells)`);
// Compact table to stdout for the verification commit.
console.log("");
console.log(
[
"endpoint".padEnd(40),
"N".padStart(8),
"C".padStart(4),
"ops".padStart(6),
"err".padStart(4),
"p50_ms".padStart(8),
"p90_ms".padStart(8),
"p99_ms".padStart(8),
"tp/s".padStart(9),
].join(" "),
);
for (const c of cells) {
console.log(
[
c.endpoint.padEnd(40),
String(c.N).padStart(8),
String(c.C).padStart(4),
String(c.ops).padStart(6),
String(c.errors).padStart(4),
c.p50_ms.toFixed(2).padStart(8),
c.p90_ms.toFixed(2).padStart(8),
c.p99_ms.toFixed(2).padStart(8),
c.throughput_per_sec.toFixed(2).padStart(9),
].join(" "),
);
}
} finally {
if (daemon) {
daemon.kill("SIGTERM");
// give it 2s to exit cleanly before SIGKILL
await new Promise<void>((resolveFn) => {
const t = setTimeout(() => {
try {
daemon!.kill("SIGKILL");
} catch {
/* already dead */
}
resolveFn();
}, 2000);
daemon!.once("exit", () => {
clearTimeout(t);
resolveFn();
});
});
}
}
}
main().catch((err) => {
console.error("[load-100k] failed:", err instanceof Error ? err.stack : err);
process.exit(1);
});