385 lines
12 KiB
JavaScript
385 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
import { spawn } from "node:child_process";
|
|
import { performance } from "node:perf_hooks";
|
|
|
|
const DEFAULTS = {
|
|
apiBase: "http://127.0.0.1:4224/api",
|
|
container: "dbx_bench_redis_key_search",
|
|
image: "redis:7-alpine",
|
|
host: "127.0.0.1",
|
|
port: 16151,
|
|
db: 0,
|
|
keyCount: 1_000_000,
|
|
keyPrefix: "user:",
|
|
pattern: "user:*",
|
|
scanCount: 10_000,
|
|
maxIterations: 15,
|
|
includeTyped: true,
|
|
json: false,
|
|
};
|
|
|
|
function parseArgs(argv) {
|
|
const options = { ...DEFAULTS };
|
|
for (const arg of argv) {
|
|
if (arg === "--") continue;
|
|
const [rawKey, rawValue] = arg.split("=", 2);
|
|
const key = rawKey.replace(/^--/, "");
|
|
const value = rawValue ?? "true";
|
|
switch (key) {
|
|
case "api-base":
|
|
options.apiBase = value;
|
|
break;
|
|
case "container":
|
|
options.container = value;
|
|
break;
|
|
case "image":
|
|
options.image = value;
|
|
break;
|
|
case "port":
|
|
options.port = Number.parseInt(value, 10);
|
|
break;
|
|
case "key-count":
|
|
options.keyCount = Number.parseInt(value, 10);
|
|
break;
|
|
case "key-prefix":
|
|
options.keyPrefix = value;
|
|
break;
|
|
case "pattern":
|
|
options.pattern = value;
|
|
break;
|
|
case "scan-count":
|
|
options.scanCount = Number.parseInt(value, 10);
|
|
break;
|
|
case "max-iterations":
|
|
options.maxIterations = Number.parseInt(value, 10);
|
|
break;
|
|
case "typed":
|
|
options.includeTyped = value !== "false";
|
|
break;
|
|
case "json":
|
|
options.json = value !== "false";
|
|
break;
|
|
case "help":
|
|
printHelp();
|
|
process.exit(0);
|
|
default:
|
|
throw new Error(`Unknown option: ${rawKey}`);
|
|
}
|
|
}
|
|
return options;
|
|
}
|
|
|
|
function printHelp() {
|
|
console.log(`Redis key search benchmark
|
|
|
|
Usage:
|
|
pnpm bench:redis-key-search [options]
|
|
|
|
Options:
|
|
--api-base=http://127.0.0.1:4224/api DBX Web API base URL
|
|
--container=dbx_bench_redis_key_search Redis Docker container name
|
|
--port=16151 Host port for Redis
|
|
--key-count=1000000 Number of generated keys
|
|
--pattern=user:* SCAN MATCH pattern
|
|
--scan-count=10000 DBX SCAN COUNT value
|
|
--max-iterations=15 DBX server-side SCAN iterations per API call
|
|
--typed=false Skip DBX includeTypes=true comparison
|
|
--json Print JSON only
|
|
|
|
Before running this benchmark, start DBX Web separately, for example:
|
|
DBX_DATA_DIR=/tmp/dbx-bench DBX_PORT=4224 DBX_DISABLE_PASSWORD=1 cargo run -p dbx-web
|
|
`);
|
|
}
|
|
|
|
function assertFinitePositiveInteger(name, value) {
|
|
if (!Number.isFinite(value) || value <= 0 || Math.trunc(value) !== value) {
|
|
throw new Error(`${name} must be a positive integer, got ${value}`);
|
|
}
|
|
}
|
|
|
|
function run(command, args, options = {}) {
|
|
const startedAt = performance.now();
|
|
const child = spawn(command, args, {
|
|
cwd: process.cwd(),
|
|
env: process.env,
|
|
stdio: options.stdio ?? ["ignore", "pipe", "pipe"],
|
|
});
|
|
const stdout = [];
|
|
const stderr = [];
|
|
if (child.stdout) child.stdout.on("data", (chunk) => stdout.push(chunk));
|
|
if (child.stderr) child.stderr.on("data", (chunk) => stderr.push(chunk));
|
|
return new Promise((resolve, reject) => {
|
|
child.on("error", reject);
|
|
child.on("close", (code) => {
|
|
const result = {
|
|
code,
|
|
stdout: Buffer.concat(stdout).toString(),
|
|
stderr: Buffer.concat(stderr).toString(),
|
|
elapsedMs: performance.now() - startedAt,
|
|
};
|
|
if (code === 0) resolve(result);
|
|
else reject(new Error(`${command} ${args.join(" ")} failed with ${code}\n${result.stderr || result.stdout}`));
|
|
});
|
|
});
|
|
}
|
|
|
|
async function dockerExec(container, args) {
|
|
return run("docker", ["exec", container, ...args]);
|
|
}
|
|
|
|
async function ensureRedisContainer(options) {
|
|
try {
|
|
await run("docker", ["inspect", options.container]);
|
|
await run("docker", ["start", options.container]);
|
|
} catch {
|
|
await run("docker", [
|
|
"run",
|
|
"-d",
|
|
"--name",
|
|
options.container,
|
|
"-p",
|
|
`${options.host}:${options.port}:6379`,
|
|
options.image,
|
|
"redis-server",
|
|
"--save",
|
|
"",
|
|
"--appendonly",
|
|
"no",
|
|
]);
|
|
}
|
|
|
|
const deadline = Date.now() + 15_000;
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const pong = (await dockerExec(options.container, ["redis-cli", "PING"])).stdout.trim();
|
|
if (pong === "PONG") return;
|
|
} catch {
|
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
}
|
|
}
|
|
throw new Error("Timed out waiting for Redis container");
|
|
}
|
|
|
|
async function redisDbSize(container) {
|
|
const out = await dockerExec(container, ["redis-cli", "DBSIZE"]);
|
|
return Number.parseInt(out.stdout.trim(), 10);
|
|
}
|
|
|
|
function redisSetCommand(key, value = "1") {
|
|
const keyBytes = Buffer.byteLength(key);
|
|
const valueBytes = Buffer.byteLength(value);
|
|
return `*3\r\n$3\r\nSET\r\n$${keyBytes}\r\n${key}\r\n$${valueBytes}\r\n${value}\r\n`;
|
|
}
|
|
|
|
async function seedRedis(options) {
|
|
const currentSize = await redisDbSize(options.container);
|
|
if (currentSize === options.keyCount) return { skipped: true, elapsedMs: 0 };
|
|
|
|
await dockerExec(options.container, ["redis-cli", "FLUSHALL"]);
|
|
|
|
const startedAt = performance.now();
|
|
const child = spawn("docker", ["exec", "-i", options.container, "redis-cli", "--pipe"], {
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
});
|
|
const stderr = [];
|
|
child.stderr.on("data", (chunk) => stderr.push(chunk));
|
|
|
|
const width = Math.max(7, String(options.keyCount - 1).length);
|
|
for (let index = 0; index < options.keyCount; index += 1) {
|
|
const key = `${options.keyPrefix}${String(index).padStart(width, "0")}`;
|
|
if (!child.stdin.write(redisSetCommand(key))) {
|
|
await new Promise((resolve) => child.stdin.once("drain", resolve));
|
|
}
|
|
}
|
|
child.stdin.end();
|
|
|
|
await new Promise((resolve, reject) => {
|
|
child.on("error", reject);
|
|
child.on("close", (code) => {
|
|
if (code === 0) resolve();
|
|
else reject(new Error(`redis-cli --pipe failed with ${code}\n${Buffer.concat(stderr).toString()}`));
|
|
});
|
|
});
|
|
|
|
return { skipped: false, elapsedMs: performance.now() - startedAt };
|
|
}
|
|
|
|
async function measureRedisCliScan(options) {
|
|
const startedAt = performance.now();
|
|
const child = spawn("docker", ["exec", options.container, "redis-cli", "--scan", "--pattern", options.pattern], {
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
let count = 0;
|
|
let pending = "";
|
|
const stderr = [];
|
|
|
|
child.stdout.on("data", (chunk) => {
|
|
pending += chunk.toString();
|
|
const lines = pending.split(/\r?\n/);
|
|
pending = lines.pop() ?? "";
|
|
count += lines.filter(Boolean).length;
|
|
});
|
|
child.stderr.on("data", (chunk) => stderr.push(chunk));
|
|
|
|
await new Promise((resolve, reject) => {
|
|
child.on("error", reject);
|
|
child.on("close", (code) => {
|
|
if (pending.trim()) count += 1;
|
|
if (code === 0) resolve();
|
|
else reject(new Error(`redis-cli --scan failed with ${code}\n${Buffer.concat(stderr).toString()}`));
|
|
});
|
|
});
|
|
|
|
return { label: "redis-cli --scan", keys: count, elapsedMs: performance.now() - startedAt };
|
|
}
|
|
|
|
async function postJsonText(url, body) {
|
|
const response = await fetch(url, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
const text = await response.text();
|
|
if (!response.ok) throw new Error(`${url} ${response.status}\n${text}`);
|
|
return { text, json: JSON.parse(text) };
|
|
}
|
|
|
|
async function ensureDbxConnection(options) {
|
|
const connectionId = `bench-redis-key-search-${options.port}`;
|
|
const config = {
|
|
id: connectionId,
|
|
name: `Bench Redis Key Search ${options.port}`,
|
|
db_type: "redis",
|
|
host: options.host,
|
|
port: options.port,
|
|
username: "",
|
|
password: "",
|
|
database: String(options.db),
|
|
auth_database: "",
|
|
ssl: false,
|
|
redis_key_separator: ":",
|
|
connection_timeout: 10,
|
|
connect_timeout_secs: 10,
|
|
};
|
|
|
|
await postJsonText(`${options.apiBase}/connection/save`, { configs: [config] });
|
|
await postJsonText(`${options.apiBase}/connection/connect`, { config });
|
|
return connectionId;
|
|
}
|
|
|
|
async function measureDbxScan(options, connectionId, includeTypes) {
|
|
let cursor = 0;
|
|
let calls = 0;
|
|
let keys = 0;
|
|
let payloadBytes = 0;
|
|
const startedAt = performance.now();
|
|
|
|
do {
|
|
const { text, json } = await postJsonText(`${options.apiBase}/redis/scan-keys-batch`, {
|
|
connectionId,
|
|
db: options.db,
|
|
cursor,
|
|
pattern: options.pattern,
|
|
count: options.scanCount,
|
|
maxIterations: options.maxIterations,
|
|
includeTypes,
|
|
});
|
|
payloadBytes += Buffer.byteLength(text);
|
|
cursor = json.cursor;
|
|
keys += json.keys.length;
|
|
calls += 1;
|
|
} while (cursor !== 0);
|
|
|
|
return {
|
|
label: includeTypes ? "DBX scan-keys-batch includeTypes=true" : "DBX scan-keys-batch includeTypes=false",
|
|
keys,
|
|
calls,
|
|
payloadBytes,
|
|
elapsedMs: performance.now() - startedAt,
|
|
};
|
|
}
|
|
|
|
function formatMs(ms) {
|
|
return `${Math.round(ms)}ms`;
|
|
}
|
|
|
|
function formatBytes(bytes) {
|
|
if (!bytes) return "0 B";
|
|
const units = ["B", "KB", "MB", "GB"];
|
|
let value = bytes;
|
|
let unit = 0;
|
|
while (value >= 1024 && unit < units.length - 1) {
|
|
value /= 1024;
|
|
unit += 1;
|
|
}
|
|
return `${value.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
|
|
}
|
|
|
|
function printReport(result) {
|
|
console.log("# Redis key search benchmark");
|
|
console.log("");
|
|
console.log(`- API: ${result.config.apiBase}`);
|
|
console.log(`- Redis: ${result.config.container} on ${result.config.host}:${result.config.port}`);
|
|
console.log(`- Keys: ${result.config.keyCount}`);
|
|
console.log(`- Pattern: ${result.config.pattern}`);
|
|
console.log(`- DBX scan count: ${result.config.scanCount}`);
|
|
console.log(`- DBX max iterations: ${result.config.maxIterations}`);
|
|
console.log(`- Seed: ${result.seed.skipped ? "reused existing dataset" : `loaded in ${formatMs(result.seed.elapsedMs)}`}`);
|
|
console.log("");
|
|
console.log("| Case | Keys | Calls | Payload | Time |");
|
|
console.log("| --- | ---: | ---: | ---: | ---: |");
|
|
for (const row of result.measurements) {
|
|
console.log(`| ${row.label} | ${row.keys} | ${row.calls ?? "-"} | ${row.payloadBytes ? formatBytes(row.payloadBytes) : "-"} | ${formatMs(row.elapsedMs)} |`);
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
const options = parseArgs(process.argv.slice(2));
|
|
assertFinitePositiveInteger("port", options.port);
|
|
assertFinitePositiveInteger("key-count", options.keyCount);
|
|
assertFinitePositiveInteger("scan-count", options.scanCount);
|
|
assertFinitePositiveInteger("max-iterations", options.maxIterations);
|
|
|
|
if (!options.json) {
|
|
console.log("Preparing Redis benchmark dataset...");
|
|
}
|
|
await ensureRedisContainer(options);
|
|
const seed = await seedRedis(options);
|
|
|
|
if (!options.json) {
|
|
console.log("Connecting DBX Web API...");
|
|
}
|
|
const connectionId = await ensureDbxConnection(options);
|
|
|
|
const measurements = [];
|
|
measurements.push(await measureRedisCliScan(options));
|
|
measurements.push(await measureDbxScan(options, connectionId, false));
|
|
if (options.includeTyped) {
|
|
measurements.push(await measureDbxScan(options, connectionId, true));
|
|
}
|
|
|
|
const result = {
|
|
config: {
|
|
apiBase: options.apiBase,
|
|
container: options.container,
|
|
host: options.host,
|
|
port: options.port,
|
|
keyCount: options.keyCount,
|
|
pattern: options.pattern,
|
|
scanCount: options.scanCount,
|
|
maxIterations: options.maxIterations,
|
|
},
|
|
seed,
|
|
measurements,
|
|
generatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
if (options.json) console.log(JSON.stringify(result, null, 2));
|
|
else printReport(result);
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
|
process.exit(1);
|
|
});
|