Ctrl K
ComunidadGuías…

scripts/bench_pg_net_vs_pg_notify.ts

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

Archivo original ↗Preguntar a KiBot
// Benchmark: net.http_post (pg_net) vs pg_notify + an external LISTEN relay.
//
//   bun scripts/bench_pg_net_vs_pg_notify.ts
//
// Needs Docker and `bun install`. The script starts three containers on one
// Docker network (no host NAT in the request path):
//   - db:    the Supabase Postgres image production runs, with pg_net (4 CPU, 4 GB)
//   - sink:  a Bun HTTP server standing in for /functions/v1/triggers/*
//   - relay: a Bun process that LISTENs and forwards each payload as an HTTP
//            POST (1 CPU, 256 MB). pg_notify cannot send HTTP itself, so a
//            production switch needs this always-on process.
//   - load:  pgbench in its own container (2 CPU), so the load generator's CPU
//            is not counted as database CPU
// For each variant it reports caller latency, TPS, WAL per transaction, database CPU (db container cgroup), pg_net
// worker CPU / memory, relay CPU / memory, net.* table growth and the time
// from the first enqueue until the sink received the whole burst (BENCH_SINK_DELAY_MS adds endpoint latency). Containers are removed at the end
// (BENCH_KEEP=1 keeps them).

import { spawn } from 'node:child_process'
import { resolve } from 'node:path'
import process from 'node:process'
import pg from 'pg'

const IMAGE = process.env.BENCH_PG_IMAGE ?? 'public.ecr.aws/supabase/postgres:17.6.1.143'
const BUN_IMAGE = process.env.BENCH_BUN_IMAGE ?? 'oven/bun:1.3-alpine'
const NETWORK = 'pgnet-bench'
const DB = 'pgnet-bench-db'
const SINK = 'pgnet-bench-sink'
const RELAY = 'pgnet-bench-relay'
const LOAD = 'pgnet-bench-load'
const DB_PORT = Number(process.env.BENCH_DB_PORT ?? 55439)
const SINK_PORT = Number(process.env.BENCH_SINK_PORT ?? 18080)
const HOST_PG_URL = `postgres://supabase_admin:postgres@127.0.0.1:${DB_PORT}/postgres`
const NET_PG_URL = `postgres://supabase_admin:postgres@${DB}:5432/postgres`
const SINK_URL = `http://${SINK}:8080/sink`
const CHANNEL = 'capgo_http_request'
const TOTAL_TX = Number(process.env.BENCH_TOTAL_TX ?? 20000)
const CLIENTS = (process.env.BENCH_CLIENTS ?? '1,16,64').split(',').map(Number)
// Simulated endpoint latency (an edge function answers in tens of ms).
const SINK_DELAY_MS = Number(process.env.BENCH_SINK_DELAY_MS ?? 0)
const REPO = resolve(import.meta.dir, '..')
const SCRIPT_IN_CONTAINER = '/app/scripts/bench_pg_net_vs_pg_notify.ts'

// Same headers and body shape process_function_queue sends today.
const HEADERS = { 'Content-Type': 'application/json', 'apisecret': 'bench-secret-0123456789abcdef0123456789abcdef' }
const BODY_SQL = `pg_catalog.jsonb_build_object('queue_name', 'on_version_update', 'batch_size', 950, 'wait_for_completion', false)`

function sh(cmd: string, args: string[], input?: string): Promise<{ code: number, out: string, err: string }> {
  return new Promise((done) => {
    const child = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'] })
    let out = ''
    let err = ''
    child.stdout.on('data', d => out += d)
    child.stderr.on('data', d => err += d)
    child.on('close', code => done({ code: code ?? 1, out, err }))
    if (input)
      child.stdin.write(input)
    child.stdin.end()
  })
}

async function docker(args: string[], input?: string) {
  const res = await sh('docker', args, input)
  if (res.code !== 0)
    throw new Error(`docker ${args.join(' ')} failed: ${res.err}`)
  return res.out
}

const exec = (container: string, args: string[], input?: string) => docker(['exec', '-i', container, ...args], input)

