diff --git a/packages/perf-harness/README.md b/packages/perf-harness/README.md new file mode 100644 index 0000000000..3526969b05 --- /dev/null +++ b/packages/perf-harness/README.md @@ -0,0 +1,55 @@ +# Graphile scoped/retirement performance harness + +This package measures only the two changes in the preceding stacked PRs: + +| Arm | Catalog introspection | Introspection client | Build state | +| --- | --- | --- | --- | +| `stock` | stock | reuse | retained | +| `scoped` | dependency closure | destroy | retained | +| `retire` | stock | reuse | retired | +| `scoped-retire` | dependency closure | destroy | retired | + +Every arm/repetition runs in a new Node process with `--expose-gc`. The harness +uses the Graphile default presets directly, so CNC application plugins (including +pg-many-to-many) are not part of the measurement. It verifies a simple query and +requires every successful run to produce the same printed-schema hash. + +## Run + +Build the package, point it at a disposable PostgreSQL database, and use the +same schema fixture for every arm: + +```sh +pnpm --filter @constructive-io/perf-harness build +export CPERF_DATABASE_URL=postgres:///cperf +node packages/perf-harness/dist/index.js prepare \ + --schema cperf_example \ + --tables 64 +node packages/perf-harness/dist/index.js run \ + --schemas cperf_example \ + --repetitions 5 \ + --seed 20260813 \ + --output perf-results/scoped-retirement.json +``` + +`prepare` is intentionally conservative: it only accepts a previously absent +schema whose name starts with `cperf_`; it never drops or replaces a schema. +For an existing representative database, omit `prepare` and pass its exposed +schemas to `run`. Add cross-schema dependencies explicitly with +`--allowed-dependency-schemas`. + +By default each repetition gets a deterministic seeded shuffle. To pin an exact +order, pass all four arms once, for example: + +```sh +--order stock,scoped,retire,scoped-retire +``` + +The output JSON contains every raw sample, median summaries, all useful 2×2 +pairwise deltas, the complete schedule, process IDs, and validation results. +Memory values are bytes and include both post-build snapshots and the process's +cumulative peak RSS. Database credentials are passed to workers through the +environment and are not written to the result. + +This is a focused regression/attribution harness. It is not a production +capacity, concurrency, or tenant-density test. diff --git a/packages/perf-harness/__tests__/fixture.test.ts b/packages/perf-harness/__tests__/fixture.test.ts new file mode 100644 index 0000000000..46366df012 --- /dev/null +++ b/packages/perf-harness/__tests__/fixture.test.ts @@ -0,0 +1,20 @@ +import { + validateFixtureSchema, + validateFixtureTableCount, +} from '../src/fixture'; + +describe('fixture safety', () => { + test('only accepts a narrowly scoped benchmark schema name', () => { + expect(validateFixtureSchema('cperf_example_1')).toBe('cperf_example_1'); + expect(() => validateFixtureSchema('public')).toThrow('must start with cperf_'); + expect(() => validateFixtureSchema('cperf_example; drop schema public')).toThrow( + 'must start with cperf_' + ); + }); + + test('bounds generated fixture size', () => { + expect(validateFixtureTableCount(64)).toBe(64); + expect(() => validateFixtureTableCount(0)).toThrow('between 1 and 500'); + expect(() => validateFixtureTableCount(501)).toThrow('between 1 and 500'); + }); +}); diff --git a/packages/perf-harness/__tests__/fixtures/fake-worker.js b/packages/perf-harness/__tests__/fixtures/fake-worker.js new file mode 100644 index 0000000000..68dfea90c7 --- /dev/null +++ b/packages/perf-harness/__tests__/fixtures/fake-worker.js @@ -0,0 +1,42 @@ +'use strict'; + +const config = JSON.parse( + Buffer.from(process.env.CPERF_WORKER_CONFIG, 'base64url').toString('utf8') +); +const scoped = config.arm === 'scoped' || config.arm === 'scoped-retire'; +const retire = config.arm === 'retire' || config.arm === 'scoped-retire'; +const value = { stock: 40, scoped: 30, retire: 20, 'scoped-retire': 10 }[ + config.arm +]; +const memory = { + rss: value, + heapTotal: value, + heapUsed: value, + external: value, + arrayBuffers: value, +}; +const result = { + status: 'ok', + pid: process.pid, + arm: config.arm, + definition: { + name: config.arm, + scopedIntrospection: scoped, + retireBuildState: retire, + introspectionMode: scoped ? 'scoped-required' : 'stock', + scopedCatalogTypes: scoped ? 'dependency-closure' : null, + introspectionClientReleaseMode: scoped ? 'destroy' : 'reuse', + }, + buildMs: value, + schemaHash: 'fixture-schema-hash', + schemaTypeCount: 10, + queryVerified: true, + buildStateReleased: retire, + memory: { + baseline: memory, + afterBuild: memory, + delta: memory, + processPeakRss: value, + }, +}; +process.stdout.write(`CPERF_RESULT ${JSON.stringify(result)}\n`); diff --git a/packages/perf-harness/__tests__/matrix.test.ts b/packages/perf-harness/__tests__/matrix.test.ts new file mode 100644 index 0000000000..72d961b17d --- /dev/null +++ b/packages/perf-harness/__tests__/matrix.test.ts @@ -0,0 +1,105 @@ +import { compareArms, makeSchedule, summarizeArm } from '../src/matrix'; +import { ARM_NAMES, type MatrixRun, type SuccessfulWorkerResult } from '../src/types'; + +const successfulResult = ( + arm: (typeof ARM_NAMES)[number], + value: number +): SuccessfulWorkerResult => ({ + status: 'ok', + pid: value, + arm, + definition: { + name: arm, + scopedIntrospection: arm === 'scoped' || arm === 'scoped-retire', + retireBuildState: arm === 'retire' || arm === 'scoped-retire', + introspectionMode: + arm === 'scoped' || arm === 'scoped-retire' + ? 'scoped-required' + : 'stock', + scopedCatalogTypes: + arm === 'scoped' || arm === 'scoped-retire' + ? 'dependency-closure' + : null, + introspectionClientReleaseMode: + arm === 'scoped' || arm === 'scoped-retire' ? 'destroy' : 'reuse', + }, + buildMs: value, + schemaHash: 'same', + schemaTypeCount: 10, + queryVerified: true, + buildStateReleased: arm === 'retire' || arm === 'scoped-retire', + memory: { + baseline: { + rss: 10, + heapTotal: 10, + heapUsed: 10, + external: 10, + arrayBuffers: 10, + }, + afterBuild: { + rss: value, + heapTotal: value, + heapUsed: value, + external: value, + arrayBuffers: value, + }, + delta: { + rss: value - 10, + heapTotal: value - 10, + heapUsed: value - 10, + external: value - 10, + arrayBuffers: value - 10, + }, + processPeakRss: value, + }, +}); + +describe('benchmark matrix', () => { + test('seeded schedules are deterministic and cover every arm per repetition', () => { + const first = makeSchedule(5, 1234); + expect(makeSchedule(5, 1234)).toEqual(first); + expect(makeSchedule(5, 4321)).not.toEqual(first); + for (let repetition = 1; repetition <= 5; repetition += 1) { + expect( + first + .filter((coordinate) => coordinate.repetition === repetition) + .map((coordinate) => coordinate.arm) + .sort() + ).toEqual([...ARM_NAMES].sort()); + } + }); + + test('exact order is repeated without changing its positions', () => { + const order = ['retire', 'stock', 'scoped-retire', 'scoped'] as const; + expect(makeSchedule(2, 1, order).map((coordinate) => coordinate.arm)).toEqual( + [...order, ...order] + ); + }); + + test('summaries use medians and comparisons keep the factor direction', () => { + const runs: MatrixRun[] = [10, 30, 20].map((value, index) => ({ + repetition: index + 1, + position: 1, + arm: 'stock', + result: successfulResult('stock', value), + })); + runs.push( + ...[5, 15, 10].map((value, index) => ({ + repetition: index + 1, + position: 2, + arm: 'scoped' as const, + result: successfulResult('scoped', value), + })) + ); + const stock = summarizeArm(runs, 'stock'); + const scoped = summarizeArm(runs, 'scoped'); + expect(stock?.buildMs.median).toBe(20); + expect(scoped?.buildMs.median).toBe(10); + expect(compareArms('stock', 'scoped', stock!, scoped!).buildMs).toEqual({ + baseline: 20, + candidate: 10, + difference: -10, + percentChange: -50, + }); + }); +}); diff --git a/packages/perf-harness/__tests__/process.test.ts b/packages/perf-harness/__tests__/process.test.ts new file mode 100644 index 0000000000..0b36ec8c28 --- /dev/null +++ b/packages/perf-harness/__tests__/process.test.ts @@ -0,0 +1,29 @@ +import { resolve } from 'node:path'; + +import { runWorkerProcess } from '../src/process'; +import type { WorkerConfig } from '../src/types'; + +describe('fresh worker process', () => { + test('starts a distinct process for each measurement and forwards no database URL in output', async () => { + const worker = resolve(__dirname, 'fixtures/fake-worker.js'); + const config: WorkerConfig = { + arm: 'stock' as const, + schemas: ['example'], + allowedDependencySchemas: [], + }; + const first = await runWorkerProcess( + worker, + 'postgres://secret@example.test/database', + config + ); + const second = await runWorkerProcess( + worker, + 'postgres://secret@example.test/database', + config + ); + expect(first.pid).not.toBe(process.pid); + expect(second.pid).not.toBe(process.pid); + expect(first.pid).not.toBe(second.pid); + expect(JSON.stringify([first.result, second.result])).not.toContain('secret'); + }); +}); diff --git a/packages/perf-harness/__tests__/run.test.ts b/packages/perf-harness/__tests__/run.test.ts new file mode 100644 index 0000000000..f82bb8a1d8 --- /dev/null +++ b/packages/perf-harness/__tests__/run.test.ts @@ -0,0 +1,64 @@ +import { resolve } from 'node:path'; + +import { parseRunOptions, runMatrix } from '../src/run'; + +describe('matrix runner', () => { + test('parses the exact four-arm order', () => { + const options = parseRunOptions([ + '--database-url', + 'postgres:///test', + '--schemas', + 'app,private', + '--allowed-dependency-schemas', + 'shared', + '--order', + 'stock,scoped,retire,scoped-retire', + '--repetitions', + '2', + ]); + expect(options.schemas).toEqual(['app', 'private']); + expect(options.allowedDependencySchemas).toEqual(['shared']); + expect(options.order).toEqual([ + 'stock', + 'scoped', + 'retire', + 'scoped-retire', + ]); + }); + + test('requires all four fresh workers to agree on schema identity', async () => { + const options = parseRunOptions([ + '--database-url', + 'postgres:///not-used-by-fake-worker', + '--schemas', + 'example', + '--repetitions', + '1', + '--order', + 'stock,scoped,retire,scoped-retire', + ]); + const report = await runMatrix( + options, + resolve(__dirname, 'fixtures/fake-worker.js') + ); + expect(report.validation).toEqual( + expect.objectContaining({ + allRunsSucceeded: true, + freshProcessPerRun: true, + schemaEquivalent: true, + schemaHash: 'fixture-schema-hash', + errors: [], + }) + ); + expect(new Set(report.runs.map((run) => run.result.pid)).size).toBe(4); + expect(report.comparisons).toEqual( + expect.objectContaining({ + scopedVsStock: expect.any(Object), + retireVsStock: expect.any(Object), + combinedVsStock: expect.any(Object), + retireWithinScoped: expect.any(Object), + scopedWithinRetire: expect.any(Object), + }) + ); + }); +}); diff --git a/packages/perf-harness/jest.config.js b/packages/perf-harness/jest.config.js new file mode 100644 index 0000000000..735a4e2af3 --- /dev/null +++ b/packages/perf-harness/jest.config.js @@ -0,0 +1,12 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + transform: { + '^.+\\.tsx?$': ['ts-jest', { tsconfig: 'tsconfig.json' }] + }, + testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$', + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + modulePathIgnorePatterns: ['dist/*'], + testPathIgnorePatterns: ['/__tests__/fixtures/'] +}; diff --git a/packages/perf-harness/package.json b/packages/perf-harness/package.json new file mode 100644 index 0000000000..b12ecc37c1 --- /dev/null +++ b/packages/perf-harness/package.json @@ -0,0 +1,37 @@ +{ + "name": "@constructive-io/perf-harness", + "version": "0.1.0", + "private": true, + "description": "Focused fresh-process benchmark for Graphile catalog scoping and build-state retirement", + "main": "index.js", + "module": "esm/index.js", + "types": "index.d.ts", + "bin": { + "cperf": "index.js" + }, + "scripts": { + "clean": "makage clean", + "build": "makage build", + "build:dev": "makage build --dev", + "lint": "eslint . --fix", + "test": "jest" + }, + "dependencies": { + "graphile-build": "5.1.1", + "graphile-build-pg": "5.1.3", + "graphile-config": "1.1.0", + "graphile-settings": "workspace:*", + "graphql": "16.13.0", + "pg": "^8.21.0", + "postgraphile": "5.1.4" + }, + "devDependencies": { + "@types/node": "^22.19.11", + "@types/pg": "^8.20.4", + "makage": "^0.3.0" + }, + "engines": { + "node": ">=22" + }, + "license": "MIT" +} diff --git a/packages/perf-harness/src/fixture.ts b/packages/perf-harness/src/fixture.ts new file mode 100644 index 0000000000..b7f35935b6 --- /dev/null +++ b/packages/perf-harness/src/fixture.ts @@ -0,0 +1,127 @@ +import { Pool } from 'pg'; + +export const FIXTURE_VERSION = 1; + +export interface PrepareFixtureOptions { + databaseUrl: string; + schema: string; + tables: number; +} + +export interface PreparedFixture { + fixtureVersion: number; + database: string; + serverVersion: string; + schema: string; + tableCount: number; + functionCount: number; +} + +export const validateFixtureSchema = (schema: string): string => { + if ( + !/^cperf_[a-z0-9_]*$/.test(schema) || + schema.length > 63 || + schema.includes('\0') + ) { + throw new Error( + 'fixture schema must start with cperf_, use only lowercase letters, digits, and underscores, and fit PostgreSQL identifiers' + ); + } + return schema; +}; + +export const validateFixtureTableCount = (tables: number): number => { + if (!Number.isSafeInteger(tables) || tables < 1 || tables > 500) { + throw new Error('fixture table count must be an integer between 1 and 500'); + } + return tables; +}; + +const quoteIdentifier = (identifier: string): string => + `"${identifier.replaceAll('"', '""')}"`; + +export const prepareFixture = async ( + options: PrepareFixtureOptions +): Promise => { + const schema = validateFixtureSchema(options.schema); + const tables = validateFixtureTableCount(options.tables); + const quotedSchema = quoteIdentifier(schema); + const pool = new Pool({ connectionString: options.databaseUrl, max: 1 }); + const client = await pool.connect(); + try { + await client.query('begin'); + const existing = await client.query<{ exists: boolean }>( + 'select exists(select 1 from pg_catalog.pg_namespace where nspname = $1) as exists', + [schema] + ); + if (existing.rows[0]?.exists) { + throw new Error( + `fixture schema '${schema}' already exists; this command never replaces schemas` + ); + } + await client.query(`create schema ${quotedSchema}`); + await client.query( + `comment on schema ${quotedSchema} is 'cperf fixture version ${FIXTURE_VERSION}'` + ); + await client.query( + `create type ${quotedSchema}."entity_status" as enum ('draft', 'active', 'archived')` + ); + await client.query(` + create table ${quotedSchema}."account" ( + id bigint generated always as identity primary key, + external_id uuid not null unique, + name text not null, + metadata jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now() + ) + `); + for (let index = 1; index <= tables; index += 1) { + const table = quoteIdentifier(`entity_${index}`); + const functionName = quoteIdentifier(`entity_${index}_by_account`); + const indexName = quoteIdentifier(`entity_${index}_account_created_idx`); + await client.query(` + create table ${quotedSchema}.${table} ( + id bigint generated always as identity primary key, + account_id bigint not null references ${quotedSchema}."account"(id), + status ${quotedSchema}."entity_status" not null default 'draft', + title text not null, + tags text[] not null default array[]::text[], + metadata jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + unique (account_id, title) + ); + create index ${indexName} + on ${quotedSchema}.${table} (account_id, created_at desc); + create function ${quotedSchema}.${functionName}(requested_account_id bigint) + returns setof ${quotedSchema}.${table} + language sql stable + as 'select * from ${quotedSchema}.${table} where account_id = requested_account_id'; + `); + } + const identity = await client.query<{ + database: string; + server_version: string; + }>( + "select current_database() as database, current_setting('server_version') as server_version" + ); + await client.query('commit'); + return { + fixtureVersion: FIXTURE_VERSION, + database: identity.rows[0].database, + serverVersion: identity.rows[0].server_version, + schema, + tableCount: tables + 1, + functionCount: tables, + }; + } catch (error) { + try { + await client.query('rollback'); + } catch { + // Preserve the fixture preparation error; the client is discarded below. + } + throw error; + } finally { + client.release(); + await pool.end(); + } +}; diff --git a/packages/perf-harness/src/index.ts b/packages/perf-harness/src/index.ts new file mode 100644 index 0000000000..04123be130 --- /dev/null +++ b/packages/perf-harness/src/index.ts @@ -0,0 +1,22 @@ +#!/usr/bin/env node + +export * from './fixture'; +export * from './matrix'; +export * from './process'; +export * from './run'; +export * from './types'; + +import { cliMain } from './run'; + +if ( + typeof require !== 'undefined' && + typeof module !== 'undefined' && + require.main === module +) { + void cliMain().catch((error: unknown) => { + process.stderr.write( + `${error instanceof Error ? error.message : String(error)}\n` + ); + process.exitCode = 1; + }); +} diff --git a/packages/perf-harness/src/matrix.ts b/packages/perf-harness/src/matrix.ts new file mode 100644 index 0000000000..4be92dc7f6 --- /dev/null +++ b/packages/perf-harness/src/matrix.ts @@ -0,0 +1,143 @@ +import { + ARM_DEFINITIONS, + ARM_NAMES, + type ArmComparison, + type ArmName, + type ArmSummary, + type MatrixCoordinate, + type MatrixRun, + type MetricComparison, + type MetricSummary, + type SuccessfulWorkerResult, +} from './types'; + +const seededRandom = (seed: number): (() => number) => { + let state = seed >>> 0; + return () => { + state += 0x6d2b79f5; + let value = state; + value = Math.imul(value ^ (value >>> 15), value | 1); + value ^= value + Math.imul(value ^ (value >>> 7), value | 61); + return ((value ^ (value >>> 14)) >>> 0) / 4294967296; + }; +}; + +const shuffledArms = (seed: number, repetition: number): ArmName[] => { + const result = [...ARM_NAMES]; + const random = seededRandom((seed ^ Math.imul(repetition, 0x9e3779b1)) >>> 0); + for (let index = result.length - 1; index > 0; index -= 1) { + const swapIndex = Math.floor(random() * (index + 1)); + [result[index], result[swapIndex]] = [result[swapIndex], result[index]]; + } + return result; +}; + +export const makeSchedule = ( + repetitions: number, + seed: number, + exactOrder: readonly ArmName[] | null = null +): MatrixCoordinate[] => { + if (!Number.isSafeInteger(repetitions) || repetitions < 1) { + throw new Error('repetitions must be a positive safe integer'); + } + const schedule: MatrixCoordinate[] = []; + for (let repetition = 1; repetition <= repetitions; repetition += 1) { + const order = exactOrder ? [...exactOrder] : shuffledArms(seed, repetition); + if ( + order.length !== ARM_NAMES.length || + new Set(order).size !== ARM_NAMES.length || + order.some((arm) => !(arm in ARM_DEFINITIONS)) + ) { + throw new Error('exact order must contain each benchmark arm exactly once'); + } + order.forEach((arm, index) => { + schedule.push({ repetition, position: index + 1, arm }); + }); + } + return schedule; +}; + +const median = (values: readonly number[]): number => { + if (values.length === 0) throw new Error('cannot summarize zero values'); + const sorted = [...values].sort((left, right) => left - right); + const middle = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 + ? (sorted[middle - 1] + sorted[middle]) / 2 + : sorted[middle]; +}; + +const metricSummary = (values: number[]): MetricSummary => ({ + median: median(values), + min: Math.min(...values), + max: Math.max(...values), + samples: values, +}); + +const successfulResultsFor = ( + runs: readonly MatrixRun[], + arm: ArmName +): SuccessfulWorkerResult[] => + runs + .filter((run) => run.arm === arm && run.result.status === 'ok') + .map((run) => run.result as SuccessfulWorkerResult); + +export const summarizeArm = ( + runs: readonly MatrixRun[], + arm: ArmName +): ArmSummary | undefined => { + const results = successfulResultsFor(runs, arm); + if (results.length === 0) return undefined; + return { + sampleCount: results.length, + buildMs: metricSummary(results.map((result) => result.buildMs)), + heapUsedAfterBuild: metricSummary( + results.map((result) => result.memory.afterBuild.heapUsed) + ), + heapUsedDelta: metricSummary( + results.map((result) => result.memory.delta.heapUsed) + ), + rssAfterBuild: metricSummary( + results.map((result) => result.memory.afterBuild.rss) + ), + rssDelta: metricSummary(results.map((result) => result.memory.delta.rss)), + processPeakRss: metricSummary( + results.map((result) => result.memory.processPeakRss) + ), + }; +}; + +const compareMetric = ( + baseline: MetricSummary, + candidate: MetricSummary +): MetricComparison => ({ + baseline: baseline.median, + candidate: candidate.median, + difference: candidate.median - baseline.median, + percentChange: + baseline.median === 0 + ? null + : ((candidate.median - baseline.median) / Math.abs(baseline.median)) * + 100, +}); + +export const compareArms = ( + baselineArm: ArmName, + candidateArm: ArmName, + baseline: ArmSummary, + candidate: ArmSummary +): ArmComparison => ({ + baselineArm, + candidateArm, + buildMs: compareMetric(baseline.buildMs, candidate.buildMs), + heapUsedAfterBuild: compareMetric( + baseline.heapUsedAfterBuild, + candidate.heapUsedAfterBuild + ), + heapUsedDelta: compareMetric(baseline.heapUsedDelta, candidate.heapUsedDelta), + rssAfterBuild: compareMetric(baseline.rssAfterBuild, candidate.rssAfterBuild), + rssDelta: compareMetric(baseline.rssDelta, candidate.rssDelta), + processPeakRss: compareMetric( + baseline.processPeakRss, + candidate.processPeakRss + ), +}); diff --git a/packages/perf-harness/src/process.ts b/packages/perf-harness/src/process.ts new file mode 100644 index 0000000000..36bc4e77e4 --- /dev/null +++ b/packages/perf-harness/src/process.ts @@ -0,0 +1,92 @@ +import { spawn } from 'node:child_process'; + +import type { WorkerConfig, WorkerResult } from './types'; + +export const WORKER_RESULT_PREFIX = 'CPERF_RESULT '; +export const DATABASE_URL_ENV = 'CPERF_DATABASE_URL'; +export const WORKER_CONFIG_ENV = 'CPERF_WORKER_CONFIG'; + +export interface SpawnedWorkerResult { + pid: number; + result: WorkerResult; +} + +const lastLines = (value: string, count = 20): string => + value.trim().split('\n').slice(-count).join('\n'); + +export const runWorkerProcess = ( + workerPath: string, + databaseUrl: string, + config: WorkerConfig +): Promise => + new Promise((resolve, reject) => { + const child = spawn(process.execPath, ['--expose-gc', workerPath], { + env: { + ...process.env, + NODE_ENV: 'production', + GRAPHILE_ENV: 'production', + [DATABASE_URL_ENV]: databaseUrl, + [WORKER_CONFIG_ENV]: Buffer.from(JSON.stringify(config)).toString( + 'base64url' + ), + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + const pid = child.pid; + let stdout = ''; + let stderr = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk: string) => { + stderr += chunk; + }); + child.once('error', reject); + child.once('close', (code, signal) => { + const resultLine = stdout + .split('\n') + .reverse() + .find((line: string) => line.startsWith(WORKER_RESULT_PREFIX)); + if (!resultLine) { + reject( + new Error( + `benchmark worker ${pid ?? 'unknown'} exited without a result ` + + `(code=${String(code)}, signal=${String(signal)})` + + (stderr.trim() ? `\n${lastLines(stderr)}` : '') + ) + ); + return; + } + try { + const result = JSON.parse( + resultLine.slice(WORKER_RESULT_PREFIX.length) + ) as WorkerResult; + if (typeof pid !== 'number' || result.pid !== pid) { + throw new Error( + `worker PID mismatch: spawned ${String(pid)}, reported ${String( + result.pid + )}` + ); + } + if (result.arm !== config.arm) { + throw new Error( + `worker arm mismatch: expected ${config.arm}, reported ${result.arm}` + ); + } + if (result.status === 'ok' && code !== 0) { + throw new Error(`successful worker exited with code ${String(code)}`); + } + resolve({ pid, result }); + } catch (error) { + reject( + new Error( + `invalid result from benchmark worker ${String(pid)}: ${String( + error instanceof Error ? error.message : error + )}` + ) + ); + } + }); + }); diff --git a/packages/perf-harness/src/run.ts b/packages/perf-harness/src/run.ts new file mode 100644 index 0000000000..7dc61806ed --- /dev/null +++ b/packages/perf-harness/src/run.ts @@ -0,0 +1,365 @@ +import { mkdir, rename, writeFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; + +import { prepareFixture } from './fixture'; +import { compareArms, makeSchedule, summarizeArm } from './matrix'; +import { DATABASE_URL_ENV, runWorkerProcess } from './process'; +import { + ARM_DEFINITIONS, + ARM_NAMES, + type ArmName, + type MatrixReport, + type MatrixRun, +} from './types'; + +interface ParsedArgs { + values: Map; +} + +interface RunOptions { + databaseUrl: string; + schemas: string[]; + allowedDependencySchemas: string[]; + repetitions: number; + seed: number; + order: ArmName[] | null; + output: string; +} + +const parseArgs = (args: readonly string[]): ParsedArgs => { + const values = new Map(); + for (let index = 0; index < args.length; index += 2) { + const flag = args[index]; + const value = args[index + 1]; + if (!flag?.startsWith('--') || value === undefined || value.startsWith('--')) { + throw new Error(`expected --name value near '${flag ?? ''}'`); + } + const name = flag.slice(2); + if (values.has(name)) throw new Error(`--${name} may only be specified once`); + values.set(name, value); + } + return { values }; +}; + +const exactStringList = ( + value: string | undefined, + name: string, + allowEmpty = false +): string[] => { + if (value === undefined) { + if (allowEmpty) return []; + throw new Error(`--${name} is required`); + } + const result = value.split(','); + if ( + result.some( + (item) => + item.length === 0 || item.trim() !== item || item.includes('\0') + ) || + new Set(result).size !== result.length + ) { + throw new Error( + `--${name} must be a comma-separated list of unique exact non-empty values` + ); + } + return result; +}; + +const positiveInteger = ( + value: string | undefined, + name: string, + defaultValue: number, + maximum: number +): number => { + if (value === undefined) return defaultValue; + if (!/^\d+$/.test(value)) throw new Error(`--${name} must be an integer`); + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > maximum) { + throw new Error(`--${name} must be between 1 and ${maximum}`); + } + return parsed; +}; + +const seedValue = (value: string | undefined): number => { + if (value === undefined) return 20260813; + if (!/^\d+$/.test(value)) throw new Error('--seed must be an integer'); + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 0 || parsed > 0xffffffff) { + throw new Error('--seed must be between 0 and 4294967295'); + } + return parsed; +}; + +const databaseUrl = (args: ParsedArgs): string => { + const value = args.values.get('database-url') ?? process.env[DATABASE_URL_ENV]; + if (!value) { + throw new Error( + `--database-url or the ${DATABASE_URL_ENV} environment variable is required` + ); + } + return value; +}; + +const parseOrder = (value: string | undefined): ArmName[] | null => { + if (value === undefined) return null; + const order = exactStringList(value, 'order'); + if ( + order.length !== ARM_NAMES.length || + order.some((arm) => !(arm in ARM_DEFINITIONS)) || + new Set(order).size !== ARM_NAMES.length + ) { + throw new Error('--order must contain each benchmark arm exactly once'); + } + return order as ArmName[]; +}; + +const rejectUnknown = ( + args: ParsedArgs, + allowed: readonly string[] +): void => { + for (const name of args.values.keys()) { + if (!allowed.includes(name)) throw new Error(`unknown option --${name}`); + } +}; + +export const parseRunOptions = (rawArgs: readonly string[]): RunOptions => { + const args = parseArgs(rawArgs); + rejectUnknown(args, [ + 'database-url', + 'schemas', + 'allowed-dependency-schemas', + 'repetitions', + 'seed', + 'order', + 'output', + ]); + return { + databaseUrl: databaseUrl(args), + schemas: exactStringList(args.values.get('schemas'), 'schemas'), + allowedDependencySchemas: exactStringList( + args.values.get('allowed-dependency-schemas'), + 'allowed-dependency-schemas', + true + ), + repetitions: positiveInteger( + args.values.get('repetitions'), + 'repetitions', + 3, + 50 + ), + seed: seedValue(args.values.get('seed')), + order: parseOrder(args.values.get('order')), + output: + args.values.get('output') ?? 'scoped-retirement-performance.json', + }; +}; + +const writeJsonAtomically = async ( + output: string, + value: unknown +): Promise => { + const absoluteOutput = resolve(output); + await mkdir(dirname(absoluteOutput), { recursive: true }); + const temporary = `${absoluteOutput}.tmp-${process.pid}`; + await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { + encoding: 'utf8', + mode: 0o600, + }); + await rename(temporary, absoluteOutput); + return absoluteOutput; +}; + +const redactDatabaseUrl = (message: string, databaseUrl: string): string => + message.replaceAll(databaseUrl, ''); + +export const runMatrix = async ( + options: RunOptions, + workerPath = resolve(__dirname, 'worker.js') +): Promise => { + const schedule = makeSchedule( + options.repetitions, + options.seed, + options.order + ); + const runs: MatrixRun[] = []; + for (const coordinate of schedule) { + process.stderr.write( + `[${runs.length + 1}/${schedule.length}] repetition ${ + coordinate.repetition + }, ${coordinate.arm}\n` + ); + try { + const spawned = await runWorkerProcess(workerPath, options.databaseUrl, { + arm: coordinate.arm, + schemas: options.schemas, + allowedDependencySchemas: options.allowedDependencySchemas, + }); + runs.push({ ...coordinate, result: spawned.result }); + } catch (error) { + runs.push({ + ...coordinate, + result: { + status: 'error', + pid: -1, + arm: coordinate.arm, + error: redactDatabaseUrl( + error instanceof Error ? error.message : String(error), + options.databaseUrl + ), + }, + }); + } + } + + const successfulRuns = runs.filter((run) => run.result.status === 'ok'); + const allRunsSucceeded = successfulRuns.length === schedule.length; + const pids = successfulRuns.map((run) => run.result.pid); + const freshProcessPerRun = + allRunsSucceeded && + pids.every((pid) => pid > 0 && pid !== process.pid) && + new Set(pids).size === pids.length; + const schemaHashes = new Set( + successfulRuns.map((run) => + run.result.status === 'ok' ? run.result.schemaHash : '' + ) + ); + const schemaEquivalent = allRunsSucceeded && schemaHashes.size === 1; + const errors = runs.flatMap((run) => + run.result.status === 'error' + ? [`${run.arm} repetition ${run.repetition}: ${run.result.error}`] + : [] + ); + if (!freshProcessPerRun) { + errors.push('fresh-process validation did not pass for every run'); + } + if (!schemaEquivalent) { + errors.push('schema hashes were not equivalent across all four arms and repetitions'); + } + + const summaries: MatrixReport['summaries'] = {}; + for (const arm of ARM_NAMES) { + const summary = summarizeArm(runs, arm); + if (summary) summaries[arm] = summary; + } + const stock = summaries.stock; + const scoped = summaries.scoped; + const retire = summaries.retire; + const combined = summaries['scoped-retire']; + + return { + format: 'constructive-scoped-retirement-matrix/v1', + generatedAt: new Date().toISOString(), + node: process.version, + platform: process.platform, + architecture: process.arch, + config: { + schemas: options.schemas, + allowedDependencySchemas: options.allowedDependencySchemas, + repetitions: options.repetitions, + seed: options.seed, + order: options.order, + }, + arms: ARM_NAMES.map((arm) => ARM_DEFINITIONS[arm]), + schedule, + runs, + validation: { + allRunsSucceeded, + freshProcessPerRun, + schemaEquivalent, + schemaHash: schemaEquivalent ? [...schemaHashes][0] : null, + errors, + }, + summaries, + comparisons: { + ...(stock && scoped + ? { scopedVsStock: compareArms('stock', 'scoped', stock, scoped) } + : {}), + ...(stock && retire + ? { retireVsStock: compareArms('stock', 'retire', stock, retire) } + : {}), + ...(stock && combined + ? { + combinedVsStock: compareArms( + 'stock', + 'scoped-retire', + stock, + combined + ), + } + : {}), + ...(scoped && combined + ? { + retireWithinScoped: compareArms( + 'scoped', + 'scoped-retire', + scoped, + combined + ), + } + : {}), + ...(retire && combined + ? { + scopedWithinRetire: compareArms( + 'retire', + 'scoped-retire', + retire, + combined + ), + } + : {}), + }, + }; +}; + +const runCommand = async (rawArgs: readonly string[]): Promise => { + const options = parseRunOptions(rawArgs); + const report = await runMatrix(options); + const output = await writeJsonAtomically(options.output, report); + process.stdout.write( + `${JSON.stringify({ + output, + validation: report.validation, + comparisons: report.comparisons, + })}\n` + ); + if ( + !report.validation.allRunsSucceeded || + !report.validation.freshProcessPerRun || + !report.validation.schemaEquivalent + ) { + process.exitCode = 1; + } +}; + +const prepareCommand = async (rawArgs: readonly string[]): Promise => { + const args = parseArgs(rawArgs); + rejectUnknown(args, ['database-url', 'schema', 'tables']); + const schema = args.values.get('schema'); + if (!schema) throw new Error('--schema is required'); + const result = await prepareFixture({ + databaseUrl: databaseUrl(args), + schema, + tables: positiveInteger(args.values.get('tables'), 'tables', 64, 500), + }); + process.stdout.write(`${JSON.stringify(result)}\n`); +}; + +const usage = `Usage: + cperf prepare --schema cperf_NAME [--tables 64] [--database-url URL] + cperf run --schemas NAME[,NAME] [--allowed-dependency-schemas NAME[,NAME]] + [--repetitions 3] [--seed 20260813] + [--order stock,scoped,retire,scoped-retire] [--output FILE] + +Database URL may also be supplied in ${DATABASE_URL_ENV}. +`; + +export const cliMain = async (args = process.argv.slice(2)): Promise => { + const [command, ...rest] = args; + if (command === 'run') { + await runCommand(rest); + } else if (command === 'prepare') { + await prepareCommand(rest); + } else { + throw new Error(usage.trimEnd()); + } +}; diff --git a/packages/perf-harness/src/types.ts b/packages/perf-harness/src/types.ts new file mode 100644 index 0000000000..2e5b994272 --- /dev/null +++ b/packages/perf-harness/src/types.ts @@ -0,0 +1,171 @@ +export const ARM_NAMES = [ + 'stock', + 'scoped', + 'retire', + 'scoped-retire', +] as const; + +export type ArmName = (typeof ARM_NAMES)[number]; + +export interface ArmDefinition { + name: ArmName; + scopedIntrospection: boolean; + retireBuildState: boolean; + introspectionMode: 'stock' | 'scoped-required'; + scopedCatalogTypes: 'dependency-closure' | null; + introspectionClientReleaseMode: 'reuse' | 'destroy'; +} + +export const ARM_DEFINITIONS: Record = { + stock: { + name: 'stock', + scopedIntrospection: false, + retireBuildState: false, + introspectionMode: 'stock', + scopedCatalogTypes: null, + introspectionClientReleaseMode: 'reuse', + }, + scoped: { + name: 'scoped', + scopedIntrospection: true, + retireBuildState: false, + introspectionMode: 'scoped-required', + scopedCatalogTypes: 'dependency-closure', + introspectionClientReleaseMode: 'destroy', + }, + retire: { + name: 'retire', + scopedIntrospection: false, + retireBuildState: true, + introspectionMode: 'stock', + scopedCatalogTypes: null, + introspectionClientReleaseMode: 'reuse', + }, + 'scoped-retire': { + name: 'scoped-retire', + scopedIntrospection: true, + retireBuildState: true, + introspectionMode: 'scoped-required', + scopedCatalogTypes: 'dependency-closure', + introspectionClientReleaseMode: 'destroy', + }, +}; + +export interface MatrixCoordinate { + repetition: number; + position: number; + arm: ArmName; +} + +export interface WorkerConfig { + arm: ArmName; + schemas: string[]; + allowedDependencySchemas: string[]; +} + +export interface MemorySnapshot { + rss: number; + heapTotal: number; + heapUsed: number; + external: number; + arrayBuffers: number; +} + +export interface SuccessfulWorkerResult { + status: 'ok'; + pid: number; + arm: ArmName; + definition: ArmDefinition; + buildMs: number; + schemaHash: string; + schemaTypeCount: number; + queryVerified: true; + buildStateReleased: boolean; + memory: { + baseline: MemorySnapshot; + afterBuild: MemorySnapshot; + delta: MemorySnapshot; + processPeakRss: number; + }; +} + +export interface FailedWorkerResult { + status: 'error'; + pid: number; + arm: ArmName; + error: string; +} + +export type WorkerResult = SuccessfulWorkerResult | FailedWorkerResult; + +export interface MatrixRun extends MatrixCoordinate { + result: WorkerResult; +} + +export interface MetricSummary { + median: number; + min: number; + max: number; + samples: number[]; +} + +export interface ArmSummary { + sampleCount: number; + buildMs: MetricSummary; + heapUsedAfterBuild: MetricSummary; + heapUsedDelta: MetricSummary; + rssAfterBuild: MetricSummary; + rssDelta: MetricSummary; + processPeakRss: MetricSummary; +} + +export interface MetricComparison { + baseline: number; + candidate: number; + difference: number; + percentChange: number | null; +} + +export interface ArmComparison { + baselineArm: ArmName; + candidateArm: ArmName; + buildMs: MetricComparison; + heapUsedAfterBuild: MetricComparison; + heapUsedDelta: MetricComparison; + rssAfterBuild: MetricComparison; + rssDelta: MetricComparison; + processPeakRss: MetricComparison; +} + +export interface MatrixReport { + format: 'constructive-scoped-retirement-matrix/v1'; + generatedAt: string; + node: string; + platform: string; + architecture: string; + config: { + schemas: string[]; + allowedDependencySchemas: string[]; + repetitions: number; + seed: number; + order: ArmName[] | null; + }; + arms: ArmDefinition[]; + schedule: MatrixCoordinate[]; + runs: MatrixRun[]; + validation: { + allRunsSucceeded: boolean; + freshProcessPerRun: boolean; + schemaEquivalent: boolean; + schemaHash: string | null; + errors: string[]; + }; + summaries: Partial>; + comparisons: { + scopedVsStock?: ArmComparison; + retireVsStock?: ArmComparison; + combinedVsStock?: ArmComparison; + retireWithinScoped?: ArmComparison; + scopedWithinRetire?: ArmComparison; + }; +} diff --git a/packages/perf-harness/src/worker.ts b/packages/perf-harness/src/worker.ts new file mode 100644 index 0000000000..0ac300761d --- /dev/null +++ b/packages/perf-harness/src/worker.ts @@ -0,0 +1,282 @@ +import { createHash } from 'node:crypto'; +import { performance } from 'node:perf_hooks'; + +import { + defaultPreset as graphileBuildPreset, + makeSchema, +} from 'graphile-build'; +import { defaultPreset as graphileBuildPgPreset } from 'graphile-build-pg'; +import type { GraphileConfig } from 'graphile-config'; +import { BuildStateRetirementPlugin } from 'graphile-settings/plugins/build-state-retirement'; +import { execute, lexicographicSortSchema, parse, printSchema } from 'graphql'; +import { makePgService as makePostGraphilePgService } from 'postgraphile/adaptors/pg'; + +import { + DATABASE_URL_ENV, + WORKER_CONFIG_ENV, + WORKER_RESULT_PREFIX, +} from './process'; +import { + ARM_DEFINITIONS, + type ArmName, + type MemorySnapshot, + type SuccessfulWorkerResult, + type WorkerConfig, + type WorkerResult, +} from './types'; + +const gc = (): void => { + if (typeof global.gc !== 'function') { + throw new Error('benchmark worker requires Node --expose-gc'); + } + global.gc(); + global.gc(); + global.gc(); +}; + +const memorySnapshot = (): MemorySnapshot => { + const memory = process.memoryUsage(); + return { + rss: memory.rss, + heapTotal: memory.heapTotal, + heapUsed: memory.heapUsed, + external: memory.external, + arrayBuffers: memory.arrayBuffers, + }; +}; + +const memoryDelta = ( + baseline: MemorySnapshot, + afterBuild: MemorySnapshot +): MemorySnapshot => ({ + rss: afterBuild.rss - baseline.rss, + heapTotal: afterBuild.heapTotal - baseline.heapTotal, + heapUsed: afterBuild.heapUsed - baseline.heapUsed, + external: afterBuild.external - baseline.external, + arrayBuffers: afterBuild.arrayBuffers - baseline.arrayBuffers, +}); + +const stringArray = (value: unknown, name: string): string[] => { + if ( + !Array.isArray(value) || + value.length === 0 || + value.some( + (item) => + typeof item !== 'string' || + item.trim() !== item || + item.length === 0 || + item.includes('\0') + ) + ) { + throw new Error(`${name} must contain exact non-empty strings`); + } + return [...new Set(value)]; +}; + +export const parseWorkerConfig = (encoded: string | undefined): WorkerConfig => { + if (!encoded) throw new Error(`${WORKER_CONFIG_ENV} is required`); + const value = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')) as { + arm?: unknown; + schemas?: unknown; + allowedDependencySchemas?: unknown; + }; + if (typeof value.arm !== 'string' || !(value.arm in ARM_DEFINITIONS)) { + throw new Error('worker arm is invalid'); + } + const schemas = stringArray(value.schemas, 'schemas'); + const allowedDependencySchemas = + value.allowedDependencySchemas === undefined + ? [] + : stringArrayOrEmpty( + value.allowedDependencySchemas, + 'allowedDependencySchemas' + ); + return { + arm: value.arm as ArmName, + schemas, + allowedDependencySchemas, + }; +}; + +const stringArrayOrEmpty = (value: unknown, name: string): string[] => { + if (!Array.isArray(value)) throw new Error(`${name} must be an array`); + if (value.length === 0) return []; + return stringArray(value, name); +}; + +const CaptureBuildPlugin = ( + capture: (build: GraphileBuild.Build) => void +): GraphileConfig.Plugin => ({ + name: 'CperfCaptureBuildPlugin', + schema: { + hooks: { + build(build) { + // The build hook type models the object while it is being assembled; by + // the time this hook returns Graphile completes it into Build. + capture(build as GraphileBuild.Build); + return build; + }, + }, + }, +}); + +export const makeWorkerPreset = ( + config: WorkerConfig, + databaseUrl: string, + capture: (build: GraphileBuild.Build) => void +): { preset: GraphileConfig.Preset; release: () => Promise } => { + const definition = ARM_DEFINITIONS[config.arm]; + const service = Object.assign( + makePostGraphilePgService({ + connectionString: databaseUrl, + schemas: config.schemas, + pubsub: false, + pgSettingsForIntrospection: definition.scopedIntrospection + ? { + statement_timeout: '120s', + jit: 'off', + work_mem: '512kB', + } + : { statement_timeout: '120s' }, + }), + { + introspectionMode: definition.introspectionMode, + introspectionClientReleaseMode: + definition.introspectionClientReleaseMode, + ...(definition.scopedCatalogTypes + ? { introspectionScopedCatalogTypes: definition.scopedCatalogTypes } + : {}), + introspectionAllowedDependencySchemas: config.allowedDependencySchemas, + } + ); + const plugins: GraphileConfig.Plugin[] = [CaptureBuildPlugin(capture)]; + if (definition.retireBuildState) plugins.push(BuildStateRetirementPlugin); + return { + preset: { + extends: [graphileBuildPreset, graphileBuildPgPreset], + plugins, + pgServices: [service], + }, + release: async () => { + await service.release(); + }, + }; +}; + +const buildStateWasReleased = (build: GraphileBuild.Build): boolean => { + try { + void build.input; + return false; + } catch (error) { + if ( + error instanceof Error && + (error as Error & { code?: string }).code === + 'GRAPHILE_BUILD_STATE_RELEASED' + ) { + return true; + } + throw error; + } +}; + +export const runWorker = async ( + config: WorkerConfig, + databaseUrl: string +): Promise => { + const definition = ARM_DEFINITIONS[config.arm]; + let capturedBuild: GraphileBuild.Build | undefined; + const { preset, release } = makeWorkerPreset( + config, + databaseUrl, + (build) => { + capturedBuild = build; + } + ); + try { + gc(); + const baseline = memorySnapshot(); + const startedAt = performance.now(); + const { schema } = await makeSchema(preset); + const buildMs = performance.now() - startedAt; + if (!capturedBuild) throw new Error('capture plugin did not observe a build'); + const buildStateReleased = buildStateWasReleased(capturedBuild); + if (buildStateReleased !== definition.retireBuildState) { + throw new Error( + `build-state lifecycle mismatch: expected released=${String( + definition.retireBuildState + )}, observed released=${String(buildStateReleased)}` + ); + } + const queryResult = await execute({ + schema, + document: parse('{ __typename }'), + }); + if (queryResult.errors?.length || queryResult.data?.__typename !== 'Query') { + throw new Error( + `schema verification query failed: ${queryResult.errors + ?.map((error) => error.message) + .join('; ')}` + ); + } + const schemaText = printSchema(lexicographicSortSchema(schema)); + const schemaHash = createHash('sha256').update(schemaText).digest('hex'); + gc(); + const afterBuild = memorySnapshot(); + return { + status: 'ok', + pid: process.pid, + arm: config.arm, + definition, + buildMs, + schemaHash, + schemaTypeCount: Object.keys(schema.getTypeMap()).length, + queryVerified: true, + buildStateReleased, + memory: { + baseline, + afterBuild, + delta: memoryDelta(baseline, afterBuild), + // Node reports resourceUsage().maxRSS in KiB on supported platforms. + processPeakRss: process.resourceUsage().maxRSS * 1024, + }, + }; + } finally { + await release(); + } +}; + +const safeError = (error: unknown, databaseUrl: string): string => { + const message = error instanceof Error ? error.message : String(error); + return databaseUrl + ? message.replaceAll(databaseUrl, '') + : message; +}; + +export const workerMain = async (): Promise => { + let arm: ArmName = 'stock'; + const databaseUrl = process.env[DATABASE_URL_ENV] ?? ''; + let result: WorkerResult; + try { + if (!databaseUrl) throw new Error(`${DATABASE_URL_ENV} is required`); + const config = parseWorkerConfig(process.env[WORKER_CONFIG_ENV]); + arm = config.arm; + result = await runWorker(config, databaseUrl); + } catch (error) { + result = { + status: 'error', + pid: process.pid, + arm, + error: safeError(error, databaseUrl), + }; + process.exitCode = 1; + } + process.stdout.write(`${WORKER_RESULT_PREFIX}${JSON.stringify(result)}\n`); +}; + +if ( + typeof require !== 'undefined' && + typeof module !== 'undefined' && + require.main === module +) { + void workerMain(); +} diff --git a/packages/perf-harness/tsconfig.esm.json b/packages/perf-harness/tsconfig.esm.json new file mode 100644 index 0000000000..1a3c9914f1 --- /dev/null +++ b/packages/perf-harness/tsconfig.esm.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "dist/esm", + "module": "esnext", + "moduleResolution": "bundler" + } +} diff --git a/packages/perf-harness/tsconfig.json b/packages/perf-harness/tsconfig.json new file mode 100644 index 0000000000..319daa4b0d --- /dev/null +++ b/packages/perf-harness/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "moduleResolution": "nodenext", + "module": "nodenext", + "isolatedModules": true + }, + "include": ["src/**/*"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 788082602f..e17e7fd3e4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2632,6 +2632,40 @@ importers: version: 0.3.0 publishDirectory: dist + packages/perf-harness: + dependencies: + graphile-build: + specifier: 5.1.1 + version: 5.1.1(patch_hash=3988d3e8cb6efb11e654b6404b6b85d14e992d92a9f2316f3c7b8c0beaebed7c)(grafast@1.1.2(graphql@16.13.0))(graphile-config@1.1.0)(graphql@16.13.0) + graphile-build-pg: + specifier: 5.1.3 + version: 5.1.3(patch_hash=9145c8e4ae69ef92f0812d00676cd3242fd16122ee922fb3b82584b23ff7e2e5)(@dataplan/pg@1.1.1(patch_hash=a7c958d2d1d134e846226ca965d40145679ae0d5d7970ab61e7a1638295e38a2)(@dataplan/json@1.0.1(grafast@1.1.2(graphql@16.13.0)))(grafast@1.1.2(graphql@16.13.0))(graphile-config@1.1.0)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.1.2(graphql@16.13.0))(graphile-build@5.1.1(patch_hash=3988d3e8cb6efb11e654b6404b6b85d14e992d92a9f2316f3c7b8c0beaebed7c)(grafast@1.1.2(graphql@16.13.0))(graphile-config@1.1.0)(graphql@16.13.0))(graphile-config@1.1.0)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) + graphile-config: + specifier: 1.1.0 + version: 1.1.0 + graphile-settings: + specifier: workspace:* + version: link:../../graphile/graphile-settings/dist + graphql: + specifier: 16.13.0 + version: 16.13.0 + pg: + specifier: ^8.21.0 + version: 8.21.0 + postgraphile: + specifier: 5.1.4 + version: 5.1.4(30f6c21ce20175065337dc728508839f) + devDependencies: + '@types/node': + specifier: ^22.19.11 + version: 22.19.19 + '@types/pg': + specifier: ^8.20.4 + version: 8.20.4 + makage: + specifier: ^0.3.0 + version: 0.3.0 + packages/postmaster: dependencies: 12factor-env: