Ctrl K
ComunidadGuías…

scripts/bench_updates_manifest_path.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env bun
/**
 * Load-test the /updates manifest query hypothesis in a local Postgres.
 *
 * Compares production-shaped SQL paths:
 *   OLD: channel lookup LEFT JOIN manifest + json_agg + GROUP BY
 *        (paid on every check when the app has manifests, including up-to-date)
 *   NEW: light channel lookup, then indexed
 *        SELECT … FROM manifest WHERE app_version_id = $1
 *        (only on the new-version path; overlaps gates in the app code)
 *
 * Usage:
 *   bun scripts/bench_updates_manifest_path.ts
 *   bun scripts/bench_updates_manifest_path.ts --files 5000 --concurrency 50 --requests 1000
 *   MANIFEST_BENCH_DATABASE_URL=postgres://… bun scripts/bench_updates_manifest_path.ts
 *
 * Default URL points at the local docker bench container on :55432 / db manifest_bench.
 * Intentionally ignores DATABASE_URL so a destructive DROP SCHEMA cannot hit the app DB.
 *
 * Exit code 1 if the new-version path is slower than OLD beyond noise (see PASS_GATE).
 */

import { mkdirSync, writeFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { performance } from 'node:perf_hooks'
import { Client, Pool } from 'pg'

interface LatencyStats {
  count: number
  minMs: number
  p50Ms: number
  p95Ms: number
  p99Ms: number
  maxMs: number
  meanMs: number
  qps: number
  rowBytesApprox: number
}

interface ScenarioResult {
  name: string
  path: 'old_json_agg' | 'new_deferred'
  scenario: 'up_to_date' | 'new_version'
  files: number
  concurrency: number
  stats: LatencyStats
  explain?: string
}

interface BenchReport {
  generatedAt: string
  gitHead: string
  databaseUrlHost: string
  files: number
  concurrency: number
  requests: number
  warmup: number
  results: ScenarioResult[]
  gates: {
    newVersionP95RegressionPct: number
    newVersionP95DeltaMs: number
    upToDateP95ImprovementPct: number
    passed: boolean
    reasons: string[]
  }
}

const ROOT = resolve(import.meta.dirname, '..')
const DEFAULT_URL = 'postgres://postgres:postgres@127.0.0.1:55432/manifest_bench'

function argValue(flag: string, fallback: string): string {
  const idx = process.argv.indexOf(flag)
  if (idx >= 0 && process.argv[idx + 1])
    return process.argv[idx + 1]!
  return fallback
}

function numArg(flag: string, fallback: string, opts?: { min?: number }): number {
  const raw = argValue(flag, fallback)
  const n = Number(raw)
  const min = opts?.min ?? 0
  if (!Number.isFinite(n) || !Number.isInteger(n) || n < min)
    throw new Error(`Invalid value for ${flag}: ${raw} (need integer >= ${min})`)
  return n
}

function percentile(sorted: number[], p: number): number {
  if (sorted.length === 0)
    return 0
  const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1))
  return sorted[idx]!
}

function statsOf(samplesMs: number[], wallSec: number, rowBytesApprox: number): LatencyStats {
  const sorted = [...samplesMs].sort((a, b) => a - b)
  const sum = samplesMs.reduce((a, b) => a + b, 0)
  return {
    count: samplesMs.length,
    minMs: sorted[0] ?? 0,
    p50Ms: percentile(sorted, 50),
    p95Ms: percentile(sorted, 95),
    p99Ms: percentile(sorted, 99),
    maxMs: sorted[sorted.length - 1] ?? 0,
    meanMs: samplesMs.length ? sum / samplesMs.length : 0,
    qps: wallSec > 0 ? samplesMs.length / wallSec : 0,
    rowBytesApprox,
  }
}