/** CPU (ms) and memory (KB) of a whole container from its cgroup. */
async function cgroup(container: string) {
  const out = await exec(container, ['sh', '-c', 'cat /sys/fs/cgroup/cpu.stat /sys/fs/cgroup/memory.stat'])
  const num = (name: string) => Number(new RegExp(`^${name} (\\d+)`, 'm').exec(out)?.[1] ?? 0)
  return { cpuMs: num('usage_usec') / 1000, anonKb: num('anon') / 1024 }
}

/** CPU (ms) and private memory (KB) of one Postgres process. */
async function proc(pid: number) {
  const stat = await exec(DB, ['cat', `/proc/${pid}/stat`])
  const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' ')
  const status = await exec(DB, ['cat', `/proc/${pid}/status`])
  return {
    cpuMs: (Number(fields[11]) + Number(fields[12])) * 10,
    anonKb: Number(/RssAnon:\s+(\d+)/.exec(status)?.[1] ?? 0),
  }
}

// ---------------------------------------------------------------------------
// sink (runs in its container): counts requests
// ---------------------------------------------------------------------------
function runSink() {
  let count = 0
  Bun.serve({
    port: 8080,
    hostname: '0.0.0.0',
    async fetch(req) {
      const path = new URL(req.url).pathname
      if (path === '/count')
        return Response.json({ count })
      if (path === '/reset') {
        count = 0
        return Response.json({ count })
      }
      await req.text()
      if (SINK_DELAY_MS)
        await Bun.sleep(SINK_DELAY_MS)
      count++
      return Response.json({ status: 'ok' })
    },
  })
}

// ---------------------------------------------------------------------------
// relay (runs in its container): LISTEN -> HTTP POST
// ---------------------------------------------------------------------------
async function runRelay() {
  const client = new pg.Client({ connectionString: NET_PG_URL, application_name: 'bench_notify_relay' })
  await client.connect()
  client.on('notification', (msg) => {
    fetch(SINK_URL, { method: 'POST', headers: HEADERS, body: msg.payload, signal: AbortSignal.timeout(8000) })
      .then(r => r.text())
      .catch(error => console.error('relay POST failed', error))
  })
  await client.query(`LISTEN ${CHANNEL}`)
  console.log('relay ready')
}

// ---------------------------------------------------------------------------
// orchestrator (host)
// ---------------------------------------------------------------------------
type Variant = 'baseline' | 'pg_net' | 'pg_notify'
type Workload = 'enqueue_only' | 'update_plus_enqueue'

function pgbenchScript(variant: Variant, workload: Workload) {
  const enqueue = {
    baseline: `SELECT 1;`,
    pg_net: `SELECT net.http_post(url := '${SINK_URL}', headers := '${JSON.stringify(HEADERS)}'::jsonb, body := ${BODY_SQL}, timeout_milliseconds := 8000);`,
    pg_notify: `SELECT pg_catalog.pg_notify('${CHANNEL}', ${BODY_SQL}::text);`,
  }[variant]
  if (workload === 'enqueue_only')
    return `${enqueue}\n`
  // A business write that fires the enqueue in the same transaction, like the
  // updates_cache_purge triggers on channels / apps / app_versions.
  return `\\set id random(1, 10000)\nBEGIN;\nUPDATE public.bench_rows SET n = n + 1 WHERE id = :id;\n${enqueue}\nCOMMIT;\n`
}

async function sinkCount() {
  const res = await fetch(`http://127.0.0.1:${SINK_PORT}/count`)
  return ((await res.json()) as { count: number }).count
}

/**
 * Resolves with the performance.now() at which the sink had every request.
 * The default allows pg_net's ~200 req/s at a quarter of that rate, plus 60 s.
 */
async function waitDelivered(target: number, timeoutMs = 60_000 + target * 20) {
  const start = performance.now()
  while (performance.now() - start < timeoutMs) {
    if (await sinkCount() >= target)
      return performance.now()
    await Bun.sleep(5)
  }
  throw new Error(`only ${await sinkCount()} of ${target} requests delivered after ${timeoutMs} ms`)
}

