~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
222 lines · 6.6 KB Raw History
#!/usr/bin/env node
/**
 * Server-side cost of a smart HTTP clone, measured against a local PDS.
 *
 * The upload-pack response is assembled in the server process, so the peak
 * this reports is what a Cloudflare isolate must hold. An isolate gets 128
 * MB, which is the number the ratio column is read against.
 *
 * Repository sizes are random bytes, so packs do not compress and the
 * content size is the pack size.
 *
 * --gc collects on every sample, which separates memory the response holds
 * from memory it has dropped but V8 has not swept.
 *
 * Run from the workspace root after `pnpm install`:
 *
 *   node packages/git/bench/clone-memory.mjs --sizes 8,24,48
 */

import { execFile } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import {
  chmodSync,
  mkdirSync,
  mkdtempSync,
  rmSync,
  writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';
import { defineLexicon } from '@bigmoves/lexicon';
import { GIT_REPO_COLLECTION, gitRepoLexicon } from '@pdsjs/git';
import { LexiconResolver } from '@pdsjs/lexicon-resolver';
import { createServer } from '@pdsjs/node';

const execFileAsync = promisify(execFile);

function arg(name, fallback) {
  const i = process.argv.indexOf(`--${name}`);
  return i === -1 ? fallback : process.argv[i + 1];
}

const SIZES = String(arg('sizes', '8,24,48'))
  .split(',')
  .map((s) => Number(s.trim()))
  .filter((n) => Number.isFinite(n) && n > 0);
const COMMITS = Number(arg('commits', 6));
const PORT = Number(arg('port', 2490));
const BASE = `http://127.0.0.1:${PORT}`;
const PASSWORD = 'bench-password';
const chars = 'abcdefghijklmnopqrstuvwxyz234567';
const DID = `did:plc:${Array.from(randomBytes(24))
  .map((b) => chars[b % 32])
  .join('')}`;

const workDir = mkdtempSync(join(tmpdir(), 'pdsjs-git-clone-bench-'));
const server = await createServer({
  port: PORT,
  dbPath: join(workDir, 'pds.db'),
  blobsDir: join(workDir, 'blobs'),
  jwtSecret: 'bench-secret',
  hostname: `127.0.0.1:${PORT}`,
  password: PASSWORD,
  lexiconResolver: new LexiconResolver({
    schemas: [defineLexicon(gitRepoLexicon)],
  }),
  experimental: { git: { http: true } },
});
await server.listen();
const init = await fetch(`${BASE}/init?did=${DID}`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    did: DID,
    privateKey: randomBytes(32).toString('hex'),
    handle: 'bench.test',
    password: PASSWORD,
  }),
});
if (!init.ok) throw new Error('account init failed');

const binDir = join(workDir, 'bin');
mkdirSync(binDir);
const cliPath = join(
  dirname(fileURLToPath(import.meta.url)),
  '..',
  'src',
  'cli.js',
);
writeFileSync(
  join(binDir, 'git-remote-atproto'),
  `#!/bin/sh\nexec node ${cliPath} "$@"\n`,
);
chmodSync(join(binDir, 'git-remote-atproto'), 0o755);

const baseEnv = {
  ...process.env,
  PATH: `${binDir}:${process.env.PATH}`,
  ATPROTO_GIT_SERVICE: BASE,
  ATPROTO_GIT_IDENTIFIER: DID,
  ATPROTO_GIT_PASSWORD: PASSWORD,
  GIT_TERMINAL_PROMPT: '0',
  GIT_AUTHOR_NAME: 'Bench',
  GIT_AUTHOR_EMAIL: 'bench@example.com',
  GIT_COMMITTER_NAME: 'Bench',
  GIT_COMMITTER_EMAIL: 'bench@example.com',
};

function runGit(cwd, args, env) {
  return execFileAsync('git', args, {
    cwd,
    env,
    encoding: 'utf8',
    maxBuffer: 1 << 28,
  });
}