async function setupSchema(client: Client) {
  await client.query(`
    DROP SCHEMA IF EXISTS public CASCADE;
    CREATE SCHEMA public;

    CREATE TABLE app_versions (
      id bigint PRIMARY KEY,
      app_id text NOT NULL,
      name text NOT NULL,
      deleted boolean NOT NULL DEFAULT false,
      r2_path text,
      external_url text,
      checksum text,
      key_id text,
      session_key text,
      manifest_count integer NOT NULL DEFAULT 0
    );

    CREATE TABLE channels (
      id bigint PRIMARY KEY,
      app_id text NOT NULL,
      name text NOT NULL,
      version bigint REFERENCES app_versions(id),
      public boolean NOT NULL DEFAULT true,
      allow_device_self_set boolean NOT NULL DEFAULT false,
      ios boolean NOT NULL DEFAULT true,
      android boolean NOT NULL DEFAULT true,
      electron boolean NOT NULL DEFAULT true
    );

    CREATE TABLE manifest (
      app_version_id bigint NOT NULL REFERENCES app_versions(id) ON DELETE CASCADE,
      file_name text NOT NULL,
      file_hash text NOT NULL,
      s3_path text NOT NULL
    );

    -- Production index used by the deferred path
    CREATE INDEX idx_manifest_app_version_id ON manifest USING btree (app_version_id);
    CREATE INDEX idx_channels_app_name ON channels (app_id, name);
  `)
}

async function seed(client: Client, files: number, noiseVersions = 40) {
  // Live channel points at version 1002. Extra versions fill the table so
  // idx_manifest_app_version_id is selective (prod: millions of rows, many versions).
  const versionRows: string[] = []
  for (let v = 1001; v <= 1001 + noiseVersions; v++) {
    versionRows.push(`(${v}, 'com.bench.manifest', '${(v - 1000)}.0.0', false, NULL, 'checksum-${v}', ${files})`)
  }
  // SQL built from local loop integers/literals only — not user input.
  await client.query(`
    INSERT INTO app_versions (id, app_id, name, deleted, r2_path, checksum, manifest_count)
    VALUES ${versionRows.join(',')}
  `)

  await client.query(`
    INSERT INTO channels (id, app_id, name, version, public, ios, android)
    VALUES (1, 'com.bench.manifest', 'production', 1002, true, true, true)
  `)

  const batchSize = 500
  const versionsToFill = Array.from({ length: noiseVersions + 1 }, (_, i) => 1001 + i)
  for (const versionId of versionsToFill) {
    for (let offset = 0; offset < files; offset += batchSize) {
      const values: unknown[] = []
      const placeholders: string[] = []
      const end = Math.min(files, offset + batchSize)
      let p = 1
      for (let i = offset; i < end; i++) {
        placeholders.push(`($${p++}, $${p++}, $${p++}, $${p++})`)
        values.push(
          versionId,
          `assets/file-${i}.js`,
          `hash-${versionId}-${i}`,
          `apps/com.bench.manifest/${versionId}/file-${i}.js`,
        )
      }
      await client.query(
        `INSERT INTO manifest (app_version_id, file_name, file_hash, s3_path) VALUES ${placeholders.join(',')}`,
        values,
      )
    }
  }

  await client.query('ANALYZE app_versions')
  await client.query('ANALYZE channels')
  await client.query('ANALYZE manifest')
}

/** OLD hot path: channel + version + json_agg(manifest) in one statement */
const OLD_CHANNEL_WITH_MANIFEST = `
SELECT
  c.id AS channel_id,
  c.name AS channel_name,
  v.id AS version_id,
  v.name AS version_name,
  COALESCE(json_agg(
    json_build_object(
      'file_name', m.file_name,
      'file_hash', m.file_hash,
      's3_path', m.s3_path
    )
  ) FILTER (WHERE m.file_name IS NOT NULL), '[]'::json) AS manifest_entries
FROM channels c
LEFT JOIN app_versions v
  ON c.version = v.id
 AND (v.deleted = false OR v.name = 'builtin')
LEFT JOIN manifest m
  ON m.app_version_id = v.id
WHERE c.app_id = $1
  AND c.name = $2
  AND c.android = true
  AND (c.public = true OR c.allow_device_self_set = true)
GROUP BY c.id, v.id
ORDER BY c.name, c.id
LIMIT 1
`

/** NEW light channel lookup (no manifest join) */
const NEW_CHANNEL_LIGHT = `
SELECT
  c.id AS channel_id,
  c.name AS channel_name,
  v.id AS version_id,
  v.name AS version_name
FROM channels c
LEFT JOIN app_versions v
  ON c.version = v.id
 AND (v.deleted = false OR v.name = 'builtin')
WHERE c.app_id = $1
  AND c.name = $2
  AND c.android = true
  AND (c.public = true OR c.allow_device_self_set = true)
ORDER BY c.name, c.id
LIMIT 1
`

/** NEW deferred indexed manifest fetch */
const NEW_MANIFEST_BY_VERSION = `
SELECT file_name, file_hash, s3_path
FROM manifest
WHERE app_version_id = $1
`