async function waitForDb() {
  for (let i = 0; i < 120; i++) {
    const res = await sh('docker', ['exec', DB, 'pg_isready', '-h', '127.0.0.1', '-U', 'supabase_admin'])
    if (res.code === 0) {
      const client = new pg.Client({ connectionString: HOST_PG_URL })
      try {
        await client.connect()
        await client.end()
        return
      }
      catch {}
    }
    await Bun.sleep(500)
  }
  throw new Error('database did not start')
}

async function removeContainers() {
  await sh('docker', ['rm', '-f', LOAD, RELAY, SINK, DB])
  await sh('docker', ['network', 'rm', NETWORK])
}

async function startContainers() {
  await removeContainers()
  await docker(['network', 'create', NETWORK])
  await docker(['run', '-d', '--name', DB, '--network', NETWORK, '--cpus=4', '--memory=4g', '-p', `${DB_PORT}:5432`, '-e', 'POSTGRES_PASSWORD=postgres', IMAGE, 'postgres', '-c', 'listen_addresses=*', '-c', 'shared_preload_libraries=pg_net', '-c', 'max_connections=200'])
  const bun = (name: string, mode: string, extra: string[]) => docker(['run', '-d', '--name', name, '--network', NETWORK, ...extra, '-v', `${REPO}:/app:ro`, '-w', '/app', BUN_IMAGE, 'bun', SCRIPT_IN_CONTAINER, mode])
  await bun(SINK, 'sink', ['--cpus=2', '-p', `${SINK_PORT}:8080`, '-e', `BENCH_SINK_DELAY_MS=${SINK_DELAY_MS}`])
  await waitForDb()
  await bun(RELAY, 'relay', ['--cpus=1', '--memory=256m'])
  await docker(['run', '-d', '--name', LOAD, '--network', NETWORK, '--cpus=2', '-e', 'PGPASSWORD=postgres', '--entrypoint', 'sleep', IMAGE, 'infinity'])
}

interface Result {
  variant: Variant
  workload: Workload
  clients: number
  tx: number
  tps: number
  latencyAvgMs: number
  walBytesPerTx: number
  dbCpuMsPerTx: number
  /** First enqueue -> sink received every request. */
  deliveredMs: number
  /** pgbench end -> sink received every request. */
  drainAfterEnqueueMs: number
  delivered: number
  pgNetWorkerCpuMs?: number
  pgNetWorkerAnonKb?: number
  netTablesKb?: number
  relayCpuMs?: number
  relayAnonKb?: number
  relayBackendCpuMs?: number
}

