OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / test / bench / runner.js

260 lines · javascript ref:
/**
 * pgh-bench runner — execute a named scenario and emit one JSONL row.
 *
 * Scenarios are JS modules exporting:
 *   {
 *     name,                              // unique scenario id
 *     description,                       // one-line human summary
 *     setup    : async (ctx) => {...},   // prepares the world; returns metadata
 *     run      : async (ctx) => {...},   // the timed operation; returns metrics
 *     teardown : async (ctx) => {...}    // cleans up; optional
 *   }
 *
 * The runner provides a `ctx` object containing:
 *   {
 *     storeRoot    : <isolated $PGH_STORE_ROOT for this run>,
 *     appsDir      : <PGH_STORE_ROOT/apps>,
 *     binDir       : <a sandboxed bin dir>,
 *     sidecarSock  : <UDS path for an isolated sidecar; null when not started>,
 *     env          : <env vars to pass to spawned pgh CLIs>,
 *     emit         : <(label, fields) => void — adds a row to the metrics buffer>,
 *     metrics      : <accumulator written into the JSONL row at the end>,
 *     workspace    : <scratch tmpdir for the scenario>
 *   }
 *
 * Each scenario is run in a freshly-mkdtemp'd workspace with its own
 * isolated tree-store so chaos in one run never bleeds into another or
 * into the developer's real install.
 */

import fs from 'fs'
import path from 'path'
import os from 'os'
import { execSync, spawn } from 'child_process'
import { performance } from 'perf_hooks'

export function createCtx (opts = {}) {
  const workspace = opts.workspace || fs.mkdtempSync(path.join(os.tmpdir(), 'pgh-bench-'))
  const storeRoot = path.join(workspace, 'store')
  const appsDir = path.join(storeRoot, 'apps')
  const binDir = path.join(workspace, 'bin')
  const sidecarSock = path.join(workspace, 'svc.sock')

  fs.mkdirSync(storeRoot, { recursive: true })
  fs.mkdirSync(appsDir, { recursive: true })
  fs.mkdirSync(binDir, { recursive: true })

  const env = {
    ...process.env,
    PGH_STORE_ROOT: storeRoot,
    PGH_BIN_DIR: binDir,
    PGH_SIDECAR_SOCK: sidecarSock,
    PGH_LINKED_DEVICE_FILE: path.join(workspace, 'no-linked.json'),
    PGH_PROFILE: '1'
  }

  const metrics = {}
  const phaseTimings = {}

  return {
    workspace,
    storeRoot,
    appsDir,
    binDir,
    sidecarSock,
    env,
    metrics,
    phaseTimings,
    emit (key, value) {
      metrics[key] = value
    },
    markPhase (label, ms) {
      phaseTimings[label] = ms
    }
  }
}

export async function timed (fn) {
  const t0 = performance.now()
  let result, error
  try {
    result = await fn()
  } catch (err) {
    error = err
  }
  const elapsed = Math.round(performance.now() - t0)
  return { result, error, elapsed }
}

/**
 * Spawn a child pgh CLI with the scenario's isolated env. Captures
 * stdout + stderr. Parses PGH_PROFILE timing lines into ctx.phaseTimings.
 *
 * @returns {{ code, stdout, stderr, durationMs }}
 */
export function runPgh (ctx, args, opts = {}) {
  const cliPath = opts.cliPath || path.resolve(
    path.dirname(new URL(import.meta.url).pathname),
    '..', '..', 'bin', 'pgh'
  )

  const t0 = performance.now()
  let stdout = ''
  let stderr = ''
  let code = null

  return new Promise((resolve) => {
    const child = spawn('node', [cliPath, ...args], {
      env: ctx.env,
      stdio: ['ignore', 'pipe', 'pipe']
    })
    child.stdout.on('data', d => { stdout += d.toString('utf8') })
    child.stderr.on('data', d => {
      const s = d.toString('utf8')
      stderr += s
      // Parse `[profile] +<ms>ms <label>` lines
      for (const line of s.split('\n')) {
        const m = line.match(/^\[profile\]\s+\+(\d+)ms\s+(.+)$/)
        if (m) ctx.markPhase(m[2].trim(), parseInt(m[1], 10))
      }
    })
    child.on('close', (c) => {
      code = c
      resolve({ code, stdout, stderr, durationMs: Math.round(performance.now() - t0) })
    })
    child.on('error', (err) => {
      resolve({ code: -1, stdout, stderr, durationMs: Math.round(performance.now() - t0), error: err.message })
    })
    if (opts.killAfterMs) {
      setTimeout(() => {
        try { child.kill(opts.killSignal || 'SIGTERM') } catch {}
      }, opts.killAfterMs)
    }
  })
}

