From 701a988e7d89d5dafb9d3be966d75f9a73c4f5b2 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 21 Jan 2026 13:02:55 +1100 Subject: [PATCH 01/25] feat(benchmark): add k6 Dockerfile with xk6-pgxpool --- tests/benchmark/k6/Dockerfile | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 tests/benchmark/k6/Dockerfile diff --git a/tests/benchmark/k6/Dockerfile b/tests/benchmark/k6/Dockerfile new file mode 100644 index 00000000..77d98704 --- /dev/null +++ b/tests/benchmark/k6/Dockerfile @@ -0,0 +1,11 @@ +FROM golang:1.22-alpine AS builder +RUN apk add --no-cache git +RUN go install go.k6.io/xk6/cmd/xk6@latest +WORKDIR /build +RUN xk6 build --with github.com/gcfabri/xk6-pgxpool@latest --output /build/k6 + +FROM alpine:3.19 +RUN apk add --no-cache ca-certificates +COPY --from=builder /build/k6 /usr/local/bin/k6 +WORKDIR /scripts +ENTRYPOINT ["k6"] From 195741d0ae4b47b7212fcb315bd76f6d82f57f18 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 21 Jan 2026 13:02:56 +1100 Subject: [PATCH 02/25] feat(benchmark): add k6 config.js with connection helpers --- tests/benchmark/k6/scripts/lib/config.js | 47 ++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 tests/benchmark/k6/scripts/lib/config.js diff --git a/tests/benchmark/k6/scripts/lib/config.js b/tests/benchmark/k6/scripts/lib/config.js new file mode 100644 index 00000000..8993cf18 --- /dev/null +++ b/tests/benchmark/k6/scripts/lib/config.js @@ -0,0 +1,47 @@ +// Connection configuration for k6 benchmarks +// Ports: postgres=5532, proxy=6432 +// +// xk6-pgxpool API: +// import pgxpool from 'k6/x/pgxpool' +// const pool = pgxpool.open(connString, minConns, maxConns) +// pgxpool.query(pool, sql, ...args) +// pgxpool.exec(pool, sql, ...args) + +export const POSTGRES_PORT = 5532; +export const PROXY_PORT = 6432; + +export function getConnectionString(target) { + // Default to 127.0.0.1 (works on Linux CI and macOS with --network=host) + const host = __ENV.K6_DB_HOST || '127.0.0.1'; + const port = target === 'proxy' ? PROXY_PORT : POSTGRES_PORT; + const user = __ENV.K6_DB_USER || 'cipherstash'; + const password = __ENV.K6_DB_PASSWORD || 'p@ssword'; + const database = __ENV.K6_DB_NAME || 'cipherstash'; + // Default sslmode=disable for local/CI; override via K6_DB_SSLMODE if needed + const sslmode = __ENV.K6_DB_SSLMODE || 'disable'; + + return `postgres://${user}:${password}@${host}:${port}/${database}?sslmode=${sslmode}`; +} + +export function getPoolConfig() { + return { + minConns: parseInt(__ENV.K6_POOL_MIN || '2'), + maxConns: parseInt(__ENV.K6_POOL_MAX || '10'), + }; +} + +export function getDefaultOptions(thresholds = {}) { + return { + scenarios: { + default: { + executor: 'constant-vus', + vus: parseInt(__ENV.K6_VUS || '10'), + duration: __ENV.K6_DURATION || '30s', + }, + }, + thresholds: { + 'iteration_duration': ['p(95)<500'], + ...thresholds, + }, + }; +} From ed70ad8b3821241f9e6cb7baa46a1b7fd0f3a22d Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 21 Jan 2026 13:03:10 +1100 Subject: [PATCH 03/25] feat(benchmark): add k6 data.js with JSONB generators --- tests/benchmark/k6/scripts/lib/data.js | 62 ++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 tests/benchmark/k6/scripts/lib/data.js diff --git a/tests/benchmark/k6/scripts/lib/data.js b/tests/benchmark/k6/scripts/lib/data.js new file mode 100644 index 00000000..018b3065 --- /dev/null +++ b/tests/benchmark/k6/scripts/lib/data.js @@ -0,0 +1,62 @@ +// JSONB payload generators for k6 benchmarks +// Matches integration test fixtures in common.rs + +export function randomId() { + return Math.floor(Math.random() * Number.MAX_SAFE_INTEGER); +} + +export function generateStandardJsonb(id) { + return { + id: id, + string: 'hello', + number: 42, + nested: { + number: 1815, + string: 'world', + }, + array_string: ['hello', 'world'], + array_number: [42, 84], + }; +} + +export function generateLargeJsonb(id) { + // Credit report structure: 50 tradelines x 24 months = ~500KB + const tradelines = []; + for (let i = 0; i < 50; i++) { + const history = []; + for (let m = 0; m < 24; m++) { + history.push({ + month: m + 1, + balance: Math.floor(Math.random() * 10000), + status: 'current', + payment: Math.floor(Math.random() * 500), + }); + } + tradelines.push({ + creditor: `Creditor ${i}`, + account_number: `ACCT${id}${i}`.padStart(16, '0'), + account_type: 'revolving', + opened_date: '2020-01-15', + credit_limit: 5000 + (i * 100), + current_balance: Math.floor(Math.random() * 5000), + payment_history: history, + }); + } + + return { + id: id, + report_id: `RPT-${id}`, + subject: { + name: 'Test Subject', + ssn_last4: '1234', + dob: '1990-01-01', + }, + tradelines: tradelines, + inquiries: [ + { date: '2024-01-01', creditor: 'Bank A' }, + { date: '2024-02-15', creditor: 'Bank B' }, + ], + public_records: [], + score: 750, + }; +} From 3a43f8dc3523ceeb8e2e767593716477cb03cfa6 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 21 Jan 2026 13:04:21 +1100 Subject: [PATCH 04/25] feat(benchmark): add k6 summary.js for benchmark-action output --- tests/benchmark/k6/scripts/lib/summary.js | 57 +++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 tests/benchmark/k6/scripts/lib/summary.js diff --git a/tests/benchmark/k6/scripts/lib/summary.js b/tests/benchmark/k6/scripts/lib/summary.js new file mode 100644 index 00000000..b507700f --- /dev/null +++ b/tests/benchmark/k6/scripts/lib/summary.js @@ -0,0 +1,57 @@ +// benchmark-action compatible output formatter +// Outputs JSON array for github-action-benchmark +// +// Usage in scripts: +// import { createSummaryHandler } from './lib/summary.js'; +// export const handleSummary = createSummaryHandler('script-name'); + +export function createSummaryHandler(scriptName) { + return function(data) { + const iterationsPerSecond = data.metrics.iterations + ? data.metrics.iterations.values.rate + : 0; + + const p95Duration = data.metrics.iteration_duration + ? data.metrics.iteration_duration.values['p(95)'] + : 0; + + const output = [ + { + name: `${scriptName}_iterations_per_second`, + unit: 'Number', + value: Math.round(iterationsPerSecond * 100) / 100, + }, + { + name: `${scriptName}_p95_ms`, + unit: 'ms', + value: Math.round(p95Duration * 100) / 100, + }, + ]; + + return { + 'stdout': textSummary(data), + [`results/k6/${scriptName}-output.json`]: JSON.stringify(output, null, 2), + }; + }; +} + +// Minimal text summary (k6 doesn't export textSummary by default in extensions) +function textSummary(data) { + const lines = []; + lines.push(''); + lines.push('=== Summary ==='); + + if (data.metrics.iterations) { + lines.push(`iterations: ${data.metrics.iterations.values.count}`); + lines.push(`rate: ${data.metrics.iterations.values.rate.toFixed(2)}/s`); + } + + if (data.metrics.iteration_duration) { + const dur = data.metrics.iteration_duration.values; + lines.push(`duration p95: ${dur['p(95)'].toFixed(2)}ms`); + lines.push(`duration avg: ${dur.avg.toFixed(2)}ms`); + } + + lines.push(''); + return lines.join('\n'); +} From 8d50683ea6085dccb06d689e3b3731363e1079fb Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 21 Jan 2026 13:04:22 +1100 Subject: [PATCH 05/25] feat(benchmark): add k6 text-equality.js baseline benchmark --- tests/benchmark/k6/scripts/text-equality.js | 58 +++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 tests/benchmark/k6/scripts/text-equality.js diff --git a/tests/benchmark/k6/scripts/text-equality.js b/tests/benchmark/k6/scripts/text-equality.js new file mode 100644 index 00000000..cdc82ca2 --- /dev/null +++ b/tests/benchmark/k6/scripts/text-equality.js @@ -0,0 +1,58 @@ +// Text equality benchmark - baseline matching pgbench encrypted transaction +// +// Uses xk6-pgxpool API: +// pgxpool.open(connString, minConns, maxConns) +// pgxpool.exec(pool, sql, ...args) + +import pgxpool from 'k6/x/pgxpool'; +import { getConnectionString, getPoolConfig, getDefaultOptions } from './lib/config.js'; +import { createSummaryHandler } from './lib/summary.js'; + +const target = __ENV.K6_TARGET || 'proxy'; +const connectionString = getConnectionString(target); +const poolConfig = getPoolConfig(); + +// ID range for text-equality benchmark data (isolated from other tests) +const ID_START = 1000000; +const ID_COUNT = 100; + +export const options = getDefaultOptions({ + 'iteration_duration': ['p(95)<100'], +}); + +const pool = pgxpool.open(connectionString, poolConfig.minConns, poolConfig.maxConns); + +export function setup() { + // Clean up any leftover data from crashed runs before inserting + pgxpool.exec(pool, `DELETE FROM benchmark_encrypted WHERE id BETWEEN $1 AND $2`, ID_START, ID_START + ID_COUNT - 1); + + // Insert seed data for queries + for (let i = 0; i < ID_COUNT; i++) { + const id = ID_START + i; + const email = `user${i}@example.com`; + pgxpool.exec( + pool, + `INSERT INTO benchmark_encrypted (id, username, email) VALUES ($1, $2, $3)`, + id, + `user${i}`, + email + ); + } +} + +export default function() { + const i = Math.floor(Math.random() * ID_COUNT); + const email = `user${i}@example.com`; + + // Use exec instead of query - we only need to verify the query runs, not inspect results + // This avoids potential resource leaks if pgxpool.query returns a cursor + pgxpool.exec(pool, `SELECT username FROM benchmark_encrypted WHERE email = $1`, email); +} + +export function teardown() { + // Clean up seed data + pgxpool.exec(pool, `DELETE FROM benchmark_encrypted WHERE id BETWEEN $1 AND $2`, ID_START, ID_START + ID_COUNT - 1); + pgxpool.close(pool); +} + +export const handleSummary = createSummaryHandler('text-equality'); From c1ecc87293bae69b3a3648bcc98fa70efef34d22 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 21 Jan 2026 13:04:27 +1100 Subject: [PATCH 06/25] feat(benchmark): add k6 jsonb-insert.js benchmark --- tests/benchmark/k6/scripts/jsonb-insert.js | 38 ++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 tests/benchmark/k6/scripts/jsonb-insert.js diff --git a/tests/benchmark/k6/scripts/jsonb-insert.js b/tests/benchmark/k6/scripts/jsonb-insert.js new file mode 100644 index 00000000..3ab75b15 --- /dev/null +++ b/tests/benchmark/k6/scripts/jsonb-insert.js @@ -0,0 +1,38 @@ +// JSONB INSERT benchmark - primary CI benchmark for encrypted JSONB performance +// +// Uses xk6-pgxpool API: +// pgxpool.open(connString, minConns, maxConns) +// pgxpool.exec(pool, sql, ...args) + +import pgxpool from 'k6/x/pgxpool'; +import { getConnectionString, getPoolConfig, getDefaultOptions } from './lib/config.js'; +import { randomId, generateStandardJsonb } from './lib/data.js'; +import { createSummaryHandler } from './lib/summary.js'; + +const target = __ENV.K6_TARGET || 'proxy'; +const connectionString = getConnectionString(target); +const poolConfig = getPoolConfig(); + +export const options = getDefaultOptions({ + 'iteration_duration': ['p(95)<500'], +}); + +const pool = pgxpool.open(connectionString, poolConfig.minConns, poolConfig.maxConns); + +export default function() { + const id = randomId(); + const jsonb = generateStandardJsonb(id); + + pgxpool.exec( + pool, + `INSERT INTO encrypted (id, encrypted_jsonb) VALUES ($1, $2)`, + id, + JSON.stringify(jsonb) + ); +} + +export function teardown() { + pgxpool.close(pool); +} + +export const handleSummary = createSummaryHandler('jsonb-insert'); From 760ddf7831f50a72fcacba710381a9789b1044f7 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 21 Jan 2026 13:05:47 +1100 Subject: [PATCH 07/25] feat(benchmark): add k6 jsonb-containment.js benchmark --- .../benchmark/k6/scripts/jsonb-containment.js | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 tests/benchmark/k6/scripts/jsonb-containment.js diff --git a/tests/benchmark/k6/scripts/jsonb-containment.js b/tests/benchmark/k6/scripts/jsonb-containment.js new file mode 100644 index 00000000..f4f57db8 --- /dev/null +++ b/tests/benchmark/k6/scripts/jsonb-containment.js @@ -0,0 +1,73 @@ +// JSONB containment (@>) benchmark +// +// The @> operator on eql_v2_encrypted is confirmed working via integration tests: +// packages/cipherstash-proxy-integration/src/select/jsonb_contains.rs:13-14 +// "SELECT encrypted_jsonb @> $1 FROM encrypted LIMIT 1" +// +// Uses xk6-pgxpool API: +// pgxpool.open(connString, minConns, maxConns) +// pgxpool.exec(pool, sql, ...args) + +import pgxpool from 'k6/x/pgxpool'; +import { getConnectionString, getPoolConfig, getDefaultOptions } from './lib/config.js'; +import { createSummaryHandler } from './lib/summary.js'; + +const target = __ENV.K6_TARGET || 'proxy'; +const connectionString = getConnectionString(target); +const poolConfig = getPoolConfig(); + +// ID range for containment benchmark data (isolated from other tests) +const ID_START = 2000000; +const ID_COUNT = 100; + +export const options = getDefaultOptions({ + 'iteration_duration': ['p(95)<200'], +}); + +const pool = pgxpool.open(connectionString, poolConfig.minConns, poolConfig.maxConns); + +export function setup() { + // Clean up any leftover data from crashed runs before inserting + pgxpool.exec(pool, `DELETE FROM encrypted WHERE id BETWEEN $1 AND $2`, ID_START, ID_START + ID_COUNT - 1); + + // Insert seed data with known values for containment queries + for (let i = 0; i < ID_COUNT; i++) { + const id = ID_START + i; + const jsonb = { + id: id, + string: `value${i % 10}`, + number: i % 10, + nested: { string: 'world', number: i }, + }; + pgxpool.exec( + pool, + `INSERT INTO encrypted (id, encrypted_jsonb) VALUES ($1, $2)`, + id, + JSON.stringify(jsonb) + ); + } +} + +export default function() { + const i = Math.floor(Math.random() * 10); + const pattern = JSON.stringify({ string: `value${i}` }); + + // Use exec instead of query - we only need to verify the query runs, not inspect results + // This avoids potential resource leaks if pgxpool.query returns a cursor + // Query uses @> containment operator on encrypted JSONB + pgxpool.exec( + pool, + `SELECT id FROM encrypted WHERE encrypted_jsonb @> $1 AND id BETWEEN $2 AND $3`, + pattern, + ID_START, + ID_START + ID_COUNT - 1 + ); +} + +export function teardown() { + // Clean up seed data + pgxpool.exec(pool, `DELETE FROM encrypted WHERE id BETWEEN $1 AND $2`, ID_START, ID_START + ID_COUNT - 1); + pgxpool.close(pool); +} + +export const handleSummary = createSummaryHandler('jsonb-containment'); From 8990c892585d5962f2d22ee77e68e15559976bc5 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 21 Jan 2026 13:05:48 +1100 Subject: [PATCH 08/25] feat(benchmark): add k6 mise tasks --- tests/benchmark/mise.toml | 82 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/tests/benchmark/mise.toml b/tests/benchmark/mise.toml index c3d8b073..967c923a 100644 --- a/tests/benchmark/mise.toml +++ b/tests/benchmark/mise.toml @@ -172,3 +172,85 @@ run = """ set -e echo docker compose up --build {{arg(name="service",default="pgcat")}} {{option(name="extra-args",default="")}} | bash """ + +# ==================================================================================================== +# k6 Benchmarks +# ==================================================================================================== + +[tasks."k6:build"] +description = "Build k6 Docker image with xk6-pgxpool" +run = """ +docker build -t k6-pgxpool tests/benchmark/k6/ +""" + +[tasks."k6:run"] +description = "Run a single k6 benchmark script" +run = """ +#USAGE flag "--script