async function main() {
  await startContainers()
  const db = new pg.Client({ connectionString: HOST_PG_URL })
  await db.connect()
  await db.query(`CREATE EXTENSION IF NOT EXISTS pg_net`)
  await db.query(`CREATE TABLE public.bench_rows (id int PRIMARY KEY, n bigint NOT NULL DEFAULT 0)`)
  await db.query(`INSERT INTO public.bench_rows (id) SELECT g FROM generate_series(1, 10000) g`)

  // The relay connects once the database accepts connections.
  let relayBackendPid = 0
  for (let i = 0; i < 100 && !relayBackendPid; i++) {
    relayBackendPid = Number((await db.query(`SELECT pid FROM pg_stat_activity WHERE application_name = 'bench_notify_relay' AND query ILIKE 'LISTEN%'`)).rows[0]?.pid ?? 0)
    if (!relayBackendPid)
      await Bun.sleep(200)
  }
  const workerPid = Number((await db.query(`SELECT pid FROM pg_stat_activity WHERE backend_type ILIKE 'pg_net%'`)).rows[0]?.pid ?? 0)
  if (!relayBackendPid || !workerPid)
    throw new Error(`missing relay backend (${relayBackendPid}) or pg_net worker (${workerPid})`)

  const results: Result[] = []
  for (const workload of ['enqueue_only', 'update_plus_enqueue'] as Workload[]) {
    for (const clients of CLIENTS) {
      for (const variant of ['baseline', 'pg_net', 'pg_notify'] as Variant[]) {
        const perClient = Math.ceil(TOTAL_TX / clients)
        const tx = perClient * clients
        // Same state for every run: empty pg_net tables (prod truncates
        // net._http_response hourly), clean checkpoint, zeroed sink.
        await db.query(`TRUNCATE net._http_response`)
        await db.query(`VACUUM FULL net.http_request_queue, net._http_response, public.bench_rows`)
        await db.query(`CHECKPOINT`)
        await fetch(`http://127.0.0.1:${SINK_PORT}/reset`)
        await Bun.sleep(500)

        await exec(LOAD, ['sh', '-c', 'cat > /tmp/bench.sql'], pgbenchScript(variant, workload))
        const walStart = (await db.query(`SELECT pg_current_wal_insert_lsn() AS lsn`)).rows[0].lsn
        const dbStart = await cgroup(DB)
        const relayStart = await cgroup(RELAY)
        const workerStart = await proc(workerPid)
        const relayBackendStart = await proc(relayBackendPid)

        const enqueueStart = performance.now()
        const out = await exec(LOAD, ['pgbench', '-n', '-U', 'supabase_admin', '-h', DB, '-d', 'postgres', '-c', String(clients), '-j', String(Math.min(clients, 4)), '-t', String(perClient), '-M', 'prepared', '-f', '/tmp/bench.sql'])
        const enqueueEnd = performance.now()
        const deliveredAt = variant === 'baseline' ? enqueueEnd : await waitDelivered(tx)
        // pg_net writes each response row after the request; let it settle.
        await Bun.sleep(1000)

        const walBytes = Number((await db.query(`SELECT pg_wal_lsn_diff(pg_current_wal_insert_lsn(), $1) AS b`, [walStart])).rows[0].b)
        const dbEnd = await cgroup(DB)
        const relayEnd = await cgroup(RELAY)
        const workerEnd = await proc(workerPid)
        const relayBackendEnd = await proc(relayBackendPid)
        const netTablesKb = Number((await db.query(`SELECT (pg_total_relation_size('net.http_request_queue') + pg_total_relation_size('net._http_response')) / 1024 AS kb`)).rows[0].kb)

        const result: Result = {
          variant,
          workload,
          clients,
          tx,
          tps: Math.round(Number(/tps = ([\d.]+)/.exec(out)?.[1] ?? 0)),
          latencyAvgMs: Number(/latency average = ([\d.]+) ms/.exec(out)?.[1] ?? 0),
          walBytesPerTx: Math.round(walBytes / tx),
          // Includes the pg_net worker and the relay's backend: all of it is
          // database CPU.
          dbCpuMsPerTx: Number(((dbEnd.cpuMs - dbStart.cpuMs) / tx).toFixed(4)),
          deliveredMs: Math.round(deliveredAt - enqueueStart),
          drainAfterEnqueueMs: Math.round(deliveredAt - enqueueEnd),
          delivered: await sinkCount(),
        }
        if (variant === 'pg_net') {
          result.pgNetWorkerCpuMs = workerEnd.cpuMs - workerStart.cpuMs
          result.pgNetWorkerAnonKb = workerEnd.anonKb
          result.netTablesKb = netTablesKb
        }
        if (variant === 'pg_notify') {
          result.relayCpuMs = Math.round(relayEnd.cpuMs - relayStart.cpuMs)
          result.relayAnonKb = Math.round(relayEnd.anonKb)
          result.relayBackendCpuMs = relayBackendEnd.cpuMs - relayBackendStart.cpuMs
        }
        results.push(result)
        console.error(JSON.stringify(result))
      }
    }
  }
  await db.end()
  console.log(JSON.stringify(results, null, 2))
}

const mode = process.argv[2] ?? 'run'
if (mode === 'sink') {
  runSink()
}
else if (mode === 'relay') {
  await runRelay()
}
else {
  try {
    await main()
  }
  finally {
    if (!process.env.BENCH_KEEP)
      await removeContainers()
  }
}