From cc3c992b1deaab14348f13729c655f42a6e5d114 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Fri, 22 May 2026 03:05:44 +0000 Subject: [PATCH] cli: add large-file-test and benchmark between couchdb and p2p --- src/apps/cli/.gitignore | 3 +- src/apps/cli/testdeno/bench-couchdb.ts | 310 +++++++++++++++++++++++ src/apps/cli/testdeno/bench-p2p.ts | 219 ++++++++++++++++ src/apps/cli/testdeno/bench-run-item1.sh | 45 ++++ src/apps/cli/testdeno/deno.json | 4 + 5 files changed, 580 insertions(+), 1 deletion(-) create mode 100644 src/apps/cli/testdeno/bench-couchdb.ts create mode 100644 src/apps/cli/testdeno/bench-p2p.ts create mode 100644 src/apps/cli/testdeno/bench-run-item1.sh diff --git a/src/apps/cli/.gitignore b/src/apps/cli/.gitignore index 24987cb..e775b86 100644 --- a/src/apps/cli/.gitignore +++ b/src/apps/cli/.gitignore @@ -5,4 +5,5 @@ test/test-init.local.sh node_modules .*.json *.env -!.test.env \ No newline at end of file +!.test.env +bench-results \ No newline at end of file diff --git a/src/apps/cli/testdeno/bench-couchdb.ts b/src/apps/cli/testdeno/bench-couchdb.ts new file mode 100644 index 0000000..678b236 --- /dev/null +++ b/src/apps/cli/testdeno/bench-couchdb.ts @@ -0,0 +1,310 @@ +import { TempDir } from "./helpers/temp.ts"; +import { applyRemoteSyncSettings, initSettingsFile } from "./helpers/settings.ts"; +import { assertFilesEqual, runCliOrFail } from "./helpers/cli.ts"; +import { startCouchdb, stopCouchdb } from "./helpers/docker.ts"; +import { createDeterministicDataset, type DatasetEntry } from "./helpers/dataset.ts"; + +type BenchmarkConfig = { + couchdbBackendUri: string; + couchdbProxyUri: string; + couchdbUser: string; + couchdbPassword: string; + couchdbDbname: string; + datasetDirName: string; + datasetSeed: string; + mdFileCount: number; + mdMinSizeBytes: number; + mdMaxSizeBytes: number; + binFileCount: number; + binSizeBytes: number; + syncTimeoutSeconds: number; + requestedRttMs: number; + passphrase: string; + encrypt: boolean; +}; + +function readEnvString(name: string, fallback: string): string { + const value = Deno.env.get(name)?.trim(); + return value && value.length > 0 ? value : fallback; +} + +function readEnvNumber(name: string, fallback: number): number { + const raw = Deno.env.get(name); + if (raw === undefined || raw.trim() === "") { + return fallback; + } + + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`${name} must be a positive number, got '${raw}'`); + } + return parsed; +} + +function readEnvBool(name: string, fallback: boolean): boolean { + const raw = Deno.env.get(name); + if (raw === undefined || raw.trim() === "") { + return fallback; + } + return /^(1|true|yes|on)$/i.test(raw.trim()); +} + +function nowMs(): number { + return performance.now(); +} + +function formatMs(value: number): string { + return `${value.toFixed(1)} ms`; +} + +function formatBytes(value: number): string { + if (value < 1024) { + return `${value} B`; + } + const kib = value / 1024; + if (kib < 1024) { + return `${kib.toFixed(1)} KiB`; + } + return `${(kib / 1024).toFixed(1)} MiB`; +} + +function buildConfig(): BenchmarkConfig { + return { + couchdbBackendUri: readEnvString("BENCH_COUCHDB_BACKEND_URI", "http://127.0.0.1:5989"), + couchdbProxyUri: readEnvString("BENCH_COUCHDB_URI", "http://127.0.0.1:15989"), + couchdbUser: readEnvString("BENCH_COUCHDB_USER", readEnvString("username", "admin")), + couchdbPassword: readEnvString("BENCH_COUCHDB_PASSWORD", readEnvString("password", "password")), + couchdbDbname: readEnvString("BENCH_COUCHDB_DBNAME", `bench-couchdb-${Date.now()}`), + datasetDirName: readEnvString("BENCH_DATASET_DIR", "bench-dataset"), + datasetSeed: readEnvString("BENCH_SEED", "livesync-benchmark-seed"), + mdFileCount: Math.floor(readEnvNumber("BENCH_MD_FILE_COUNT", 1500)), + mdMinSizeBytes: Math.floor(readEnvNumber("BENCH_MD_MIN_SIZE_BYTES", 1024)), + mdMaxSizeBytes: Math.floor(readEnvNumber("BENCH_MD_MAX_SIZE_BYTES", 20 * 1024)), + binFileCount: Math.floor(readEnvNumber("BENCH_BIN_FILE_COUNT", 500)), + binSizeBytes: Math.floor(readEnvNumber("BENCH_BIN_SIZE_BYTES", 100 * 1024)), + syncTimeoutSeconds: readEnvNumber("BENCH_SYNC_TIMEOUT", 240), + requestedRttMs: Math.floor(readEnvNumber("BENCH_COUCHDB_RTT_MS", 50)), + passphrase: readEnvString("BENCH_PASSPHRASE", `bench-${Date.now()}`), + encrypt: readEnvBool("BENCH_ENCRYPT", true), + }; +} + +function readOptionalResultPath(): string | undefined { + const raw = Deno.env.get("BENCH_RESULT_JSON")?.trim(); + if (!raw) { + return undefined; + } + return raw; +} + +function pickSampleFiles(entries: DatasetEntry[]): DatasetEntry[] { + if (entries.length === 0) { + return []; + } + const md = entries.find((e) => e.kind === "md"); + const bin = entries.find((e) => e.kind === "bin"); + const middle = entries[Math.floor(entries.length / 2)]; + const last = entries[entries.length - 1]; + const unique = new Map(); + for (const entry of [md, bin, middle, last]) { + if (entry) { + unique.set(entry.relativePath, entry); + } + } + return [...unique.values()]; +} + +type ProxyHandle = { + stop: () => Promise; + applied: boolean; + note: string; +}; + +function startCouchdbProxy(options: { + backendUri: string; + proxyUri: string; + requestedRttMs: number; +}): ProxyHandle { + const backend = new URL(options.backendUri); + const proxy = new URL(options.proxyUri); + const halfDelayMs = Math.max(1, Math.floor(options.requestedRttMs / 2)); + const controller = new AbortController(); + + const listener = Deno.serve( + { + hostname: proxy.hostname, + port: Number(proxy.port), + signal: controller.signal, + onError(error) { + console.error(`[Proxy] ${String(error)}`); + return new Response("proxy error", { status: 502 }); + }, + }, + async (request) => { + await new Promise((resolve) => setTimeout(resolve, halfDelayMs)); + + const targetUrl = new URL(request.url); + targetUrl.protocol = backend.protocol; + targetUrl.host = backend.host; + + const headers = new Headers(request.headers); + headers.delete("host"); + headers.delete("content-length"); + + let requestBody: ArrayBuffer | undefined; + if (request.method !== "GET" && request.method !== "HEAD") { + try { + requestBody = await request.arrayBuffer(); + } catch { + requestBody = undefined; + } + } + + const upstream = await fetch(targetUrl, { + method: request.method, + headers, + body: requestBody, + redirect: "manual", + }); + + const responseHeaders = new Headers(upstream.headers); + responseHeaders.delete("content-length"); + const responseBody = await upstream.arrayBuffer(); + + return new Response(responseBody, { + status: upstream.status, + statusText: upstream.statusText, + headers: responseHeaders, + }); + } + ); + + return { + applied: true, + note: `local reverse proxy on ${proxy.origin} with ${halfDelayMs}ms pre-forward delay`, + stop: async () => { + controller.abort(); + await listener.finished.catch(() => {}); + }, + }; +} + +async function main(): Promise { + const config = buildConfig(); + const resultPath = readOptionalResultPath(); + + await using workDir = await TempDir.create("livesync-cli-couchdb-bench"); + const vaultA = workDir.join("vault-a"); + const vaultB = workDir.join("vault-b"); + const settingsA = workDir.join("settings-a.json"); + const settingsB = workDir.join("settings-b.json"); + await Deno.mkdir(vaultA, { recursive: true }); + await Deno.mkdir(vaultB, { recursive: true }); + + await initSettingsFile(settingsA); + await initSettingsFile(settingsB); + + await startCouchdb(config.couchdbBackendUri, config.couchdbUser, config.couchdbPassword, config.couchdbDbname); + + const proxy = startCouchdbProxy({ + backendUri: config.couchdbBackendUri, + proxyUri: config.couchdbProxyUri, + requestedRttMs: config.requestedRttMs, + }); + + try { + await Promise.all([ + applyRemoteSyncSettings(settingsA, { + remoteType: "COUCHDB", + couchdbUri: config.couchdbProxyUri, + couchdbUser: config.couchdbUser, + couchdbPassword: config.couchdbPassword, + couchdbDbname: config.couchdbDbname, + encrypt: config.encrypt, + passphrase: config.passphrase, + }), + applyRemoteSyncSettings(settingsB, { + remoteType: "COUCHDB", + couchdbUri: config.couchdbProxyUri, + couchdbUser: config.couchdbUser, + couchdbPassword: config.couchdbPassword, + couchdbDbname: config.couchdbDbname, + encrypt: config.encrypt, + passphrase: config.passphrase, + }), + ]); + + const seedFiles = await createDeterministicDataset({ + rootDir: vaultA, + datasetDirName: config.datasetDirName, + seed: config.datasetSeed, + mdCount: config.mdFileCount, + mdMinSizeBytes: config.mdMinSizeBytes, + mdMaxSizeBytes: config.mdMaxSizeBytes, + binCount: config.binFileCount, + binSizeBytes: config.binSizeBytes, + }); + + const mirrorStart = nowMs(); + await runCliOrFail(vaultA, "--settings", settingsA, "mirror"); + const mirrorElapsed = nowMs() - mirrorStart; + + const syncAStart = nowMs(); + await runCliOrFail(vaultA, "--settings", settingsA, "sync"); + const syncAElapsed = nowMs() - syncAStart; + + const syncBStart = nowMs(); + await runCliOrFail(vaultB, "--settings", settingsB, "sync"); + const syncBElapsed = nowMs() - syncBStart; + + const sampleFiles = pickSampleFiles(seedFiles.entries); + for (const sample of sampleFiles) { + const pulledPath = workDir.join(`pulled-${sample.relativePath.split("/").join("_")}`); + await runCliOrFail(vaultB, "--settings", settingsB, "pull", sample.relativePath, pulledPath); + await assertFilesEqual(sample.absolutePath, pulledPath, `sample file mismatch after CouchDB sync: ${sample.relativePath}`); + } + + const result = { + mode: "couchdb-cli-benchmark", + couchdbBackendUri: config.couchdbBackendUri, + couchdbProxyUri: config.couchdbProxyUri, + couchdbDbname: config.couchdbDbname, + rttRequestedMs: config.requestedRttMs, + proxyApplied: proxy.applied, + proxyNote: proxy.note, + datasetSeed: config.datasetSeed, + datasetDirName: config.datasetDirName, + totalFiles: seedFiles.totalFiles, + totalBytes: seedFiles.totalBytes, + mdFileCount: seedFiles.mdCount, + binFileCount: seedFiles.binCount, + mirrorElapsedMs: Number(mirrorElapsed.toFixed(1)), + syncAElapsedMs: Number(syncAElapsed.toFixed(1)), + syncBElapsedMs: Number(syncBElapsed.toFixed(1)), + totalSyncElapsedMs: Number((syncAElapsed + syncBElapsed).toFixed(1)), + throughputBytesPerSec: Number((seedFiles.totalBytes / ((syncAElapsed + syncBElapsed) / 1000)).toFixed(2)), + throughputMiBPerSec: Number((seedFiles.totalBytes / ((syncAElapsed + syncBElapsed) / 1000) / 1024 / 1024).toFixed(4)), + }; + + if (resultPath) { + await Deno.writeTextFile(resultPath, JSON.stringify(result, null, 2)); + } + + console.log(JSON.stringify(result, null, 2)); + console.error( + `[Benchmark] couchdb mirrored ${seedFiles.totalFiles} files (${formatBytes(seedFiles.totalBytes)}) in ${formatMs( + mirrorElapsed + )}, synced in ${formatMs(syncAElapsed + syncBElapsed)} (${result.throughputBytesPerSec} B/s, ${result.throughputMiBPerSec} MiB/s)` + ); + } finally { + await proxy.stop(); + await stopCouchdb().catch(() => {}); + } +} + +if (import.meta.main) { + main().catch((error) => { + console.error(`[Fatal Error]`, error); + Deno.exit(1); + }); +} \ No newline at end of file diff --git a/src/apps/cli/testdeno/bench-p2p.ts b/src/apps/cli/testdeno/bench-p2p.ts new file mode 100644 index 0000000..40be30a --- /dev/null +++ b/src/apps/cli/testdeno/bench-p2p.ts @@ -0,0 +1,219 @@ +import { TempDir } from "./helpers/temp.ts"; +import { applyP2pSettings, applyP2pTestTweaks, initSettingsFile } from "./helpers/settings.ts"; +import { startCliInBackground } from "./helpers/backgroundCli.ts"; +import { discoverPeer, maybeStartLocalRelay, stopLocalRelayIfStarted } from "./helpers/p2p.ts"; +import { assertFilesEqual, runCliOrFail } from "./helpers/cli.ts"; +import { createDeterministicDataset, type DatasetEntry } from "./helpers/dataset.ts"; + +type BenchmarkConfig = { + relay: string; + appId: string; + roomId: string; + passphrase: string; + datasetDirName: string; + datasetSeed: string; + mdFileCount: number; + mdMinSizeBytes: number; + mdMaxSizeBytes: number; + binFileCount: number; + binSizeBytes: number; + peersTimeoutSeconds: number; + syncTimeoutSeconds: number; +}; + +function readEnvString(name: string, fallback: string): string { + const value = Deno.env.get(name)?.trim(); + return value && value.length > 0 ? value : fallback; +} + +function readEnvNumber(name: string, fallback: number): number { + const raw = Deno.env.get(name); + if (raw === undefined || raw.trim() === "") { + return fallback; + } + + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`${name} must be a positive number, got '${raw}'`); + } + return parsed; +} + +function nowMs(): number { + return performance.now(); +} + +function formatMs(value: number): string { + return `${value.toFixed(1)} ms`; +} + +function formatBytes(value: number): string { + if (value < 1024) { + return `${value} B`; + } + const kib = value / 1024; + if (kib < 1024) { + return `${kib.toFixed(1)} KiB`; + } + const mib = kib / 1024; + return `${mib.toFixed(1)} MiB`; +} + +function buildConfig(): BenchmarkConfig { + return { + relay: readEnvString("BENCH_RELAY", "ws://localhost:4000/"), + appId: readEnvString("BENCH_APP_ID", "self-hosted-livesync-cli-benchmark"), + roomId: readEnvString("BENCH_ROOM_ID", `bench-room-${Date.now()}`), + passphrase: readEnvString("BENCH_PASSPHRASE", `bench-${Date.now()}`), + datasetDirName: readEnvString("BENCH_DATASET_DIR", "bench-dataset"), + datasetSeed: readEnvString("BENCH_SEED", "livesync-benchmark-seed"), + mdFileCount: Math.floor(readEnvNumber("BENCH_MD_FILE_COUNT", 1500)), + mdMinSizeBytes: Math.floor(readEnvNumber("BENCH_MD_MIN_SIZE_BYTES", 1024)), + mdMaxSizeBytes: Math.floor(readEnvNumber("BENCH_MD_MAX_SIZE_BYTES", 20 * 1024)), + binFileCount: Math.floor(readEnvNumber("BENCH_BIN_FILE_COUNT", 500)), + binSizeBytes: Math.floor(readEnvNumber("BENCH_BIN_SIZE_BYTES", 100 * 1024)), + peersTimeoutSeconds: readEnvNumber("BENCH_PEERS_TIMEOUT", 20), + syncTimeoutSeconds: readEnvNumber("BENCH_SYNC_TIMEOUT", 240), + }; +} + +function readOptionalResultPath(): string | undefined { + const raw = Deno.env.get("BENCH_RESULT_JSON")?.trim(); + if (!raw) { + return undefined; + } + return raw; +} + +function pickSampleFiles(entries: DatasetEntry[]): DatasetEntry[] { + if (entries.length === 0) { + return []; + } + const md = entries.find((e) => e.kind === "md"); + const bin = entries.find((e) => e.kind === "bin"); + const middle = entries[Math.floor(entries.length / 2)]; + const last = entries[entries.length - 1]; + const unique = new Map(); + for (const entry of [md, bin, middle, last]) { + if (entry) { + unique.set(entry.relativePath, entry); + } + } + return [...unique.values()]; +} + +async function main(): Promise { + const config = buildConfig(); + const resultPath = readOptionalResultPath(); + + const relayStarted = await maybeStartLocalRelay(config.relay); + await using workDir = await TempDir.create("livesync-cli-p2p-bench"); + + const hostVault = workDir.join("vault-host"); + const clientVault = workDir.join("vault-client"); + const hostSettings = workDir.join("settings-host.json"); + const clientSettings = workDir.join("settings-client.json"); + + await Promise.all([ + Deno.mkdir(hostVault, { recursive: true }), + Deno.mkdir(clientVault, { recursive: true }), + initSettingsFile(hostSettings), + initSettingsFile(clientSettings), + ]); + + await Promise.all([ + applyP2pSettings(hostSettings, config.roomId, config.passphrase, config.appId, config.relay, "~.*"), + applyP2pSettings(clientSettings, config.roomId, config.passphrase, config.appId, config.relay, "~.*"), + ]); + + await Promise.all([ + applyP2pTestTweaks(hostSettings, "p2p-bench-host", config.passphrase), + applyP2pTestTweaks(clientSettings, "p2p-bench-client", config.passphrase), + ]); + + const seedFiles = await createDeterministicDataset({ + rootDir: hostVault, + datasetDirName: config.datasetDirName, + seed: config.datasetSeed, + mdCount: config.mdFileCount, + mdMinSizeBytes: config.mdMinSizeBytes, + mdMaxSizeBytes: config.mdMaxSizeBytes, + binCount: config.binFileCount, + binSizeBytes: config.binSizeBytes, + }); + + const mirrorStart = nowMs(); + await runCliOrFail(hostVault, "--settings", hostSettings, "mirror"); + const mirrorElapsed = nowMs() - mirrorStart; + + const host = startCliInBackground(hostVault, "--settings", hostSettings, "p2p-host"); + try { + const hostReadyStart = nowMs(); + await host.waitUntilContains("P2P host is running", 20000); + const hostReadyElapsed = nowMs() - hostReadyStart; + + const peerDiscoveryStart = nowMs(); + const peer = await discoverPeer(clientVault, clientSettings, config.peersTimeoutSeconds); + const peerDiscoveryElapsed = nowMs() - peerDiscoveryStart; + + const syncStart = nowMs(); + await runCliOrFail( + clientVault, + "--settings", + clientSettings, + "p2p-sync", + peer.id, + String(config.syncTimeoutSeconds) + ); + const syncElapsed = nowMs() - syncStart; + + const sampleFiles = pickSampleFiles(seedFiles.entries); + for (const sample of sampleFiles) { + const pulledPath = workDir.join(`pulled-${sample.relativePath.replaceAll("/", "_")}`); + await runCliOrFail(clientVault, "--settings", clientSettings, "pull", sample.relativePath, pulledPath); + await assertFilesEqual(sample.absolutePath, pulledPath, `sample file mismatch after sync: ${sample.relativePath}`); + } + + const result = { + mode: "p2p-cli-benchmark", + relay: config.relay, + appId: config.appId, + roomId: config.roomId, + datasetSeed: config.datasetSeed, + datasetDirName: config.datasetDirName, + peerId: peer.id, + peerName: peer.name, + totalFiles: seedFiles.totalFiles, + totalBytes: seedFiles.totalBytes, + mdFileCount: seedFiles.mdCount, + binFileCount: seedFiles.binCount, + mirrorElapsedMs: Number(mirrorElapsed.toFixed(1)), + hostReadyElapsedMs: Number(hostReadyElapsed.toFixed(1)), + peerDiscoveryElapsedMs: Number(peerDiscoveryElapsed.toFixed(1)), + syncElapsedMs: Number(syncElapsed.toFixed(1)), + throughputBytesPerSec: Number((seedFiles.totalBytes / (syncElapsed / 1000)).toFixed(2)), + throughputMiBPerSec: Number((seedFiles.totalBytes / (syncElapsed / 1000) / 1024 / 1024).toFixed(4)), + }; + + if (resultPath) { + await Deno.writeTextFile(resultPath, JSON.stringify(result, null, 2)); + } + + console.log(JSON.stringify(result, null, 2)); + console.error( + `[Benchmark] mirrored ${seedFiles.totalFiles} files (${formatBytes(seedFiles.totalBytes)}) in ${formatMs(mirrorElapsed)}, ` + + `synced in ${formatMs(syncElapsed)} ` + + `(${result.throughputBytesPerSec} B/s, ${result.throughputMiBPerSec} MiB/s)` + ); + } finally { + await host.stop(); + await stopLocalRelayIfStarted(relayStarted); + } +} + +if (import.meta.main) { + main().catch((error) => { + console.error(`[Fatal Error]`, error); + Deno.exit(1); + }); +} \ No newline at end of file diff --git a/src/apps/cli/testdeno/bench-run-item1.sh b/src/apps/cli/testdeno/bench-run-item1.sh new file mode 100644 index 0000000..53d2998 --- /dev/null +++ b/src/apps/cli/testdeno/bench-run-item1.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +RESULTS_ROOT="${SCRIPT_DIR}/bench-results" +TIMESTAMP="$(date +%Y%m%d-%H%M%S)" +OUT_DIR="${RESULTS_ROOT}/${TIMESTAMP}" + +mkdir -p "${OUT_DIR}" + +echo "[bench-wrapper] output directory: ${OUT_DIR}" + +echo "[bench-wrapper] running p2p benchmark" +( + cd "${SCRIPT_DIR}" + BENCH_RESULT_JSON="${OUT_DIR}/p2p.json" deno task bench:p2p +) + +echo "[bench-wrapper] running couchdb benchmark with RTT ${BENCH_COUCHDB_RTT_MS:-default} ms (emulating HTTP network latency)" +( + cd "${SCRIPT_DIR}" + BENCH_RESULT_JSON="${OUT_DIR}/couchdb.json" deno task bench:couchdb +) + +cat > "${OUT_DIR}/README.txt" <