From 2f9dad02522cedb84ec0944079743a0c5236ae54 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 8 Apr 2026 16:40:03 +1000 Subject: [PATCH 1/8] feat(bench): add benchmark table with 10K rows, indexes, and verification tests Migration 007 creates bench table with encrypted_text, encrypted_int, encrypted_bigint columns and seeds 10K rows via create_encrypted_json() with cycling offsets for distribution variety. Fixture creates 5 indexes (hash/btree/GIN) split from migration for before/after testing. 9 tests verify row count, column population, index term extraction, index usage via EXPLAIN, and seq scan baseline. --- tests/sqlx/fixtures/bench_setup.sql | 31 ++++ .../migrations/007_install_bench_data.sql | 28 ++++ tests/sqlx/tests/bench_data_tests.rs | 141 ++++++++++++++++++ 3 files changed, 200 insertions(+) create mode 100644 tests/sqlx/fixtures/bench_setup.sql create mode 100644 tests/sqlx/migrations/007_install_bench_data.sql create mode 100644 tests/sqlx/tests/bench_data_tests.rs diff --git a/tests/sqlx/fixtures/bench_setup.sql b/tests/sqlx/fixtures/bench_setup.sql new file mode 100644 index 000000000..164a4b204 --- /dev/null +++ b/tests/sqlx/fixtures/bench_setup.sql @@ -0,0 +1,31 @@ +-- Fixture: bench_setup.sql +-- +-- Creates benchmark indexes and refreshes planner statistics. +-- Table and 10K rows created by migration 007_install_bench_data.sql. +-- +-- Indexes: +-- bench_text_hmac_idx - hash on eql_v2.hmac_256(encrypted_text) for equality +-- bench_text_ore_idx - btree on encrypted_text via operator class for text ordering +-- bench_int_ore_idx - btree on encrypted_int via operator class for range/ORDER BY +-- bench_bigint_ore_idx - btree on encrypted_bigint via operator class +-- bench_text_bloom_idx - GIN on eql_v2.bloom_filter(encrypted_text) for containment +-- +-- Pattern follows containment_with_index_tests.rs: indexes in fixture (not migration) +-- so tests can verify before/after index creation. + +CREATE INDEX IF NOT EXISTS bench_text_hmac_idx + ON bench USING hash (eql_v2.hmac_256(encrypted_text)); + +CREATE INDEX IF NOT EXISTS bench_text_ore_idx + ON bench USING btree (encrypted_text eql_v2.encrypted_operator_class); + +CREATE INDEX IF NOT EXISTS bench_int_ore_idx + ON bench USING btree (encrypted_int eql_v2.encrypted_operator_class); + +CREATE INDEX IF NOT EXISTS bench_bigint_ore_idx + ON bench USING btree (encrypted_bigint eql_v2.encrypted_operator_class); + +CREATE INDEX IF NOT EXISTS bench_text_bloom_idx + ON bench USING gin (eql_v2.bloom_filter(encrypted_text)); + +ANALYZE bench; diff --git a/tests/sqlx/migrations/007_install_bench_data.sql b/tests/sqlx/migrations/007_install_bench_data.sql new file mode 100644 index 000000000..7786d9715 --- /dev/null +++ b/tests/sqlx/migrations/007_install_bench_data.sql @@ -0,0 +1,28 @@ +-- Migration: 007_install_bench_data.sql +-- +-- Creates benchmark table with 10K rows for performance testing. +-- Each column cycles through 100 distinct encrypted values (from ore ids 1-100). +-- +-- Columns: +-- encrypted_text - text equality (hmac), pattern match (bloom), ordering (ore) +-- encrypted_int - integer ORE range/equality/ordering +-- encrypted_bigint - bigint ORE at scale +-- +-- Index terms per row: hm (hmac), b3 (blake3), bf (bloom filter), ob (ORE blocks), sv (STE vec) +-- Data generated via create_encrypted_json() from 004_install_test_helpers.sql. + +CREATE TABLE bench ( + id SERIAL PRIMARY KEY, + encrypted_text eql_v2_encrypted, + encrypted_int eql_v2_encrypted, + encrypted_bigint eql_v2_encrypted +); + +-- Seed 10K rows. Each column uses a different offset to create varied distributions. +-- create_encrypted_json(id) valid for ids 1-100 (ore table lookup at 10*id, max ore.id=1000). +INSERT INTO bench (encrypted_text, encrypted_int, encrypted_bigint) +SELECT + create_encrypted_json(((gs - 1) % 100) + 1), + create_encrypted_json(((gs + 33) % 100) + 1), + create_encrypted_json(((gs + 66) % 100) + 1) +FROM generate_series(1, 10000) AS gs; diff --git a/tests/sqlx/tests/bench_data_tests.rs b/tests/sqlx/tests/bench_data_tests.rs new file mode 100644 index 000000000..c58ca929d --- /dev/null +++ b/tests/sqlx/tests/bench_data_tests.rs @@ -0,0 +1,141 @@ +//! Benchmark data verification tests +//! +//! Validates migration 007_install_bench_data.sql and bench_setup fixture: +//! - 10K rows seeded correctly across 3 encrypted columns +//! - Index terms (hmac, bloom, ORE) are extractable +//! - Indexes are used by the query planner (EXPLAIN assertions) +//! - Sequential scan baseline without indexes + +use anyhow::Result; +use eql_tests::{analyze_table, assert_uses_index, assert_uses_seq_scan, explain_query}; +use sqlx::PgPool; + +// ========== Data Integrity Tests ========== + +/// Verify migration seeded exactly 10K rows +#[sqlx::test] +async fn bench_table_has_expected_row_count(pool: PgPool) -> Result<()> { + let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM bench") + .fetch_one(&pool) + .await?; + assert_eq!(count.0, 10000, "bench table should have 10000 rows"); + Ok(()) +} + +/// Verify all three columns have non-null encrypted data +#[sqlx::test] +async fn bench_columns_are_populated(pool: PgPool) -> Result<()> { + let count: (i64,) = sqlx::query_as( + "SELECT COUNT(*) FROM bench + WHERE encrypted_text IS NOT NULL + AND encrypted_int IS NOT NULL + AND encrypted_bigint IS NOT NULL", + ) + .fetch_one(&pool) + .await?; + assert_eq!( + count.0, 10000, + "all rows should have non-null encrypted columns" + ); + Ok(()) +} + +/// Verify hmac_256 index terms are extractable from encrypted_text +#[sqlx::test] +async fn bench_encrypted_text_has_hmac_terms(pool: PgPool) -> Result<()> { + let count: (i64,) = sqlx::query_as( + "SELECT COUNT(*) FROM bench WHERE eql_v2.hmac_256(encrypted_text) IS NOT NULL", + ) + .fetch_one(&pool) + .await?; + assert_eq!(count.0, 10000, "all rows should have hmac_256 index terms"); + Ok(()) +} + +/// Verify bloom_filter index terms are extractable from encrypted_text +#[sqlx::test] +async fn bench_encrypted_text_has_bloom_filter_terms(pool: PgPool) -> Result<()> { + let count: (i64,) = sqlx::query_as( + "SELECT COUNT(*) FROM bench WHERE eql_v2.bloom_filter(encrypted_text) IS NOT NULL", + ) + .fetch_one(&pool) + .await?; + assert_eq!( + count.0, 10000, + "all rows should have bloom_filter index terms" + ); + Ok(()) +} + +/// Verify ORE terms are extractable from encrypted_int (3 of 5 indexes are ORE btree) +#[sqlx::test] +async fn bench_encrypted_int_has_ore_terms(pool: PgPool) -> Result<()> { + let count: (i64,) = sqlx::query_as( + "SELECT COUNT(*) FROM bench WHERE eql_v2.ore_block_u64_8_256(encrypted_int) IS NOT NULL", + ) + .fetch_one(&pool) + .await?; + assert_eq!(count.0, 10000, "all rows should have ORE block index terms"); + Ok(()) +} + +// ========== Index Usage Tests (with fixture) ========== + +/// Verify hash index is used for hmac_256 equality lookup +#[sqlx::test(fixtures(path = "../fixtures", scripts("bench_setup")))] +async fn bench_hmac_equality_uses_hash_index(pool: PgPool) -> Result<()> { + let encrypted: String = + sqlx::query_scalar("SELECT (encrypted_text).data::text FROM bench WHERE id = 1") + .fetch_one(&pool) + .await?; + + let sql = format!( + "SELECT * FROM bench WHERE eql_v2.hmac_256(encrypted_text) = eql_v2.hmac_256('{}'::jsonb::eql_v2_encrypted)", + encrypted + ); + assert_uses_index(&pool, &sql, "bench_text_hmac_idx").await?; + Ok(()) +} + +/// Verify btree index is used for ORDER BY with LIMIT on encrypted_int +#[sqlx::test(fixtures(path = "../fixtures", scripts("bench_setup")))] +async fn bench_ore_order_uses_btree_index(pool: PgPool) -> Result<()> { + let sql = "SELECT * FROM bench ORDER BY encrypted_int LIMIT 10"; + assert_uses_index(&pool, sql, "bench_int_ore_idx").await?; + Ok(()) +} + +/// Verify GIN index is used for bloom_filter containment +#[sqlx::test(fixtures(path = "../fixtures", scripts("bench_setup")))] +async fn bench_bloom_containment_uses_gin_index(pool: PgPool) -> Result<()> { + let encrypted: String = + sqlx::query_scalar("SELECT (encrypted_text).data::text FROM bench WHERE id = 1") + .fetch_one(&pool) + .await?; + + let sql = format!( + "SELECT * FROM bench WHERE eql_v2.bloom_filter(encrypted_text) @> eql_v2.bloom_filter('{}'::jsonb::eql_v2_encrypted)", + encrypted + ); + assert_uses_index(&pool, &sql, "bench_text_bloom_idx").await?; + Ok(()) +} + +/// Verify sequential scan without indexes (before/after pattern sanity check) +#[sqlx::test] +async fn bench_hmac_without_index_uses_seq_scan(pool: PgPool) -> Result<()> { + analyze_table(&pool, "bench").await?; + + let encrypted: String = + sqlx::query_scalar("SELECT (encrypted_text).data::text FROM bench WHERE id = 1") + .fetch_one(&pool) + .await?; + + let sql = format!( + "SELECT * FROM bench WHERE eql_v2.hmac_256(encrypted_text) = eql_v2.hmac_256('{}'::jsonb::eql_v2_encrypted)", + encrypted + ); + let explain = explain_query(&pool, &sql).await?; + assert_uses_seq_scan(&explain); + Ok(()) +} From 44dabd7d1cdb1bd4877e2b13d5fa9d433ad7f7d9 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Thu, 9 Apr 2026 09:25:25 +1000 Subject: [PATCH 2/8] refactor(bench): extract BENCH_ROW_COUNT constant from magic number Addresses code review feedback: the literal 10000 appeared in 5 assert_eq calls. Single constant makes it easy to adjust if row count changes for CI. --- tests/sqlx/tests/bench_data_tests.rs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/tests/sqlx/tests/bench_data_tests.rs b/tests/sqlx/tests/bench_data_tests.rs index c58ca929d..55400b380 100644 --- a/tests/sqlx/tests/bench_data_tests.rs +++ b/tests/sqlx/tests/bench_data_tests.rs @@ -10,6 +10,8 @@ use anyhow::Result; use eql_tests::{analyze_table, assert_uses_index, assert_uses_seq_scan, explain_query}; use sqlx::PgPool; +const BENCH_ROW_COUNT: i64 = 10000; + // ========== Data Integrity Tests ========== /// Verify migration seeded exactly 10K rows @@ -18,7 +20,10 @@ async fn bench_table_has_expected_row_count(pool: PgPool) -> Result<()> { let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM bench") .fetch_one(&pool) .await?; - assert_eq!(count.0, 10000, "bench table should have 10000 rows"); + assert_eq!( + count.0, BENCH_ROW_COUNT, + "bench table should have 10000 rows" + ); Ok(()) } @@ -48,7 +53,10 @@ async fn bench_encrypted_text_has_hmac_terms(pool: PgPool) -> Result<()> { ) .fetch_one(&pool) .await?; - assert_eq!(count.0, 10000, "all rows should have hmac_256 index terms"); + assert_eq!( + count.0, BENCH_ROW_COUNT, + "all rows should have hmac_256 index terms" + ); Ok(()) } @@ -75,7 +83,10 @@ async fn bench_encrypted_int_has_ore_terms(pool: PgPool) -> Result<()> { ) .fetch_one(&pool) .await?; - assert_eq!(count.0, 10000, "all rows should have ORE block index terms"); + assert_eq!( + count.0, BENCH_ROW_COUNT, + "all rows should have ORE block index terms" + ); Ok(()) } From 1934a91fc6337136dc6262eb17d1e4a8b820c804 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Thu, 9 Apr 2026 12:17:59 +1000 Subject: [PATCH 3/8] fix(bench): move 10K row INSERT from migration to opt-in fixture The INSERT ... generate_series in migration 007 bloated the sqlx template database, making every test pay the copy cost and causing disk space crashes. Now migration 007 is DDL-only (CREATE TABLE bench) and the 10K row seed lives in bench_data.sql fixture. Only bench tests opt in. Also fixes remaining BENCH_ROW_COUNT literals missed by earlier replace. --- tests/sqlx/fixtures/bench_data.sql | 19 ++++++++++++++ .../migrations/007_install_bench_data.sql | 17 +++--------- tests/sqlx/tests/bench_data_tests.rs | 26 +++++++++---------- 3 files changed, 35 insertions(+), 27 deletions(-) create mode 100644 tests/sqlx/fixtures/bench_data.sql diff --git a/tests/sqlx/fixtures/bench_data.sql b/tests/sqlx/fixtures/bench_data.sql new file mode 100644 index 000000000..baeae5ad1 --- /dev/null +++ b/tests/sqlx/fixtures/bench_data.sql @@ -0,0 +1,19 @@ +-- Fixture: bench_data.sql +-- +-- Seeds 10K rows into the bench table for performance testing. +-- Each column cycles through 100 distinct encrypted values (from ore ids 1-100). +-- +-- Index terms per row: hm (hmac), b3 (blake3), bf (bloom filter), ob (ORE blocks), sv (STE vec) +-- Data generated via create_encrypted_json() from 004_install_test_helpers.sql. +-- +-- Cycling offsets create varied distributions: +-- encrypted_text: ids 1, 2, ..., 100, 1, 2, ... (offset 0) +-- encrypted_int: ids 34, 35, ..., 100, 1, ..., 33 (offset +33) +-- encrypted_bigint: ids 67, 68, ..., 100, 1, ..., 66 (offset +66) + +INSERT INTO bench (encrypted_text, encrypted_int, encrypted_bigint) +SELECT + create_encrypted_json(((gs - 1) % 100) + 1), + create_encrypted_json(((gs + 33) % 100) + 1), + create_encrypted_json(((gs + 66) % 100) + 1) +FROM generate_series(1, 10000) AS gs; diff --git a/tests/sqlx/migrations/007_install_bench_data.sql b/tests/sqlx/migrations/007_install_bench_data.sql index 7786d9715..04db76952 100644 --- a/tests/sqlx/migrations/007_install_bench_data.sql +++ b/tests/sqlx/migrations/007_install_bench_data.sql @@ -1,15 +1,13 @@ -- Migration: 007_install_bench_data.sql -- --- Creates benchmark table with 10K rows for performance testing. --- Each column cycles through 100 distinct encrypted values (from ore ids 1-100). +-- Creates benchmark table for performance testing. +-- DDL only — data is loaded by the bench_data.sql fixture so that +-- only bench tests pay the 10K-row seeding cost, not the entire suite. -- -- Columns: -- encrypted_text - text equality (hmac), pattern match (bloom), ordering (ore) -- encrypted_int - integer ORE range/equality/ordering -- encrypted_bigint - bigint ORE at scale --- --- Index terms per row: hm (hmac), b3 (blake3), bf (bloom filter), ob (ORE blocks), sv (STE vec) --- Data generated via create_encrypted_json() from 004_install_test_helpers.sql. CREATE TABLE bench ( id SERIAL PRIMARY KEY, @@ -17,12 +15,3 @@ CREATE TABLE bench ( encrypted_int eql_v2_encrypted, encrypted_bigint eql_v2_encrypted ); - --- Seed 10K rows. Each column uses a different offset to create varied distributions. --- create_encrypted_json(id) valid for ids 1-100 (ore table lookup at 10*id, max ore.id=1000). -INSERT INTO bench (encrypted_text, encrypted_int, encrypted_bigint) -SELECT - create_encrypted_json(((gs - 1) % 100) + 1), - create_encrypted_json(((gs + 33) % 100) + 1), - create_encrypted_json(((gs + 66) % 100) + 1) -FROM generate_series(1, 10000) AS gs; diff --git a/tests/sqlx/tests/bench_data_tests.rs b/tests/sqlx/tests/bench_data_tests.rs index 55400b380..8225df5d3 100644 --- a/tests/sqlx/tests/bench_data_tests.rs +++ b/tests/sqlx/tests/bench_data_tests.rs @@ -1,6 +1,6 @@ //! Benchmark data verification tests //! -//! Validates migration 007_install_bench_data.sql and bench_setup fixture: +//! Validates bench_data fixture (10K rows) and bench_setup fixture (indexes): //! - 10K rows seeded correctly across 3 encrypted columns //! - Index terms (hmac, bloom, ORE) are extractable //! - Indexes are used by the query planner (EXPLAIN assertions) @@ -14,8 +14,8 @@ const BENCH_ROW_COUNT: i64 = 10000; // ========== Data Integrity Tests ========== -/// Verify migration seeded exactly 10K rows -#[sqlx::test] +/// Verify fixture seeded exactly 10K rows +#[sqlx::test(fixtures(path = "../fixtures", scripts("bench_data")))] async fn bench_table_has_expected_row_count(pool: PgPool) -> Result<()> { let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM bench") .fetch_one(&pool) @@ -28,7 +28,7 @@ async fn bench_table_has_expected_row_count(pool: PgPool) -> Result<()> { } /// Verify all three columns have non-null encrypted data -#[sqlx::test] +#[sqlx::test(fixtures(path = "../fixtures", scripts("bench_data")))] async fn bench_columns_are_populated(pool: PgPool) -> Result<()> { let count: (i64,) = sqlx::query_as( "SELECT COUNT(*) FROM bench @@ -39,14 +39,14 @@ async fn bench_columns_are_populated(pool: PgPool) -> Result<()> { .fetch_one(&pool) .await?; assert_eq!( - count.0, 10000, + count.0, BENCH_ROW_COUNT, "all rows should have non-null encrypted columns" ); Ok(()) } /// Verify hmac_256 index terms are extractable from encrypted_text -#[sqlx::test] +#[sqlx::test(fixtures(path = "../fixtures", scripts("bench_data")))] async fn bench_encrypted_text_has_hmac_terms(pool: PgPool) -> Result<()> { let count: (i64,) = sqlx::query_as( "SELECT COUNT(*) FROM bench WHERE eql_v2.hmac_256(encrypted_text) IS NOT NULL", @@ -61,7 +61,7 @@ async fn bench_encrypted_text_has_hmac_terms(pool: PgPool) -> Result<()> { } /// Verify bloom_filter index terms are extractable from encrypted_text -#[sqlx::test] +#[sqlx::test(fixtures(path = "../fixtures", scripts("bench_data")))] async fn bench_encrypted_text_has_bloom_filter_terms(pool: PgPool) -> Result<()> { let count: (i64,) = sqlx::query_as( "SELECT COUNT(*) FROM bench WHERE eql_v2.bloom_filter(encrypted_text) IS NOT NULL", @@ -69,14 +69,14 @@ async fn bench_encrypted_text_has_bloom_filter_terms(pool: PgPool) -> Result<()> .fetch_one(&pool) .await?; assert_eq!( - count.0, 10000, + count.0, BENCH_ROW_COUNT, "all rows should have bloom_filter index terms" ); Ok(()) } /// Verify ORE terms are extractable from encrypted_int (3 of 5 indexes are ORE btree) -#[sqlx::test] +#[sqlx::test(fixtures(path = "../fixtures", scripts("bench_data")))] async fn bench_encrypted_int_has_ore_terms(pool: PgPool) -> Result<()> { let count: (i64,) = sqlx::query_as( "SELECT COUNT(*) FROM bench WHERE eql_v2.ore_block_u64_8_256(encrypted_int) IS NOT NULL", @@ -93,7 +93,7 @@ async fn bench_encrypted_int_has_ore_terms(pool: PgPool) -> Result<()> { // ========== Index Usage Tests (with fixture) ========== /// Verify hash index is used for hmac_256 equality lookup -#[sqlx::test(fixtures(path = "../fixtures", scripts("bench_setup")))] +#[sqlx::test(fixtures(path = "../fixtures", scripts("bench_data", "bench_setup")))] async fn bench_hmac_equality_uses_hash_index(pool: PgPool) -> Result<()> { let encrypted: String = sqlx::query_scalar("SELECT (encrypted_text).data::text FROM bench WHERE id = 1") @@ -109,7 +109,7 @@ async fn bench_hmac_equality_uses_hash_index(pool: PgPool) -> Result<()> { } /// Verify btree index is used for ORDER BY with LIMIT on encrypted_int -#[sqlx::test(fixtures(path = "../fixtures", scripts("bench_setup")))] +#[sqlx::test(fixtures(path = "../fixtures", scripts("bench_data", "bench_setup")))] async fn bench_ore_order_uses_btree_index(pool: PgPool) -> Result<()> { let sql = "SELECT * FROM bench ORDER BY encrypted_int LIMIT 10"; assert_uses_index(&pool, sql, "bench_int_ore_idx").await?; @@ -117,7 +117,7 @@ async fn bench_ore_order_uses_btree_index(pool: PgPool) -> Result<()> { } /// Verify GIN index is used for bloom_filter containment -#[sqlx::test(fixtures(path = "../fixtures", scripts("bench_setup")))] +#[sqlx::test(fixtures(path = "../fixtures", scripts("bench_data", "bench_setup")))] async fn bench_bloom_containment_uses_gin_index(pool: PgPool) -> Result<()> { let encrypted: String = sqlx::query_scalar("SELECT (encrypted_text).data::text FROM bench WHERE id = 1") @@ -133,7 +133,7 @@ async fn bench_bloom_containment_uses_gin_index(pool: PgPool) -> Result<()> { } /// Verify sequential scan without indexes (before/after pattern sanity check) -#[sqlx::test] +#[sqlx::test(fixtures(path = "../fixtures", scripts("bench_data")))] async fn bench_hmac_without_index_uses_seq_scan(pool: PgPool) -> Result<()> { analyze_table(&pool, "bench").await?; From a57682aa8f78397c6db94d228cd8518fc1d06378 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Thu, 9 Apr 2026 12:18:05 +1000 Subject: [PATCH 4/8] fix(test): correct pg_stat_statements_reset argument order Database OID was passed as 3rd arg (queryid) instead of 2nd arg (dbid). read_pg_stat_statements correctly filters by dbid, confirming the intent. The reset now scopes to the current database instead of matching a non-existent query ID. --- tests/sqlx/src/helpers.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/sqlx/src/helpers.rs b/tests/sqlx/src/helpers.rs index a2f454e9f..8fe128443 100644 --- a/tests/sqlx/src/helpers.rs +++ b/tests/sqlx/src/helpers.rs @@ -701,7 +701,7 @@ pub async fn ensure_pg_stat_statements(pool: &PgPool) -> Result<()> { /// let stats = read_pg_stat_statements(&pool, "%FROM bench%").await?; /// ``` pub async fn reset_pg_stat_statements(pool: &PgPool) -> Result<()> { - sqlx::query("SELECT pg_stat_statements_reset(NULL::oid, NULL::oid, (SELECT oid FROM pg_database WHERE datname = current_database()))") + sqlx::query("SELECT pg_stat_statements_reset(NULL::oid, (SELECT oid FROM pg_database WHERE datname = current_database()), 0::bigint)") .execute(pool) .await .with_context(|| "resetting pg_stat_statements counters for current database")?; From 89f86f665399232d6d4a155c8a5a4446793b7807 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Tue, 21 Apr 2026 13:23:59 +1000 Subject: [PATCH 5/8] fix(bench): address code review feedback - Fix misleading bench_setup.sql comment (DDL-only migration, rows from fixture) - Fix off-by-one in bench_data.sql offset documentation comments - Add missing ORE term extraction test for encrypted_bigint - Add missing index-usage tests for bench_text_ore_idx and bench_bigint_ore_idx - Document bench_data and bench_setup fixtures in FIXTURE_SCHEMA.md - Update migrations README to list all migrations 002-007 --- tests/sqlx/fixtures/FIXTURE_SCHEMA.md | 53 ++++++++++++++++++++++++++- tests/sqlx/fixtures/bench_data.sql | 4 +- tests/sqlx/fixtures/bench_setup.sql | 2 +- tests/sqlx/migrations/README.md | 11 ++++-- tests/sqlx/tests/bench_data_tests.rs | 31 ++++++++++++++++ 5 files changed, 93 insertions(+), 8 deletions(-) diff --git a/tests/sqlx/fixtures/FIXTURE_SCHEMA.md b/tests/sqlx/fixtures/FIXTURE_SCHEMA.md index 7988fb230..70d3b0d9a 100644 --- a/tests/sqlx/fixtures/FIXTURE_SCHEMA.md +++ b/tests/sqlx/fixtures/FIXTURE_SCHEMA.md @@ -9,7 +9,8 @@ EQL Extension (via migrations) ├── encrypted_json.sql ├── array_data.sql ├── order_by_null_data.sql (depends on ore migration) - └── ore_data.sql + ├── ore_data.sql + └── bench_data.sql + bench_setup.sql (depend on migration 007) ``` All fixtures depend on the EQL extension being installed via SQLx migrations. @@ -132,6 +133,56 @@ CREATE TABLE ore ( --- +## bench_data.sql + +**Purpose:** Seeds 10K rows into the `bench` table for performance benchmarking. Opt-in fixture — only loaded when a test explicitly includes `scripts("bench_data")`, so other tests don't pay the cost. + +**Dependencies:** +- Requires `bench` table from migration `007_install_bench_data.sql` +- Uses `create_encrypted_json()` from migration `004_install_test_helpers.sql` + +**Schema:** Uses `bench` table (DDL in migration 007): +```sql +CREATE TABLE bench ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + encrypted_text eql_v2_encrypted, + encrypted_int eql_v2_encrypted, + encrypted_bigint eql_v2_encrypted +); +``` + +**Data:** +- 10,000 rows cycling through 100 distinct encrypted values (ore ids 1-100) +- Cycling offsets create varied column distributions: + - `encrypted_text`: ids 1, 2, ..., 100, 1, 2, ... (offset 0) + - `encrypted_int`: ids 35, 36, ..., 100, 1, ..., 34 (offset +33) + - `encrypted_bigint`: ids 68, 69, ..., 100, 1, ..., 67 (offset +66) +- Each row has HMAC, bloom filter, and ORE index terms + +**Used By:** +- bench_data_tests.rs (all tests) + +--- + +## bench_setup.sql + +**Purpose:** Creates the 5 benchmark indexes and refreshes planner statistics. Always loaded after `bench_data.sql` in tests that verify index usage. + +**Dependencies:** +- Requires `bench` table with data from `bench_data.sql` + +**Indexes created:** +- `bench_text_hmac_idx` — hash on `eql_v2.hmac_256(encrypted_text)` for equality +- `bench_text_ore_idx` — btree on `encrypted_text` via operator class for text ordering +- `bench_int_ore_idx` — btree on `encrypted_int` via operator class for range/ORDER BY +- `bench_bigint_ore_idx` — btree on `encrypted_bigint` via operator class +- `bench_text_bloom_idx` — GIN on `eql_v2.bloom_filter(encrypted_text)` for containment + +**Used By:** +- bench_data_tests.rs (index-usage tests: `scripts("bench_data", "bench_setup")`) + +--- + ## Validation Tests Each fixture should have a validation test to ensure correct structure: diff --git a/tests/sqlx/fixtures/bench_data.sql b/tests/sqlx/fixtures/bench_data.sql index baeae5ad1..ca0db6dd5 100644 --- a/tests/sqlx/fixtures/bench_data.sql +++ b/tests/sqlx/fixtures/bench_data.sql @@ -8,8 +8,8 @@ -- -- Cycling offsets create varied distributions: -- encrypted_text: ids 1, 2, ..., 100, 1, 2, ... (offset 0) --- encrypted_int: ids 34, 35, ..., 100, 1, ..., 33 (offset +33) --- encrypted_bigint: ids 67, 68, ..., 100, 1, ..., 66 (offset +66) +-- encrypted_int: ids 35, 36, ..., 100, 1, ..., 34 (offset +33) +-- encrypted_bigint: ids 68, 69, ..., 100, 1, ..., 67 (offset +66) INSERT INTO bench (encrypted_text, encrypted_int, encrypted_bigint) SELECT diff --git a/tests/sqlx/fixtures/bench_setup.sql b/tests/sqlx/fixtures/bench_setup.sql index 164a4b204..0f9979403 100644 --- a/tests/sqlx/fixtures/bench_setup.sql +++ b/tests/sqlx/fixtures/bench_setup.sql @@ -1,7 +1,7 @@ -- Fixture: bench_setup.sql -- -- Creates benchmark indexes and refreshes planner statistics. --- Table and 10K rows created by migration 007_install_bench_data.sql. +-- Table DDL from migration 007_install_bench_data.sql; 10K rows from bench_data.sql fixture. -- -- Indexes: -- bench_text_hmac_idx - hash on eql_v2.hmac_256(encrypted_text) for equality diff --git a/tests/sqlx/migrations/README.md b/tests/sqlx/migrations/README.md index a03dcaa03..f8b5b169b 100644 --- a/tests/sqlx/migrations/README.md +++ b/tests/sqlx/migrations/README.md @@ -10,10 +10,13 @@ These migrations install EQL and test helpers into the test database using a **h - In `.gitignore` - never commit this file - Ensures tests always use current EQL version -**Migrations 002-004 are static fixtures**: -- 002: Test helpers (`test_helpers.sql`) -- 003: ORE test data (`ore.sql`) -- 004: STE Vec test data (`ste_vec.sql`) +**Migrations 002-007 are static fixtures**: +- 002: ORE test data (`ore.sql`) +- 003: STE Vec test data (`ste_vec.sql`) +- 004: Test helpers (`test_helpers.sql`) +- 005: STE Vec vast data +- 006: ORE text data +- 007: Benchmark table DDL (`bench` table with 3 encrypted columns — DDL only, no rows) ## How SQLx Uses These Migrations diff --git a/tests/sqlx/tests/bench_data_tests.rs b/tests/sqlx/tests/bench_data_tests.rs index 8225df5d3..24ea99558 100644 --- a/tests/sqlx/tests/bench_data_tests.rs +++ b/tests/sqlx/tests/bench_data_tests.rs @@ -90,6 +90,21 @@ async fn bench_encrypted_int_has_ore_terms(pool: PgPool) -> Result<()> { Ok(()) } +/// Verify ORE terms are extractable from encrypted_bigint +#[sqlx::test(fixtures(path = "../fixtures", scripts("bench_data")))] +async fn bench_encrypted_bigint_has_ore_terms(pool: PgPool) -> Result<()> { + let count: (i64,) = sqlx::query_as( + "SELECT COUNT(*) FROM bench WHERE eql_v2.ore_block_u64_8_256(encrypted_bigint) IS NOT NULL", + ) + .fetch_one(&pool) + .await?; + assert_eq!( + count.0, BENCH_ROW_COUNT, + "all rows should have ORE block index terms" + ); + Ok(()) +} + // ========== Index Usage Tests (with fixture) ========== /// Verify hash index is used for hmac_256 equality lookup @@ -132,6 +147,22 @@ async fn bench_bloom_containment_uses_gin_index(pool: PgPool) -> Result<()> { Ok(()) } +/// Verify btree index is used for ORDER BY with LIMIT on encrypted_text +#[sqlx::test(fixtures(path = "../fixtures", scripts("bench_data", "bench_setup")))] +async fn bench_ore_text_order_uses_btree_index(pool: PgPool) -> Result<()> { + let sql = "SELECT * FROM bench ORDER BY encrypted_text LIMIT 10"; + assert_uses_index(&pool, sql, "bench_text_ore_idx").await?; + Ok(()) +} + +/// Verify btree index is used for ORDER BY with LIMIT on encrypted_bigint +#[sqlx::test(fixtures(path = "../fixtures", scripts("bench_data", "bench_setup")))] +async fn bench_ore_bigint_order_uses_btree_index(pool: PgPool) -> Result<()> { + let sql = "SELECT * FROM bench ORDER BY encrypted_bigint LIMIT 10"; + assert_uses_index(&pool, sql, "bench_bigint_ore_idx").await?; + Ok(()) +} + /// Verify sequential scan without indexes (before/after pattern sanity check) #[sqlx::test(fixtures(path = "../fixtures", scripts("bench_data")))] async fn bench_hmac_without_index_uses_seq_scan(pool: PgPool) -> Result<()> { From 493f085da952a38cf0b492430e70e463ab571bea Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Tue, 21 Apr 2026 15:33:56 +1000 Subject: [PATCH 6/8] fix(bench): address second code review round MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use BIGINT GENERATED ALWAYS AS IDENTITY for bench.id (matches documented schema in FIXTURE_SCHEMA.md and other tables in suite) - Fix stale migration range in README (001-004 → 001-007) - Add comment clarifying int/bigint ORE tests verify data seeding, not distinct encoding paths --- tests/sqlx/migrations/007_install_bench_data.sql | 2 +- tests/sqlx/migrations/README.md | 2 +- tests/sqlx/tests/bench_data_tests.rs | 3 +++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/sqlx/migrations/007_install_bench_data.sql b/tests/sqlx/migrations/007_install_bench_data.sql index 04db76952..49ff6975b 100644 --- a/tests/sqlx/migrations/007_install_bench_data.sql +++ b/tests/sqlx/migrations/007_install_bench_data.sql @@ -10,7 +10,7 @@ -- encrypted_bigint - bigint ORE at scale CREATE TABLE bench ( - id SERIAL PRIMARY KEY, + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, encrypted_text eql_v2_encrypted, encrypted_int eql_v2_encrypted, encrypted_bigint eql_v2_encrypted diff --git a/tests/sqlx/migrations/README.md b/tests/sqlx/migrations/README.md index f8b5b169b..f78f06358 100644 --- a/tests/sqlx/migrations/README.md +++ b/tests/sqlx/migrations/README.md @@ -22,7 +22,7 @@ These migrations install EQL and test helpers into the test database using a **h When using `#[sqlx::test]`: - Each test gets a fresh database -- All migrations (001-004) run automatically before each test +- All migrations (001-007) run automatically before each test - Migration 001 contains the latest built EQL - No need to manually reset database between tests diff --git a/tests/sqlx/tests/bench_data_tests.rs b/tests/sqlx/tests/bench_data_tests.rs index 24ea99558..a6912b462 100644 --- a/tests/sqlx/tests/bench_data_tests.rs +++ b/tests/sqlx/tests/bench_data_tests.rs @@ -91,6 +91,9 @@ async fn bench_encrypted_int_has_ore_terms(pool: PgPool) -> Result<()> { } /// Verify ORE terms are extractable from encrypted_bigint +/// +/// Both int and bigint columns use the same eql_v2_encrypted type and ob index structure. +/// These tests verify that data seeding populated both columns, not that encoding differs. #[sqlx::test(fixtures(path = "../fixtures", scripts("bench_data")))] async fn bench_encrypted_bigint_has_ore_terms(pool: PgPool) -> Result<()> { let count: (i64,) = sqlx::query_as( From 2e371e6480bf9d63b9b9444f949cdcea0799982b Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 22 Apr 2026 09:25:03 +1000 Subject: [PATCH 7/8] docs(bench): address CodeRabbit feedback on fixture docs - bench_data.sql, FIXTURE_SCHEMA.md: correct offset labels from +33/+66 to +34/+67 (formulas unchanged; labels now match the id sequences) - FIXTURE_SCHEMA.md: remove self-contradictory ore_data.sql fixture references; the ore table is migration-only, not a fixture - migrations/README.md: update stale example filename from 005_my_fixture.sql (slot occupied) to 008_my_fixture.sql --- tests/sqlx/fixtures/FIXTURE_SCHEMA.md | 14 +++++++------- tests/sqlx/fixtures/bench_data.sql | 4 ++-- tests/sqlx/migrations/README.md | 2 +- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/sqlx/fixtures/FIXTURE_SCHEMA.md b/tests/sqlx/fixtures/FIXTURE_SCHEMA.md index 70d3b0d9a..87c352ef8 100644 --- a/tests/sqlx/fixtures/FIXTURE_SCHEMA.md +++ b/tests/sqlx/fixtures/FIXTURE_SCHEMA.md @@ -9,7 +9,7 @@ EQL Extension (via migrations) ├── encrypted_json.sql ├── array_data.sql ├── order_by_null_data.sql (depends on ore migration) - ├── ore_data.sql + ├── ore table (migration 002 — not a fixture) └── bench_data.sql + bench_setup.sql (depend on migration 007) ``` @@ -155,8 +155,8 @@ CREATE TABLE bench ( - 10,000 rows cycling through 100 distinct encrypted values (ore ids 1-100) - Cycling offsets create varied column distributions: - `encrypted_text`: ids 1, 2, ..., 100, 1, 2, ... (offset 0) - - `encrypted_int`: ids 35, 36, ..., 100, 1, ..., 34 (offset +33) - - `encrypted_bigint`: ids 68, 69, ..., 100, 1, ..., 67 (offset +66) + - `encrypted_int`: ids 35, 36, ..., 100, 1, ..., 34 (offset +34) + - `encrypted_bigint`: ids 68, 69, ..., 100, 1, ..., 67 (offset +67) - Each row has HMAC, bloom filter, and ORE index terms **Used By:** @@ -199,15 +199,15 @@ async fn fixture_encrypted_json_has_three_records(pool: PgPool) { } ``` -### ore_data Validation +### ore Migration Validation ```rust -#[sqlx::test(fixtures(path = "../fixtures", scripts("ore_data")))] +#[sqlx::test] async fn fixture_ore_data_has_99_records(pool: PgPool) { let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ore") .fetch_one(&pool) .await .unwrap(); - assert_eq!(count, 99, "ore_data fixture should create 99 records"); + assert_eq!(count, 99, "ore migration should provide 99 records"); } ``` @@ -217,7 +217,7 @@ async fn fixture_ore_data_has_99_records(pool: PgPool) { - Use snake_case for fixture file names - Name should describe the data, not the test using it -- Examples: `encrypted_json.sql`, `ore_data.sql`, `array_data.sql` +- Examples: `encrypted_json.sql`, `array_data.sql`, `bench_data.sql` ## Adding New Fixtures diff --git a/tests/sqlx/fixtures/bench_data.sql b/tests/sqlx/fixtures/bench_data.sql index ca0db6dd5..149c08dbf 100644 --- a/tests/sqlx/fixtures/bench_data.sql +++ b/tests/sqlx/fixtures/bench_data.sql @@ -8,8 +8,8 @@ -- -- Cycling offsets create varied distributions: -- encrypted_text: ids 1, 2, ..., 100, 1, 2, ... (offset 0) --- encrypted_int: ids 35, 36, ..., 100, 1, ..., 34 (offset +33) --- encrypted_bigint: ids 68, 69, ..., 100, 1, ..., 67 (offset +66) +-- encrypted_int: ids 35, 36, ..., 100, 1, ..., 34 (offset +34) +-- encrypted_bigint: ids 68, 69, ..., 100, 1, ..., 67 (offset +67) INSERT INTO bench (encrypted_text, encrypted_int, encrypted_bigint) SELECT diff --git a/tests/sqlx/migrations/README.md b/tests/sqlx/migrations/README.md index f78f06358..abfc74711 100644 --- a/tests/sqlx/migrations/README.md +++ b/tests/sqlx/migrations/README.md @@ -39,7 +39,7 @@ cp release/cipherstash-encrypt.sql tests/sqlx/migrations/001_install_eql.sql ## Adding New Test Fixtures To add new test data or helpers: -1. Create a new migration: `tests/sqlx/migrations/005_my_fixture.sql` +1. Create a new migration using the next unused number (e.g. `tests/sqlx/migrations/008_my_fixture.sql`) 2. Add your SQL fixtures 3. Commit it (static migrations are version-controlled) 4. SQLx will apply it automatically in test runs From 53972f90a4086e939e80ae842ad8eb03477d1a5e Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 22 Apr 2026 10:09:43 +1000 Subject: [PATCH 8/8] refactor(bench): use Zipf-like skew for bench fixture distribution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the uniform, phase-locked cycling (each column got 100 rows each of 100 distinct ids, offsets +34/+67) with a deterministic Zipf-like skew seeded via setseed(0.42) and transformed through random()^2. Key differences: - Skewed distribution — top id gets ~5% of rows, tail ids ~0.5% (~10x ratio), giving the planner realistic histograms instead of a perfectly flat distribution. - Three independent draws per row decorrelate the columns; previously all three were the same cycle with fixed phase shifts. - Id range tightened to [1, 99] — create_encrypted_json(id) looks up ore.id = 10*id, so id=100 previously resolved to a missing ore row. Existing bench tests are distribution-agnostic (row-count / non-null / read-and-query-by-id=1) and continue to pass. --- tests/sqlx/fixtures/FIXTURE_SCHEMA.md | 9 ++++----- tests/sqlx/fixtures/bench_data.sql | 22 +++++++++++++--------- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/tests/sqlx/fixtures/FIXTURE_SCHEMA.md b/tests/sqlx/fixtures/FIXTURE_SCHEMA.md index 87c352ef8..52c2daa72 100644 --- a/tests/sqlx/fixtures/FIXTURE_SCHEMA.md +++ b/tests/sqlx/fixtures/FIXTURE_SCHEMA.md @@ -152,11 +152,10 @@ CREATE TABLE bench ( ``` **Data:** -- 10,000 rows cycling through 100 distinct encrypted values (ore ids 1-100) -- Cycling offsets create varied column distributions: - - `encrypted_text`: ids 1, 2, ..., 100, 1, 2, ... (offset 0) - - `encrypted_int`: ids 35, 36, ..., 100, 1, ..., 34 (offset +34) - - `encrypted_bigint`: ids 68, 69, ..., 100, 1, ..., 67 (offset +67) +- 10,000 rows drawn from 99 distinct encrypted values (ore ids 1-99) +- Zipf-like skew via `setseed(0.42)` + `random()^2` — deterministic and byte-identical across runs +- Top id gets ~5% of rows; tail ids ~0.5% each (top:bottom ratio ~10x) +- Each column draws independently, so column values are decorrelated within a row - Each row has HMAC, bloom filter, and ORE index terms **Used By:** diff --git a/tests/sqlx/fixtures/bench_data.sql b/tests/sqlx/fixtures/bench_data.sql index 149c08dbf..247d4ed50 100644 --- a/tests/sqlx/fixtures/bench_data.sql +++ b/tests/sqlx/fixtures/bench_data.sql @@ -1,19 +1,23 @@ -- Fixture: bench_data.sql -- -- Seeds 10K rows into the bench table for performance testing. --- Each column cycles through 100 distinct encrypted values (from ore ids 1-100). +-- Each column draws independently from 99 distinct encrypted values (ore ids 1-99) +-- using a Zipf-like skew so the planner sees realistic histograms. -- -- Index terms per row: hm (hmac), b3 (blake3), bf (bloom filter), ob (ORE blocks), sv (STE vec) -- Data generated via create_encrypted_json() from 004_install_test_helpers.sql. -- --- Cycling offsets create varied distributions: --- encrypted_text: ids 1, 2, ..., 100, 1, 2, ... (offset 0) --- encrypted_int: ids 35, 36, ..., 100, 1, ..., 34 (offset +34) --- encrypted_bigint: ids 68, 69, ..., 100, 1, ..., 67 (offset +67) +-- Distribution: +-- Deterministic via setseed(0.42) — byte-identical across runs. +-- random()^2 produces a power-law skew: P(id=k) is proportional to 1/sqrt(k). +-- Top id gets ~5% of rows (~500); tail ids get ~0.5% each (~50). Ratio ~10x. +-- Three independent draws per row decorrelate the columns. + +SELECT setseed(0.42); INSERT INTO bench (encrypted_text, encrypted_int, encrypted_bigint) SELECT - create_encrypted_json(((gs - 1) % 100) + 1), - create_encrypted_json(((gs + 33) % 100) + 1), - create_encrypted_json(((gs + 66) % 100) + 1) -FROM generate_series(1, 10000) AS gs; + create_encrypted_json(1 + floor(99 * power(random(), 2))::int), + create_encrypted_json(1 + floor(99 * power(random(), 2))::int), + create_encrypted_json(1 + floor(99 * power(random(), 2))::int) +FROM generate_series(1, 10000);