import assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; import { spawn } from 'node:child_process'; import { promises as fs } from 'node:fs'; import http from 'node:http'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { setTimeout as delay } from 'node:timers/promises'; import { createNewVaultSettings, REMOTE_COUCHDB, REMOTE_MINIO, SALT_OF_PASSPHRASE } from '@vrtmrz/livesync-commonlib/compat/common/types'; import { encryptString } from '@vrtmrz/livesync-commonlib/compat/encryption/stringEncryption'; import { S3Client, CreateBucketCommand, ListObjectsV2Command, DeleteObjectsCommand, DeleteBucketCommand } from '@aws-sdk/client-s3'; const here = dirname(fileURLToPath(import.meta.url)); const root = resolve(here, '../..'); const cli = join(root, 'src/apps/cli/dist/index.cjs'); const args = process.argv.slice(2); const option = (key, fallback) => { const at = args.indexOf(`--${key}`); return at < 0 ? fallback : args[at + 1]; }; const sourceCli = resolve(option('source-cli', cli)); const targetCli = resolve(option('target-cli', cli)); const output = resolve(option('output', join(root, '.bench/chunk-size'))); const corpusPath = resolve(option('corpus', join(output, 'corpus/manifest.json'))); const sizes = option('sizes', '0,4,10,20,40').split(',').map(Number); const providers = option('providers', 'couchdb,s3').split(','); const repeats = Number(option('repeats', '3')); const rttMs = Number(option('rtt-ms', '0')); const user = 'bench'; const password = 'benchmark-only-password'; const accessKeyId = 'bench-access-key'; const secretAccessKey = 'benchmark-only-secret-key'; const couchUri = 'http://127.0.0.1:15994'; const s3Uri = 'http://127.0.0.1:19094'; const auth = `Basic ${Buffer.from(`${user}:${password}`).toString('base64')}`; const key = '0123456789abcdef'.repeat(4); const sha256 = (value) => createHash('sha256').update(value).digest('hex'); const sourceCliSha256 = sha256(await fs.readFile(sourceCli)); const targetCliSha256 = sha256(await fs.readFile(targetCli)); const credentials = { accessKeyId, secretAccessKey }; const s3 = new S3Client({ endpoint: s3Uri, region: 'us-east-1', forcePathStyle: true, credentials }); function emptyCounters() { return { requests: 0, requestBodyBytes: 0, responseBodyBytes: 0, maxRequestBodyBytes: 0, byMethod: {}, byOperation: {} }; } async function startProxy(backend) { let counters = emptyCounters(); const server = http.createServer(async (request, response) => { counters.requests++; counters.byMethod[request.method] = (counters.byMethod[request.method] || 0) + 1; const url = new URL(request.url, backend); const operation = url.pathname.split('/').find((part) => part.startsWith('_')) || (url.searchParams.has('list-type') ? 'list' : 'object'); counters.byOperation[operation] = (counters.byOperation[operation] || 0) + 1; if (rttMs) await delay(rttMs / 2); const target = new URL(request.url, backend); const upstream = http.request(target, { method: request.method, headers: request.headers }, async (incoming) => { if (rttMs) await delay(rttMs / 2); response.writeHead(incoming.statusCode, incoming.headers); incoming.on('data', (chunk) => { counters.responseBodyBytes += chunk.length; }); incoming.pipe(response); }); upstream.on('error', (error) => { response.writeHead(502); response.end(String(error)); }); let requestBytes = 0; request.on('data', (chunk) => { counters.requestBodyBytes += chunk.length; requestBytes += chunk.length; }); request.on('end', () => { counters.maxRequestBodyBytes = Math.max(counters.maxRequestBodyBytes, requestBytes); }); request.pipe(upstream); }); await new Promise((accept, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', accept); }); return { uri: `http://127.0.0.1:${server.address().port}`, reset: () => { counters = emptyCounters(); }, snapshot: () => structuredClone(counters), close: () => new Promise((accept) => server.close(accept)) }; } async function couch(path, method = 'GET', body) { const response = await fetch(`${couchUri}${path}`, { method, headers: { Authorization: auth, 'Content-Type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body) }); if (!response.ok) throw new Error(`CouchDB ${method} ${path}: ${response.status} ${await response.text()}`); return response.json(); } async function ready() { for (let attempt = 0; attempt < 30; attempt++) { try { await couch('/_up'); await s3.send(new ListObjectsV2Command({ Bucket: 'missing-benchmark-bucket' })).catch((error) => { if (error.$metadata?.httpStatusCode !== 404) throw error; }); return; } catch (error) { if (attempt === 29) throw error; await delay(1000); } } } async function runCli(directory, settings, command, commandArgs, logPath, entrypoint = cli) { const marker = '__BENCH_TIME__'; const started = performance.now(); const child = spawn('/usr/bin/time', ['-f', `${marker}%U\t%S\t%M`, process.execPath, entrypoint, directory, '--settings', settings, command, ...commandArgs], { cwd: root, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, NODE_OPTIONS: '--max-old-space-size=2048' } }); let stdout = ''; let stderr = ''; child.stdout.on('data', (data) => { stdout += data; }); child.stderr.on('data', (data) => { stderr += data; }); const timeout = setTimeout(() => child.kill('SIGTERM'), 180000); const code = await new Promise((accept, reject) => { child.once('error', reject); child.once('exit', accept); }); clearTimeout(timeout); const elapsedMs = performance.now() - started; await fs.writeFile(logPath, `${stdout}\n${stderr}`); if (code !== 0) throw new Error(`CLI ${command} exited ${code}; ${logPath}\n${stderr.slice(-2200)}`); const line = stderr.split(/\r?\n/).find((row) => row.startsWith(marker)); assert(line, 'GNU time output is missing'); const [userSeconds, systemSeconds, maxResidentSetKiB] = line.slice(marker.length).split('\t').map(Number); return { elapsedMs, userCpuMs: userSeconds * 1000, systemCpuMs: systemSeconds * 1000, maxResidentSetKiB }; } async function settingsFor(provider, endpoint, resource, size) { return { ...createNewVaultSettings(), isConfigured: true, remoteType: provider === 'couchdb' ? REMOTE_COUCHDB : REMOTE_MINIO, couchDB_URI: endpoint, couchDB_DBNAME: resource, couchDB_USER: user, couchDB_PASSWORD: password, endpoint, bucket: resource, accessKey: accessKeyId, secretKey: secretAccessKey, region: 'us-east-1', forcePathStyle: true, encrypt: true, passphrase: 'chunk-size-benchmark-only-passphrase', usePathObfuscation: true, encryptInternalMetadata: true, idDerivationVersion: 1, idDerivationKey: '', encryptedIdDerivationKey: await encryptString(key, '*' + SALT_OF_PASSPHRASE), E2EEAlgorithm: 'v2', hashAlg: 'xxhash64', chunkSplitterVersion: 'v3-rabin-karp', minimumChunkSize: 20, customChunkSize: size, autoAcceptCompatibleTweak: false, enableCompression: false, useEden: false, useIndexedDBAdapter: false, handleFilenameCaseSensitive: false, pathNormalizationVersion: 0, readChunksOnline: false, liveSync: false, syncOnStart: false, syncOnSave: false, periodicReplication: false, syncOnFileOpen: false, syncMaxSizeInMB: 50, P2P_Enabled: false, }; } async function copyStage(stage, vault, previous) { const old = new Map((previous?.files || []).map((file) => [file.path, file.sha256])); const changed = []; for (const file of stage.files) { if (old.get(file.path) === file.sha256) continue; const from = join(stage.directory, file.path); const to = join(vault, file.path); await fs.mkdir(dirname(to), { recursive: true }); await fs.copyFile(from, to); const stat = await fs.stat(from); await fs.utimes(to, stat.atime, stat.mtime); changed.push(file.path); } return changed; } async function inspect(provider, resource, previousIds) { if (provider === 'couchdb') { const info = await couch(`/${resource}`); const all = await couch(`/${resource}/_all_docs?include_docs=true`); const chunks = all.rows.filter(({ doc }) => doc?.type === 'leaf'); const added = chunks.filter(({ id }) => !previousIds.has(id)); const ids = chunks.map(({ id }) => id); const jsonBytes = ({ doc }) => Buffer.byteLength(JSON.stringify(doc)); return { ids, metrics: { documentCount: info.doc_count, chunkCount: chunks.length, newChunkCount: added.length, chunkJsonBytes: chunks.reduce((sum, row) => sum + jsonBytes(row), 0), newChunkJsonBytes: added.reduce((sum, row) => sum + jsonBytes(row), 0), largestChunkJsonBytes: Math.max(0, ...chunks.map(jsonBytes)), largestDocumentJsonBytes: Math.max(0, ...all.rows.map(jsonBytes)), storedChunkDataBytes: chunks.reduce((sum, { doc }) => sum + Buffer.byteLength(doc.data || ''), 0), sizes: info.sizes, idLengthMin: Math.min(...ids.map((id) => id.length)), idLengthMax: Math.max(...ids.map((id) => id.length)) } }; } const objects = []; let token; do { const result = await s3.send(new ListObjectsV2Command({ Bucket: resource, ContinuationToken: token })); objects.push(...result.Contents || []); token = result.NextContinuationToken; } while (token); return { ids: objects.map(({ Key }) => Key), metrics: { objectCount: objects.length, storedObjectBytes: objects.reduce((sum, { Size }) => sum + Size, 0), newObjectCount: objects.filter(({ Key }) => !previousIds.has(Key)).length } }; } async function removeResource(provider, resource) { if (provider === 'couchdb') { await couch(`/${resource}`, 'DELETE'); return; } let token; do { const listing = await s3.send(new ListObjectsV2Command({ Bucket: resource, ContinuationToken: token })); if (listing.Contents?.length) await s3.send(new DeleteObjectsCommand({ Bucket: resource, Delete: { Objects: listing.Contents.map(({ Key }) => ({ Key })) } })); token = listing.NextContinuationToken; } while (token); await s3.send(new DeleteBucketCommand({ Bucket: resource })); } await fs.mkdir(join(output, 'remote'), { recursive: true }); const manifest = JSON.parse(await fs.readFile(corpusPath, 'utf8')); for (const stage of manifest.stages) stage.directory = resolve(dirname(corpusPath), stage.directory); const digest = sha256(JSON.stringify(manifest.stages.map((stage) => ({ name: stage.name, files: stage.files })))); const resultPath = join(output, 'remote', `results-rtt${rttMs}.json`); let results = []; try { const saved = JSON.parse(await fs.readFile(resultPath, 'utf8')); assert.equal(saved.corpusDigest, digest); assert.equal(saved.sourceCliSha256, sourceCliSha256); assert.equal(saved.targetCliSha256, targetCliSha256); results = saved.results; } catch (error) { if (error.code !== 'ENOENT') throw error; } const proxies = { couchdb: await startProxy(couchUri), s3: await startProxy(s3Uri) }; const save = async () => fs.writeFile(resultPath, JSON.stringify({ version: '1.0.39', commonlib: '0.1.44', variant: process.env.LIVESYNC_BENCH_VARIANT || 'unlabelled', sourceCliSha256, targetCliSha256, corpusDigest: digest, rttMs, mode: 'local services; HTTP body bytes exclude headers; full chunk download; E2EE V2 with independent IDs; compression disabled', results }, null, 2)); try { await ready(); for (let repeat = 0; repeat < repeats; repeat++) { const rotation = repeat % sizes.length; const order = [...sizes.slice(rotation), ...sizes.slice(0, rotation)]; for (const provider of (repeat % 2 ? [...providers].reverse() : providers)) for (const size of order) { const name = `${provider}-c${size}-r${repeat + 1}`; if (results.some((result) => result.name === name && result.complete)) continue; const resource = `chunks-${Date.now().toString(36)}-${name}`; const work = join(output, 'remote', name); await fs.mkdir(join(work, 'logs'), { recursive: true }); const a = join(work, 'source'); const b = join(work, 'target'); await fs.rm(a, { recursive: true, force: true }); await fs.rm(b, { recursive: true, force: true }); await fs.mkdir(a, { recursive: true }); await fs.mkdir(b, { recursive: true }); const settingsA = join(work, 'source-settings.json'); const settingsB = join(work, 'target-settings.json'); const settings = await settingsFor(provider, proxies[provider].uri, resource, size); await fs.writeFile(settingsA, JSON.stringify(settings)); await fs.writeFile(settingsB, JSON.stringify(settings)); if (provider === 'couchdb') await couch(`/${resource}`, 'PUT'); else await s3.send(new CreateBucketCommand({ Bucket: resource })); const result = { name, provider, customChunkSize: size, repeat: repeat + 1, stages: [], complete: false }; results.push(result); await save(); try { let previous; let ids = new Set(); for (const stage of manifest.stages) { const changedFiles = await copyStage(stage, a, previous); const log = (label) => join(work, 'logs', `${stage.name}-${label}.txt`); const mirror = await runCli(a, settingsA, 'mirror', [], log('mirror'), sourceCli); proxies[provider].reset(); const upload = await runCli(a, settingsA, 'sync', [], log('upload'), sourceCli); upload.http = proxies[provider].snapshot(); const remote = await inspect(provider, resource, ids); ids = new Set(remote.ids); proxies[provider].reset(); const download = await runCli(b, settingsB, 'sync', [], log('download'), targetCli); download.http = proxies[provider].snapshot(); const verified = []; const materialisedByPull = []; for (const file of stage.files) { let bytes; try { bytes = await fs.readFile(join(b, file.path)); } catch (error) { if (error.code !== 'ENOENT') throw error; } if (!bytes || sha256(bytes) !== file.sha256) { const target = join(work, 'verification', file.path); await fs.mkdir(dirname(target), { recursive: true }); const measurement = await runCli(b, settingsB, 'pull', [file.path, target], log(`verify-${file.path.replace(/[^a-zA-Z0-9]/g, '-')}`), targetCli); bytes = await fs.readFile(target); materialisedByPull.push({ path: file.path, measurement }); } assert.equal(sha256(bytes), file.sha256, `${name} ${stage.name} ${file.path}`); verified.push(file.path); } result.stages.push({ name: stage.name, changedFiles, mirror, upload, download, remote: remote.metrics, verifiedFiles: verified.length, materialisedByPull }); await save(); console.log(JSON.stringify({ case: name, stage: stage.name, uploadMs: Math.round(upload.elapsedMs), downloadMs: Math.round(download.elapsedMs), requestBytes: upload.http.requestBodyBytes, responseBytes: download.http.responseBodyBytes, remote: remote.metrics })); previous = stage; } result.complete = true; await save(); } catch (error) { result.error = String(error); await save(); throw error; } finally { await removeResource(provider, resource); } } } } finally { await Promise.all(Object.values(proxies).map((proxy) => proxy.close())); s3.destroy(); } console.log(`Completed remote measurements: ${resultPath}`);