mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-13 08:13:11 +00:00
Add P2P split-container netem stress fixture
This commit is contained in:
@@ -0,0 +1,435 @@
|
|||||||
|
import { join } from "@std/path";
|
||||||
|
import { startCliInBackground } from "./helpers/backgroundCli.ts";
|
||||||
|
import { assertFilesEqual, runCliOrFail } from "./helpers/cli.ts";
|
||||||
|
import { createDeterministicDataset, type DatasetEntry } from "./helpers/dataset.ts";
|
||||||
|
import { discoverPeer } from "./helpers/p2p.ts";
|
||||||
|
import { applyP2pSettings, applyP2pTestTweaks, initSettingsFile } from "./helpers/settings.ts";
|
||||||
|
|
||||||
|
type Role = "host" | "client";
|
||||||
|
|
||||||
|
type NetemSummary = {
|
||||||
|
enabled: boolean;
|
||||||
|
profile: string;
|
||||||
|
interface: string;
|
||||||
|
delayMs: number;
|
||||||
|
jitterMs: number;
|
||||||
|
lossPercent: number;
|
||||||
|
bandwidthMbit: number;
|
||||||
|
mtu: number;
|
||||||
|
tcQdisc?: string;
|
||||||
|
ipAddr?: string;
|
||||||
|
ipRoute?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type HostReady = {
|
||||||
|
generatedAt: string;
|
||||||
|
totalFiles: number;
|
||||||
|
totalBytes: number;
|
||||||
|
mdFileCount: number;
|
||||||
|
binFileCount: number;
|
||||||
|
mirrorElapsedMs: number;
|
||||||
|
netem: NetemSummary;
|
||||||
|
};
|
||||||
|
|
||||||
|
type P2PConnectionStats = {
|
||||||
|
candidatePathCollected: boolean;
|
||||||
|
selectedPath: string;
|
||||||
|
localCandidate?: { candidateType: string; protocol: string; relayProtocol: string };
|
||||||
|
remoteCandidate?: { candidateType: string; protocol: string; relayProtocol: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
function errorToRecord(error: unknown): Record<string, unknown> {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return {
|
||||||
|
name: error.name,
|
||||||
|
message: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: "UnknownError",
|
||||||
|
message: String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 non-negative number, got '${raw}'`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nowMs(): number {
|
||||||
|
return performance.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commandOutput(command: string, args: string[]): Promise<string> {
|
||||||
|
const output = await new Deno.Command(command, {
|
||||||
|
args,
|
||||||
|
stdin: "null",
|
||||||
|
stdout: "piped",
|
||||||
|
stderr: "piped",
|
||||||
|
}).output();
|
||||||
|
const stdout = new TextDecoder().decode(output.stdout);
|
||||||
|
const stderr = new TextDecoder().decode(output.stderr);
|
||||||
|
if (!output.success) {
|
||||||
|
throw new Error(`${command} ${args.join(" ")} failed\nstdout: ${stdout}\nstderr: ${stderr}`);
|
||||||
|
}
|
||||||
|
return stdout.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commandOk(command: string, args: string[]): Promise<void> {
|
||||||
|
await commandOutput(command, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyNetemIfRequested(): Promise<NetemSummary> {
|
||||||
|
const enabled = readEnvString("BENCH_NETEM_ENABLED", "0") === "1";
|
||||||
|
const profile = readEnvString("NETEM_PROFILE", "home-wifi");
|
||||||
|
const iface = readEnvString("NETEM_INTERFACE", "eth0");
|
||||||
|
const delayMs = readEnvNumber("NETEM_DELAY_MS", 20);
|
||||||
|
const jitterMs = readEnvNumber("NETEM_JITTER_MS", 5);
|
||||||
|
const lossPercent = readEnvNumber("NETEM_LOSS_PERCENT", 0.1);
|
||||||
|
const bandwidthMbit = readEnvNumber("NETEM_BANDWIDTH_MBIT", 100);
|
||||||
|
const mtu = readEnvNumber("NETEM_MTU", 1500);
|
||||||
|
|
||||||
|
const summary: NetemSummary = {
|
||||||
|
enabled,
|
||||||
|
profile,
|
||||||
|
interface: iface,
|
||||||
|
delayMs,
|
||||||
|
jitterMs,
|
||||||
|
lossPercent,
|
||||||
|
bandwidthMbit,
|
||||||
|
mtu,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
await commandOk("ip", ["link", "set", "dev", iface, "mtu", String(mtu)]);
|
||||||
|
await new Deno.Command("tc", { args: ["qdisc", "del", "dev", iface, "root"] }).output();
|
||||||
|
await commandOk("tc", [
|
||||||
|
"qdisc",
|
||||||
|
"add",
|
||||||
|
"dev",
|
||||||
|
iface,
|
||||||
|
"root",
|
||||||
|
"netem",
|
||||||
|
"delay",
|
||||||
|
`${delayMs}ms`,
|
||||||
|
`${jitterMs}ms`,
|
||||||
|
"loss",
|
||||||
|
`${lossPercent}%`,
|
||||||
|
"rate",
|
||||||
|
`${bandwidthMbit}mbit`,
|
||||||
|
]);
|
||||||
|
summary.tcQdisc = await commandOutput("tc", ["qdisc", "show", "dev", iface]);
|
||||||
|
summary.ipAddr = await commandOutput("ip", ["addr", "show", iface]);
|
||||||
|
summary.ipRoute = await commandOutput("ip", ["route"]);
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForFile(path: string, timeoutMs: number): Promise<void> {
|
||||||
|
const started = Date.now();
|
||||||
|
while (Date.now() - started < timeoutMs) {
|
||||||
|
try {
|
||||||
|
const stat = await Deno.stat(path);
|
||||||
|
if (stat.isFile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// wait
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||||
|
}
|
||||||
|
throw new Error(`Timed out waiting for ${path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJsonFile<T>(path: string): Promise<T> {
|
||||||
|
return JSON.parse(await Deno.readTextFile(path)) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickSampleFiles(entries: DatasetEntry[]): DatasetEntry[] {
|
||||||
|
const unique = new Map<string, DatasetEntry>();
|
||||||
|
for (const entry of [entries.find((e) => e.kind === "md"), entries.find((e) => e.kind === "bin"), entries.at(-1)]) {
|
||||||
|
if (entry) {
|
||||||
|
unique.set(entry.relativePath, entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...unique.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readLatestP2PConnectionStats(path: string): Promise<P2PConnectionStats | undefined> {
|
||||||
|
try {
|
||||||
|
const lines = (await Deno.readTextFile(path))
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.length > 0);
|
||||||
|
return lines.length === 0 ? undefined : (JSON.parse(lines.at(-1)!) as P2PConnectionStats);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCommonConfig() {
|
||||||
|
const runId = readEnvString("BENCH_SPLIT_RUN_ID", readEnvString("BENCH_ROOM_ID", "bench-split-run"));
|
||||||
|
const baseWorkRoot = readEnvString("BENCH_SPLIT_WORK_ROOT", "/p2p-work");
|
||||||
|
return {
|
||||||
|
runId,
|
||||||
|
workRoot: join(baseWorkRoot, runId),
|
||||||
|
resultRoot: readEnvString("BENCH_SPLIT_RESULT_ROOT", "/workspace/src/apps/cli/testdeno/bench-results"),
|
||||||
|
relay: readEnvString("BENCH_RELAY", "ws://nostr-relay:7777/"),
|
||||||
|
appId: readEnvString("BENCH_APP_ID", "self-hosted-livesync-cli-benchmark"),
|
||||||
|
roomId: readEnvString("BENCH_ROOM_ID", "bench-split-room"),
|
||||||
|
passphrase: readEnvString("BENCH_PASSPHRASE", "bench-split-passphrase"),
|
||||||
|
turnServers: readEnvString("BENCH_TURN_SERVERS", ""),
|
||||||
|
datasetDirName: readEnvString("BENCH_DATASET_DIR", "bench-dataset"),
|
||||||
|
datasetSeed: readEnvString("BENCH_SEED", "livesync-benchmark-seed"),
|
||||||
|
mdFileCount: Math.floor(readEnvNumber("BENCH_MD_FILE_COUNT", 20)),
|
||||||
|
mdMinSizeBytes: Math.floor(readEnvNumber("BENCH_MD_MIN_SIZE_BYTES", 512)),
|
||||||
|
mdMaxSizeBytes: Math.floor(readEnvNumber("BENCH_MD_MAX_SIZE_BYTES", 2048)),
|
||||||
|
binFileCount: Math.floor(readEnvNumber("BENCH_BIN_FILE_COUNT", 5)),
|
||||||
|
binSizeBytes: Math.floor(readEnvNumber("BENCH_BIN_SIZE_BYTES", 8192)),
|
||||||
|
peersTimeoutSeconds: readEnvNumber("BENCH_PEERS_TIMEOUT", 60),
|
||||||
|
syncTimeoutSeconds: readEnvNumber("BENCH_SYNC_TIMEOUT", 300),
|
||||||
|
nodeTimeoutMs: readEnvNumber("BENCH_SPLIT_NODE_TIMEOUT_MS", 360_000),
|
||||||
|
profile: readEnvString("BENCH_NETWORK_PROFILE", readEnvString("NETEM_PROFILE", "split-compose")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareP2PSettings(
|
||||||
|
settingsPath: string,
|
||||||
|
peerName: string,
|
||||||
|
config: ReturnType<typeof buildCommonConfig>
|
||||||
|
) {
|
||||||
|
await initSettingsFile(settingsPath);
|
||||||
|
await applyP2pSettings(
|
||||||
|
settingsPath,
|
||||||
|
config.roomId,
|
||||||
|
config.passphrase,
|
||||||
|
config.appId,
|
||||||
|
config.relay,
|
||||||
|
"~.*",
|
||||||
|
config.turnServers
|
||||||
|
);
|
||||||
|
await applyP2pTestTweaks(settingsPath, peerName, config.passphrase);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runHost(): Promise<void> {
|
||||||
|
const config = buildCommonConfig();
|
||||||
|
const netem = await applyNetemIfRequested();
|
||||||
|
await Deno.mkdir(config.workRoot, { recursive: true });
|
||||||
|
await Deno.mkdir(config.resultRoot, { recursive: true });
|
||||||
|
|
||||||
|
const hostVault = join(config.workRoot, "vault-host");
|
||||||
|
const hostSettings = join(config.workRoot, "settings-host.json");
|
||||||
|
await Deno.mkdir(hostVault, { recursive: true });
|
||||||
|
await prepareP2PSettings(hostSettings, "p2p-split-host", config);
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
await Deno.writeTextFile(
|
||||||
|
join(config.workRoot, "sample-files.json"),
|
||||||
|
JSON.stringify(pickSampleFiles(seedFiles.entries), null, 2)
|
||||||
|
);
|
||||||
|
|
||||||
|
const mirrorStart = nowMs();
|
||||||
|
await runCliOrFail(hostVault, "--settings", hostSettings, "mirror");
|
||||||
|
const mirrorElapsedMs = Number((nowMs() - mirrorStart).toFixed(1));
|
||||||
|
const hostReady: HostReady = {
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
totalFiles: seedFiles.totalFiles,
|
||||||
|
totalBytes: seedFiles.totalBytes,
|
||||||
|
mdFileCount: seedFiles.mdCount,
|
||||||
|
binFileCount: seedFiles.binCount,
|
||||||
|
mirrorElapsedMs,
|
||||||
|
netem,
|
||||||
|
};
|
||||||
|
await Deno.writeTextFile(join(config.workRoot, "host-ready.json"), JSON.stringify(hostReady, null, 2));
|
||||||
|
|
||||||
|
const host = startCliInBackground(hostVault, "--settings", hostSettings, "p2p-host");
|
||||||
|
try {
|
||||||
|
await host.waitUntilContains("P2P host is running", 20_000);
|
||||||
|
await Deno.writeTextFile(
|
||||||
|
join(config.workRoot, "p2p-host-ready.json"),
|
||||||
|
JSON.stringify({ generatedAt: new Date().toISOString() })
|
||||||
|
);
|
||||||
|
await waitForFile(join(config.workRoot, "client-done.json"), config.nodeTimeoutMs);
|
||||||
|
} finally {
|
||||||
|
await host.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runClient(): Promise<void> {
|
||||||
|
const config = buildCommonConfig();
|
||||||
|
const netem = await applyNetemIfRequested();
|
||||||
|
await Deno.mkdir(config.resultRoot, { recursive: true });
|
||||||
|
await waitForFile(join(config.workRoot, "host-ready.json"), config.nodeTimeoutMs);
|
||||||
|
await waitForFile(join(config.workRoot, "p2p-host-ready.json"), config.nodeTimeoutMs);
|
||||||
|
|
||||||
|
const clientVault = join(config.workRoot, "vault-client");
|
||||||
|
const clientSettings = join(config.workRoot, "settings-client.json");
|
||||||
|
const statsPath = join(config.workRoot, "p2p-connection-stats.jsonl");
|
||||||
|
await Deno.mkdir(clientVault, { recursive: true });
|
||||||
|
await prepareP2PSettings(clientSettings, "p2p-split-client", config);
|
||||||
|
|
||||||
|
const hostReady = await readJsonFile<HostReady>(join(config.workRoot, "host-ready.json"));
|
||||||
|
const timestamp = new Date().toISOString().replace(/[-:]/g, "").slice(0, 15);
|
||||||
|
const outputDir = join(config.resultRoot, `p2p-split-${config.profile}-${timestamp}`);
|
||||||
|
await Deno.mkdir(outputDir, { recursive: true });
|
||||||
|
|
||||||
|
const previousStatsPath = Deno.env.get("LIVESYNC_P2P_STATS_JSONL");
|
||||||
|
Deno.env.set("LIVESYNC_P2P_STATS_JSONL", statsPath);
|
||||||
|
let stage = "peer-discovery";
|
||||||
|
let peerDiscoveryCommandElapsedMs: number | undefined;
|
||||||
|
let syncElapsedMs: number | undefined;
|
||||||
|
try {
|
||||||
|
const peerDiscoveryCommandStart = nowMs();
|
||||||
|
const peer = await discoverPeer(clientVault, clientSettings, config.peersTimeoutSeconds);
|
||||||
|
peerDiscoveryCommandElapsedMs = Number((nowMs() - peerDiscoveryCommandStart).toFixed(1));
|
||||||
|
|
||||||
|
stage = "p2p-sync";
|
||||||
|
const syncStart = nowMs();
|
||||||
|
await runCliOrFail(
|
||||||
|
clientVault,
|
||||||
|
"--settings",
|
||||||
|
clientSettings,
|
||||||
|
"p2p-sync",
|
||||||
|
peer.id,
|
||||||
|
String(config.syncTimeoutSeconds)
|
||||||
|
);
|
||||||
|
syncElapsedMs = Number((nowMs() - syncStart).toFixed(1));
|
||||||
|
|
||||||
|
stage = "sample-verification";
|
||||||
|
const samples = await readJsonFile<DatasetEntry[]>(join(config.workRoot, "sample-files.json"));
|
||||||
|
for (const sample of samples) {
|
||||||
|
const pulledPath = join(config.workRoot, `pulled-${sample.relativePath.replaceAll("/", "_")}`);
|
||||||
|
await runCliOrFail(clientVault, "--settings", clientSettings, "pull", sample.relativePath, pulledPath);
|
||||||
|
await assertFilesEqual(
|
||||||
|
sample.absolutePath,
|
||||||
|
pulledPath,
|
||||||
|
`sample file mismatch after split sync: ${sample.relativePath}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const p2pConnectionStats = await readLatestP2PConnectionStats(statsPath);
|
||||||
|
const result = {
|
||||||
|
ok: true,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
caseName: "p2p-split-compose",
|
||||||
|
mode: "p2p-split-compose-benchmark",
|
||||||
|
runId: config.runId,
|
||||||
|
simulationTier: Deno.env.get("BENCH_NETEM_ENABLED") === "1" ? "2" : "1",
|
||||||
|
networkProfile: config.profile,
|
||||||
|
networkModel:
|
||||||
|
Deno.env.get("BENCH_NETEM_ENABLED") === "1" ? "split-compose-egress-netem" : "split-compose-no-netem",
|
||||||
|
relay: config.relay,
|
||||||
|
turnServers: config.turnServers,
|
||||||
|
turnEnabled: config.turnServers.trim().length > 0,
|
||||||
|
p2pCandidatePathVerified: p2pConnectionStats?.candidatePathCollected === true,
|
||||||
|
p2pConnectionStats,
|
||||||
|
hostNetem: hostReady.netem,
|
||||||
|
clientNetem: netem,
|
||||||
|
totalFiles: hostReady.totalFiles,
|
||||||
|
totalBytes: hostReady.totalBytes,
|
||||||
|
mdFileCount: hostReady.mdFileCount,
|
||||||
|
binFileCount: hostReady.binFileCount,
|
||||||
|
mirrorElapsedMs: hostReady.mirrorElapsedMs,
|
||||||
|
peerDiscoveryTimeoutSeconds: config.peersTimeoutSeconds,
|
||||||
|
peerDiscoveryCommandElapsedMs,
|
||||||
|
syncElapsedMs,
|
||||||
|
throughputBytesPerSec: Number((hostReady.totalBytes / (syncElapsedMs / 1000)).toFixed(2)),
|
||||||
|
throughputMiBPerSec: Number((hostReady.totalBytes / (syncElapsedMs / 1000) / 1024 / 1024).toFixed(4)),
|
||||||
|
};
|
||||||
|
await Deno.writeTextFile(join(outputDir, "summary.json"), JSON.stringify(result, null, 2));
|
||||||
|
await Deno.writeTextFile(
|
||||||
|
join(config.workRoot, "client-done.json"),
|
||||||
|
JSON.stringify({ generatedAt: new Date().toISOString(), outputDir, ok: true })
|
||||||
|
);
|
||||||
|
console.log(JSON.stringify(result, null, 2));
|
||||||
|
} catch (error) {
|
||||||
|
const p2pConnectionStats = await readLatestP2PConnectionStats(statsPath);
|
||||||
|
const result = {
|
||||||
|
ok: false,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
caseName: "p2p-split-compose",
|
||||||
|
mode: "p2p-split-compose-benchmark",
|
||||||
|
runId: config.runId,
|
||||||
|
simulationTier: Deno.env.get("BENCH_NETEM_ENABLED") === "1" ? "2" : "1",
|
||||||
|
networkProfile: config.profile,
|
||||||
|
networkModel:
|
||||||
|
Deno.env.get("BENCH_NETEM_ENABLED") === "1" ? "split-compose-egress-netem" : "split-compose-no-netem",
|
||||||
|
relay: config.relay,
|
||||||
|
turnServers: config.turnServers,
|
||||||
|
turnEnabled: config.turnServers.trim().length > 0,
|
||||||
|
p2pCandidatePathVerified: p2pConnectionStats?.candidatePathCollected === true,
|
||||||
|
p2pConnectionStats,
|
||||||
|
hostNetem: hostReady.netem,
|
||||||
|
clientNetem: netem,
|
||||||
|
totalFiles: hostReady.totalFiles,
|
||||||
|
totalBytes: hostReady.totalBytes,
|
||||||
|
mdFileCount: hostReady.mdFileCount,
|
||||||
|
binFileCount: hostReady.binFileCount,
|
||||||
|
mirrorElapsedMs: hostReady.mirrorElapsedMs,
|
||||||
|
peerDiscoveryTimeoutSeconds: config.peersTimeoutSeconds,
|
||||||
|
peerDiscoveryCommandElapsedMs,
|
||||||
|
syncElapsedMs,
|
||||||
|
failure: {
|
||||||
|
stage,
|
||||||
|
...errorToRecord(error),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await Deno.writeTextFile(join(outputDir, "summary.json"), JSON.stringify(result, null, 2));
|
||||||
|
await Deno.writeTextFile(
|
||||||
|
join(config.workRoot, "client-done.json"),
|
||||||
|
JSON.stringify({ generatedAt: new Date().toISOString(), outputDir, ok: false })
|
||||||
|
);
|
||||||
|
console.log(JSON.stringify(result, null, 2));
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
if (previousStatsPath === undefined) {
|
||||||
|
Deno.env.delete("LIVESYNC_P2P_STATS_JSONL");
|
||||||
|
} else {
|
||||||
|
Deno.env.set("LIVESYNC_P2P_STATS_JSONL", previousStatsPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const role = readEnvString("BENCH_P2P_SPLIT_ROLE", "") as Role;
|
||||||
|
if (role === "host") {
|
||||||
|
await runHost();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (role === "client") {
|
||||||
|
await runClient();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error("BENCH_P2P_SPLIT_ROLE must be 'host' or 'client'");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.main) {
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error("[Fatal Error]", error);
|
||||||
|
Deno.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
"bench:couchdb": "deno run --env-file=.test.env -A --no-check bench-couchdb.ts",
|
"bench:couchdb": "deno run --env-file=.test.env -A --no-check bench-couchdb.ts",
|
||||||
"bench:cases": "deno run --env-file=.test.env -A --no-check bench-network-cases.ts",
|
"bench:cases": "deno run --env-file=.test.env -A --no-check bench-network-cases.ts",
|
||||||
"bench:latency-sweep": "deno run --env-file=.test.env -A --no-check bench-latency-sweep.ts",
|
"bench:latency-sweep": "deno run --env-file=.test.env -A --no-check bench-latency-sweep.ts",
|
||||||
|
"bench:p2p-split-node": "deno run --env-file=.test.env -A --no-check bench-p2p-split-node.ts",
|
||||||
"bench:item1": "bash ./bench-run-item1.sh",
|
"bench:item1": "bash ./bench-run-item1.sh",
|
||||||
"bench:item1:full": "BENCH_MD_FILE_COUNT=1500 BENCH_MD_MIN_SIZE_BYTES=1024 BENCH_MD_MAX_SIZE_BYTES=20480 BENCH_BIN_FILE_COUNT=500 BENCH_BIN_SIZE_BYTES=102400 BENCH_COUCHDB_RTT_MS=50 bash ./bench-run-item1.sh",
|
"bench:item1:full": "BENCH_MD_FILE_COUNT=1500 BENCH_MD_MIN_SIZE_BYTES=1024 BENCH_MD_MAX_SIZE_BYTES=20480 BENCH_BIN_FILE_COUNT=500 BENCH_BIN_SIZE_BYTES=102400 BENCH_COUCHDB_RTT_MS=50 bash ./bench-run-item1.sh",
|
||||||
"test:e2e-couchdb": "deno test --env-file=.test.env -A --no-check test-e2e-two-vaults-couchdb.ts",
|
"test:e2e-couchdb": "deno test --env-file=.test.env -A --no-check test-e2e-two-vaults-couchdb.ts",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
FROM node:24-slim
|
FROM node:24-slim
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends ca-certificates curl unzip python3 make g++ \
|
&& apt-get install -y --no-install-recommends ca-certificates curl unzip python3 make g++ iproute2 \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
ENV DENO_INSTALL=/usr/local
|
ENV DENO_INSTALL=/usr/local
|
||||||
@@ -25,6 +25,7 @@ WORKDIR /workspace/src/apps/cli/testdeno
|
|||||||
RUN deno cache --lock=deno.lock \
|
RUN deno cache --lock=deno.lock \
|
||||||
bench-network-cases.ts \
|
bench-network-cases.ts \
|
||||||
bench-latency-sweep.ts \
|
bench-latency-sweep.ts \
|
||||||
|
bench-p2p-split-node.ts \
|
||||||
bench-p2p.ts \
|
bench-p2p.ts \
|
||||||
bench-couchdb.ts
|
bench-couchdb.ts
|
||||||
|
|
||||||
|
|||||||
@@ -57,9 +57,9 @@ is executed in an actual tethered/VPN environment.
|
|||||||
The primary local comparison is between a remote-database path and a direct P2P
|
The primary local comparison is between a remote-database path and a direct P2P
|
||||||
path:
|
path:
|
||||||
|
|
||||||
| Case | Data path | What is measured | What is not measured |
|
| Case | Data path | What is measured | What is not measured |
|
||||||
| --- | --- | --- | --- |
|
| ------------------ | ------------------------------------------- | ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- |
|
||||||
| `couchdb-baseline` | Device A -> CouchDB -> Device B | Two one-shot CLI synchronisation commands through a local HTTP latency proxy | Real WAN jitter, packet loss, bandwidth limits, VPN encapsulation, and server contention |
|
| `couchdb-baseline` | Device A -> CouchDB -> Device B | Two one-shot CLI synchronisation commands through a local HTTP latency proxy | Real WAN jitter, packet loss, bandwidth limits, VPN encapsulation, and server contention |
|
||||||
| `p2p-direct-local` | Device A -> Device B after Nostr signalling | One CLI P2P synchronisation command over WebRTC DataChannel with TURN disabled | Public relay operation, mobile carrier behaviour, TURN relay throughput, and first-peer discovery latency |
|
| `p2p-direct-local` | Device A -> Device B after Nostr signalling | One CLI P2P synchronisation command over WebRTC DataChannel with TURN disabled | Public relay operation, mobile carrier behaviour, TURN relay throughput, and first-peer discovery latency |
|
||||||
|
|
||||||
Use the CouchDB result as the remote-store baseline and the P2P result as the
|
Use the CouchDB result as the remote-store baseline and the P2P result as the
|
||||||
@@ -138,6 +138,61 @@ NETEM_MTU=1380 \
|
|||||||
docker compose -f test/bench-network/compose.yml --profile netem run --rm netem-smoke
|
docker compose -f test/bench-network/compose.yml --profile netem run --rm netem-smoke
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Split-container P2P emulation
|
||||||
|
|
||||||
|
The optional `p2p-split` profile runs the P2P host and client in separate
|
||||||
|
Compose services. Each service can apply `tc netem` to its own egress interface
|
||||||
|
and the client result records the selected WebRTC ICE candidate pair.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BENCH_MD_FILE_COUNT=2 \
|
||||||
|
BENCH_BIN_FILE_COUNT=1 \
|
||||||
|
BENCH_PEERS_TIMEOUT=10 \
|
||||||
|
BENCH_SPLIT_RUN_ID="$(date -u +%Y%m%d%H%M%S)" \
|
||||||
|
docker compose -f test/bench-network/compose.yml --profile p2p-split up \
|
||||||
|
--abort-on-container-exit --exit-code-from p2p-split-client \
|
||||||
|
p2p-split-host p2p-split-client
|
||||||
|
```
|
||||||
|
|
||||||
|
By default this uses the `home-wifi` profile (`20 ms` delay, `5 ms` jitter,
|
||||||
|
`0.1%` loss, `100 Mbit`, and `1500` MTU) on both P2P containers. Override the
|
||||||
|
same `NETEM_*` variables used by the TCP shim to model a stricter profile.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BENCH_MD_FILE_COUNT=100 \
|
||||||
|
BENCH_MD_MIN_SIZE_BYTES=512 \
|
||||||
|
BENCH_MD_MAX_SIZE_BYTES=2048 \
|
||||||
|
BENCH_BIN_FILE_COUNT=25 \
|
||||||
|
BENCH_BIN_SIZE_BYTES=8192 \
|
||||||
|
BENCH_PEERS_TIMEOUT=60 \
|
||||||
|
BENCH_SYNC_TIMEOUT=420 \
|
||||||
|
BENCH_SPLIT_RUN_ID="$(date -u +%Y%m%d%H%M%S)" \
|
||||||
|
BENCH_NETWORK_PROFILE=tethering-vpn \
|
||||||
|
NETEM_PROFILE=tethering-vpn \
|
||||||
|
NETEM_DELAY_MS=140 \
|
||||||
|
NETEM_JITTER_MS=50 \
|
||||||
|
NETEM_LOSS_PERCENT=1.0 \
|
||||||
|
NETEM_BANDWIDTH_MBIT=10 \
|
||||||
|
NETEM_MTU=1380 \
|
||||||
|
docker compose -f test/bench-network/compose.yml --profile p2p-split up \
|
||||||
|
--abort-on-container-exit --exit-code-from p2p-split-client \
|
||||||
|
p2p-split-host p2p-split-client
|
||||||
|
```
|
||||||
|
|
||||||
|
This is a Linux-only manual benchmark fixture, not a required pull-request CI
|
||||||
|
job. It shapes each P2P container's egress path, including signalling traffic,
|
||||||
|
and should be reported separately from the CouchDB TCP-shim measurements. The
|
||||||
|
result JSON includes `ok: true` for completed runs; failed runs still write a
|
||||||
|
summary with `ok: false` and a `failure` object before returning a non-zero
|
||||||
|
exit code.
|
||||||
|
|
||||||
|
Remove the shared work volume between repeated manual runs when you do not use
|
||||||
|
a unique `BENCH_SPLIT_RUN_ID`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f test/bench-network/compose.yml --profile p2p-split down --volumes
|
||||||
|
```
|
||||||
|
|
||||||
## Shimmed CouchDB benchmark
|
## Shimmed CouchDB benchmark
|
||||||
|
|
||||||
The optional `shim` profile runs a CouchDB benchmark through a TCP forwarding
|
The optional `shim` profile runs a CouchDB benchmark through a TCP forwarding
|
||||||
|
|||||||
@@ -160,6 +160,91 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./bench-results:/workspace/src/apps/cli/testdeno/bench-results
|
- ./bench-results:/workspace/src/apps/cli/testdeno/bench-results
|
||||||
|
|
||||||
|
p2p-split-host:
|
||||||
|
build:
|
||||||
|
context: ../..
|
||||||
|
dockerfile: test/bench-network/Dockerfile.runner
|
||||||
|
profiles:
|
||||||
|
- p2p-split
|
||||||
|
depends_on:
|
||||||
|
nostr-relay:
|
||||||
|
condition: service_healthy
|
||||||
|
cap_add:
|
||||||
|
- NET_ADMIN
|
||||||
|
environment:
|
||||||
|
BENCH_COMMAND: p2p-split-node
|
||||||
|
BENCH_P2P_SPLIT_ROLE: host
|
||||||
|
BENCH_SPLIT_RUN_ID: ${BENCH_SPLIT_RUN_ID:-bench-split-run}
|
||||||
|
BENCH_SPLIT_WORK_ROOT: /p2p-work
|
||||||
|
BENCH_SPLIT_RESULT_ROOT: /workspace/src/apps/cli/testdeno/bench-results
|
||||||
|
BENCH_RELAY: ws://nostr-relay:7777/
|
||||||
|
BENCH_APP_ID: ${BENCH_APP_ID:-self-hosted-livesync-cli-benchmark}
|
||||||
|
BENCH_ROOM_ID: ${BENCH_ROOM_ID:-bench-split-room}
|
||||||
|
BENCH_PASSPHRASE: ${BENCH_PASSPHRASE:-bench-split-passphrase}
|
||||||
|
BENCH_TURN_SERVERS: ${BENCH_TURN_SERVERS:-}
|
||||||
|
BENCH_MD_FILE_COUNT: ${BENCH_MD_FILE_COUNT:-20}
|
||||||
|
BENCH_MD_MIN_SIZE_BYTES: ${BENCH_MD_MIN_SIZE_BYTES:-512}
|
||||||
|
BENCH_MD_MAX_SIZE_BYTES: ${BENCH_MD_MAX_SIZE_BYTES:-2048}
|
||||||
|
BENCH_BIN_FILE_COUNT: ${BENCH_BIN_FILE_COUNT:-5}
|
||||||
|
BENCH_BIN_SIZE_BYTES: ${BENCH_BIN_SIZE_BYTES:-8192}
|
||||||
|
BENCH_SYNC_TIMEOUT: ${BENCH_SYNC_TIMEOUT:-300}
|
||||||
|
BENCH_PEERS_TIMEOUT: ${BENCH_PEERS_TIMEOUT:-60}
|
||||||
|
BENCH_NETEM_ENABLED: ${BENCH_NETEM_ENABLED:-1}
|
||||||
|
BENCH_NETWORK_PROFILE: ${BENCH_NETWORK_PROFILE:-home-wifi}
|
||||||
|
NETEM_PROFILE: ${NETEM_PROFILE:-home-wifi}
|
||||||
|
NETEM_INTERFACE: ${NETEM_INTERFACE:-eth0}
|
||||||
|
NETEM_DELAY_MS: ${NETEM_DELAY_MS:-20}
|
||||||
|
NETEM_JITTER_MS: ${NETEM_JITTER_MS:-5}
|
||||||
|
NETEM_LOSS_PERCENT: ${NETEM_LOSS_PERCENT:-0.1}
|
||||||
|
NETEM_BANDWIDTH_MBIT: ${NETEM_BANDWIDTH_MBIT:-100}
|
||||||
|
NETEM_MTU: ${NETEM_MTU:-1500}
|
||||||
|
LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS: ${LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS:-60000}
|
||||||
|
LIVESYNC_TEST_TEE: ${BENCH_LIVESYNC_TEST_TEE:-0}
|
||||||
|
volumes:
|
||||||
|
- p2p-split-work:/p2p-work
|
||||||
|
- ./bench-results:/workspace/src/apps/cli/testdeno/bench-results
|
||||||
|
|
||||||
|
p2p-split-client:
|
||||||
|
build:
|
||||||
|
context: ../..
|
||||||
|
dockerfile: test/bench-network/Dockerfile.runner
|
||||||
|
profiles:
|
||||||
|
- p2p-split
|
||||||
|
depends_on:
|
||||||
|
nostr-relay:
|
||||||
|
condition: service_healthy
|
||||||
|
p2p-split-host:
|
||||||
|
condition: service_started
|
||||||
|
cap_add:
|
||||||
|
- NET_ADMIN
|
||||||
|
environment:
|
||||||
|
BENCH_COMMAND: p2p-split-node
|
||||||
|
BENCH_P2P_SPLIT_ROLE: client
|
||||||
|
BENCH_SPLIT_RUN_ID: ${BENCH_SPLIT_RUN_ID:-bench-split-run}
|
||||||
|
BENCH_SPLIT_WORK_ROOT: /p2p-work
|
||||||
|
BENCH_SPLIT_RESULT_ROOT: /workspace/src/apps/cli/testdeno/bench-results
|
||||||
|
BENCH_RELAY: ws://nostr-relay:7777/
|
||||||
|
BENCH_APP_ID: ${BENCH_APP_ID:-self-hosted-livesync-cli-benchmark}
|
||||||
|
BENCH_ROOM_ID: ${BENCH_ROOM_ID:-bench-split-room}
|
||||||
|
BENCH_PASSPHRASE: ${BENCH_PASSPHRASE:-bench-split-passphrase}
|
||||||
|
BENCH_TURN_SERVERS: ${BENCH_TURN_SERVERS:-}
|
||||||
|
BENCH_SYNC_TIMEOUT: ${BENCH_SYNC_TIMEOUT:-300}
|
||||||
|
BENCH_PEERS_TIMEOUT: ${BENCH_PEERS_TIMEOUT:-60}
|
||||||
|
BENCH_NETEM_ENABLED: ${BENCH_NETEM_ENABLED:-1}
|
||||||
|
BENCH_NETWORK_PROFILE: ${BENCH_NETWORK_PROFILE:-home-wifi}
|
||||||
|
NETEM_PROFILE: ${NETEM_PROFILE:-home-wifi}
|
||||||
|
NETEM_INTERFACE: ${NETEM_INTERFACE:-eth0}
|
||||||
|
NETEM_DELAY_MS: ${NETEM_DELAY_MS:-20}
|
||||||
|
NETEM_JITTER_MS: ${NETEM_JITTER_MS:-5}
|
||||||
|
NETEM_LOSS_PERCENT: ${NETEM_LOSS_PERCENT:-0.1}
|
||||||
|
NETEM_BANDWIDTH_MBIT: ${NETEM_BANDWIDTH_MBIT:-100}
|
||||||
|
NETEM_MTU: ${NETEM_MTU:-1500}
|
||||||
|
LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS: ${LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS:-60000}
|
||||||
|
LIVESYNC_TEST_TEE: ${BENCH_LIVESYNC_TEST_TEE:-0}
|
||||||
|
volumes:
|
||||||
|
- p2p-split-work:/p2p-work
|
||||||
|
- ./bench-results:/workspace/src/apps/cli/testdeno/bench-results
|
||||||
|
|
||||||
netem-smoke:
|
netem-smoke:
|
||||||
build:
|
build:
|
||||||
context: ../..
|
context: ../..
|
||||||
@@ -179,3 +264,6 @@ services:
|
|||||||
NETEM_RESULT_ROOT: /bench-results
|
NETEM_RESULT_ROOT: /bench-results
|
||||||
volumes:
|
volumes:
|
||||||
- ./bench-results:/bench-results
|
- ./bench-results:/bench-results
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
p2p-split-work:
|
||||||
|
|||||||
@@ -8,9 +8,12 @@ case "${BENCH_COMMAND:-cases}" in
|
|||||||
latency-sweep)
|
latency-sweep)
|
||||||
exec deno task bench:latency-sweep
|
exec deno task bench:latency-sweep
|
||||||
;;
|
;;
|
||||||
|
p2p-split-node)
|
||||||
|
exec deno task bench:p2p-split-node
|
||||||
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Unknown BENCH_COMMAND: ${BENCH_COMMAND}" >&2
|
echo "Unknown BENCH_COMMAND: ${BENCH_COMMAND}" >&2
|
||||||
echo "Expected one of: cases, latency-sweep" >&2
|
echo "Expected one of: cases, latency-sweep, p2p-split-node" >&2
|
||||||
exit 2
|
exit 2
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
Reference in New Issue
Block a user