const mb = (n) => n / 1024 / 1024;
const fmt = (n) => `${mb(n).toFixed(1)} MB`;

/**
 * Chain policies differ in what a clone must merge. A repacked chain is one
 * bundle; an unmerged chain is one bundle per push.
 */
const POLICIES = [
  { label: 'single bundle', factor: '2', threshold: '2' },
  { label: 'chain of 6   ', factor: '0', threshold: '100' },
];

async function measure(sizeMb, policy) {
  const rkey = `bench-${sizeMb}-${policy.threshold}`;
  const env = {
    ...baseEnv,
    ATPROTO_GIT_COMPACT_FACTOR: policy.factor,
    ATPROTO_GIT_REPACK_THRESHOLD: policy.threshold,
  };
  const repoDir = join(workDir, rkey);
  mkdirSync(repoDir);
  await runGit(repoDir, ['init', '-b', 'main'], env);
  await runGit(
    repoDir,
    ['remote', 'add', 'origin', `atproto://${DID}/${rkey}`],
    env,
  );
  const perCommit = Math.floor((sizeMb * 1024 * 1024) / COMMITS);
  for (let i = 0; i < COMMITS; i++) {
    writeFileSync(join(repoDir, `data-${i}.bin`), randomBytes(perCommit));
    await runGit(repoDir, ['add', '.'], env);
    await runGit(repoDir, ['commit', '-m', `commit ${i}`], env);
    await runGit(repoDir, ['push', 'origin', 'main'], env);
  }

  const params = new URLSearchParams({
    repo: DID,
    collection: GIT_REPO_COLLECTION,
    rkey,
  });
  const res = await fetch(`${BASE}/xrpc/com.atproto.repo.getRecord?${params}`);
  const record = (await res.json()).value;
  const packBytes = record.bundles.reduce(
    (sum, b) => sum + b.parts.reduce((t, p) => t + p.size, 0),
    0,
  );

  if (global.gc) global.gc();
  await new Promise((resolve) => setTimeout(resolve, 100));
  const before = process.memoryUsage();
  let peakBuffers = before.arrayBuffers;
  let peakRss = before.rss;
  const collect = process.argv.includes('--gc');
  const sampler = setInterval(() => {
    if (collect && global.gc) global.gc();
    const m = process.memoryUsage();
    peakBuffers = Math.max(peakBuffers, m.arrayBuffers);
    peakRss = Math.max(peakRss, m.rss);
  }, 20);
  const t0 = performance.now();
  let failure = null;
  try {
    await runGit(
      workDir,
      ['clone', `${BASE}/git/${DID}/${rkey}`, join(workDir, `${rkey}-clone`)],
      baseEnv,
    );
  } catch (err) {
    failure = err.stderr?.trim().split('\n').pop() ?? String(err);
  }
  const secs = (performance.now() - t0) / 1000;
  clearInterval(sampler);

  return {
    sizeMb,
    policy: policy.label,
    bundles: record.bundles.length,
    packBytes,
    buffers: peakBuffers - before.arrayBuffers,
    rss: peakRss - before.rss,
    secs,
    failure,
  };
}

console.log(
  `clone cost by repository size, ${COMMITS} commits each, sampling the server process every 20 ms\n`,
);
console.log(
  '  policy          size    bundles   peak buffers   peak rss    x pack   clone',
);
for (const sizeMb of SIZES) {
  for (const policy of POLICIES) {
    const r = await measure(sizeMb, policy);
    const ratio = (r.buffers / r.packBytes).toFixed(1);
    console.log(
      `  ${r.policy}  ${String(sizeMb).padStart(3)} MB   ${String(r.bundles).padStart(4)}    ${fmt(r.buffers).padStart(9)}    ${fmt(r.rss).padStart(9)}   ${ratio.padStart(5)}x   ${r.failure ? `FAILED ${r.failure}` : `${r.secs.toFixed(1)}s`}`,
    );
  }
}

await server.close();
rmSync(workDir, { recursive: true, force: true });