Skip to content

Commit e57b53f

Browse files
authored
Merge pull request #424 from cipherstash/dan/bench-package
perf(bench): add @cipherstash/bench for index-engagement validation
2 parents 6c67303 + 0ab1d66 commit e57b53f

17 files changed

Lines changed: 985 additions & 130 deletions

File tree

packages/bench/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
results/

packages/bench/README.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# @cipherstash/bench
2+
3+
Performance / index-engagement benchmarks for stack integrations.
4+
5+
This package validates that each integration emits SQL that engages the canonical
6+
EQL functional indexes (`eql_v2.hmac_256`, `eql_v2.bloom_filter`, `eql_v2.ste_vec`)
7+
on a Supabase-shaped install (no operator classes). It runs in two layers:
8+
9+
1. **EXPLAIN-shape tests** (`__tests__/`) — vitest tests that assert on
10+
`EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)` output. Pass/fail. Cheap.
11+
2. **Wall-clock benches** (`__benches__/`) — vitest `--bench` (tinybench)
12+
measuring median / p95 latency. On-demand; emits JSON to `results/`.
13+
14+
## Prerequisites
15+
16+
- Local Postgres + EQL via the repo-root `local/docker-compose.yml`:
17+
```bash
18+
cd ../../local && docker compose up -d
19+
```
20+
- A CipherStash profile signed in (`stash login`). Auth is read from the
21+
CipherStash profile; no environment variables required.
22+
- `DATABASE_URL` only needs to be set if you want to override the default
23+
(`postgres://cipherstash:password@localhost:5432/cipherstash`).
24+
25+
## Run
26+
27+
The bench package's tests are **developer-run only** — they're not invoked by
28+
the repo's CI `test` step (the scripts are deliberately named `test:local` /
29+
`bench:local` so turbo's default `test` task skips this package).
30+
31+
```bash
32+
# Credential-free smoke (verifies schema + EXPLAIN harness):
33+
pnpm test:local -- db-only
34+
35+
# Full suite (requires CipherStash auth via `stash login`, seeds 10k rows on first run):
36+
pnpm db:setup # apply schema + seed BENCH_ROWS rows (default 10k)
37+
pnpm test:local # EXPLAIN-shape assertions for #421 / #422
38+
pnpm bench:local # timing benches (slow)
39+
pnpm db:reset # drop schema (keeps EQL install)
40+
```
41+
42+
`__tests__/db-only.test.ts` only touches Postgres + the EQL install and is the
43+
recommended starter — it's enough to verify the harness locally before wiring
44+
auth. The other tests under `__tests__/` and the benches under `__benches__/`
45+
use `@cipherstash/stack`'s `Encryption` client for real encryption.
46+
47+
## Why this exists
48+
49+
See cipherstash/stack issues #420, #421, #422.
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { createEncryptionOperators } from '@cipherstash/stack/drizzle'
2+
import type { SQL } from 'drizzle-orm'
3+
import { afterAll, beforeAll, bench, describe } from 'vitest'
4+
import {
5+
type BenchHandle,
6+
benchTable,
7+
buildBench,
8+
teardownBench,
9+
} from '../../src/drizzle/setup.js'
10+
import { applySchema } from '../../src/harness/db.js'
11+
import { seed } from '../../src/harness/seed.js'
12+
13+
let handle: BenchHandle
14+
let ops: ReturnType<typeof createEncryptionOperators>
15+
16+
beforeAll(async () => {
17+
handle = await buildBench()
18+
await applySchema(handle.pgClient)
19+
await seed(handle)
20+
ops = createEncryptionOperators(handle.encryptionClient)
21+
})
22+
23+
afterAll(async () => {
24+
if (handle) await teardownBench(handle)
25+
})
26+
27+
/**
28+
* Encryption cost is paid inside each iteration too — folding it into the
29+
* timed loop reflects what customer code actually does, and the index
30+
* engagement signal still dominates the differential between operators.
31+
*/
32+
describe('drizzle', () => {
33+
bench('eq (string match)', async () => {
34+
const where = (await ops.eq(benchTable.encText, 'value-0000042')) as SQL
35+
await handle.db.select().from(benchTable).where(where)
36+
})
37+
38+
bench('inArray (3 string matches)', async () => {
39+
const where = await ops.inArray(benchTable.encText, [
40+
'value-0000042',
41+
'value-0000123',
42+
'value-0000999',
43+
])
44+
await handle.db.select().from(benchTable).where(where)
45+
})
46+
47+
bench('like (prefix)', async () => {
48+
const where = (await ops.like(benchTable.encText, '%value-00000%')) as SQL
49+
await handle.db.select().from(benchTable).where(where)
50+
})
51+
52+
bench('gt (int)', async () => {
53+
const where = (await ops.gt(benchTable.encInt, 9990)) as SQL
54+
await handle.db.select().from(benchTable).where(where)
55+
})
56+
57+
bench('between (int)', async () => {
58+
const where = (await ops.between(benchTable.encInt, 4000, 4100)) as SQL
59+
await handle.db.select().from(benchTable).where(where)
60+
})
61+
62+
bench('orderBy desc + limit 10', async () => {
63+
await handle.db
64+
.select()
65+
.from(benchTable)
66+
.orderBy(ops.desc(benchTable.encInt))
67+
.limit(10)
68+
})
69+
})
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/**
2+
* DB-only smoke tests — exercise the schema/mode/EXPLAIN path against the
3+
* existing local-postgres container without requiring CipherStash credentials.
4+
* The seed/encryption path is covered separately by `harness.test.ts`, which
5+
* does require credentials.
6+
*/
7+
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
8+
import { applySchema, connect, countBenchRows } from '../src/harness/db.js'
9+
import { explain, hasNodeType, summarize } from '../src/harness/explain.js'
10+
import type pg from 'pg'
11+
12+
let client: pg.Client
13+
14+
beforeAll(async () => {
15+
client = await connect()
16+
await applySchema(client)
17+
})
18+
19+
afterAll(async () => {
20+
if (client) await client.end()
21+
})
22+
23+
describe('db-only harness', () => {
24+
it('schema applied (bench table exists, count is 0)', async () => {
25+
const rows = await countBenchRows(client)
26+
expect(rows).toBe(0)
27+
})
28+
29+
it('EXPLAIN parses a trivial plan', async () => {
30+
const plan = await explain(client, 'SELECT id FROM bench LIMIT 1', [], {
31+
analyze: false,
32+
})
33+
expect(plan['Node Type']).toBeTruthy()
34+
expect(typeof summarize(plan)).toBe('string')
35+
})
36+
37+
it('functional indexes exist after schema apply', async () => {
38+
const res = await client.query<{ indexname: string }>(
39+
`SELECT indexname FROM pg_indexes WHERE tablename = 'bench' ORDER BY indexname`,
40+
)
41+
const names = res.rows.map((r) => r.indexname)
42+
expect(names).toContain('bench_text_hmac_idx')
43+
expect(names).toContain('bench_text_bloom_idx')
44+
expect(names).toContain('bench_jsonb_stevec_idx')
45+
})
46+
47+
it('plan walker traverses nested Plans nodes', async () => {
48+
const plan = await explain(
49+
client,
50+
'SELECT b1.id FROM bench b1 JOIN bench b2 ON b1.id = b2.id LIMIT 1',
51+
[],
52+
{ analyze: false },
53+
)
54+
expect(hasNodeType(plan, 'Limit')).toBe(true)
55+
})
56+
})

0 commit comments

Comments
 (0)