mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-23 04:52:58 +00:00
Re-evaluate optional features for 1.0
This commit is contained in:
@@ -0,0 +1,603 @@
|
||||
import { join } from "@std/path";
|
||||
import { TempDir } from "./helpers/temp.ts";
|
||||
import { applyRemoteSyncSettings, initSettingsFile } from "./helpers/settings.ts";
|
||||
import { assertFilesEqual } from "./helpers/cli.ts";
|
||||
import { runMeasuredCliOrFail, type CliProcessMeasurement } from "./helpers/measuredCli.ts";
|
||||
import { createCouchdbDatabase, startCouchdb, stopCouchdb } from "./helpers/docker.ts";
|
||||
import {
|
||||
createCompressionBenchmarkDataset,
|
||||
type CompressionDataset,
|
||||
type CompressionDatasetEntry,
|
||||
} from "./helpers/compressionDataset.ts";
|
||||
import { computeDatasetDigestSha256 } from "./helpers/benchmarkVerification.ts";
|
||||
import { startCouchdbProxy, type CouchdbProxyCounters } from "./bench-couchdb.ts";
|
||||
import type { DatasetKind } from "./helpers/dataset.ts";
|
||||
|
||||
type CompressionCondition = {
|
||||
name: string;
|
||||
encrypt: boolean;
|
||||
enableCompression: boolean;
|
||||
};
|
||||
|
||||
type CouchDbSizes = {
|
||||
file: number;
|
||||
external: number;
|
||||
active: number;
|
||||
};
|
||||
|
||||
type PerKindRemoteMeasurement = {
|
||||
sourceFiles: number;
|
||||
sourceBytes: number;
|
||||
mappedFiles: number;
|
||||
uniqueReferencedChunks: number;
|
||||
storedChunkDataBytes: number;
|
||||
storedChunkJsonBytes: number;
|
||||
};
|
||||
|
||||
type RemoteMeasurement = {
|
||||
couchdbSizes: CouchDbSizes;
|
||||
documentCount: number;
|
||||
chunkDocumentCount: number;
|
||||
metadataDocumentCount: number;
|
||||
compressedMarkerCount: number;
|
||||
encryptedChunkCount: number;
|
||||
storedChunkDataBytes: number;
|
||||
storedChunkJsonBytes: number;
|
||||
perKind: Record<DatasetKind, PerKindRemoteMeasurement>;
|
||||
};
|
||||
|
||||
type RunResult = {
|
||||
condition: CompressionCondition;
|
||||
repeatIndex: number;
|
||||
executionOrder: number;
|
||||
databaseName: string;
|
||||
datasetDigestSha256: string;
|
||||
dataset: {
|
||||
totalFiles: number;
|
||||
totalBytes: number;
|
||||
filesByKind: CompressionDataset["filesByKind"];
|
||||
bytesByKind: CompressionDataset["bytesByKind"];
|
||||
jpegGenerator: string;
|
||||
};
|
||||
effectiveSettings: Record<string, unknown>;
|
||||
mirror: CliProcessMeasurement;
|
||||
upload: CliProcessMeasurement & { http: CouchdbProxyCounters };
|
||||
download: CliProcessMeasurement & { http: CouchdbProxyCounters };
|
||||
materialisation: CliProcessMeasurement & { http: CouchdbProxyCounters };
|
||||
verification: {
|
||||
verifiedFiles: number;
|
||||
complete: boolean;
|
||||
};
|
||||
remote: RemoteMeasurement;
|
||||
};
|
||||
|
||||
const CONDITIONS: CompressionCondition[] = [
|
||||
{ name: "plain", encrypt: false, enableCompression: false },
|
||||
{ name: "plain-compressed", encrypt: false, enableCompression: true },
|
||||
{ name: "e2ee", encrypt: true, enableCompression: false },
|
||||
{ name: "e2ee-compressed", encrypt: true, enableCompression: true },
|
||||
];
|
||||
|
||||
const DATASET_KINDS: DatasetKind[] = ["md", "jpg", "png", "json", "ts", "gz", "bin"];
|
||||
const COMPRESSED_MARKER = "\u000eLZ\u001d";
|
||||
|
||||
function readEnvString(name: string, fallback: string): string {
|
||||
const value = Deno.env.get(name)?.trim();
|
||||
return value ? value : fallback;
|
||||
}
|
||||
|
||||
function readEnvPositiveInteger(name: string, fallback: number): number {
|
||||
const raw = Deno.env.get(name)?.trim();
|
||||
if (!raw) return fallback;
|
||||
const value = Number(raw);
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
throw new Error(`${name} must be a positive integer, got '${raw}'`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function readEnvPositiveNumber(name: string, fallback: number): number {
|
||||
const raw = Deno.env.get(name)?.trim();
|
||||
if (!raw) return fallback;
|
||||
const value = Number(raw);
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
throw new Error(`${name} must be positive, got '${raw}'`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function readEnvBoolean(name: string, fallback: boolean): boolean {
|
||||
const raw = Deno.env.get(name)?.trim();
|
||||
if (!raw) return fallback;
|
||||
return /^(1|true|yes|on)$/i.test(raw);
|
||||
}
|
||||
|
||||
function byteLength(value: string): number {
|
||||
return new TextEncoder().encode(value).byteLength;
|
||||
}
|
||||
|
||||
function median(values: number[]): number {
|
||||
if (values.length === 0) return 0;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const middle = Math.floor(sorted.length / 2);
|
||||
if (sorted.length % 2 === 1) return sorted[middle];
|
||||
return (sorted[middle - 1] + sorted[middle]) / 2;
|
||||
}
|
||||
|
||||
function rounded(value: number): number {
|
||||
return Number(value.toFixed(4));
|
||||
}
|
||||
|
||||
function deltaPercent(enabled: number, disabled: number): number | null {
|
||||
if (disabled === 0) return null;
|
||||
return rounded(((enabled - disabled) / disabled) * 100);
|
||||
}
|
||||
|
||||
function reductionPercent(enabled: number, disabled: number): number | null {
|
||||
if (disabled === 0) return null;
|
||||
return rounded((1 - enabled / disabled) * 100);
|
||||
}
|
||||
|
||||
function basicAuth(user: string, password: string): string {
|
||||
return `Basic ${btoa(`${user}:${password}`)}`;
|
||||
}
|
||||
|
||||
async function couchRequest(
|
||||
baseUri: string,
|
||||
user: string,
|
||||
password: string,
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
allowedStatuses: number[] = []
|
||||
): Promise<Response> {
|
||||
const response = await fetch(`${baseUri.replace(/\/$/, "")}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
Authorization: basicAuth(user, password),
|
||||
...(init.method === "POST" || init.body ? { "Content-Type": "application/json" } : {}),
|
||||
...init.headers,
|
||||
},
|
||||
});
|
||||
if (!response.ok && !allowedStatuses.includes(response.status)) {
|
||||
throw new Error(`${init.method ?? "GET"} ${path}: ${response.status} ${await response.text()}`);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function deleteDatabase(baseUri: string, user: string, password: string, databaseName: string): Promise<void> {
|
||||
const response = await couchRequest(
|
||||
baseUri,
|
||||
user,
|
||||
password,
|
||||
`/${encodeURIComponent(databaseName)}`,
|
||||
{ method: "DELETE" },
|
||||
[404]
|
||||
);
|
||||
await response.body?.cancel().catch(() => {});
|
||||
}
|
||||
|
||||
function blankPerKind(dataset: CompressionDataset): Record<DatasetKind, PerKindRemoteMeasurement> {
|
||||
return Object.fromEntries(
|
||||
DATASET_KINDS.map((kind) => [
|
||||
kind,
|
||||
{
|
||||
sourceFiles: dataset.filesByKind[kind],
|
||||
sourceBytes: dataset.bytesByKind[kind],
|
||||
mappedFiles: 0,
|
||||
uniqueReferencedChunks: 0,
|
||||
storedChunkDataBytes: 0,
|
||||
storedChunkJsonBytes: 0,
|
||||
},
|
||||
])
|
||||
) as Record<DatasetKind, PerKindRemoteMeasurement>;
|
||||
}
|
||||
|
||||
function findDatasetEntry(path: unknown, entries: CompressionDatasetEntry[]): CompressionDatasetEntry | undefined {
|
||||
if (typeof path !== "string") return undefined;
|
||||
return entries.find((entry) => path === entry.relativePath || path.endsWith(`/${entry.relativePath}`));
|
||||
}
|
||||
|
||||
async function inspectRemoteDatabase(options: {
|
||||
baseUri: string;
|
||||
user: string;
|
||||
password: string;
|
||||
databaseName: string;
|
||||
dataset: CompressionDataset;
|
||||
}): Promise<RemoteMeasurement> {
|
||||
const dbPath = `/${encodeURIComponent(options.databaseName)}`;
|
||||
await couchRequest(options.baseUri, options.user, options.password, `${dbPath}/_ensure_full_commit`, {
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
});
|
||||
const [info, allDocs] = await Promise.all([
|
||||
couchRequest(options.baseUri, options.user, options.password, dbPath).then((response) => response.json()),
|
||||
couchRequest(options.baseUri, options.user, options.password, `${dbPath}/_all_docs?include_docs=true`).then(
|
||||
(response) => response.json()
|
||||
),
|
||||
]);
|
||||
const rows = (allDocs as { rows?: Array<{ doc?: Record<string, unknown> }> }).rows ?? [];
|
||||
const docs = rows.flatMap((row) => (row.doc ? [row.doc] : []));
|
||||
const docsById = new Map(docs.flatMap((doc) => (typeof doc._id === "string" ? [[doc._id, doc] as const] : [])));
|
||||
const metadataDocs = docs.filter((doc) => Array.isArray(doc.children) && typeof doc.path === "string");
|
||||
const chunkDocs = docs.filter(
|
||||
(doc) =>
|
||||
(doc.type === "leaf" || doc.type === "chunkpack") &&
|
||||
typeof doc.data === "string" &&
|
||||
typeof doc._id === "string"
|
||||
);
|
||||
const perKind = blankPerKind(options.dataset);
|
||||
const chunkIdsByKind = new Map(DATASET_KINDS.map((kind) => [kind, new Set<string>()] as const));
|
||||
|
||||
for (const doc of metadataDocs) {
|
||||
const entry = findDatasetEntry(doc.path, options.dataset.entries);
|
||||
if (!entry) continue;
|
||||
perKind[entry.kind].mappedFiles += 1;
|
||||
for (const child of doc.children as unknown[]) {
|
||||
if (typeof child === "string") chunkIdsByKind.get(entry.kind)!.add(child);
|
||||
}
|
||||
}
|
||||
for (const kind of DATASET_KINDS) {
|
||||
const chunkIds = chunkIdsByKind.get(kind)!;
|
||||
perKind[kind].uniqueReferencedChunks = chunkIds.size;
|
||||
for (const chunkId of chunkIds) {
|
||||
const chunk = docsById.get(chunkId);
|
||||
if (!chunk || typeof chunk.data !== "string") continue;
|
||||
perKind[kind].storedChunkDataBytes += byteLength(chunk.data);
|
||||
perKind[kind].storedChunkJsonBytes += byteLength(JSON.stringify(chunk));
|
||||
}
|
||||
}
|
||||
|
||||
const sizeInfo = (info as { sizes?: Partial<CouchDbSizes>; doc_count?: number }).sizes ?? {};
|
||||
return {
|
||||
couchdbSizes: {
|
||||
file: sizeInfo.file ?? 0,
|
||||
external: sizeInfo.external ?? 0,
|
||||
active: sizeInfo.active ?? 0,
|
||||
},
|
||||
documentCount: (info as { doc_count?: number }).doc_count ?? docs.length,
|
||||
chunkDocumentCount: chunkDocs.length,
|
||||
metadataDocumentCount: metadataDocs.length,
|
||||
compressedMarkerCount: chunkDocs.filter(
|
||||
(doc) => typeof doc.data === "string" && doc.data.startsWith(COMPRESSED_MARKER)
|
||||
).length,
|
||||
encryptedChunkCount: chunkDocs.filter((doc) => doc.e_ === true).length,
|
||||
storedChunkDataBytes: chunkDocs.reduce(
|
||||
(sum, doc) => sum + (typeof doc.data === "string" ? byteLength(doc.data) : 0),
|
||||
0
|
||||
),
|
||||
storedChunkJsonBytes: chunkDocs.reduce((sum, doc) => sum + byteLength(JSON.stringify(doc)), 0),
|
||||
perKind,
|
||||
};
|
||||
}
|
||||
|
||||
async function verifyDataset(
|
||||
workDir: TempDir,
|
||||
vaultB: string,
|
||||
settingsB: string,
|
||||
entries: CompressionDatasetEntry[]
|
||||
): Promise<CliProcessMeasurement> {
|
||||
const started = performance.now();
|
||||
const measurements: CliProcessMeasurement[] = [];
|
||||
for (const entry of entries) {
|
||||
const pulledPath = workDir.join(`verify-${entry.kind}-${entry.relativePath.split("/").at(-1)}`);
|
||||
measurements.push(
|
||||
await runMeasuredCliOrFail(vaultB, "--settings", settingsB, "pull", entry.relativePath, pulledPath)
|
||||
);
|
||||
await assertFilesEqual(entry.absolutePath, pulledPath, `compression benchmark mismatch: ${entry.relativePath}`);
|
||||
}
|
||||
const elapsedMs = performance.now() - started;
|
||||
const userCpuMs = measurements.reduce((sum, measurement) => sum + measurement.userCpuMs, 0);
|
||||
const systemCpuMs = measurements.reduce((sum, measurement) => sum + measurement.systemCpuMs, 0);
|
||||
const totalCpuMs = userCpuMs + systemCpuMs;
|
||||
return {
|
||||
elapsedMs: rounded(elapsedMs),
|
||||
userCpuMs: rounded(userCpuMs),
|
||||
systemCpuMs: rounded(systemCpuMs),
|
||||
totalCpuMs: rounded(totalCpuMs),
|
||||
cpuToWallRatio: rounded(totalCpuMs / elapsedMs),
|
||||
maxResidentSetKiB: Math.max(...measurements.map((measurement) => measurement.maxResidentSetKiB)),
|
||||
};
|
||||
}
|
||||
|
||||
function summariseResults(results: RunResult[]) {
|
||||
const byCondition = Object.fromEntries(
|
||||
CONDITIONS.map((condition) => {
|
||||
const runs = results.filter((result) => result.condition.name === condition.name);
|
||||
return [
|
||||
condition.name,
|
||||
{
|
||||
repeats: runs.length,
|
||||
remoteStoredChunkDataBytesMedian: median(runs.map((run) => run.remote.storedChunkDataBytes)),
|
||||
couchdbExternalBytesMedian: median(runs.map((run) => run.remote.couchdbSizes.external)),
|
||||
couchdbFileBytesMedian: median(runs.map((run) => run.remote.couchdbSizes.file)),
|
||||
uploadRequestBodyBytesMedian: median(runs.map((run) => run.upload.http.requestBodyBytes)),
|
||||
uploadResponseBodyBytesMedian: median(runs.map((run) => run.upload.http.responseBodyBytes)),
|
||||
downloadRequestBodyBytesMedian: median(runs.map((run) => run.download.http.requestBodyBytes)),
|
||||
downloadResponseBodyBytesMedian: median(runs.map((run) => run.download.http.responseBodyBytes)),
|
||||
uploadElapsedMsMedian: median(runs.map((run) => run.upload.elapsedMs)),
|
||||
uploadCpuMsMedian: median(runs.map((run) => run.upload.totalCpuMs)),
|
||||
downloadElapsedMsMedian: median(runs.map((run) => run.download.elapsedMs)),
|
||||
downloadCpuMsMedian: median(runs.map((run) => run.download.totalCpuMs)),
|
||||
materialisationElapsedMsMedian: median(runs.map((run) => run.materialisation.elapsedMs)),
|
||||
materialisationCpuMsMedian: median(runs.map((run) => run.materialisation.totalCpuMs)),
|
||||
materialisationResponseBodyBytesMedian: median(
|
||||
runs.map((run) => run.materialisation.http.responseBodyBytes)
|
||||
),
|
||||
completeDownloadResponseBodyBytesMedian: median(
|
||||
runs.map(
|
||||
(run) => run.download.http.responseBodyBytes + run.materialisation.http.responseBodyBytes
|
||||
)
|
||||
),
|
||||
completeDownloadElapsedMsMedian: median(
|
||||
runs.map((run) => run.download.elapsedMs + run.materialisation.elapsedMs)
|
||||
),
|
||||
completeDownloadCpuMsMedian: median(
|
||||
runs.map((run) => run.download.totalCpuMs + run.materialisation.totalCpuMs)
|
||||
),
|
||||
maxResidentSetKiBMedian: median(
|
||||
runs.map((run) =>
|
||||
Math.max(
|
||||
run.upload.maxResidentSetKiB,
|
||||
run.download.maxResidentSetKiB,
|
||||
run.materialisation.maxResidentSetKiB
|
||||
)
|
||||
)
|
||||
),
|
||||
perKindStoredChunkDataBytesMedian: Object.fromEntries(
|
||||
DATASET_KINDS.map((kind) => [
|
||||
kind,
|
||||
median(runs.map((run) => run.remote.perKind[kind].storedChunkDataBytes)),
|
||||
])
|
||||
),
|
||||
},
|
||||
];
|
||||
})
|
||||
);
|
||||
|
||||
const comparisons = [false, true].map((encrypt) => {
|
||||
const disabledName = encrypt ? "e2ee" : "plain";
|
||||
const enabledName = encrypt ? "e2ee-compressed" : "plain-compressed";
|
||||
const disabled = byCondition[disabledName] as Record<string, unknown>;
|
||||
const enabled = byCondition[enabledName] as Record<string, unknown>;
|
||||
const disabledPerKind = disabled.perKindStoredChunkDataBytesMedian as Record<DatasetKind, number>;
|
||||
const enabledPerKind = enabled.perKindStoredChunkDataBytesMedian as Record<DatasetKind, number>;
|
||||
return {
|
||||
encrypt,
|
||||
disabledCondition: disabledName,
|
||||
enabledCondition: enabledName,
|
||||
storedChunkDataReductionPercent: reductionPercent(
|
||||
enabled.remoteStoredChunkDataBytesMedian as number,
|
||||
disabled.remoteStoredChunkDataBytesMedian as number
|
||||
),
|
||||
couchdbExternalReductionPercent: reductionPercent(
|
||||
enabled.couchdbExternalBytesMedian as number,
|
||||
disabled.couchdbExternalBytesMedian as number
|
||||
),
|
||||
couchdbFileReductionPercent: reductionPercent(
|
||||
enabled.couchdbFileBytesMedian as number,
|
||||
disabled.couchdbFileBytesMedian as number
|
||||
),
|
||||
uploadRequestBodyReductionPercent: reductionPercent(
|
||||
enabled.uploadRequestBodyBytesMedian as number,
|
||||
disabled.uploadRequestBodyBytesMedian as number
|
||||
),
|
||||
completeDownloadResponseBodyReductionPercent: reductionPercent(
|
||||
enabled.completeDownloadResponseBodyBytesMedian as number,
|
||||
disabled.completeDownloadResponseBodyBytesMedian as number
|
||||
),
|
||||
uploadElapsedDeltaPercent: deltaPercent(
|
||||
enabled.uploadElapsedMsMedian as number,
|
||||
disabled.uploadElapsedMsMedian as number
|
||||
),
|
||||
uploadCpuDeltaPercent: deltaPercent(
|
||||
enabled.uploadCpuMsMedian as number,
|
||||
disabled.uploadCpuMsMedian as number
|
||||
),
|
||||
downloadElapsedDeltaPercent: deltaPercent(
|
||||
enabled.downloadElapsedMsMedian as number,
|
||||
disabled.downloadElapsedMsMedian as number
|
||||
),
|
||||
downloadCpuDeltaPercent: deltaPercent(
|
||||
enabled.downloadCpuMsMedian as number,
|
||||
disabled.downloadCpuMsMedian as number
|
||||
),
|
||||
completeDownloadElapsedDeltaPercent: deltaPercent(
|
||||
enabled.completeDownloadElapsedMsMedian as number,
|
||||
disabled.completeDownloadElapsedMsMedian as number
|
||||
),
|
||||
completeDownloadCpuDeltaPercent: deltaPercent(
|
||||
enabled.completeDownloadCpuMsMedian as number,
|
||||
disabled.completeDownloadCpuMsMedian as number
|
||||
),
|
||||
perKindStoredChunkDataReductionPercent: Object.fromEntries(
|
||||
DATASET_KINDS.map((kind) => [kind, reductionPercent(enabledPerKind[kind], disabledPerKind[kind])])
|
||||
),
|
||||
};
|
||||
});
|
||||
return { byCondition, comparisons };
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const backendUri = readEnvString("BENCH_COUCHDB_BACKEND_URI", "http://127.0.0.1:5989");
|
||||
const proxyUri = readEnvString("BENCH_COUCHDB_URI", "http://127.0.0.1:15989");
|
||||
const user = readEnvString("BENCH_COUCHDB_USER", readEnvString("username", "admin"));
|
||||
const password = readEnvString("BENCH_COUCHDB_PASSWORD", readEnvString("password", "password"));
|
||||
const databasePrefix = readEnvString("BENCH_COUCHDB_DBNAME", `compression-bench-${Date.now()}`);
|
||||
const repeatCount = readEnvPositiveInteger("BENCH_COMPRESSION_REPEAT_COUNT", 1);
|
||||
const requestedRttMs = readEnvPositiveNumber("BENCH_COUCHDB_RTT_MS", 1);
|
||||
const managedCouchdb = readEnvBoolean("BENCH_COUCHDB_MANAGED", true);
|
||||
const passphrase = readEnvString("BENCH_PASSPHRASE", "compression-benchmark-passphrase");
|
||||
const resultRoot = readEnvString("BENCH_COMPRESSION_RESULT_ROOT", "bench-results");
|
||||
const resultPath =
|
||||
Deno.env.get("BENCH_COMPRESSION_RESULT_JSON")?.trim() ||
|
||||
join(resultRoot, `compression-${new Date().toISOString().replaceAll(":", "-")}.json`);
|
||||
const createdDatabases = new Set<string>();
|
||||
const results: RunResult[] = [];
|
||||
let managedStarted = false;
|
||||
|
||||
await Deno.mkdir(resultRoot, { recursive: true });
|
||||
const proxy = startCouchdbProxy({ backendUri, proxyUri, requestedRttMs });
|
||||
|
||||
try {
|
||||
for (let repeatIndex = 1; repeatIndex <= repeatCount; repeatIndex++) {
|
||||
const rotation = (repeatIndex - 1) % CONDITIONS.length;
|
||||
const orderedConditions = [...CONDITIONS.slice(rotation), ...CONDITIONS.slice(0, rotation)];
|
||||
for (const [executionOffset, condition] of orderedConditions.entries()) {
|
||||
const databaseName = `${databasePrefix}-${repeatIndex}-${condition.name}`.toLowerCase();
|
||||
if (managedCouchdb && !managedStarted) {
|
||||
await startCouchdb(backendUri, user, password, databaseName);
|
||||
managedStarted = true;
|
||||
} else {
|
||||
await createCouchdbDatabase(backendUri, user, password, databaseName);
|
||||
}
|
||||
createdDatabases.add(databaseName);
|
||||
|
||||
await using workDir = await TempDir.create(`livesync-compression-${condition.name}`);
|
||||
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 Promise.all(
|
||||
[settingsA, settingsB].map((settingsFile) =>
|
||||
applyRemoteSyncSettings(settingsFile, {
|
||||
remoteType: "COUCHDB",
|
||||
couchdbUri: proxyUri,
|
||||
couchdbUser: user,
|
||||
couchdbPassword: password,
|
||||
couchdbDbname: databaseName,
|
||||
encrypt: condition.encrypt,
|
||||
passphrase,
|
||||
enableCompression: condition.enableCompression,
|
||||
usePathObfuscation: false,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const dataset = await createCompressionBenchmarkDataset({ rootDir: vaultA });
|
||||
const datasetDigestSha256 = await computeDatasetDigestSha256(dataset.entries);
|
||||
const mirror = await runMeasuredCliOrFail(vaultA, "--settings", settingsA, "mirror");
|
||||
|
||||
proxy.resetCounters();
|
||||
const uploadMeasurement = await runMeasuredCliOrFail(vaultA, "--settings", settingsA, "sync");
|
||||
const upload = { ...uploadMeasurement, http: proxy.snapshotCounters() };
|
||||
|
||||
proxy.resetCounters();
|
||||
const downloadMeasurement = await runMeasuredCliOrFail(vaultB, "--settings", settingsB, "sync");
|
||||
const download = { ...downloadMeasurement, http: proxy.snapshotCounters() };
|
||||
|
||||
proxy.resetCounters();
|
||||
const materialisationMeasurement = await verifyDataset(workDir, vaultB, settingsB, dataset.entries);
|
||||
const materialisation = {
|
||||
...materialisationMeasurement,
|
||||
http: proxy.snapshotCounters(),
|
||||
};
|
||||
const remote = await inspectRemoteDatabase({
|
||||
baseUri: backendUri,
|
||||
user,
|
||||
password,
|
||||
databaseName,
|
||||
dataset,
|
||||
});
|
||||
const settings = JSON.parse(await Deno.readTextFile(settingsA)) as Record<string, unknown>;
|
||||
const effectiveSettings = Object.fromEntries(
|
||||
[
|
||||
"encrypt",
|
||||
"enableCompression",
|
||||
"E2EEAlgorithm",
|
||||
"usePathObfuscation",
|
||||
"chunkSplitterVersion",
|
||||
"customChunkSize",
|
||||
"minimumChunkSize",
|
||||
"hashAlg",
|
||||
].map((key) => [key, settings[key]])
|
||||
);
|
||||
|
||||
results.push({
|
||||
condition,
|
||||
repeatIndex,
|
||||
executionOrder: executionOffset + 1,
|
||||
databaseName,
|
||||
datasetDigestSha256,
|
||||
dataset: {
|
||||
totalFiles: dataset.totalFiles,
|
||||
totalBytes: dataset.totalBytes,
|
||||
filesByKind: dataset.filesByKind,
|
||||
bytesByKind: dataset.bytesByKind,
|
||||
jpegGenerator: dataset.jpegGenerator,
|
||||
},
|
||||
effectiveSettings,
|
||||
mirror,
|
||||
upload,
|
||||
download,
|
||||
materialisation,
|
||||
verification: { verifiedFiles: dataset.entries.length, complete: true },
|
||||
remote,
|
||||
});
|
||||
await deleteDatabase(backendUri, user, password, databaseName);
|
||||
createdDatabases.delete(databaseName);
|
||||
console.error(
|
||||
`[Compression benchmark] repeat ${repeatIndex}/${repeatCount} ${condition.name}: ` +
|
||||
`${dataset.totalFiles} files, ${remote.chunkDocumentCount} chunks, ` +
|
||||
`${remote.storedChunkDataBytes} stored chunk-data bytes`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const output = {
|
||||
schemaVersion: 1,
|
||||
mode: "couchdb-cli-compression-benchmark",
|
||||
generatedAt: new Date().toISOString(),
|
||||
commonlibVersion: (
|
||||
JSON.parse(
|
||||
await Deno.readTextFile(
|
||||
join(import.meta.dirname!, "../../../../node_modules/@vrtmrz/livesync-commonlib/package.json")
|
||||
)
|
||||
) as { version: string }
|
||||
).version,
|
||||
couchdbVersion: (
|
||||
(await couchRequest(backendUri, user, password, "/").then((response) => response.json())) as {
|
||||
version?: string;
|
||||
}
|
||||
).version,
|
||||
compressionImplementation: "Commonlib replicationFilter using fflate level 8 before E2EE V2",
|
||||
chunkingImplementation: "LiveSync CLI mirror using the effective settings recorded for each run",
|
||||
requestedRttMs,
|
||||
httpByteScope:
|
||||
"Decoded HTTP request and response body bytes observed by the local proxy; headers are excluded.",
|
||||
limitations: [
|
||||
"Synthetic JPEGs exercise a deterministic image-like fixture but are not a photographic corpus.",
|
||||
"PNG, Markdown, JSON, and TypeScript inputs are current repository files and therefore change with the source tree.",
|
||||
"Wall and CPU times include CLI process start-up; compare repeated medians rather than treating one run as a universal result.",
|
||||
"Full materialisation starts one CLI process per file and can repeat lazy chunk fetches; treat it as a CLI workflow measurement rather than a raw download lower bound.",
|
||||
"The benchmark uses a local CouchDB and a fixed latency proxy, not a contended production server or a real WAN.",
|
||||
"Path obfuscation is explicitly disabled so raw metadata can be mapped back to file kinds.",
|
||||
],
|
||||
repeatCount,
|
||||
conditions: CONDITIONS,
|
||||
summary: summariseResults(results),
|
||||
runs: results,
|
||||
};
|
||||
await Deno.writeTextFile(resultPath, JSON.stringify(output, null, 2));
|
||||
console.log(JSON.stringify(output, null, 2));
|
||||
console.error(`[Compression benchmark] wrote ${resultPath}`);
|
||||
} finally {
|
||||
await proxy.stop();
|
||||
for (const databaseName of createdDatabases) {
|
||||
await deleteDatabase(backendUri, user, password, databaseName).catch((error) => console.error(error));
|
||||
}
|
||||
if (managedCouchdb && managedStarted) {
|
||||
await stopCouchdb().catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
main().catch((error) => {
|
||||
console.error("[Compression benchmark fatal]", error);
|
||||
Deno.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -188,11 +188,19 @@ function readOptionalResultPath(): string | undefined {
|
||||
|
||||
export type CouchdbProxyHandle = {
|
||||
stop: () => Promise<void>;
|
||||
resetCounters: () => void;
|
||||
snapshotCounters: () => CouchdbProxyCounters;
|
||||
applied: boolean;
|
||||
note: string;
|
||||
directionalDelayMs: number;
|
||||
};
|
||||
|
||||
export type CouchdbProxyCounters = {
|
||||
requestCount: number;
|
||||
requestBodyBytes: number;
|
||||
responseBodyBytes: number;
|
||||
};
|
||||
|
||||
export function startCouchdbProxy(
|
||||
options: {
|
||||
backendUri: string;
|
||||
@@ -208,6 +216,11 @@ export function startCouchdbProxy(
|
||||
((milliseconds: number) =>
|
||||
new Promise<void>((resolve) => setTimeout(resolve, milliseconds)));
|
||||
const controller = new AbortController();
|
||||
const counters: CouchdbProxyCounters = {
|
||||
requestCount: 0,
|
||||
requestBodyBytes: 0,
|
||||
responseBodyBytes: 0,
|
||||
};
|
||||
|
||||
const listener = Deno.serve(
|
||||
{
|
||||
@@ -238,6 +251,8 @@ export function startCouchdbProxy(
|
||||
requestBody = undefined;
|
||||
}
|
||||
}
|
||||
counters.requestCount += 1;
|
||||
counters.requestBodyBytes += requestBody?.byteLength ?? 0;
|
||||
|
||||
const upstream = await fetch(targetUrl, {
|
||||
method: request.method,
|
||||
@@ -249,6 +264,7 @@ export function startCouchdbProxy(
|
||||
const responseHeaders = new Headers(upstream.headers);
|
||||
responseHeaders.delete("content-length");
|
||||
const responseBody = await upstream.arrayBuffer();
|
||||
counters.responseBodyBytes += responseBody.byteLength;
|
||||
await delay(halfDelayMs);
|
||||
|
||||
return new Response(responseBody, {
|
||||
@@ -264,6 +280,12 @@ export function startCouchdbProxy(
|
||||
directionalDelayMs: halfDelayMs,
|
||||
note:
|
||||
`local reverse proxy on ${proxy.origin} with ${halfDelayMs}ms request-path and ${halfDelayMs}ms response-path delay`,
|
||||
resetCounters: () => {
|
||||
counters.requestCount = 0;
|
||||
counters.requestBodyBytes = 0;
|
||||
counters.responseBodyBytes = 0;
|
||||
},
|
||||
snapshotCounters: () => ({ ...counters }),
|
||||
stop: async () => {
|
||||
controller.abort();
|
||||
await listener.finished.catch(() => {});
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"test:benchmark-contract": "deno test --env-file=.test.env -A --no-check test-benchmark-contract.ts",
|
||||
"bench:p2p": "deno run --env-file=.test.env -A --no-check bench-p2p.ts",
|
||||
"bench:couchdb": "deno run --env-file=.test.env -A --no-check bench-couchdb.ts",
|
||||
"bench:compression": "deno run --env-file=.test.env -A --no-check bench-compression.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:p2p-split-node": "deno run --env-file=.test.env -A --no-check bench-p2p-split-node.ts",
|
||||
|
||||
@@ -7,7 +7,7 @@ import { join } from "@std/path";
|
||||
// CLI root (src/apps/cli/) is two levels up.
|
||||
// import.meta.dirname is available in Deno 1.40+ as an OS-native path string.
|
||||
export const CLI_DIR: string = join(import.meta.dirname!, "..", "..");
|
||||
const CLI_DIST = join(CLI_DIR, "dist", "index.cjs");
|
||||
export const CLI_DIST = join(CLI_DIR, "dist", "index.cjs");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Result type
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { join } from "@std/path";
|
||||
import type { DatasetEntry, DatasetKind } from "./dataset.ts";
|
||||
|
||||
export type CompressionDatasetEntry = DatasetEntry & {
|
||||
source: string;
|
||||
};
|
||||
|
||||
export type CompressionDataset = {
|
||||
entries: CompressionDatasetEntry[];
|
||||
totalFiles: number;
|
||||
totalBytes: number;
|
||||
bytesByKind: Record<DatasetKind, number>;
|
||||
filesByKind: Record<DatasetKind, number>;
|
||||
jpegGenerator: string;
|
||||
};
|
||||
|
||||
export type JpegEncoder = (inputPpm: string, outputJpeg: string) => Promise<string>;
|
||||
|
||||
const ALL_KINDS: DatasetKind[] = ["md", "jpg", "png", "json", "ts", "gz", "bin"];
|
||||
|
||||
const REPOSITORY_ROOT = join(import.meta.dirname!, "..", "..", "..", "..", "..");
|
||||
|
||||
function fnv1a32(input: string): number {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
hash ^= input.charCodeAt(i) & 0xff;
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
function createXorshift32(seed: number): () => number {
|
||||
let state = seed || 0x9e3779b9;
|
||||
return () => {
|
||||
state ^= state << 13;
|
||||
state ^= state >>> 17;
|
||||
state ^= state << 5;
|
||||
return state >>> 0;
|
||||
};
|
||||
}
|
||||
|
||||
function createSyntheticPpm(width: number, height: number, seed: string, textured: boolean): Uint8Array {
|
||||
const header = new TextEncoder().encode(`P6\n${width} ${height}\n255\n`);
|
||||
const pixels = new Uint8Array(width * height * 3);
|
||||
const nextRandom = createXorshift32(fnv1a32(seed));
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const offset = (y * width + x) * 3;
|
||||
const noise = textured ? (nextRandom() & 0x3f) - 32 : 0;
|
||||
pixels[offset] = Math.max(0, Math.min(255, Math.floor((x * 255) / (width - 1)) + noise));
|
||||
pixels[offset + 1] = Math.max(0, Math.min(255, Math.floor((y * 255) / (height - 1)) + noise));
|
||||
pixels[offset + 2] = Math.max(0, Math.min(255, Math.floor(((x + y) * 255) / (width + height - 2)) - noise));
|
||||
}
|
||||
}
|
||||
const result = new Uint8Array(header.length + pixels.length);
|
||||
result.set(header);
|
||||
result.set(pixels, header.length);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function gzip(input: Uint8Array): Promise<Uint8Array> {
|
||||
const copied = new Uint8Array(input.byteLength);
|
||||
copied.set(input);
|
||||
const stream = new Blob([copied.buffer]).stream().pipeThrough(new CompressionStream("gzip"));
|
||||
return new Uint8Array(await new Response(stream).arrayBuffer());
|
||||
}
|
||||
|
||||
export async function encodeJpegWithCjpeg(inputPpm: string, outputJpeg: string): Promise<string> {
|
||||
const command = new Deno.Command("cjpeg", {
|
||||
args: ["-quality", "85", "-optimize", "-outfile", outputJpeg, inputPpm],
|
||||
stdin: "null",
|
||||
stdout: "piped",
|
||||
stderr: "piped",
|
||||
});
|
||||
let result: Deno.CommandOutput;
|
||||
try {
|
||||
result = await command.output();
|
||||
} catch (error) {
|
||||
if (error instanceof Deno.errors.NotFound) {
|
||||
throw new Error(
|
||||
"cjpeg is required for the compression benchmark. Use the Compose runner or install libjpeg tools."
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (!result.success) {
|
||||
throw new Error(`cjpeg failed: ${new TextDecoder().decode(result.stderr)}`);
|
||||
}
|
||||
return "cjpeg quality=85, optimise=true, synthetic PPM 640x480";
|
||||
}
|
||||
|
||||
async function writeRandomBinary(path: string, size: number, seed: string): Promise<void> {
|
||||
const bytes = new Uint8Array(size);
|
||||
const nextRandom = createXorshift32(fnv1a32(seed));
|
||||
for (let index = 0; index < bytes.length; index++) {
|
||||
bytes[index] = nextRandom() & 0xff;
|
||||
}
|
||||
await Deno.writeFile(path, bytes);
|
||||
}
|
||||
|
||||
export async function createCompressionBenchmarkDataset(options: {
|
||||
rootDir: string;
|
||||
datasetDirName?: string;
|
||||
repositoryRoot?: string;
|
||||
seed?: string;
|
||||
jpegEncoder?: JpegEncoder;
|
||||
}): Promise<CompressionDataset> {
|
||||
const datasetDirName = options.datasetDirName ?? "compression-benchmark";
|
||||
const repositoryRoot = options.repositoryRoot ?? REPOSITORY_ROOT;
|
||||
const seed = options.seed ?? "livesync-compression-benchmark";
|
||||
const jpegEncoder = options.jpegEncoder ?? encodeJpegWithCjpeg;
|
||||
const datasetRoot = join(options.rootDir, datasetDirName);
|
||||
const entries: CompressionDatasetEntry[] = [];
|
||||
let jpegGenerator = "";
|
||||
|
||||
for (const kind of ALL_KINDS) {
|
||||
await Deno.mkdir(join(datasetRoot, kind), { recursive: true });
|
||||
}
|
||||
|
||||
const addFile = async (kind: DatasetKind, absolutePath: string, source: string) => {
|
||||
const relativePath = absolutePath
|
||||
.slice(options.rootDir.length + 1)
|
||||
.split("\\")
|
||||
.join("/");
|
||||
const size = (await Deno.stat(absolutePath)).size;
|
||||
entries.push({ kind, relativePath, absolutePath, size, source });
|
||||
};
|
||||
|
||||
const copyRepositoryFile = async (kind: DatasetKind, sourcePath: string, targetName: string) => {
|
||||
const destination = join(datasetRoot, kind, targetName);
|
||||
await Deno.copyFile(join(repositoryRoot, sourcePath), destination);
|
||||
await addFile(kind, destination, sourcePath);
|
||||
};
|
||||
|
||||
await copyRepositoryFile("md", "docs/settings.md", "settings.md");
|
||||
await copyRepositoryFile("md", "docs/quick_setup.md", "quick-setup.md");
|
||||
await copyRepositoryFile("md", "updates.md", "updates.md");
|
||||
await copyRepositoryFile("png", "instruction_images/cloudant_1.png", "cloudant-1.png");
|
||||
await copyRepositoryFile(
|
||||
"png",
|
||||
"images/quick-setup/guide-quick-setup-first-setup-uri.png",
|
||||
"quick-setup-first-setup-uri.png"
|
||||
);
|
||||
await copyRepositoryFile("json", "package.json", "package.json");
|
||||
await copyRepositoryFile("json", "manifest.json", "manifest.json");
|
||||
await copyRepositoryFile("ts", "src/modules/core/ModuleReplicator.ts", "ModuleReplicator.ts");
|
||||
await copyRepositoryFile("ts", "src/modules/core/ReplicateResultProcessor.ts", "ReplicateResultProcessor.ts");
|
||||
|
||||
const markdownBytes = await Deno.readFile(join(repositoryRoot, "docs/settings.md"));
|
||||
const gzipPath = join(datasetRoot, "gz", "settings.md.gz");
|
||||
await Deno.writeFile(gzipPath, await gzip(markdownBytes));
|
||||
await addFile("gz", gzipPath, "generated gzip of docs/settings.md");
|
||||
|
||||
const randomPath = join(datasetRoot, "bin", "deterministic-random.bin");
|
||||
await writeRandomBinary(randomPath, 256 * 1024, seed);
|
||||
await addFile("bin", randomPath, `deterministic xorshift32 seed=${seed}`);
|
||||
|
||||
for (const [name, textured] of [
|
||||
["smooth-gradient.jpg", false],
|
||||
["textured-gradient.jpg", true],
|
||||
] as const) {
|
||||
const ppmPath = await Deno.makeTempFile({ dir: options.rootDir, prefix: "compression-jpeg-", suffix: ".ppm" });
|
||||
const jpegPath = join(datasetRoot, "jpg", name);
|
||||
try {
|
||||
await Deno.writeFile(ppmPath, createSyntheticPpm(640, 480, `${seed}-${name}`, textured));
|
||||
jpegGenerator = await jpegEncoder(ppmPath, jpegPath);
|
||||
} finally {
|
||||
await Deno.remove(ppmPath).catch(() => {});
|
||||
}
|
||||
await addFile("jpg", jpegPath, `${jpegGenerator}; ${textured ? "textured" : "smooth"}`);
|
||||
}
|
||||
|
||||
const bytesByKind = Object.fromEntries(ALL_KINDS.map((kind) => [kind, 0])) as Record<DatasetKind, number>;
|
||||
const filesByKind = Object.fromEntries(ALL_KINDS.map((kind) => [kind, 0])) as Record<DatasetKind, number>;
|
||||
for (const entry of entries) {
|
||||
bytesByKind[entry.kind] += entry.size;
|
||||
filesByKind[entry.kind] += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
entries,
|
||||
totalFiles: entries.length,
|
||||
totalBytes: entries.reduce((sum, entry) => sum + entry.size, 0),
|
||||
bytesByKind,
|
||||
filesByKind,
|
||||
jpegGenerator,
|
||||
};
|
||||
}
|
||||
@@ -9,8 +9,10 @@ export type DeterministicDatasetConfig = {
|
||||
binSizeBytes: number;
|
||||
};
|
||||
|
||||
export type DatasetKind = "md" | "jpg" | "png" | "json" | "ts" | "gz" | "bin";
|
||||
|
||||
export type DatasetEntry = {
|
||||
kind: "md" | "bin";
|
||||
kind: DatasetKind;
|
||||
relativePath: string;
|
||||
absolutePath: string;
|
||||
size: number;
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { CLI_DIR, CLI_DIST } from "./cli.ts";
|
||||
|
||||
export type CliProcessMeasurement = {
|
||||
elapsedMs: number;
|
||||
userCpuMs: number;
|
||||
systemCpuMs: number;
|
||||
totalCpuMs: number;
|
||||
cpuToWallRatio: number;
|
||||
maxResidentSetKiB: number;
|
||||
};
|
||||
|
||||
const MARKER = "__LIVESYNC_GNU_TIME__";
|
||||
|
||||
export async function runMeasuredCliOrFail(...args: string[]): Promise<CliProcessMeasurement> {
|
||||
const started = performance.now();
|
||||
let output: Deno.CommandOutput;
|
||||
try {
|
||||
output = await new Deno.Command("/usr/bin/time", {
|
||||
args: ["-f", `${MARKER}%U\t%S\t%M`, "node", CLI_DIST, ...args],
|
||||
cwd: CLI_DIR,
|
||||
stdin: "null",
|
||||
stdout: "piped",
|
||||
stderr: "piped",
|
||||
}).output();
|
||||
} catch (error) {
|
||||
if (error instanceof Deno.errors.NotFound) {
|
||||
throw new Error(
|
||||
"GNU /usr/bin/time is required for the compression benchmark. Use the Compose runner or install GNU time."
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const elapsedMs = performance.now() - started;
|
||||
const stderr = new TextDecoder().decode(output.stderr);
|
||||
const stdout = new TextDecoder().decode(output.stdout);
|
||||
const measurementLine = stderr.split(/\r?\n/).find((line) => line.startsWith(MARKER));
|
||||
if (!output.success) {
|
||||
throw new Error(`CLI exited with code ${output.code}\nstdout: ${stdout}\nstderr: ${stderr}`);
|
||||
}
|
||||
if (!measurementLine) {
|
||||
throw new Error(`GNU time did not emit the expected measurement marker\nstderr: ${stderr}`);
|
||||
}
|
||||
const [userSeconds, systemSeconds, maxResidentSetKiB] = measurementLine
|
||||
.slice(MARKER.length)
|
||||
.split("\t")
|
||||
.map(Number);
|
||||
if (![userSeconds, systemSeconds, maxResidentSetKiB].every(Number.isFinite)) {
|
||||
throw new Error(`Could not parse GNU time measurement: ${measurementLine}`);
|
||||
}
|
||||
const userCpuMs = userSeconds * 1000;
|
||||
const systemCpuMs = systemSeconds * 1000;
|
||||
const totalCpuMs = userCpuMs + systemCpuMs;
|
||||
return {
|
||||
elapsedMs: Number(elapsedMs.toFixed(1)),
|
||||
userCpuMs: Number(userCpuMs.toFixed(1)),
|
||||
systemCpuMs: Number(systemCpuMs.toFixed(1)),
|
||||
totalCpuMs: Number(totalCpuMs.toFixed(1)),
|
||||
cpuToWallRatio: Number((totalCpuMs / elapsedMs).toFixed(4)),
|
||||
maxResidentSetKiB,
|
||||
};
|
||||
}
|
||||
@@ -123,6 +123,8 @@ export async function applyRemoteSyncSettings(
|
||||
minioSecretKey?: string;
|
||||
encrypt?: boolean;
|
||||
passphrase?: string;
|
||||
enableCompression?: boolean;
|
||||
usePathObfuscation?: boolean;
|
||||
}
|
||||
): Promise<void> {
|
||||
const data = JSON.parse(await Deno.readTextFile(settingsFile));
|
||||
@@ -149,6 +151,12 @@ export async function applyRemoteSyncSettings(
|
||||
data.usePluginSync = false;
|
||||
data.encrypt = options.encrypt === true;
|
||||
data.passphrase = options.encrypt ? (options.passphrase ?? "") : "";
|
||||
if (options.enableCompression !== undefined) {
|
||||
data.enableCompression = options.enableCompression;
|
||||
}
|
||||
if (options.usePathObfuscation !== undefined) {
|
||||
data.usePathObfuscation = options.usePathObfuscation;
|
||||
}
|
||||
data.isConfigured = true;
|
||||
await Deno.writeTextFile(settingsFile, JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
selectVerificationEntries,
|
||||
} from "./helpers/benchmarkVerification.ts";
|
||||
import type { DatasetEntry } from "./helpers/dataset.ts";
|
||||
import { createCompressionBenchmarkDataset } from "./helpers/compressionDataset.ts";
|
||||
|
||||
function getFreePort(): number {
|
||||
const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 });
|
||||
@@ -106,6 +107,22 @@ Deno.test("CouchDB latency proxy applies half the requested RTT in each directio
|
||||
assertEquals(await response.text(), "ok");
|
||||
assertEquals(proxy.directionalDelayMs, 10);
|
||||
assertEquals(delays, [10, 10]);
|
||||
assertEquals(proxy.snapshotCounters(), {
|
||||
requestCount: 1,
|
||||
requestBodyBytes: 0,
|
||||
responseBodyBytes: 2,
|
||||
});
|
||||
proxy.resetCounters();
|
||||
const posted = await fetch(`http://127.0.0.1:${proxyPort}/probe`, {
|
||||
method: "POST",
|
||||
body: "abc",
|
||||
});
|
||||
assertEquals(await posted.text(), "ok");
|
||||
assertEquals(proxy.snapshotCounters(), {
|
||||
requestCount: 1,
|
||||
requestBodyBytes: 3,
|
||||
responseBodyBytes: 2,
|
||||
});
|
||||
} finally {
|
||||
await proxy.stop();
|
||||
await backend.shutdown();
|
||||
@@ -124,6 +141,100 @@ Deno.test("CouchDB latency proxy applies half the requested RTT in each directio
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("compression benchmark dataset covers representative file kinds deterministically", async () => {
|
||||
const fixtureRoot = await Deno.makeTempDir({
|
||||
prefix: "livesync-compression-contract-",
|
||||
});
|
||||
const repositoryRoot = `${fixtureRoot}/repository`;
|
||||
const vaultA = `${fixtureRoot}/vault-a`;
|
||||
const vaultB = `${fixtureRoot}/vault-b`;
|
||||
const repositoryFiles = [
|
||||
"docs/settings.md",
|
||||
"docs/quick_setup.md",
|
||||
"updates.md",
|
||||
"instruction_images/cloudant_1.png",
|
||||
"images/quick-setup/guide-quick-setup-first-setup-uri.png",
|
||||
"package.json",
|
||||
"manifest.json",
|
||||
"src/modules/core/ModuleReplicator.ts",
|
||||
"src/modules/core/ReplicateResultProcessor.ts",
|
||||
];
|
||||
try {
|
||||
for (const [index, relativePath] of repositoryFiles.entries()) {
|
||||
const absolutePath = `${repositoryRoot}/${relativePath}`;
|
||||
await Deno.mkdir(
|
||||
absolutePath.slice(0, absolutePath.lastIndexOf("/")),
|
||||
{ recursive: true },
|
||||
);
|
||||
await Deno.writeFile(
|
||||
absolutePath,
|
||||
new TextEncoder().encode(
|
||||
`fixture-${index}-${relativePath}\n`.repeat(20),
|
||||
),
|
||||
);
|
||||
}
|
||||
await Deno.mkdir(vaultA, { recursive: true });
|
||||
await Deno.mkdir(vaultB, { recursive: true });
|
||||
const jpegEncoder = async (_input: string, output: string) => {
|
||||
await Deno.writeFile(
|
||||
output,
|
||||
new Uint8Array([
|
||||
0xff,
|
||||
0xd8,
|
||||
0xff,
|
||||
0xdb,
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
0xff,
|
||||
0xd9,
|
||||
]),
|
||||
);
|
||||
return "contract JPEG encoder";
|
||||
};
|
||||
const first = await createCompressionBenchmarkDataset({
|
||||
rootDir: vaultA,
|
||||
repositoryRoot,
|
||||
seed: "contract-seed",
|
||||
jpegEncoder,
|
||||
});
|
||||
const second = await createCompressionBenchmarkDataset({
|
||||
rootDir: vaultB,
|
||||
repositoryRoot,
|
||||
seed: "contract-seed",
|
||||
jpegEncoder,
|
||||
});
|
||||
|
||||
assertEquals(first.filesByKind, {
|
||||
md: 3,
|
||||
jpg: 2,
|
||||
png: 2,
|
||||
json: 2,
|
||||
ts: 2,
|
||||
gz: 1,
|
||||
bin: 1,
|
||||
});
|
||||
assertEquals(first.totalFiles, 13);
|
||||
assertEquals(first.jpegGenerator, "contract JPEG encoder");
|
||||
assertEquals(
|
||||
first.entries.map((entry) => [
|
||||
entry.kind,
|
||||
entry.relativePath,
|
||||
entry.size,
|
||||
]),
|
||||
second.entries.map((entry) => [
|
||||
entry.kind,
|
||||
entry.relativePath,
|
||||
entry.size,
|
||||
]),
|
||||
);
|
||||
assert(first.entries.every((entry) => entry.size > 0));
|
||||
} finally {
|
||||
await Deno.remove(fixtureRoot, { recursive: true }).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("benchmark verification mode selects either all files or a labelled sample", () => {
|
||||
const entries: DatasetEntry[] = [
|
||||
{ kind: "md", relativePath: "a.md", absolutePath: "/a", size: 1 },
|
||||
|
||||
Reference in New Issue
Block a user