async function runOldPathMeasured(pool: Pool, appId: string, channel: string): Promise<{ ms: number, bytes: number, files: number }> {
  const start = performance.now()
  const res = await pool.query(OLD_CHANNEL_WITH_MANIFEST, [appId, channel])
  const entries = res.rows[0]?.manifest_entries ?? []
  return {
    ms: performance.now() - start,
    bytes: JSON.stringify(entries).length,
    files: Array.isArray(entries) ? entries.length : 0,
  }
}

async function runNewUpToDate(pool: Pool, appId: string, channel: string): Promise<{ ms: number, bytes: number, files: number }> {
  const start = performance.now()
  const res = await pool.query(NEW_CHANNEL_LIGHT, [appId, channel])
  return {
    ms: performance.now() - start,
    bytes: JSON.stringify(res.rows[0] ?? {}).length,
    files: 0,
  }
}

async function runNewVersion(pool: Pool, appId: string, channel: string): Promise<{ ms: number, bytes: number, files: number }> {
  const start = performance.now()
  // App code overlaps these with gates; here we measure the DB critical path as
  // light channel + indexed select (sequential worst case for DB time).
  const channelRes = await pool.query(NEW_CHANNEL_LIGHT, [appId, channel])
  const versionId = channelRes.rows[0]?.version_id
  const manifestRes = await pool.query(NEW_MANIFEST_BY_VERSION, [versionId])
  return {
    ms: performance.now() - start,
    bytes: JSON.stringify(manifestRes.rows).length,
    files: manifestRes.rows.length,
  }
}

async function loadScenario(
  pool: Pool,
  name: string,
  path: ScenarioResult['path'],
  scenario: ScenarioResult['scenario'],
  files: number,
  concurrency: number,
  requests: number,
  warmup: number,
  fn: () => Promise<{ ms: number, bytes: number, files: number }>,
): Promise<ScenarioResult> {
  for (let i = 0; i < warmup; i++)
    await fn()

  const samples: number[] = []
  let bytes = 0
  let fileCount = 0
  let next = 0
  const wallStart = performance.now()

  async function worker() {
    while (true) {
      const i = next++
      if (i >= requests)
        return
      const r = await fn()
      samples.push(r.ms)
      bytes = r.bytes
      fileCount = r.files
    }
  }

  await Promise.all(Array.from({ length: concurrency }, () => worker()))
  const wallSec = (performance.now() - wallStart) / 1000
  const s = statsOf(samples, wallSec, bytes)

  console.log(
    `[${name}] n=${s.count} conc=${concurrency} files=${fileCount}/${files} `
    + `p50=${s.p50Ms.toFixed(2)}ms p95=${s.p95Ms.toFixed(2)}ms p99=${s.p99Ms.toFixed(2)}ms `
    + `max=${s.maxMs.toFixed(2)}ms qps=${s.qps.toFixed(1)} payload~${Math.round(bytes / 1024)}KB`,
  )

  return { name, path, scenario, files, concurrency, stats: s }
}

type BenchExplainSql = typeof OLD_CHANNEL_WITH_MANIFEST | typeof NEW_CHANNEL_LIGHT | typeof NEW_MANIFEST_BY_VERSION

// sql is always one of the module-level constants above — not user input.
async function explain(client: Client, sql: BenchExplainSql, params: unknown[]): Promise<string> {
  const res = await client.query(`EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) ${sql}`, params)
  return res.rows.map((r: { 'QUERY PLAN': string }) => r['QUERY PLAN']).join('\n')
}

function assertSafeBenchDatabaseUrl(databaseUrl: string) {
  let parsed: URL
  try {
    parsed = new URL(databaseUrl)
  }
  catch {
    throw new Error(`Invalid MANIFEST_BENCH_DATABASE_URL: ${databaseUrl}`)
  }
  const dbName = decodeURIComponent(parsed.pathname.replace(/^\/+/, '').split('/')[0] ?? '')
  const host = parsed.hostname
  const port = parsed.port || (parsed.protocol === 'postgres:' || parsed.protocol === 'postgresql:' ? '5432' : '')
  const isLocalBenchHost = (host === '127.0.0.1' || host === 'localhost') && port === '55432'
  if (dbName !== 'manifest_bench' || !isLocalBenchHost) {
    throw new Error(
      `Refusing destructive bench against ${host}:${port}/${dbName}. `
      + `Only postgres://…@127.0.0.1:55432/manifest_bench (or localhost) is allowed. `
      + `Do not pass DATABASE_URL; use MANIFEST_BENCH_DATABASE_URL or --database-url.`,
    )
  }
}