/**
 * Recursively measure the size of a directory tree on disk. Symlinks
 * are followed only for the "logical" size in opts.follow; default is
 * to count what's actually allocated (no follow).
 */
export function dirSize (dir, opts = {}) {
  let total = 0
  let files = 0
  function walk (p) {
    let stat
    try { stat = opts.follow ? fs.statSync(p) : fs.lstatSync(p) } catch { return }
    if (stat.isSymbolicLink()) return
    if (stat.isFile()) {
      total += stat.size
      files++
      return
    }
    if (stat.isDirectory()) {
      let entries
      try { entries = fs.readdirSync(p) } catch { return }
      for (const e of entries) walk(path.join(p, e))
    }
  }
  walk(dir)
  return { bytes: total, files }
}

/**
 * Execute a scenario module against a fresh ctx.
 */
export async function executeScenario (scenarioModule, opts = {}) {
  const ctx = createCtx(opts)
  const startedAt = new Date().toISOString()
  const t0 = performance.now()

  let outcome = 'success'
  let error = null

  try {
    if (scenarioModule.setup) await scenarioModule.setup(ctx)
    await scenarioModule.run(ctx)
  } catch (err) {
    outcome = 'failed'
    error = { message: err.message, stack: err.stack }
  } finally {
    try {
      if (scenarioModule.teardown) await scenarioModule.teardown(ctx)
    } catch {}
  }

  const totalMs = Math.round(performance.now() - t0)

  // Pull system info for the row
  let pghSha = 'unknown'
  try {
    pghSha = execSync('git -C ' + path.resolve(
      path.dirname(new URL(import.meta.url).pathname),
      '..', '..'
    ) + ' rev-parse --short HEAD', { encoding: 'utf8' }).trim()
  } catch {}

  const row = {
    scenario: scenarioModule.name,
    description: scenarioModule.description,
    machine: os.hostname(),
    pghSha,
    nodeVersion: process.version,
    startedAt,
    totalMs,
    outcome,
    error,
    phaseTimings: ctx.phaseTimings,
    metrics: ctx.metrics
  }

  // Workspace cleanup unless caller wants it preserved (e.g. failure inspection)
  if (!opts.keepWorkspace && outcome === 'success') {
    try { fs.rmSync(ctx.workspace, { recursive: true, force: true }) } catch {}
  } else {
    row.workspace = ctx.workspace
  }

  return row
}

/**
 * CLI: node test/bench/runner.js <scenario-file> [<scenario-file> ...] [--output <file>]
 */
async function main () {
  const args = process.argv.slice(2)
  const outIdx = args.indexOf('--output')
  const outputFile = outIdx !== -1 ? args[outIdx + 1] : null
  const scenarios = args.filter((a, i) => !a.startsWith('--') && (outIdx === -1 || i !== outIdx + 1))

  if (scenarios.length === 0) {
    console.error('usage: node runner.js <scenario.js> [<scenario.js> ...] [--output results.jsonl]')
    process.exit(2)
  }

  const results = []
  for (const file of scenarios) {
    const absPath = path.resolve(file)
    process.stderr.write(`▶ ${path.basename(file)}\n`)
    const mod = await import(absPath)
    const row = await executeScenario(mod, { keepWorkspace: false })
    results.push(row)
    process.stderr.write(`  ${row.outcome} in ${row.totalMs}ms\n`)
  }

  const lines = results.map(r => JSON.stringify(r)).join('\n') + '\n'
  if (outputFile) {
    fs.appendFileSync(outputFile, lines)
    process.stderr.write(`wrote ${results.length} rows → ${outputFile}\n`)
  } else {
    process.stdout.write(lines)
  }
}

if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch(err => {
    console.error(err.stack || err)
    process.exit(1)
  })
}