async function main() {
  // Never fall back to DATABASE_URL — setupSchema() DROP SCHEMA public CASCADE.
  const databaseUrl = process.env.MANIFEST_BENCH_DATABASE_URL || argValue('--database-url', DEFAULT_URL)
  assertSafeBenchDatabaseUrl(databaseUrl)
  const files = numArg('--files', '5000', { min: 1 })
  const concurrency = numArg('--concurrency', '50', { min: 1 })
  const requests = numArg('--requests', '800', { min: 1 })
  const warmup = numArg('--warmup', '20', { min: 0 })
  const noiseVersions = numArg('--noise-versions', '40', { min: 0 })
  const savePath = argValue('--save', 'scripts/bench/updates_manifest_path_results.json')

  console.log(`DB=${databaseUrl.replace(/:[^:@/]+@/, ':***@')}`)
  console.log(`files=${files} noiseVersions=${noiseVersions} concurrency=${concurrency} requests=${requests} warmup=${warmup}`)

  const admin = new Client({ connectionString: databaseUrl })
  await admin.connect()
  console.log('Setting up schema + seed…')
  await setupSchema(admin)
  await seed(admin, files, noiseVersions)
  const countRes = await admin.query('SELECT count(*)::int AS n FROM manifest')
  console.log(`Seeded manifest rows: ${countRes.rows[0].n} (live version=${files}, table≈${files * (noiseVersions + 1)})`)

  const oldExplain = await explain(admin, OLD_CHANNEL_WITH_MANIFEST, ['com.bench.manifest', 'production'])
  const lightExplain = await explain(admin, NEW_CHANNEL_LIGHT, ['com.bench.manifest', 'production'])
  const manifestExplain = await explain(admin, NEW_MANIFEST_BY_VERSION, [1002])
  console.log('\n--- EXPLAIN OLD channel+json_agg ---\n', oldExplain)
  console.log('\n--- EXPLAIN NEW channel light ---\n', lightExplain)
  console.log('\n--- EXPLAIN NEW manifest by version ---\n', manifestExplain)

  const pool = new Pool({
    connectionString: databaseUrl,
    max: Math.max(concurrency, 10),
    idleTimeoutMillis: 10000,
  })

  const appId = 'com.bench.manifest'
  const channel = 'production'

  // Sanity: both paths return same file count for new_version
  const oldOnce = await runOldPathMeasured(pool, appId, channel)
  const newOnce = await runNewVersion(pool, appId, channel)
  if (oldOnce.files !== files || newOnce.files !== files) {
    throw new Error(`Sanity failed: old files=${oldOnce.files} new files=${newOnce.files} expected=${files}`)
  }
  console.log(`Sanity OK: both paths return ${files} files`)

  const results: ScenarioResult[] = []

  results.push(await loadScenario(
    pool,
    'OLD up_to_date (still pays json_agg)',
    'old_json_agg',
    'up_to_date',
    files,
    concurrency,
    requests,
    warmup,
    () => runOldPathMeasured(pool, appId, channel),
  ))

  results.push(await loadScenario(
    pool,
    'NEW up_to_date (channel only)',
    'new_deferred',
    'up_to_date',
    files,
    concurrency,
    requests,
    warmup,
    () => runNewUpToDate(pool, appId, channel),
  ))

  results.push(await loadScenario(
    pool,
    'OLD new_version (json_agg)',
    'old_json_agg',
    'new_version',
    files,
    concurrency,
    requests,
    warmup,
    () => runOldPathMeasured(pool, appId, channel),
  ))

  results.push(await loadScenario(
    pool,
    'NEW new_version (light + indexed)',
    'new_deferred',
    'new_version',
    files,
    concurrency,
    requests,
    warmup,
    () => runNewVersion(pool, appId, channel),
  ))

  // Attach explains to first matching results
  results[0]!.explain = oldExplain
  results[1]!.explain = lightExplain
  results[3]!.explain = `${lightExplain}\n---\n${manifestExplain}`

  const oldNew = results.find(r => r.path === 'old_json_agg' && r.scenario === 'new_version')!
  const newNew = results.find(r => r.path === 'new_deferred' && r.scenario === 'new_version')!
  const oldUtd = results.find(r => r.path === 'old_json_agg' && r.scenario === 'up_to_date')!
  const newUtd = results.find(r => r.path === 'new_deferred' && r.scenario === 'up_to_date')!

  const newVersionP95RegressionPct = oldNew.stats.p95Ms > 0
    ? ((newNew.stats.p95Ms - oldNew.stats.p95Ms) / oldNew.stats.p95Ms) * 100
    : 0
  const newVersionP95DeltaMs = newNew.stats.p95Ms - oldNew.stats.p95Ms
  const upToDateP95ImprovementPct = oldUtd.stats.p95Ms > 0
    ? ((oldUtd.stats.p95Ms - newUtd.stats.p95Ms) / oldUtd.stats.p95Ms) * 100
    : 0

  // PASS GATE (strict — user closed prior PR over new-version latency risk):
  // NEW new_version must not be slower than OLD beyond measurement noise.
  // Noise = max(10ms, 5% of OLD p95). Also require up-to-date p95 improvement >= 50%.
  const reasons: string[] = []
  const newVersionNoiseMs = Math.max(10, oldNew.stats.p95Ms * 0.05)
  if (newVersionP95DeltaMs > newVersionNoiseMs) {
    reasons.push(
      `NEW new_version p95 slower than OLD by ${newVersionP95DeltaMs.toFixed(2)}ms `
      + `(limit +${newVersionNoiseMs.toFixed(2)}ms / ${newVersionP95RegressionPct.toFixed(1)}%; `
      + `${oldNew.stats.p95Ms.toFixed(2)} → ${newNew.stats.p95Ms.toFixed(2)} ms)`,
    )
  }
  if (upToDateP95ImprovementPct < 50) {
    reasons.push(
      `NEW up_to_date p95 improvement only ${upToDateP95ImprovementPct.toFixed(1)}% `
      + `(expected >= 50%; ${oldUtd.stats.p95Ms.toFixed(2)} → ${newUtd.stats.p95Ms.toFixed(2)} ms)`,
    )
  }
  // Tail: p99 must not regress beyond max(15% , +25ms)
  const p99Delta = newNew.stats.p99Ms - oldNew.stats.p99Ms
  const p99Limit = Math.max(oldNew.stats.p99Ms * 0.15, 25)
  if (p99Delta > p99Limit) {
    reasons.push(
      `NEW new_version p99 slower than OLD by ${p99Delta.toFixed(2)}ms `
      + `(limit +${p99Limit.toFixed(2)}ms; ${oldNew.stats.p99Ms.toFixed(2)} → ${newNew.stats.p99Ms.toFixed(2)} ms)`,
    )
  }

  const passed = reasons.length === 0

  const gitHead = Bun.spawnSync(['git', 'rev-parse', '--short', 'HEAD'], { cwd: ROOT }).stdout.toString().trim()
  const report: BenchReport = {
    generatedAt: new Date().toISOString(),
    gitHead,
    databaseUrlHost: new URL(databaseUrl).host,
    files,
    concurrency,
    requests,
    warmup,
    results,
    gates: {
      newVersionP95RegressionPct,
      newVersionP95DeltaMs,
      upToDateP95ImprovementPct,
      passed,
      reasons,
    },
  }

  const out = resolve(ROOT, savePath)
  mkdirSync(resolve(out, '..'), { recursive: true })
  writeFileSync(out, `${JSON.stringify(report, null, 2)}\n`)

  console.log('\n=== GATE SUMMARY ===')
  console.log(`new_version p95: OLD ${oldNew.stats.p95Ms.toFixed(2)}ms → NEW ${newNew.stats.p95Ms.toFixed(2)}ms `
    + `(${newVersionP95RegressionPct >= 0 ? '+' : ''}${newVersionP95RegressionPct.toFixed(1)}% / ${newVersionP95DeltaMs >= 0 ? '+' : ''}${newVersionP95DeltaMs.toFixed(2)}ms)`)
  console.log(`up_to_date p95:  OLD ${oldUtd.stats.p95Ms.toFixed(2)}ms → NEW ${newUtd.stats.p95Ms.toFixed(2)}ms `
    + `(improvement ${upToDateP95ImprovementPct.toFixed(1)}%)`)
  console.log(passed ? 'PASS: new-version not slower than OLD (within noise); up-to-date improved' : `FAIL:\n- ${reasons.join('\n- ')}`)
  console.log(`Wrote ${out}`)

  await pool.end()
  await admin.end()
  process.exit(passed ? 0 : 1)
}

main().catch((err) => {
  console.error(err)
  process.exit(2)
})