From fc4b9593d733262b6a4eabaf333efec6666f41bf Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 4 Jul 2026 10:15:43 +1000 Subject: [PATCH 01/17] feat: EQL v3 benchmark suite with v2 regression comparison MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a parallel v3 bench suite targeting the upcoming EQL v3 release (domain-specific types replacing the eql_v2_encrypted composite). The v2 benches and their committed results are untouched — they are the regression baseline; v3 results land in results/{query,ingest}/v3/. Harness (src/v3.rs): v3 payloads come from eql-bindings::from_v2 over the pinned cipherstash-client's v2.3 output (the supported migration path). No v3 scalar query wire shape exists, so all query parameters are stored-shape payloads; the OPE benches synthesize the `op` term (fixed-width order-preserving hex) until a client emits CLLW-OPE. Benches: exact/match/ore/group_by/json/combo mapped onto v3 domains with scenario ids preserved for the report join, plus new ope (ORE-vs-OPE ordering), scalar_smoke (date/timestamp/numeric/bigint/double/boolean), and plaintext baselines for the overhead-vs-native story. Report (report_v3_compare.py, mise run report:v3-compare): regression table with semantics-changed annotations, index-engagement audit, ingest comparison, index build times, and PNG+SVG charts. Headlines at 1M rows: OPE range/ordered queries at 0.12ms (~1.2x plaintext, 6x faster than ORE; index builds 1s vs 44s); ORE LIMIT-100 ranges 4x faster than v2; bloom GIN engages via native array_ops. Findings for the EQL team: text_search requires the ORE term (string ingest capped at ~1.3k rec/s, -87% vs v2 — an hm+bf-only domain would restore it); ORE ordered scans +43% vs v2 (plpgsql opclass comparator); diffuse +12-44% on JSON extractor costs. Full detail and diagnosed bench artifacts in report/V3_COMPARISON.md. Claude-Session: https://claude.ai/code/session_01StQnoycoFXMDdSpQ6zKDav --- Cargo.lock | 107 + Cargo.toml | 77 + README.md | 32 + benches/combo_v3.rs | 153 + benches/exact_v3.rs | 157 + benches/group_by_v3.rs | 135 + benches/json_v3.rs | 482 +++ benches/match_v3.rs | 235 ++ benches/ope_v3.rs | 193 + benches/ore_v3.rs | 164 + benches/plaintext_v3.rs | 175 + benches/scalar_smoke_v3.rs | 205 ++ mise.toml | 354 +- report/V3_COMPARISON.md | 359 ++ report/v3/ore_vs_ope_10000.png | Bin 0 -> 53285 bytes report/v3/ore_vs_ope_10000.svg | 1574 ++++++++ report/v3/ore_vs_ope_100000.png | Bin 0 -> 52411 bytes report/v3/ore_vs_ope_100000.svg | 1573 ++++++++ report/v3/ore_vs_ope_1000000.png | Bin 0 -> 53269 bytes report/v3/ore_vs_ope_1000000.svg | 1577 ++++++++ report/v3/overhead_vs_plaintext_10000.png | Bin 0 -> 97763 bytes report/v3/overhead_vs_plaintext_10000.svg | 2228 +++++++++++ report/v3/overhead_vs_plaintext_100000.png | Bin 0 -> 99925 bytes report/v3/overhead_vs_plaintext_100000.svg | 2215 +++++++++++ report/v3/overhead_vs_plaintext_1000000.png | Bin 0 -> 99416 bytes report/v3/overhead_vs_plaintext_1000000.svg | 2181 +++++++++++ report/v3/v3_vs_v2_10000.png | Bin 0 -> 198609 bytes report/v3/v3_vs_v2_10000.svg | 3253 ++++++++++++++++ report/v3/v3_vs_v2_100000.png | Bin 0 -> 201061 bytes report/v3/v3_vs_v2_100000.svg | 3261 +++++++++++++++++ report/v3/v3_vs_v2_1000000.png | Bin 0 -> 201782 bytes report/v3/v3_vs_v2_1000000.svg | 3185 ++++++++++++++++ report_v3_compare.py | 536 +++ results/ingest/index_build_times.jsonl | 18 + .../v3/encrypt_category_v3_combined.json | 42 + .../v3/encrypt_int_ope_v3_combined.json | 42 + .../ingest/v3/encrypt_int_v3_combined.json | 42 + .../v3/encrypt_ste_vec_small_v3_combined.json | 42 + .../ingest/v3/encrypt_string_v3_combined.json | 42 + results/query/v3/combo_metadata_10000.json | 303 ++ results/query/v3/combo_metadata_100000.json | 303 ++ results/query/v3/combo_metadata_1000000.json | 303 ++ results/query/v3/combo_rows_10000.json | 4 + results/query/v3/combo_rows_100000.json | 4 + results/query/v3/combo_rows_1000000.json | 4 + results/query/v3/exact_metadata_10000.json | 281 ++ results/query/v3/exact_metadata_100000.json | 267 ++ results/query/v3/exact_metadata_1000000.json | 233 ++ results/query/v3/exact_rows_10000.json | 5 + results/query/v3/exact_rows_100000.json | 5 + results/query/v3/exact_rows_1000000.json | 5 + results/query/v3/group_by_metadata_10000.json | 247 ++ .../query/v3/group_by_metadata_100000.json | 247 ++ .../query/v3/group_by_metadata_1000000.json | 423 +++ results/query/v3/group_by_rows_10000.json | 5 + results/query/v3/group_by_rows_100000.json | 5 + results/query/v3/group_by_rows_1000000.json | 5 + results/query/v3/json_metadata_10000.json | 215 ++ results/query/v3/json_metadata_100000.json | 215 ++ results/query/v3/json_metadata_1000000.json | 215 ++ results/query/v3/json_rows_10000.json | 6 + results/query/v3/json_rows_100000.json | 6 + results/query/v3/json_rows_1000000.json | 6 + results/query/v3/match_metadata_10000.json | 485 +++ results/query/v3/match_metadata_100000.json | 269 ++ results/query/v3/match_metadata_1000000.json | 269 ++ results/query/v3/match_rows_10000.json | 10 + results/query/v3/match_rows_100000.json | 7 + results/query/v3/match_rows_1000000.json | 7 + results/query/v3/ope_metadata_10000.json | 234 ++ results/query/v3/ope_metadata_100000.json | 234 ++ results/query/v3/ope_metadata_1000000.json | 234 ++ results/query/v3/ope_rows_10000.json | 11 + results/query/v3/ope_rows_100000.json | 11 + results/query/v3/ope_rows_1000000.json | 11 + results/query/v3/ore_metadata_10000.json | 260 ++ results/query/v3/ore_metadata_100000.json | 260 ++ results/query/v3/ore_metadata_1000000.json | 260 ++ results/query/v3/ore_rows_10000.json | 11 + results/query/v3/ore_rows_100000.json | 11 + results/query/v3/ore_rows_1000000.json | 11 + .../query/v3/plaintext_metadata_10000.json | 204 ++ .../query/v3/plaintext_metadata_100000.json | 188 + .../query/v3/plaintext_metadata_1000000.json | 188 + results/query/v3/plaintext_rows_10000.json | 6 + results/query/v3/plaintext_rows_100000.json | 6 + results/query/v3/plaintext_rows_1000000.json | 6 + .../query/v3/scalar_smoke_metadata_10000.json | 495 +++ results/query/v3/scalar_smoke_rows_10000.json | 12 + .../category_encrypted_v3_1000000_down.sql | 1 + .../category_encrypted_v3_1000000_up.sql | 6 + .../category_encrypted_v3_100000_down.sql | 1 + .../category_encrypted_v3_100000_up.sql | 6 + .../category_encrypted_v3_10000_down.sql | 1 + .../category_encrypted_v3_10000_up.sql | 6 + .../combo_encrypted_v3_1000000_down.sql | 3 + sql/indexes/combo_encrypted_v3_1000000_up.sql | 11 + .../combo_encrypted_v3_100000_down.sql | 3 + sql/indexes/combo_encrypted_v3_100000_up.sql | 11 + sql/indexes/combo_encrypted_v3_10000_down.sql | 3 + sql/indexes/combo_encrypted_v3_10000_up.sql | 11 + .../integer_encrypted_ope_v3_1000000_down.sql | 1 + .../integer_encrypted_ope_v3_1000000_up.sql | 6 + .../integer_encrypted_ope_v3_100000_down.sql | 1 + .../integer_encrypted_ope_v3_100000_up.sql | 6 + .../integer_encrypted_ope_v3_10000_down.sql | 1 + .../integer_encrypted_ope_v3_10000_up.sql | 6 + .../integer_encrypted_v3_1000000_down.sql | 1 + .../integer_encrypted_v3_1000000_up.sql | 6 + .../integer_encrypted_v3_100000_down.sql | 1 + .../integer_encrypted_v3_100000_up.sql | 6 + .../integer_encrypted_v3_10000_down.sql | 1 + sql/indexes/integer_encrypted_v3_10000_up.sql | 6 + ...te_vec_small_encrypted_v3_1000000_down.sql | 1 + ..._ste_vec_small_encrypted_v3_1000000_up.sql | 11 + ...ste_vec_small_encrypted_v3_100000_down.sql | 1 + ...n_ste_vec_small_encrypted_v3_100000_up.sql | 11 + ..._ste_vec_small_encrypted_v3_10000_down.sql | 1 + ...on_ste_vec_small_encrypted_v3_10000_up.sql | 11 + .../string_encrypted_v3_1000000_down.sql | 3 + .../string_encrypted_v3_1000000_up.sql | 23 + .../string_encrypted_v3_100000_down.sql | 3 + sql/indexes/string_encrypted_v3_100000_up.sql | 23 + .../string_encrypted_v3_10000_down.sql | 3 + sql/indexes/string_encrypted_v3_10000_up.sql | 23 + sql/plaintext_baselines.sql | 39 + sql/schema_v3.sql | 177 + src/bin/decrypt_probe.rs | 47 + src/bin/encrypt_category_v3.rs | 42 + src/bin/encrypt_combo_v3.rs | 132 + src/bin/encrypt_int_ope_v3.rs | 53 + src/bin/encrypt_int_v3.rs | 42 + src/bin/encrypt_scalar_smoke_v3.rs | 149 + src/bin/encrypt_ste_vec_small_v3.rs | 60 + src/bin/encrypt_string_v3.rs | 51 + src/lib.rs | 16 +- src/v3.rs | 444 +++ 137 files changed, 33120 insertions(+), 2 deletions(-) create mode 100644 benches/combo_v3.rs create mode 100644 benches/exact_v3.rs create mode 100644 benches/group_by_v3.rs create mode 100644 benches/json_v3.rs create mode 100644 benches/match_v3.rs create mode 100644 benches/ope_v3.rs create mode 100644 benches/ore_v3.rs create mode 100644 benches/plaintext_v3.rs create mode 100644 benches/scalar_smoke_v3.rs create mode 100644 report/V3_COMPARISON.md create mode 100644 report/v3/ore_vs_ope_10000.png create mode 100644 report/v3/ore_vs_ope_10000.svg create mode 100644 report/v3/ore_vs_ope_100000.png create mode 100644 report/v3/ore_vs_ope_100000.svg create mode 100644 report/v3/ore_vs_ope_1000000.png create mode 100644 report/v3/ore_vs_ope_1000000.svg create mode 100644 report/v3/overhead_vs_plaintext_10000.png create mode 100644 report/v3/overhead_vs_plaintext_10000.svg create mode 100644 report/v3/overhead_vs_plaintext_100000.png create mode 100644 report/v3/overhead_vs_plaintext_100000.svg create mode 100644 report/v3/overhead_vs_plaintext_1000000.png create mode 100644 report/v3/overhead_vs_plaintext_1000000.svg create mode 100644 report/v3/v3_vs_v2_10000.png create mode 100644 report/v3/v3_vs_v2_10000.svg create mode 100644 report/v3/v3_vs_v2_100000.png create mode 100644 report/v3/v3_vs_v2_100000.svg create mode 100644 report/v3/v3_vs_v2_1000000.png create mode 100644 report/v3/v3_vs_v2_1000000.svg create mode 100644 report_v3_compare.py create mode 100644 results/ingest/index_build_times.jsonl create mode 100644 results/ingest/v3/encrypt_category_v3_combined.json create mode 100644 results/ingest/v3/encrypt_int_ope_v3_combined.json create mode 100644 results/ingest/v3/encrypt_int_v3_combined.json create mode 100644 results/ingest/v3/encrypt_ste_vec_small_v3_combined.json create mode 100644 results/ingest/v3/encrypt_string_v3_combined.json create mode 100644 results/query/v3/combo_metadata_10000.json create mode 100644 results/query/v3/combo_metadata_100000.json create mode 100644 results/query/v3/combo_metadata_1000000.json create mode 100644 results/query/v3/combo_rows_10000.json create mode 100644 results/query/v3/combo_rows_100000.json create mode 100644 results/query/v3/combo_rows_1000000.json create mode 100644 results/query/v3/exact_metadata_10000.json create mode 100644 results/query/v3/exact_metadata_100000.json create mode 100644 results/query/v3/exact_metadata_1000000.json create mode 100644 results/query/v3/exact_rows_10000.json create mode 100644 results/query/v3/exact_rows_100000.json create mode 100644 results/query/v3/exact_rows_1000000.json create mode 100644 results/query/v3/group_by_metadata_10000.json create mode 100644 results/query/v3/group_by_metadata_100000.json create mode 100644 results/query/v3/group_by_metadata_1000000.json create mode 100644 results/query/v3/group_by_rows_10000.json create mode 100644 results/query/v3/group_by_rows_100000.json create mode 100644 results/query/v3/group_by_rows_1000000.json create mode 100644 results/query/v3/json_metadata_10000.json create mode 100644 results/query/v3/json_metadata_100000.json create mode 100644 results/query/v3/json_metadata_1000000.json create mode 100644 results/query/v3/json_rows_10000.json create mode 100644 results/query/v3/json_rows_100000.json create mode 100644 results/query/v3/json_rows_1000000.json create mode 100644 results/query/v3/match_metadata_10000.json create mode 100644 results/query/v3/match_metadata_100000.json create mode 100644 results/query/v3/match_metadata_1000000.json create mode 100644 results/query/v3/match_rows_10000.json create mode 100644 results/query/v3/match_rows_100000.json create mode 100644 results/query/v3/match_rows_1000000.json create mode 100644 results/query/v3/ope_metadata_10000.json create mode 100644 results/query/v3/ope_metadata_100000.json create mode 100644 results/query/v3/ope_metadata_1000000.json create mode 100644 results/query/v3/ope_rows_10000.json create mode 100644 results/query/v3/ope_rows_100000.json create mode 100644 results/query/v3/ope_rows_1000000.json create mode 100644 results/query/v3/ore_metadata_10000.json create mode 100644 results/query/v3/ore_metadata_100000.json create mode 100644 results/query/v3/ore_metadata_1000000.json create mode 100644 results/query/v3/ore_rows_10000.json create mode 100644 results/query/v3/ore_rows_100000.json create mode 100644 results/query/v3/ore_rows_1000000.json create mode 100644 results/query/v3/plaintext_metadata_10000.json create mode 100644 results/query/v3/plaintext_metadata_100000.json create mode 100644 results/query/v3/plaintext_metadata_1000000.json create mode 100644 results/query/v3/plaintext_rows_10000.json create mode 100644 results/query/v3/plaintext_rows_100000.json create mode 100644 results/query/v3/plaintext_rows_1000000.json create mode 100644 results/query/v3/scalar_smoke_metadata_10000.json create mode 100644 results/query/v3/scalar_smoke_rows_10000.json create mode 100644 sql/indexes/category_encrypted_v3_1000000_down.sql create mode 100644 sql/indexes/category_encrypted_v3_1000000_up.sql create mode 100644 sql/indexes/category_encrypted_v3_100000_down.sql create mode 100644 sql/indexes/category_encrypted_v3_100000_up.sql create mode 100644 sql/indexes/category_encrypted_v3_10000_down.sql create mode 100644 sql/indexes/category_encrypted_v3_10000_up.sql create mode 100644 sql/indexes/combo_encrypted_v3_1000000_down.sql create mode 100644 sql/indexes/combo_encrypted_v3_1000000_up.sql create mode 100644 sql/indexes/combo_encrypted_v3_100000_down.sql create mode 100644 sql/indexes/combo_encrypted_v3_100000_up.sql create mode 100644 sql/indexes/combo_encrypted_v3_10000_down.sql create mode 100644 sql/indexes/combo_encrypted_v3_10000_up.sql create mode 100644 sql/indexes/integer_encrypted_ope_v3_1000000_down.sql create mode 100644 sql/indexes/integer_encrypted_ope_v3_1000000_up.sql create mode 100644 sql/indexes/integer_encrypted_ope_v3_100000_down.sql create mode 100644 sql/indexes/integer_encrypted_ope_v3_100000_up.sql create mode 100644 sql/indexes/integer_encrypted_ope_v3_10000_down.sql create mode 100644 sql/indexes/integer_encrypted_ope_v3_10000_up.sql create mode 100644 sql/indexes/integer_encrypted_v3_1000000_down.sql create mode 100644 sql/indexes/integer_encrypted_v3_1000000_up.sql create mode 100644 sql/indexes/integer_encrypted_v3_100000_down.sql create mode 100644 sql/indexes/integer_encrypted_v3_100000_up.sql create mode 100644 sql/indexes/integer_encrypted_v3_10000_down.sql create mode 100644 sql/indexes/integer_encrypted_v3_10000_up.sql create mode 100644 sql/indexes/json_ste_vec_small_encrypted_v3_1000000_down.sql create mode 100644 sql/indexes/json_ste_vec_small_encrypted_v3_1000000_up.sql create mode 100644 sql/indexes/json_ste_vec_small_encrypted_v3_100000_down.sql create mode 100644 sql/indexes/json_ste_vec_small_encrypted_v3_100000_up.sql create mode 100644 sql/indexes/json_ste_vec_small_encrypted_v3_10000_down.sql create mode 100644 sql/indexes/json_ste_vec_small_encrypted_v3_10000_up.sql create mode 100644 sql/indexes/string_encrypted_v3_1000000_down.sql create mode 100644 sql/indexes/string_encrypted_v3_1000000_up.sql create mode 100644 sql/indexes/string_encrypted_v3_100000_down.sql create mode 100644 sql/indexes/string_encrypted_v3_100000_up.sql create mode 100644 sql/indexes/string_encrypted_v3_10000_down.sql create mode 100644 sql/indexes/string_encrypted_v3_10000_up.sql create mode 100644 sql/plaintext_baselines.sql create mode 100644 sql/schema_v3.sql create mode 100644 src/bin/decrypt_probe.rs create mode 100644 src/bin/encrypt_category_v3.rs create mode 100644 src/bin/encrypt_combo_v3.rs create mode 100644 src/bin/encrypt_int_ope_v3.rs create mode 100644 src/bin/encrypt_int_v3.rs create mode 100644 src/bin/encrypt_scalar_smoke_v3.rs create mode 100644 src/bin/encrypt_ste_vec_small_v3.rs create mode 100644 src/bin/encrypt_string_v3.rs create mode 100644 src/v3.rs diff --git a/Cargo.lock b/Cargo.lock index 0c9173d..b2ad756 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1127,11 +1127,13 @@ dependencies = [ "cipherstash-client", "criterion", "criterion-table", + "eql-bindings", "fake 4.4.0", "hex", "hex-literal", "ore-rs 0.7.0", "rand 0.8.6", + "rust_decimal", "serde", "serde_json", "sqlx", @@ -1296,6 +1298,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "either" version = "1.15.0" @@ -1317,6 +1325,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "eql-bindings" +version = "0.4.0" +source = "git+https://github.com/cipherstash/encrypt-query-language?branch=eql_v3#c28e08377f5b6ca98f8c8968508d724665a73670" +dependencies = [ + "schemars", + "serde", + "serde_json", + "ts-rs", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -3275,6 +3294,26 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "regex" version = "1.12.3" @@ -3637,6 +3676,31 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -3741,6 +3805,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "serde_json" version = "1.0.149" @@ -4287,6 +4362,15 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "terminal_size" version = "0.4.4" @@ -4683,6 +4767,29 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ts-rs" +version = "10.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e640d9b0964e9d39df633548591090ab92f7a4567bc31d3891af23471a3365c6" +dependencies = [ + "lazy_static", + "thiserror 2.0.18", + "ts-rs-macros", +] + +[[package]] +name = "ts-rs-macros" +version = "10.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9d8656589772eeec2cf7a8264d9cda40fb28b9bc53118ceb9e8c07f8f38730" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "termcolor", +] + [[package]] name = "typenum" version = "1.20.0" diff --git a/Cargo.toml b/Cargo.toml index 251a12c..df20b42 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,15 @@ rand = "0.8.5" # in mise.toml's setup-db task. cipherstash-client = { version = "=0.34.1-alpha.9", features = ["tokio"] } stack-profile = "=0.34.1-alpha.9" +# EQL v2.3 -> v3 payload conversion (the supported migration path; the +# converter's docs name these benches as a consumer). The pinned +# cipherstash-client above emits v2.3 payloads; eql-bindings::from_v2 +# reshapes them into the eql_v3 domain payloads. Switch `branch` to +# `tag = "eql-"` once EQL v3 is released. +eql-bindings = { git = "https://github.com/cipherstash/encrypt-query-language", branch = "eql_v3" } +# Plaintext::Decimal takes rust_decimal::Decimal, which cipherstash-client +# does not re-export — used by the scalar_smoke_v3 numeric-family coverage. +rust_decimal = "1" anyhow = "1.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" @@ -76,6 +85,38 @@ path = "src/bin/encrypt_category.rs" name = "encrypt_combo" path = "src/bin/encrypt_combo.rs" +[[bin]] +name = "encrypt_string_v3" +path = "src/bin/encrypt_string_v3.rs" + +[[bin]] +name = "encrypt_int_v3" +path = "src/bin/encrypt_int_v3.rs" + +[[bin]] +name = "encrypt_int_ope_v3" +path = "src/bin/encrypt_int_ope_v3.rs" + +[[bin]] +name = "encrypt_category_v3" +path = "src/bin/encrypt_category_v3.rs" + +[[bin]] +name = "encrypt_combo_v3" +path = "src/bin/encrypt_combo_v3.rs" + +[[bin]] +name = "encrypt_ste_vec_small_v3" +path = "src/bin/encrypt_ste_vec_small_v3.rs" + +[[bin]] +name = "encrypt_scalar_smoke_v3" +path = "src/bin/encrypt_scalar_smoke_v3.rs" + +[[bin]] +name = "decrypt_probe" +path = "src/bin/decrypt_probe.rs" + [[bench]] name = "ore" harness = false @@ -98,4 +139,40 @@ harness = false [[bench]] name = "combo" +harness = false + +[[bench]] +name = "exact_v3" +harness = false + +[[bench]] +name = "ore_v3" +harness = false + +[[bench]] +name = "group_by_v3" +harness = false + +[[bench]] +name = "match_v3" +harness = false + +[[bench]] +name = "ope_v3" +harness = false + +[[bench]] +name = "combo_v3" +harness = false + +[[bench]] +name = "json_v3" +harness = false + +[[bench]] +name = "scalar_smoke_v3" +harness = false + +[[bench]] +name = "plaintext_v3" harness = false \ No newline at end of file diff --git a/README.md b/README.md index 9a6c38e..f3b1c00 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,38 @@ The latest benchmark results are available in the [`report/`](report/) directory - **[Benchmark Report](report/BENCHMARK_REPORT.md)** - Comprehensive report with performance tables and charts - Includes ingest throughput, query performance, SQL statements, and index configurations - Performance indicators (⚠️) highlight queries exceeding 100ms +- **[EQL v3 vs v2 Comparison](report/V3_COMPARISON.md)** - regression tables, index-engagement audit, and docs/marketing charts (`report/v3/`) + +## 🆕 EQL v3 benches + +The `*_v3` benches target the upcoming EQL v3 release (domain-specific types — +`eql_v3.text_search`, `eql_v3.integer_ord`, … — replacing the single +`eql_v2_encrypted` composite). They live alongside the v2 benches; the +committed v2 results are the regression baseline and are never overwritten +(v3 results land in `results/query/v3/` and `results/ingest/v3/`). + +```bash +# Install the v3 bundle alongside v2 (builds from a local eql_v3 checkout; +# see EQL_V3_DIR / EQL_V3_SQL in the task for overrides) +mise run setup-db-v3 + +# Populate all v3 tables at a tier +mise run prepare:v3:all 10000 + +# Query benches: exact | match | ore | ope | group_by | combo | json | plaintext | smoke +mise run bench:v3:query:all 10000 # arg = max tier (10000 | 100000 | 1000000) + +# Ingest benches (hyperfine, same tiers as v2) +mise run bench:v3:ingest + +# v3-vs-v2 + encrypted-vs-plaintext report and charts +mise run report:v3-compare +``` + +v3 payloads are produced by converting the pinned cipherstash-client's v2.3 +output through `eql-bindings::from_v2` (the supported migration path — see +`src/v3.rs` for the details and caveats, including the synthetic CLLW-OPE +term used by the `ope` benches until a client release emits `op`). ### Other benchmarks diff --git a/benches/combo_v3.rs b/benches/combo_v3.rs new file mode 100644 index 0000000..dfa4694 --- /dev/null +++ b/benches/combo_v3.rs @@ -0,0 +1,153 @@ +//! EQL v3 sibling of `benches/combo.rs` — composite-predicate scenarios over +//! `combo_encrypted_v3_` (`name` text_search, `age` integer_ord, +//! `category` text_eq). +//! +//! v2's `name LIKE $1` becomes `name @> $1` (v3 has no LIKE — identical +//! bloom-containment semantics); the extractor references move to the v3 +//! functions (`eql_v3.ord_term(age)`, `eql_v3.eq_term(category)`). Scenario +//! ids are unchanged. + +use cipherstash_client::{ + eql::Identifier, + schema::{column::Index, ColumnConfig, ColumnType}, +}; +use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use dbbenches::{ + bench_assert, init_scoped_cipher, init_tracing, + v3::{encrypt_stored_v3, V3EncryptedQuery}, + write_metadata_file_in, ScenarioMetadata, +}; +use sqlx::postgres::PgPoolOptions; +use sqlx::types::Json; +use std::sync::Arc; +use tokio::runtime::Runtime; + +static QUERY_TEMPLATES: &[(&str, &str)] = &[ + ( + "SELECT id FROM {TABLE} \ + WHERE name @> $1 \ + ORDER BY eql_v3.ord_term(age) LIMIT 10", + "bloom_ore_order_limit", + ), + ( + "SELECT eql_v3.eq_term(category), count(*) FROM {TABLE} \ + WHERE name @> $1 \ + GROUP BY 1", + "filtered_group_by", + ), + ( + "SELECT eql_v3.eq_term(category), count(*) FROM {TABLE} \ + WHERE name @> $1 \ + GROUP BY 1 ORDER BY count(*) DESC LIMIT 10", + "top_n_filtered_group_by", + ), +]; + +fn criterion_benchmark(c: &mut Criterion) { + init_tracing(); + let rt = Runtime::new().unwrap(); + + let target_rows = std::env::var("TARGET_ROWS").unwrap_or_else(|_| "unknown".to_string()); + let table_suffix = match target_rows.as_str() { + "10000" | "100000" | "1000000" | "10000000" => format!("_{}", target_rows), + _ => String::new(), + }; + let table_name = format!("combo_encrypted_v3{}", table_suffix); + + let (pool, cipher) = rt.block_on(async { + let database_url = + std::env::var("DATABASE_URL").expect("DATABASE_URL environment variable must be set"); + let pool = PgPoolOptions::new() + .max_connections(5) + .connect(&database_url) + .await + .expect("Failed to connect to database"); + let cipher = init_scoped_cipher() + .await + .expect("Failed to initialize ScopedCipher"); + (pool, cipher) + }); + + // Same pattern as the v2 bench (and match_v3). The needle payload + // carries hm+ob+bf of "Bob"; only the bloom term participates in `@>`. + let pattern = "Bob"; + let name_config = ColumnConfig::build("name") + .casts_as(ColumnType::Text) + .add_index(Index::new_unique()) + .add_index(Index::new_match()) + .add_index(Index::new_ore()); + let name_ident = Identifier::new(&table_name, "name"); + + let queries: Vec = rt.block_on(async { + let mut queries = Vec::with_capacity(QUERY_TEMPLATES.len()); + for (query_template, _) in QUERY_TEMPLATES { + let query_str = query_template.replace("{TABLE}", &table_name); + let param = encrypt_stored_v3( + Arc::clone(&cipher), + &name_config, + &name_ident, + pattern.to_string(), + "text_search", + ) + .await + .expect("failed to encrypt+convert name pattern"); + queries.push(V3EncryptedQuery::new(param, query_str, Arc::clone(&cipher))); + } + queries + }); + + // Combo result shapes don't match V3EncryptedQuery::execute's typed + // tuple, so metadata row counts and the iter loop use raw sqlx. + let metadata = rt.block_on(async { + let mut out = Vec::with_capacity(queries.len()); + for (i, query) in queries.iter().enumerate() { + let (_, scenario) = QUERY_TEMPLATES[i]; + let bench_id = format!("COMBO/combo/{}/{}", scenario, target_rows); + let explain = query.explain(&pool).await.expect("EXPLAIN failed"); + let indexes_used = dbbenches::extract_indexes_used(&explain); + let rows = sqlx::query(&query.statement) + .bind(Json(&query.param)) + .fetch_all(&pool) + .await + .expect("execute for row-count failed"); + out.push(ScenarioMetadata { + id: bench_id, + query: query.statement.clone(), + parameters: vec![query.parameter_json().expect("serialise parameter")], + explain, + indexes_used, + rows_returned: rows.len() as u64, + }); + } + out + }); + write_metadata_file_in("results/query/v3", "combo", &target_rows, metadata) + .expect("failed to write bench metadata sidecar"); + + let mut group = c.benchmark_group("COMBO"); + group.sample_size(10); + + for (i, query) in queries.into_iter().enumerate() { + let (_, scenario) = QUERY_TEMPLATES[i]; + let exec_id = format!("COMBO/combo/{}/{}", scenario, target_rows); + + let exec_id_inner = exec_id.clone(); + group.bench_function(format!("combo/{}/{}", scenario, target_rows), |b| { + b.to_async(&rt).iter(|| async { + let rows = bench_assert( + sqlx::query(&query.statement) + .bind(Json(&query.param)) + .fetch_all(&pool) + .await, + &exec_id_inner, + ); + black_box(rows.len()); + }) + }); + } + + group.finish(); +} + +criterion_group!(benches, criterion_benchmark); +criterion_main!(benches); diff --git a/benches/exact_v3.rs b/benches/exact_v3.rs new file mode 100644 index 0000000..ca0a06c --- /dev/null +++ b/benches/exact_v3.rs @@ -0,0 +1,157 @@ +//! EQL v3 sibling of `benches/exact.rs` — equality lookups against +//! `string_encrypted_v3_` (`eql_v3.text_search`). +//! +//! Scenario ids are kept IDENTICAL to the v2 bench (`eql_cast`, `eql_hash`) +//! so the comparison report joins v2 vs v3 by (group, scenario, tier); the +//! version dimension is the results directory (`results/query/v3/`). +//! +//! v3 differences worth knowing when reading numbers: +//! * The bound parameter is a STORED-shape v3 payload (encrypt Store + +//! from_v2) — no v3 scalar query wire shape exists. The `=` operator's +//! `(text_search, jsonb)` overload casts it in-plan. +//! * `eql_hash` keeps its id but the index is a functional BTREE on +//! `eql_v3.eq_term(value)` (v2 used hash — see the index DDL comments). + +use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use dbbenches::{ + bench_assert, + v3::{encrypt_stored_v3, sample_plaintext_string_v3, V3EncryptedQuery}, + init_scoped_cipher, init_tracing, write_metadata_file_in, ScenarioMetadata, +}; +use cipherstash_client::{ + eql::Identifier, + schema::{column::Index, ColumnConfig, ColumnType}, +}; +use sqlx::postgres::PgPoolOptions; +use std::sync::Arc; +use tokio::runtime::Runtime; + +static QUERY_TEMPLATES: &[(&str, &str)] = &[ + ( + "SELECT id, value FROM {TABLE} WHERE value = $1 LIMIT 1", + "eql_cast", + ), + ( + "SELECT id, value FROM {TABLE} WHERE eql_v3.eq_term(value) = eql_v3.eq_term($1::eql_v3.text_search) LIMIT 1", + "eql_hash", + ), +]; + +fn criterion_benchmark(c: &mut Criterion) { + init_tracing(); + let rt = Runtime::new().unwrap(); + + let target_rows = std::env::var("TARGET_ROWS").unwrap_or_else(|_| "unknown".to_string()); + let table_suffix = match target_rows.as_str() { + "10000" | "100000" | "1000000" | "10000000" => format!("_{}", target_rows), + _ => String::new(), + }; + let table_name = format!("string_encrypted_v3{}", table_suffix); + + let (pool, cipher) = rt.block_on(async { + let database_url = + std::env::var("DATABASE_URL").expect("DATABASE_URL environment variable must be set"); + let pool = PgPoolOptions::new() + .max_connections(5) + .connect(&database_url) + .await + .expect("Failed to connect to database"); + let cipher = init_scoped_cipher() + .await + .expect("Failed to initialize ScopedCipher"); + (pool, cipher) + }); + + // Sample a real row so the equality scenarios measure index hit + 1-row + // return, not a 0-rows early exit (same rationale as the v2 bench). + let search_term: String = rt.block_on(async { + sample_plaintext_string_v3(&pool, Arc::clone(&cipher), &table_name) + .await + .expect("failed to sample plaintext from table — is it populated?") + }); + eprintln!( + "exact_v3 bench: using sampled search term `{}` from `{}`", + &search_term, &table_name + ); + + // The needle config must match the ingest config (unique+match+ore) so + // the converted payload satisfies text_search's required terms. + let column_config = ColumnConfig::build("value") + .casts_as(ColumnType::Text) + .add_index(Index::new_unique()) + .add_index(Index::new_match()) + .add_index(Index::new_ore()); + let identifier = Identifier::new(&table_name, "value"); + + let queries: Vec = rt.block_on(async { + let mut queries = Vec::with_capacity(QUERY_TEMPLATES.len()); + for (query_template, _) in QUERY_TEMPLATES { + let query_str = query_template.replace("{TABLE}", &table_name); + let param = encrypt_stored_v3( + Arc::clone(&cipher), + &column_config, + &identifier, + search_term.clone(), + "text_search", + ) + .await + .expect("failed to encrypt+convert query needle"); + queries.push(V3EncryptedQuery::new(param, query_str, Arc::clone(&cipher))); + } + queries + }); + + let metadata = rt.block_on(async { + let mut out = Vec::with_capacity(queries.len()); + for (i, query) in queries.iter().enumerate() { + let (_, scenario) = QUERY_TEMPLATES[i]; + let bench_id = format!("EXACT/exact/{}/{}", scenario, target_rows); + let (explain, indexes_used, rows_returned) = query + .capture_metadata(&pool) + .await + .expect("metadata capture failed"); + out.push(ScenarioMetadata { + id: bench_id, + query: query.statement.clone(), + parameters: vec![query.parameter_json().expect("serialise parameter")], + explain, + indexes_used, + rows_returned, + }); + } + out + }); + write_metadata_file_in("results/query/v3", "exact", &target_rows, metadata) + .expect("failed to write bench metadata sidecar"); + + let mut group = c.benchmark_group("EXACT"); + group.sample_size(10); + + for (i, query) in queries.into_iter().enumerate() { + let (_, scenario) = QUERY_TEMPLATES[i]; + let exec_id = format!("EXACT/exact/{}/{}", scenario, target_rows); + let decrypt_id = format!("EXACT/exact_decrypt/{}/{}", scenario, target_rows); + + let exec_id_inner = exec_id.clone(); + group.bench_function(format!("exact/{}/{}", scenario, target_rows), |b| { + b.to_async(&rt).iter(|| async { + let _: Vec<_> = bench_assert(query.execute(&pool).await, &exec_id_inner); + }) + }); + + let decrypt_id_inner = decrypt_id.clone(); + group.bench_function(format!("exact_decrypt/{}/{}", scenario, target_rows), |b| { + b.to_async(&rt).iter(|| async { + let _r: Vec = black_box(bench_assert( + query.execute_and_decrypt(&pool).await, + &decrypt_id_inner, + )); + }) + }); + } + + group.finish(); +} + +criterion_group!(benches, criterion_benchmark); +criterion_main!(benches); diff --git a/benches/group_by_v3.rs b/benches/group_by_v3.rs new file mode 100644 index 0000000..7f39385 --- /dev/null +++ b/benches/group_by_v3.rs @@ -0,0 +1,135 @@ +//! EQL v3 sibling of `benches/group_by.rs` — realistic-cardinality GROUP BY +//! against `category_encrypted_v3_` (`eql_v3.text_eq`). The plaintext +//! baselines run against the SAME `category_plaintext_` tables the v2 +//! bench uses (shared baseline — pure-SQL populated, version-independent). +//! +//! v2's `eql_v2.hmac_256(value)` group key becomes `eql_v3.eq_term(value)`; +//! scenario ids are unchanged. No bound parameters (grouping only), so the +//! scenarios run via raw sqlx exactly like the v2 bench. + +use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use dbbenches::{ + bench_assert, extract_indexes_used, init_tracing, write_metadata_file_in, ScenarioMetadata, +}; +use sqlx::postgres::PgPoolOptions; +use sqlx::types::Json; +use sqlx::Row; +use tokio::runtime::Runtime; + +// (sql_template, scenario_name, base_table_name) +static QUERY_TEMPLATES: &[(&str, &str, &str)] = &[ + ( + "SELECT count(*) FROM \ + (SELECT 1 FROM {TABLE} GROUP BY eql_v3.eq_term(value)) g", + "low_cardinality_groups_encrypted", + "category_encrypted_v3", + ), + ( + "SELECT count(*) FROM \ + (SELECT 1 FROM {TABLE} GROUP BY value) g", + "low_cardinality_groups_plaintext", + "category_plaintext", + ), + ( + "SELECT eql_v3.eq_term(value), count(*) FROM {TABLE} \ + GROUP BY 1 ORDER BY count(*) DESC LIMIT 10", + "top_n_groups_encrypted", + "category_encrypted_v3", + ), + ( + "SELECT value, count(*) FROM {TABLE} \ + GROUP BY 1 ORDER BY count(*) DESC LIMIT 10", + "top_n_groups_plaintext", + "category_plaintext", + ), +]; + +fn criterion_benchmark(c: &mut Criterion) { + init_tracing(); + let rt = Runtime::new().unwrap(); + + let target_rows = std::env::var("TARGET_ROWS").unwrap_or_else(|_| "unknown".to_string()); + let table_suffix = match target_rows.as_str() { + "10000" | "100000" | "1000000" | "10000000" => format!("_{}", target_rows), + _ => String::new(), + }; + + let pool = rt.block_on(async { + let database_url = + std::env::var("DATABASE_URL").expect("DATABASE_URL environment variable must be set"); + PgPoolOptions::new() + .max_connections(5) + .connect(&database_url) + .await + .expect("Failed to connect to database") + }); + + let scenarios: Vec<(String, String)> = QUERY_TEMPLATES + .iter() + .map(|(query_template, scenario, base_table)| { + let table_name = format!("{}{}", base_table, table_suffix); + let query_str = query_template.replace("{TABLE}", &table_name); + let bench_id = format!("GROUP_BY/group_by/{}/{}", scenario, target_rows); + (bench_id, query_str) + }) + .collect(); + + let metadata = rt.block_on(async { + let mut out = Vec::with_capacity(scenarios.len()); + for (bench_id, query_str) in &scenarios { + let explain_sql = format!("EXPLAIN (FORMAT JSON) {}", query_str); + let plan: (Json,) = sqlx::query_as(&explain_sql) + .fetch_one(&pool) + .await + .expect("EXPLAIN failed for bench scenario"); + let explain = plan.0 .0; + let indexes_used = extract_indexes_used(&explain); + let rows = sqlx::query(query_str) + .fetch_all(&pool) + .await + .expect("execute for row-count failed"); + let rows_returned = rows.len() as u64; + out.push(ScenarioMetadata { + id: bench_id.clone(), + query: query_str.clone(), + parameters: Vec::new(), + explain, + indexes_used, + rows_returned, + }); + } + out + }); + + write_metadata_file_in("results/query/v3", "group_by", &target_rows, metadata) + .expect("failed to write bench metadata sidecar"); + + let mut group = c.benchmark_group("GROUP_BY"); + group.sample_size(10); + + for (bench_id, query_str) in scenarios { + let function_name = bench_id + .strip_prefix("GROUP_BY/") + .expect("bench_id missing GROUP_BY/ prefix") + .to_string(); + let scenario_id = bench_id.clone(); + group.bench_function(function_name, |b| { + b.to_async(&rt).iter(|| async { + let rows = bench_assert( + sqlx::query(&query_str).fetch_all(&pool).await, + &scenario_id, + ); + if rows.len() == 1 { + black_box(rows[0].get::(0)); + } else { + black_box(rows.iter().map(|r| r.get::(1)).sum::()); + } + }) + }); + } + + group.finish(); +} + +criterion_group!(benches, criterion_benchmark); +criterion_main!(benches); diff --git a/benches/json_v3.rs b/benches/json_v3.rs new file mode 100644 index 0000000..2aaa796 --- /dev/null +++ b/benches/json_v3.rs @@ -0,0 +1,482 @@ +//! EQL v3 sibling of `benches/json.rs` — encrypted-JSONB (SteVec) queries +//! against `json_ste_vec_small_encrypted_v3_` (`eql_v3.json`). +//! +//! Scenario ids match the v2 bench. The SQL surface maps as: +//! +//! contains/functional +//! v2: eql_v2.jsonb_array(value) @> eql_v2.jsonb_array($1::eql_v2_encrypted) +//! v3: value @> $1::jsonb::eql_v3.jsonb_query +//! One canonical containment recipe in v3: the typed @>(json, jsonb_query) +//! overload inlines to `eql_v3.to_ste_vec_query(value)::jsonb @> needle` +//! and engages the jsonb_path_ops GIN from the static index DDL. The +//! needle is the sampled row's sv entries stripped to `s` + term — +//! exactly what `eql_v3.to_ste_vec_query` produces. +//! +//! field_eq/bare (value -> ''::text) = $1::jsonb::eql_v3.jsonb_entry +//! field_eq/extractor value @> $1::jsonb::eql_v3.jsonb_query (single-entry needle) +//! field_eq/functional eql_v3.eq_term(value -> ''::text) = eql_v3.eq_term($1::jsonb::eql_v3.jsonb_entry) +//! field_order/functional ORDER BY eql_v3.ore_cllw(value -> ''::text) LIMIT 10 +//! +//! One v3-specific expectation: `eql_v3."->"` is LANGUAGE sql (v2's was +//! plpgsql), so the bare `->`+`=` form should now inline all the way down +//! and match the per-selector functional index — check `indexes_used` for +//! field_eq/bare, which could not engage in v2. +//! +//! Per-selector functional indexes (eq_term / ore_cllw over `value -> sel`) +//! are built at startup once the selector is sampled, mirroring the v2 +//! bench's create_field_indexes. + +use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use dbbenches::{ + bench_assert, extract_indexes_used, init_tracing, write_metadata_file_in, ScenarioMetadata, +}; +use serde_json::Value as JsonValue; +use sqlx::postgres::PgPoolOptions; +use sqlx::types::Json; +use sqlx::Row; +use tokio::runtime::Runtime; + +#[derive(Debug)] +struct Needles { + /// `{"sv":[{"s":..,"hm"|"oc":..}, ...]}` — the whole sampled document + /// normalized to the jsonb_query shape (drives contains/functional). + document_query: String, + hm_pick: Option, + ore_pick: Option, +} + +#[derive(Debug)] +struct HmPick { + selector: String, + /// `(value -> '')::jsonb` of the sampled row — an entry + /// merged with root meta, castable to eql_v3.jsonb_entry. + sample_field_value: JsonValue, + /// Single-entry jsonb_query needle for field_eq/extractor. + hmac_term: String, +} + +#[derive(Debug)] +struct OrePick { + selector: String, +} + +async fn sample_needles(pool: &sqlx::PgPool, table: &str) -> Needles { + // v3 payloads are jsonb domains — `value::jsonb -> 'sv'` walks the + // document directly (no composite `.data` hop like v2). + let rows = sqlx::query(&format!( + "SELECT elem ->> 's' AS sel, + elem ->> 'hm' AS hmac, + elem AS sv_elem + FROM ( + SELECT value::jsonb -> 'sv' AS sv_array + FROM {table} + LIMIT 1 + ) source, + LATERAL jsonb_array_elements(sv_array) WITH ORDINALITY AS j(elem, ord) + ORDER BY ord" + )) + .fetch_all(pool) + .await + .expect("query for sv elements failed"); + + if rows.is_empty() { + panic!("table `{table}` is empty"); + } + + // Normalize every entry to `s` + one term for the whole-document + // containment needle (what eql_v3.to_ste_vec_query does in SQL). + let mut query_entries: Vec = Vec::with_capacity(rows.len()); + let mut hm_pick: Option = None; + let mut ore_pick: Option = None; + + for row in &rows { + let sel: String = row.get("sel"); + let hmac: Option = row.get("hmac"); + let sv_elem: Json = row.get("sv_elem"); + let obj = sv_elem.0.as_object().expect("sv element is an object"); + + if let Some(h) = obj.get("hm") { + query_entries.push(serde_json::json!({"s": sel, "hm": h})); + } else if let Some(oc) = obj.get("oc") { + query_entries.push(serde_json::json!({"s": sel, "oc": oc})); + } + + if hm_pick.is_none() { + if let Some(h) = hmac.as_deref() { + let field_row = sqlx::query(&format!( + "SELECT (value -> '{sel}'::text)::jsonb AS sample_field_value + FROM {table} + LIMIT 1" + )) + .fetch_one(pool) + .await + .expect("query for hm sample field value failed"); + let sample_field_value: Json = field_row.get("sample_field_value"); + hm_pick = Some(HmPick { + selector: sel.clone(), + sample_field_value: sample_field_value.0, + hmac_term: format!(r#"{{"sv":[{{"s":"{}","hm":"{}"}}]}}"#, sel, h), + }); + } + } + + if ore_pick.is_none() && obj.contains_key("oc") { + ore_pick = Some(OrePick { + selector: sel.clone(), + }); + } + } + + Needles { + document_query: serde_json::to_string(&serde_json::json!({"sv": query_entries})) + .expect("serialise document query needle"), + hm_pick, + ore_pick, + } +} + +/// Build per-selector functional indexes (selector known only after +/// sampling). btree for both — see the v2 json bench for the hash-vs-btree +/// build-cost rationale. +async fn create_field_indexes(pool: &sqlx::PgPool, table: &str, needles: &Needles) { + eprintln!("json_v3 bench: building per-selector functional indexes..."); + + if let Some(p) = needles.hm_pick.as_ref() { + sqlx::query(&format!("DROP INDEX IF EXISTS {table}_field_eq_idx")) + .execute(pool) + .await + .expect("drop stale field_eq index"); + sqlx::query(&format!( + "CREATE INDEX {table}_field_eq_idx ON {table} \ + USING btree (eql_v3.eq_term(value -> '{}'::text))", + p.selector + )) + .execute(pool) + .await + .expect("create field_eq functional index"); + } + + if let Some(p) = needles.ore_pick.as_ref() { + sqlx::query(&format!("DROP INDEX IF EXISTS {table}_field_order_idx")) + .execute(pool) + .await + .expect("drop stale field_order index"); + sqlx::query(&format!( + "CREATE INDEX {table}_field_order_idx ON {table} \ + USING btree (eql_v3.ore_cllw(value -> '{}'::text))", + p.selector + )) + .execute(pool) + .await + .expect("create field_order functional index"); + } + + sqlx::query(&format!("ANALYZE {table}")) + .execute(pool) + .await + .expect("ANALYZE after index creation"); +} + +fn criterion_benchmark(c: &mut Criterion) { + init_tracing(); + let rt = Runtime::new().unwrap(); + + let target_rows = std::env::var("TARGET_ROWS").unwrap_or_else(|_| "unknown".to_string()); + let table_suffix = match target_rows.as_str() { + "10000" | "100000" | "1000000" | "10000000" => format!("_{}", target_rows), + _ => String::new(), + }; + let table_name = format!("json_ste_vec_small_encrypted_v3{}", table_suffix); + + let (pool, needles) = rt.block_on(async { + let database_url = + std::env::var("DATABASE_URL").expect("DATABASE_URL environment variable must be set"); + let pool = PgPoolOptions::new() + .max_connections(5) + .connect(&database_url) + .await + .expect("Failed to connect to database"); + + let needles = sample_needles(&pool, &table_name).await; + eprintln!( + "json_v3 bench picked from `{}` — hm: {} | oc: {}", + &table_name, + needles + .hm_pick + .as_ref() + .map(|p| p.selector.as_str()) + .unwrap_or(""), + needles + .ore_pick + .as_ref() + .map(|p| p.selector.as_str()) + .unwrap_or(""), + ); + + create_field_indexes(&pool, &table_name, &needles).await; + + (pool, needles) + }); + + let hm_field_value_json = needles + .hm_pick + .as_ref() + .map(|p| serde_json::to_string(&p.sample_field_value).expect("serialise hm field value")); + + // --- Query strings --- + + let q_contains_functional = format!( + "SELECT id FROM {table_name} \ + WHERE value @> $1::jsonb::eql_v3.jsonb_query LIMIT 10" + ); + + let q_field_eq_bare = needles.hm_pick.as_ref().map(|p| { + let selector = &p.selector; + format!( + "SELECT id FROM {table_name} \ + WHERE (value -> '{selector}'::text) = $1::jsonb::eql_v3.jsonb_entry LIMIT 10" + ) + }); + + let q_field_eq_extractor = needles.hm_pick.as_ref().map(|_| { + format!( + "SELECT id FROM {table_name} \ + WHERE value @> $1::jsonb::eql_v3.jsonb_query LIMIT 10" + ) + }); + + let q_field_eq_functional = needles.hm_pick.as_ref().map(|p| { + let selector = &p.selector; + format!( + "SELECT id FROM {table_name} \ + WHERE eql_v3.eq_term(value -> '{selector}'::text) \ + = eql_v3.eq_term($1::jsonb::eql_v3.jsonb_entry) LIMIT 10" + ) + }); + + let q_field_order_functional = needles.ore_pick.as_ref().map(|p| { + let selector = &p.selector; + format!( + "SELECT id FROM {table_name} \ + ORDER BY eql_v3.ore_cllw(value -> '{selector}'::text) LIMIT 10" + ) + }); + + // --- Metadata sidecar --- + + let has_hm = needles.hm_pick.is_some(); + let has_ore = needles.ore_pick.is_some(); + + let metadata = rt.block_on(async { + let mut out: Vec = Vec::with_capacity(6); + + async fn capture( + pool: &sqlx::PgPool, + id: String, + query: &str, + bind: Option<&str>, + ) -> ScenarioMetadata { + let explain_sql = format!("EXPLAIN (FORMAT JSON) {}", query); + let (Json(explain),): (Json,) = if let Some(b) = bind { + sqlx::query_as(&explain_sql) + .bind(b) + .fetch_one(pool) + .await + .expect("EXPLAIN failed") + } else { + sqlx::query_as(&explain_sql) + .fetch_one(pool) + .await + .expect("EXPLAIN failed") + }; + let indexes_used = extract_indexes_used(&explain); + + let rows: Vec = if let Some(b) = bind { + sqlx::query(query) + .bind(b) + .fetch_all(pool) + .await + .expect("row-count execute failed") + } else { + sqlx::query(query) + .fetch_all(pool) + .await + .expect("row-count execute failed") + }; + + let parameters = bind + .map(|b| vec![JsonValue::String(b.to_string())]) + .unwrap_or_default(); + + ScenarioMetadata { + id, + query: query.to_string(), + parameters, + explain, + indexes_used, + rows_returned: rows.len() as u64, + } + } + + out.push( + capture( + &pool, + format!("JSON/json/contains/functional/{}", target_rows), + &q_contains_functional, + Some(&needles.document_query), + ) + .await, + ); + + if let (Some(hm_pick), Some(q_bare), Some(q_extractor), Some(q_functional)) = ( + needles.hm_pick.as_ref(), + q_field_eq_bare.as_deref(), + q_field_eq_extractor.as_deref(), + q_field_eq_functional.as_deref(), + ) { + let hm_field = hm_field_value_json + .as_deref() + .expect("hm_field_value_json present when hm_pick is Some"); + + out.push( + capture( + &pool, + format!("JSON/json/field_eq/bare/{}", target_rows), + q_bare, + Some(hm_field), + ) + .await, + ); + out.push( + capture( + &pool, + format!("JSON/json/field_eq/extractor/{}", target_rows), + q_extractor, + Some(hm_pick.hmac_term.as_str()), + ) + .await, + ); + out.push( + capture( + &pool, + format!("JSON/json/field_eq/functional/{}", target_rows), + q_functional, + Some(hm_field), + ) + .await, + ); + } else { + eprintln!( + "json_v3 bench: skipping field_eq/* scenarios — no sv element on the \ + sampled row carries `hm`." + ); + } + + if let Some(q) = q_field_order_functional.as_deref() { + out.push( + capture( + &pool, + format!("JSON/json/field_order/functional/{}", target_rows), + q, + None, + ) + .await, + ); + } else { + eprintln!( + "json_v3 bench: skipping field_order/functional — no sv element on the \ + sampled row carries `oc`." + ); + } + + out + }); + write_metadata_file_in("results/query/v3", "json", &target_rows, metadata) + .expect("failed to write bench metadata sidecar"); + + // --- Bench loop --- + + let mut group = c.benchmark_group("JSON"); + group.sample_size(10); + + { + let id = format!("JSON/json/contains/functional/{}", target_rows); + let q = q_contains_functional.clone(); + let needle = needles.document_query.clone(); + group.bench_function(format!("json/contains/functional/{}", target_rows), |b| { + b.to_async(&rt).iter(|| async { + let rows = + bench_assert(sqlx::query(&q).bind(&needle).fetch_all(&pool).await, &id); + black_box(rows.len()) + }) + }); + } + + if has_hm { + let hm_field = hm_field_value_json + .clone() + .expect("hm_field_value_json present when has_hm"); + let hmac_term = needles + .hm_pick + .as_ref() + .expect("hm_pick present when has_hm") + .hmac_term + .clone(); + + if let Some(q) = q_field_eq_bare.clone() { + let id = format!("JSON/json/field_eq/bare/{}", target_rows); + let needle = hm_field.clone(); + group.bench_function(format!("json/field_eq/bare/{}", target_rows), |b| { + b.to_async(&rt).iter(|| async { + let rows = + bench_assert(sqlx::query(&q).bind(&needle).fetch_all(&pool).await, &id); + black_box(rows.len()) + }) + }); + } + + if let Some(q) = q_field_eq_extractor.clone() { + let id = format!("JSON/json/field_eq/extractor/{}", target_rows); + let needle = hmac_term.clone(); + group.bench_function(format!("json/field_eq/extractor/{}", target_rows), |b| { + b.to_async(&rt).iter(|| async { + let rows = + bench_assert(sqlx::query(&q).bind(&needle).fetch_all(&pool).await, &id); + black_box(rows.len()) + }) + }); + } + + if let Some(q) = q_field_eq_functional.clone() { + let id = format!("JSON/json/field_eq/functional/{}", target_rows); + let needle = hm_field.clone(); + group.bench_function(format!("json/field_eq/functional/{}", target_rows), |b| { + b.to_async(&rt).iter(|| async { + let rows = + bench_assert(sqlx::query(&q).bind(&needle).fetch_all(&pool).await, &id); + black_box(rows.len()) + }) + }); + } + } + + if has_ore { + if let Some(q) = q_field_order_functional.clone() { + let id = format!("JSON/json/field_order/functional/{}", target_rows); + group.bench_function( + format!("json/field_order/functional/{}", target_rows), + |b| { + b.to_async(&rt).iter(|| async { + let rows = bench_assert(sqlx::query(&q).fetch_all(&pool).await, &id); + black_box(rows.len()) + }) + }, + ); + } + } + + group.finish(); +} + +criterion_group!(benches, criterion_benchmark); +criterion_main!(benches); diff --git a/benches/match_v3.rs b/benches/match_v3.rs new file mode 100644 index 0000000..9d60f2d --- /dev/null +++ b/benches/match_v3.rs @@ -0,0 +1,235 @@ +//! EQL v3 sibling of `benches/match.rs` — substring/pattern matching against +//! `string_encrypted_v3_` (`eql_v3.text_search`, bloom term). +//! +//! v3 has NO `~~`/LIKE operator: bloom matching is exposed as containment. +//! v2's `value LIKE $1` scenarios keep their ids but run `value @> $1` +//! (identical semantics — needle bloom bits contained in the value's bloom; +//! the report's semantics-changed map flags the SQL delta). +//! +//! **The index question.** v3 ships no GIN opclass; the static index DDL +//! creates `GIN (eql_v3.match_term(value))`, which relies on the term's +//! base type (`smallint[]`) resolving native `array_ops`. Whether the +//! planner ENGAGES it for the inlined `@>` is exactly what this bench +//! answers — check `indexes_used` in the metadata sidecar. At the 10k tier +//! the bench additionally runs every scenario with the GIN dropped +//! (`*_noindex` ids) to quantify what the index is worth; the index is +//! recreated afterwards. + +use cipherstash_client::{ + eql::Identifier, + schema::{column::Index, ColumnConfig, ColumnType}, +}; +use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use dbbenches::{ + bench_assert, init_scoped_cipher, init_tracing, + v3::{encrypt_stored_v3, V3EncryptedQuery}, + write_metadata_file_in, ScenarioMetadata, +}; +use sqlx::postgres::PgPoolOptions; +use std::sync::Arc; +use tokio::runtime::Runtime; + +static QUERY_TEMPLATES: &[(&str, &str, &str)] = &[ + ( + "SELECT id, value FROM {TABLE} WHERE value @> $1 LIMIT 10", + "Bob", + "eql_cast_firstname", + ), + ( + "SELECT id, value FROM {TABLE} WHERE value @> $1 LIMIT 10", + "Johnson", + "eql_cast_lastname", + ), + ( + "SELECT id, value FROM {TABLE} WHERE eql_v3.match_term(value) @> eql_v3.match_term($1::eql_v3.text_search) LIMIT 10", + "Johnson", + "eql_bloom", + ), +]; + +async fn capture( + query: &V3EncryptedQuery, + pool: &sqlx::PgPool, + bench_id: String, +) -> ScenarioMetadata { + let (explain, indexes_used, rows_returned) = query + .capture_metadata(pool) + .await + .expect("metadata capture failed"); + ScenarioMetadata { + id: bench_id, + query: query.statement.clone(), + parameters: vec![query.parameter_json().expect("serialise parameter")], + explain, + indexes_used, + rows_returned, + } +} + +fn criterion_benchmark(c: &mut Criterion) { + init_tracing(); + let rt = Runtime::new().unwrap(); + + let target_rows = std::env::var("TARGET_ROWS").unwrap_or_else(|_| "unknown".to_string()); + let table_suffix = match target_rows.as_str() { + "10000" | "100000" | "1000000" | "10000000" => format!("_{}", target_rows), + _ => String::new(), + }; + let table_name = format!("string_encrypted_v3{}", table_suffix); + let gin_index = format!("{}_match_gin_index", table_name); + + let (pool, cipher) = rt.block_on(async { + let database_url = + std::env::var("DATABASE_URL").expect("DATABASE_URL environment variable must be set"); + let pool = PgPoolOptions::new() + .max_connections(5) + .connect(&database_url) + .await + .expect("Failed to connect to database"); + let cipher = init_scoped_cipher() + .await + .expect("Failed to initialize ScopedCipher"); + (pool, cipher) + }); + + // Needle config matches the ingest config so text_search's required + // terms are all present on the converted payload. + let column_config = ColumnConfig::build("value") + .casts_as(ColumnType::Text) + .add_index(Index::new_unique()) + .add_index(Index::new_match()) + .add_index(Index::new_ore()); + let identifier = Identifier::new(&table_name, "value"); + + let queries: Vec = rt.block_on(async { + let mut queries = Vec::with_capacity(QUERY_TEMPLATES.len()); + for (query_template, needle, _) in QUERY_TEMPLATES { + let query_str = query_template.replace("{TABLE}", &table_name); + let param = encrypt_stored_v3( + Arc::clone(&cipher), + &column_config, + &identifier, + needle.to_string(), + "text_search", + ) + .await + .expect("failed to encrypt+convert match needle"); + queries.push(V3EncryptedQuery::new(param, query_str, Arc::clone(&cipher))); + } + queries + }); + + let mut metadata = rt.block_on(async { + let mut out = Vec::with_capacity(queries.len()); + for (i, query) in queries.iter().enumerate() { + let (_, _, scenario) = QUERY_TEMPLATES[i]; + out.push( + capture( + query, + &pool, + format!("MATCH/match/{}/{}", scenario, target_rows), + ) + .await, + ); + } + out + }); + + let mut group = c.benchmark_group("MATCH"); + group.sample_size(10); + + for (i, query) in queries.iter().enumerate() { + let (_, _, scenario) = QUERY_TEMPLATES[i]; + let exec_id = format!("MATCH/match/{}/{}", scenario, target_rows); + let decrypt_id = format!("MATCH/match_decrypt/{}/{}", scenario, target_rows); + + let exec_id_inner = exec_id.clone(); + group.bench_function(format!("match/{}/{}", scenario, target_rows), |b| { + b.to_async(&rt).iter(|| async { + let _: Vec<_> = bench_assert(query.execute(&pool).await, &exec_id_inner); + }) + }); + + let decrypt_id_inner = decrypt_id.clone(); + group.bench_function(format!("match_decrypt/{}/{}", scenario, target_rows), |b| { + b.to_async(&rt).iter(|| async { + let _r: Vec = black_box(bench_assert( + query.execute_and_decrypt(&pool).await, + &decrypt_id_inner, + )); + }) + }); + } + + // ── No-index variants (10k tier only) ── + // Quantifies what the GIN-via-array_ops index is worth (and, if the GIN + // never engages, proves it by matching timings). criterion executes + // bench_functions as they are registered, so the drop → bench → + // recreate sequencing below is safe. + if target_rows == "10000" { + rt.block_on(async { + sqlx::query(&format!("DROP INDEX IF EXISTS {}", gin_index)) + .execute(&pool) + .await + .expect("drop match GIN index"); + sqlx::query(&format!("ANALYZE {}", table_name)) + .execute(&pool) + .await + .expect("ANALYZE after index drop"); + }); + + let noindex_metadata = rt.block_on(async { + let mut out = Vec::with_capacity(queries.len()); + for (i, query) in queries.iter().enumerate() { + let (_, _, scenario) = QUERY_TEMPLATES[i]; + out.push( + capture( + query, + &pool, + format!("MATCH/match/{}_noindex/{}", scenario, target_rows), + ) + .await, + ); + } + out + }); + metadata.extend(noindex_metadata); + + for (i, query) in queries.iter().enumerate() { + let (_, _, scenario) = QUERY_TEMPLATES[i]; + let exec_id = format!("MATCH/match/{}_noindex/{}", scenario, target_rows); + let exec_id_inner = exec_id.clone(); + group.bench_function( + format!("match/{}_noindex/{}", scenario, target_rows), + |b| { + b.to_async(&rt).iter(|| async { + let _: Vec<_> = + bench_assert(query.execute(&pool).await, &exec_id_inner); + }) + }, + ); + } + + rt.block_on(async { + sqlx::query(&format!( + "CREATE INDEX {} ON {} USING GIN (eql_v3.match_term(value))", + gin_index, table_name + )) + .execute(&pool) + .await + .expect("recreate match GIN index"); + sqlx::query(&format!("ANALYZE {}", table_name)) + .execute(&pool) + .await + .expect("ANALYZE after index recreate"); + }); + } + + group.finish(); + + write_metadata_file_in("results/query/v3", "match", &target_rows, metadata) + .expect("failed to write bench metadata sidecar"); +} + +criterion_group!(benches, criterion_benchmark); +criterion_main!(benches); diff --git a/benches/ope_v3.rs b/benches/ope_v3.rs new file mode 100644 index 0000000..044ffb7 --- /dev/null +++ b/benches/ope_v3.rs @@ -0,0 +1,193 @@ +//! EQL v3 CLLW-OPE ordering bench — NEW in v3, no v2 counterpart. +//! +//! Runs the SAME scenario shapes as benches/ore_v3.rs against +//! `integer_encrypted_ope_v3_` (`eql_v3.integer_ord_ope`) so the report +//! can chart ORE (custom plpgsql btree opclass) vs OPE (native bytea btree, +//! fully inlinable) per scenario — the headline v3 fast-ordering story. +//! +//! The `op` term is SYNTHETIC (fixed-width big-endian hex of the +//! sign-flipped plaintext; see `dbbenches::v3::to_v3_stored_with_synth_ope`) +//! because no pinned client emits it yet. Server-side plan shapes and +//! comparison costs are faithful; ciphertext size is approximate. +//! +//! Startup includes an order-parity assertion: the ordered scenario's +//! decrypt pass must return values sorted ascending — proving the synthetic +//! term's byte order agrees with plaintext order end-to-end. + +use cipherstash_client::{ + eql::Identifier, + schema::{ColumnConfig, ColumnType}, +}; +use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use dbbenches::{ + bench_assert, init_scoped_cipher, init_tracing, + v3::{i32_order_key, to_v3_stored_with_synth_ope, V3EncryptedQuery}, + write_metadata_file_in, ScenarioMetadata, +}; +use cipherstash_client::eql::{encrypt_eql, EqlOperation, EqlOutput, PreparedPlaintext}; +use sqlx::postgres::PgPoolOptions; +use std::borrow::Cow; +use std::sync::Arc; +use tokio::runtime::Runtime; + +static QUERY_TEMPLATES: &[(&str, i32, &str)] = &[ + ( + "SELECT id, value FROM {TABLE} WHERE value > $1 LIMIT 10", + 5000, + "range_gt_10", + ), + ( + "SELECT id, value FROM {TABLE} WHERE value > $1 LIMIT 100", + 5000, + "range_gt_100", + ), + ( + "SELECT id, value FROM {TABLE} WHERE value < $1 LIMIT 10", + 5000, + "range_lt_10", + ), + ( + "SELECT id, value FROM {TABLE} WHERE value < $1 LIMIT 100", + 5000, + "range_lt_100", + ), + ( + "SELECT id, value FROM {TABLE} \ + WHERE value < $1 \ + ORDER BY eql_v3.ord_ope_term(value) LIMIT 10", + 5000, + "range_lt_ordered_10", + ), +]; + +fn criterion_benchmark(c: &mut Criterion) { + init_tracing(); + let rt = Runtime::new().unwrap(); + + let target_rows = std::env::var("TARGET_ROWS").unwrap_or_else(|_| "unknown".to_string()); + let table_suffix = match target_rows.as_str() { + "10000" | "100000" | "1000000" | "10000000" => format!("_{}", target_rows), + _ => String::new(), + }; + let table_name = format!("integer_encrypted_ope_v3{}", table_suffix); + + let (pool, cipher) = rt.block_on(async { + let database_url = + std::env::var("DATABASE_URL").expect("DATABASE_URL environment variable must be set"); + let pool = PgPoolOptions::new() + .max_connections(5) + .connect(&database_url) + .await + .expect("Failed to connect to database"); + let cipher = init_scoped_cipher() + .await + .expect("Failed to initialize ScopedCipher"); + (pool, cipher) + }); + + // Mirror the ingest config (no v2 index terms; op is synthesized). + let column_config = ColumnConfig::build("value").casts_as(ColumnType::Int); + let identifier = Identifier::new(&table_name, "value"); + + let queries: Vec = rt.block_on(async { + let mut queries = Vec::with_capacity(QUERY_TEMPLATES.len()); + for (query_template, x, _) in QUERY_TEMPLATES { + let query_str = query_template.replace("{TABLE}", &table_name); + let prepared = PreparedPlaintext::new( + Cow::Owned(column_config.clone()), + identifier.clone(), + (*x).into(), + EqlOperation::Store, + ); + let mut out = encrypt_eql(Arc::clone(&cipher), vec![prepared], &Default::default()) + .await + .expect("failed to encrypt OPE threshold"); + let EqlOutput::Store(ciphertext) = out.remove(0) else { + unreachable!("EqlOperation::Store yields EqlOutput::Store"); + }; + let param = + to_v3_stored_with_synth_ope(&ciphertext, i32_order_key(*x), "integer_ord_ope") + .expect("failed to convert OPE threshold"); + queries.push(V3EncryptedQuery::new(param, query_str, Arc::clone(&cipher))); + } + queries + }); + + // Order-parity gate: the ordered scenario's results, decrypted, must be + // ascending — the synthetic op term must order exactly like plaintext. + rt.block_on(async { + let ordered = queries + .iter() + .zip(QUERY_TEMPLATES) + .find(|(_, (_, _, s))| *s == "range_lt_ordered_10") + .map(|(q, _)| q) + .expect("ordered scenario present"); + let values: Vec = ordered + .execute_and_decrypt(&pool) + .await + .expect("order-parity decrypt failed"); + assert!( + values.windows(2).all(|w| w[0] <= w[1]), + "synthetic OPE order does not match plaintext order: {:?}", + values + ); + eprintln!( + "ope_v3 bench: order-parity OK over {} decrypted rows", + values.len() + ); + }); + + let metadata = rt.block_on(async { + let mut out = Vec::with_capacity(queries.len()); + for (i, query) in queries.iter().enumerate() { + let (_, _, scenario) = QUERY_TEMPLATES[i]; + let bench_id = format!("OPE/ope/{}/{}", scenario, target_rows); + let (explain, indexes_used, rows_returned) = query + .capture_metadata(&pool) + .await + .expect("metadata capture failed"); + out.push(ScenarioMetadata { + id: bench_id, + query: query.statement.clone(), + parameters: vec![query.parameter_json().expect("serialise parameter")], + explain, + indexes_used, + rows_returned, + }); + } + out + }); + write_metadata_file_in("results/query/v3", "ope", &target_rows, metadata) + .expect("failed to write bench metadata sidecar"); + + let mut group = c.benchmark_group("OPE"); + group.sample_size(10); + + for (i, query) in queries.into_iter().enumerate() { + let (_, _, scenario) = QUERY_TEMPLATES[i]; + let exec_id = format!("OPE/ope/{}/{}", scenario, target_rows); + let decrypt_id = format!("OPE/ope_decrypt/{}/{}", scenario, target_rows); + + let exec_id_inner = exec_id.clone(); + group.bench_function(format!("ope/{}/{}", scenario, target_rows), |b| { + b.to_async(&rt).iter(|| async { + let _: Vec<_> = bench_assert(query.execute(&pool).await, &exec_id_inner); + }) + }); + + let decrypt_id_inner = decrypt_id.clone(); + group.bench_function(format!("ope_decrypt/{}/{}", scenario, target_rows), |b| { + b.to_async(&rt).iter(|| async { + let _r: Vec = black_box(bench_assert( + query.execute_and_decrypt(&pool).await, + &decrypt_id_inner, + )); + }) + }); + } + + group.finish(); +} + +criterion_group!(benches, criterion_benchmark); +criterion_main!(benches); diff --git a/benches/ore_v3.rs b/benches/ore_v3.rs new file mode 100644 index 0000000..f36626a --- /dev/null +++ b/benches/ore_v3.rs @@ -0,0 +1,164 @@ +//! EQL v3 sibling of `benches/ore.rs` — range queries against +//! `integer_encrypted_v3_` (`eql_v3.integer_ord`, ORE block term). +//! +//! Scenario ids match the v2 bench exactly. Bound parameters are +//! STORED-shape v3 payloads (see benches/exact_v3.rs — no scalar query wire +//! shape exists in v3); the domain's comparison operators carry +//! `RIGHTARG = jsonb` overloads that cast in-plan. +//! +//! The selective scenarios stay DISABLED, mirroring the v2 bench: the +//! selectivity limitation (planner can't estimate a bound-parameter +//! encrypted comparison, falls back to DEFAULT_INEQ_SEL) is a property of +//! functional-extractor predicates generally — v3's `=`-only RESTRICT +//! hints don't change range estimation. Re-check via the metadata sidecar +//! if a v3 selectivity fix lands (EQL issue #230). + +use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use dbbenches::{ + bench_assert, + v3::{encrypt_stored_v3, V3EncryptedQuery}, + init_scoped_cipher, init_tracing, write_metadata_file_in, ScenarioMetadata, +}; +use cipherstash_client::{ + eql::Identifier, + schema::{column::Index, ColumnConfig, ColumnType}, +}; +use sqlx::postgres::PgPoolOptions; +use std::sync::Arc; +use tokio::runtime::Runtime; + +static QUERY_TEMPLATES: &[(&str, i32, &str)] = &[ + // ── Non-selective baselines (≈50% selectivity → Seq Scan + LIMIT) ── + ( + "SELECT id, value FROM {TABLE} WHERE value > $1 LIMIT 10", + 5000, + "range_gt_10", + ), + ( + "SELECT id, value FROM {TABLE} WHERE value > $1 LIMIT 100", + 5000, + "range_gt_100", + ), + ( + "SELECT id, value FROM {TABLE} WHERE value < $1 LIMIT 10", + 5000, + "range_lt_10", + ), + ( + "SELECT id, value FROM {TABLE} WHERE value < $1 LIMIT 100", + 5000, + "range_lt_100", + ), + // ── Ordered range (extractor in ORDER BY, matches the functional + // ord_term index expression → Index Scan, no Sort node) ── + ( + "SELECT id, value FROM {TABLE} \ + WHERE value < $1 \ + ORDER BY eql_v3.ord_term(value) LIMIT 10", + 5000, + "range_lt_ordered_10", + ), +]; + +fn criterion_benchmark(c: &mut Criterion) { + init_tracing(); + let rt = Runtime::new().unwrap(); + + let target_rows = std::env::var("TARGET_ROWS").unwrap_or_else(|_| "unknown".to_string()); + let table_suffix = match target_rows.as_str() { + "10000" | "100000" | "1000000" | "10000000" => format!("_{}", target_rows), + _ => String::new(), + }; + let table_name = format!("integer_encrypted_v3{}", table_suffix); + + let (pool, cipher) = rt.block_on(async { + let database_url = + std::env::var("DATABASE_URL").expect("DATABASE_URL environment variable must be set"); + let pool = PgPoolOptions::new() + .max_connections(5) + .connect(&database_url) + .await + .expect("Failed to connect to database"); + let cipher = init_scoped_cipher() + .await + .expect("Failed to initialize ScopedCipher"); + (pool, cipher) + }); + + let column_config = ColumnConfig::build("value") + .casts_as(ColumnType::Int) + .add_index(Index::new_ore()); + let identifier = Identifier::new(&table_name, "value"); + + let queries: Vec = rt.block_on(async { + let mut queries = Vec::with_capacity(QUERY_TEMPLATES.len()); + for (query_template, x, _) in QUERY_TEMPLATES { + let query_str = query_template.replace("{TABLE}", &table_name); + let param = encrypt_stored_v3( + Arc::clone(&cipher), + &column_config, + &identifier, + *x, + "integer_ord", + ) + .await + .expect("failed to encrypt+convert query threshold"); + queries.push(V3EncryptedQuery::new(param, query_str, Arc::clone(&cipher))); + } + queries + }); + + let metadata = rt.block_on(async { + let mut out = Vec::with_capacity(queries.len()); + for (i, query) in queries.iter().enumerate() { + let (_, _, scenario) = QUERY_TEMPLATES[i]; + let bench_id = format!("ORE/ore/{}/{}", scenario, target_rows); + let (explain, indexes_used, rows_returned) = query + .capture_metadata(&pool) + .await + .expect("metadata capture failed"); + out.push(ScenarioMetadata { + id: bench_id, + query: query.statement.clone(), + parameters: vec![query.parameter_json().expect("serialise parameter")], + explain, + indexes_used, + rows_returned, + }); + } + out + }); + write_metadata_file_in("results/query/v3", "ore", &target_rows, metadata) + .expect("failed to write bench metadata sidecar"); + + let mut group = c.benchmark_group("ORE"); + group.sample_size(10); + + for (i, query) in queries.into_iter().enumerate() { + let (_, _, scenario) = QUERY_TEMPLATES[i]; + let exec_id = format!("ORE/ore/{}/{}", scenario, target_rows); + let decrypt_id = format!("ORE/ore_decrypt/{}/{}", scenario, target_rows); + + let exec_id_inner = exec_id.clone(); + group.bench_function(format!("ore/{}/{}", scenario, target_rows), |b| { + b.to_async(&rt).iter(|| async { + let _: Vec<_> = bench_assert(query.execute(&pool).await, &exec_id_inner); + }) + }); + + let decrypt_id_inner = decrypt_id.clone(); + group.bench_function(format!("ore_decrypt/{}/{}", scenario, target_rows), |b| { + b.to_async(&rt).iter(|| async { + let _r: Vec = black_box(bench_assert( + query.execute_and_decrypt(&pool).await, + &decrypt_id_inner, + )); + }) + }); + } + + group.finish(); +} + +criterion_group!(benches, criterion_benchmark); +criterion_main!(benches); diff --git a/benches/plaintext_v3.rs b/benches/plaintext_v3.rs new file mode 100644 index 0000000..59e454a --- /dev/null +++ b/benches/plaintext_v3.rs @@ -0,0 +1,175 @@ +//! Plaintext baselines for the v3 encrypted-vs-plaintext comparison — the +//! docs/marketing "overhead vs native Postgres" story. Runs the SAME query +//! shapes as the headline encrypted scenarios against plaintext tables with +//! equivalent indexes: +//! +//! plaintext/exact_eq string_plaintext_ WHERE value = $1 LIMIT 1 (btree) +//! plaintext/range_gt_10 integer_plaintext_ WHERE value > $1 LIMIT 10 (btree) +//! plaintext/range_lt_ordered_10 integer_plaintext_ WHERE value < $1 ORDER BY value LIMIT 10 +//! plaintext/json_contains json_small_plaintext_ WHERE value @> $1 LIMIT 10 (jsonb_path_ops GIN) +//! plaintext/json_field_eq json_small_plaintext_ WHERE value -> 'age' = $1 LIMIT 10 +//! +//! (GROUP BY plaintext baselines already live inside benches/group_by_v3.rs, +//! carried over from the v2 bench.) +//! +//! The comparison report maps these onto their encrypted counterparts by +//! scenario name. No encryption client is involved anywhere in this bench. + +use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use dbbenches::{ + bench_assert, extract_indexes_used, init_tracing, write_metadata_file_in, ScenarioMetadata, +}; +use serde_json::Value as JsonValue; +use sqlx::postgres::PgPoolOptions; +use sqlx::types::Json; +use tokio::runtime::Runtime; + +fn criterion_benchmark(c: &mut Criterion) { + init_tracing(); + let rt = Runtime::new().unwrap(); + + let target_rows = std::env::var("TARGET_ROWS").unwrap_or_else(|_| "unknown".to_string()); + let table_suffix = match target_rows.as_str() { + "10000" | "100000" | "1000000" | "10000000" => format!("_{}", target_rows), + _ => String::new(), + }; + let string_table = format!("string_plaintext{}", table_suffix); + let integer_table = format!("integer_plaintext{}", table_suffix); + let json_table = format!("json_small_plaintext{}", table_suffix); + + let pool = rt.block_on(async { + let database_url = + std::env::var("DATABASE_URL").expect("DATABASE_URL environment variable must be set"); + PgPoolOptions::new() + .max_connections(5) + .connect(&database_url) + .await + .expect("Failed to connect to database") + }); + + // Sample needles from real rows (same guarantee as the encrypted + // benches: equality scenarios hit at least one row). + let (string_needle, json_age_needle): (String, i64) = rt.block_on(async { + let s: (String,) = + sqlx::query_as(&format!("SELECT value FROM {string_table} LIMIT 1")) + .fetch_one(&pool) + .await + .expect("sample from string_plaintext failed — is it prepared?"); + let a: (JsonValue,) = sqlx::query_as(&format!( + "SELECT (value -> 'age')::jsonb FROM {json_table} LIMIT 1" + )) + .fetch_one(&pool) + .await + .expect("sample from json_small_plaintext failed — is it prepared?"); + (s.0, a.0.as_i64().expect("age is an integer")) + }); + let json_contains_needle = format!(r#"{{"age": {}}}"#, json_age_needle); + let json_field_eq_needle = json_age_needle.to_string(); + + // (scenario, statement, bind) + enum Bind { + Text(String), + Int(i32), + Jsonb(String), + } + let scenarios: Vec<(&str, String, Option)> = vec![ + ( + "exact_eq", + format!("SELECT id, value FROM {string_table} WHERE value = $1 LIMIT 1"), + Some(Bind::Text(string_needle)), + ), + ( + "range_gt_10", + format!("SELECT id, value FROM {integer_table} WHERE value > $1 LIMIT 10"), + Some(Bind::Int(5000)), + ), + ( + "range_lt_ordered_10", + format!( + "SELECT id, value FROM {integer_table} WHERE value < $1 ORDER BY value LIMIT 10" + ), + Some(Bind::Int(5000)), + ), + ( + "json_contains", + format!("SELECT id FROM {json_table} WHERE value @> $1::jsonb LIMIT 10"), + Some(Bind::Jsonb(json_contains_needle)), + ), + ( + "json_field_eq", + format!("SELECT id FROM {json_table} WHERE value -> 'age' = $1::jsonb LIMIT 10"), + Some(Bind::Jsonb(json_field_eq_needle)), + ), + ]; + + fn bind_query<'q>( + q: &'q str, + bind: &'q Option, + ) -> sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments> { + let query = sqlx::query(q); + match bind { + Some(Bind::Text(s)) => query.bind(s), + Some(Bind::Int(i)) => query.bind(i), + Some(Bind::Jsonb(j)) => query.bind(j), + None => query, + } + } + + let metadata = rt.block_on(async { + let mut out = Vec::with_capacity(scenarios.len()); + for (scenario, statement, bind) in &scenarios { + let bench_id = format!("PLAINTEXT/plaintext/{}/{}", scenario, target_rows); + let explain_sql = format!("EXPLAIN (FORMAT JSON) {}", statement); + let (Json(explain),): (Json,) = { + let q = sqlx::query_as(&explain_sql); + match bind { + Some(Bind::Text(s)) => q.bind(s), + Some(Bind::Int(i)) => q.bind(i), + Some(Bind::Jsonb(j)) => q.bind(j), + None => q, + } + .fetch_one(&pool) + .await + .expect("EXPLAIN failed") + }; + let indexes_used = extract_indexes_used(&explain); + let rows = bind_query(statement, bind) + .fetch_all(&pool) + .await + .expect("execute for row-count failed"); + out.push(ScenarioMetadata { + id: bench_id, + query: statement.clone(), + parameters: Vec::new(), + explain, + indexes_used, + rows_returned: rows.len() as u64, + }); + } + out + }); + write_metadata_file_in("results/query/v3", "plaintext", &target_rows, metadata) + .expect("failed to write bench metadata sidecar"); + + let mut group = c.benchmark_group("PLAINTEXT"); + group.sample_size(10); + + for (scenario, statement, bind) in scenarios { + let bench_id = format!("PLAINTEXT/plaintext/{}/{}", scenario, target_rows); + let inner_id = bench_id.clone(); + group.bench_function(format!("plaintext/{}/{}", scenario, target_rows), |b| { + b.to_async(&rt).iter(|| async { + let rows = bench_assert( + bind_query(&statement, &bind).fetch_all(&pool).await, + &inner_id, + ); + black_box(rows.len()); + }) + }); + } + + group.finish(); +} + +criterion_group!(benches, criterion_benchmark); +criterion_main!(benches); diff --git a/benches/scalar_smoke_v3.rs b/benches/scalar_smoke_v3.rs new file mode 100644 index 0000000..db13fc3 --- /dev/null +++ b/benches/scalar_smoke_v3.rs @@ -0,0 +1,205 @@ +//! New-family smoke bench (v3-only, no v2 counterpart, 10k rows, no tiers). +//! +//! One range scenario + one extractor-ordered scenario per ordered scalar +//! family the v2 benches never covered (date / timestamp / numeric / bigint +//! / double), plus a select-back scenario for storage-only `boolean`. +//! Purpose: catch per-family CHECK / operator-routing / inlining breakage +//! and index engagement — NOT to produce tier curves. Runs against the +//! fixed `scalar_smoke_v3` table populated by `prepare:scalar_smoke_v3`. +//! +//! Startup builds a functional `eql_v3.ord_term(col)` btree per ordered +//! family so the ordered scenario's `indexes_used` proves the family's +//! whole extractor chain inlines. + +use chrono::{Duration, TimeZone, Utc}; +use cipherstash_client::{ + encryption::Plaintext, + eql::Identifier, + schema::{column::Index, ColumnConfig, ColumnType}, +}; +use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use dbbenches::{ + bench_assert, init_scoped_cipher, init_tracing, + v3::{encrypt_stored_v3, V3EncryptedQuery}, + write_metadata_file_in, ScenarioMetadata, +}; +use rust_decimal::Decimal; +use sqlx::postgres::PgPoolOptions; +use std::sync::Arc; +use tokio::runtime::Runtime; + +const TABLE: &str = "scalar_smoke_v3"; + +fn criterion_benchmark(c: &mut Criterion) { + init_tracing(); + let rt = Runtime::new().unwrap(); + + // Fixed-size table; keep a tier-like id segment for report consistency. + let target_rows = "10000".to_string(); + + let (pool, cipher) = rt.block_on(async { + let database_url = + std::env::var("DATABASE_URL").expect("DATABASE_URL environment variable must be set"); + let pool = PgPoolOptions::new() + .max_connections(5) + .connect(&database_url) + .await + .expect("Failed to connect to database"); + let cipher = init_scoped_cipher() + .await + .expect("Failed to initialize ScopedCipher"); + (pool, cipher) + }); + + let epoch = Utc.with_ymd_and_hms(2000, 1, 1, 0, 0, 0).unwrap(); + + // (family, column, ColumnType, mid-range needle) — needle sits near the + // middle of the ingest distribution (see encrypt_scalar_smoke_v3) so the + // range scenarios return rows. + let families: Vec<(&str, &str, ColumnType, Plaintext)> = vec![ + ( + "date_ord", + "date_val", + ColumnType::Date, + Plaintext::NaiveDate(Some((epoch + Duration::days(5_000)).date_naive())), + ), + ( + "timestamp_ord", + "timestamp_val", + ColumnType::Timestamp, + Plaintext::Timestamp(Some(epoch + Duration::seconds(400_000_000))), + ), + ( + "numeric_ord", + "numeric_val", + ColumnType::Decimal, + Plaintext::Decimal(Some(Decimal::new(5_000_000, 2))), + ), + ( + "bigint_ord", + "bigint_val", + ColumnType::BigInt, + Plaintext::BigInt(Some(i64::MAX / 2)), + ), + ( + "double_ord", + "double_val", + ColumnType::Float, + Plaintext::Float(Some(500_000.0)), + ), + ]; + + // Per-family functional ord_term indexes. + rt.block_on(async { + for (_, col, _, _) in &families { + sqlx::query(&format!("DROP INDEX IF EXISTS {TABLE}_{col}_ord_idx")) + .execute(&pool) + .await + .expect("drop stale smoke index"); + sqlx::query(&format!( + "CREATE INDEX {TABLE}_{col}_ord_idx ON {TABLE} (eql_v3.ord_term({col}))" + )) + .execute(&pool) + .await + .expect("create smoke ord index"); + } + sqlx::query(&format!("ANALYZE {TABLE}")) + .execute(&pool) + .await + .expect("ANALYZE smoke table"); + }); + + // Build queries: (id_segment, statement, param) + let queries: Vec<(String, V3EncryptedQuery)> = rt.block_on(async { + let mut out = Vec::new(); + for (family, col, ty, needle) in families { + let config = ColumnConfig::build(col) + .casts_as(ty) + .add_index(Index::new_ore()); + let ident = Identifier::new(TABLE, col); + let param = encrypt_stored_v3( + Arc::clone(&cipher), + &config, + &ident, + needle, + family, + ) + .await + .unwrap_or_else(|e| panic!("needle encrypt+convert failed for `{family}`: {e:?}")); + + out.push(( + format!("{family}/range_gt_10"), + V3EncryptedQuery::new( + param.clone(), + format!("SELECT id, {col} FROM {TABLE} WHERE {col} > $1 LIMIT 10"), + Arc::clone(&cipher), + ), + )); + out.push(( + format!("{family}/range_gt_ordered_10"), + V3EncryptedQuery::new( + param, + format!( + "SELECT id, {col} FROM {TABLE} WHERE {col} > $1 \ + ORDER BY eql_v3.ord_term({col}) LIMIT 10" + ), + Arc::clone(&cipher), + ), + )); + } + out + }); + + let metadata = rt.block_on(async { + let mut out = Vec::with_capacity(queries.len() + 1); + for (segment, query) in &queries { + let bench_id = format!("SMOKE_V3/smoke/{}/{}", segment, target_rows); + let (explain, indexes_used, rows_returned) = query + .capture_metadata(&pool) + .await + .unwrap_or_else(|e| panic!("metadata capture failed for `{segment}`: {e:?}")); + out.push(ScenarioMetadata { + id: bench_id, + query: query.statement.clone(), + parameters: vec![query.parameter_json().expect("serialise parameter")], + explain, + indexes_used, + rows_returned, + }); + } + out + }); + write_metadata_file_in("results/query/v3", "scalar_smoke", &target_rows, metadata) + .expect("failed to write bench metadata sidecar"); + + let mut group = c.benchmark_group("SMOKE_V3"); + group.sample_size(10); + + for (segment, query) in queries { + let bench_id = format!("SMOKE_V3/smoke/{}/{}", segment, target_rows); + let inner_id = bench_id.clone(); + group.bench_function(format!("smoke/{}/{}", segment, target_rows), |b| { + b.to_async(&rt).iter(|| async { + let _: Vec<_> = bench_assert(query.execute(&pool).await, &inner_id); + }) + }); + } + + // Storage-only boolean: select-back is the entire supported surface. + { + let q = format!("SELECT id, boolean_val FROM {TABLE} LIMIT 10"); + let id = format!("SMOKE_V3/smoke/boolean/select_back/{}", target_rows); + let inner_id = id.clone(); + group.bench_function(format!("smoke/boolean/select_back/{}", target_rows), |b| { + b.to_async(&rt).iter(|| async { + let rows = bench_assert(sqlx::query(&q).fetch_all(&pool).await, &inner_id); + black_box(rows.len()); + }) + }); + } + + group.finish(); +} + +criterion_group!(benches, criterion_benchmark); +criterion_main!(benches); diff --git a/mise.toml b/mise.toml index ec3fb6d..f3ad545 100644 --- a/mise.toml +++ b/mise.toml @@ -169,6 +169,61 @@ psql -v ON_ERROR_STOP=1 -U postgres -d postgres < sql/schema.sql echo "Database setup complete!" """ +[tasks.setup-db-v3] +description = "Install the EQL v3 bundle and create the v3 bench tables (coexists with v2)" +depends = ["postgres"] +run = """ +#!/usr/bin/env bash +set -e + +echo "Waiting for PostgreSQL to be ready..." +until pg_isready -U postgres > /dev/null 2>&1; do + sleep 1 +done + +# EQL v3 is not released yet: default is to build the bundle from a local +# checkout of the eql_v3 branch. Overrides: +# EQL_V3_SQL= use a prebuilt bundle +# EQL_V3_DIR= checkout to build from +# Once eql-3.x releases, switch the default here to the release download +# (same pattern as setup-db above) and pin the tag. +if [ -z "${EQL_V3_SQL:-}" ]; then + EQL_V3_DIR="${EQL_V3_DIR:-../encrypt-query-language}" + if [ ! -d "$EQL_V3_DIR" ]; then + echo "Error: EQL checkout not found at $EQL_V3_DIR (set EQL_V3_DIR or EQL_V3_SQL)" + exit 1 + fi + BRANCH=$(git -C "$EQL_V3_DIR" rev-parse --abbrev-ref HEAD) + if [ "$BRANCH" != "eql_v3" ]; then + echo "warning: $EQL_V3_DIR is on branch '$BRANCH', not 'eql_v3' — building anyway" + fi + echo "Building EQL v3 bundle from $EQL_V3_DIR..." + (cd "$EQL_V3_DIR" && mise run build --version 3.0.0-dev) + EQL_V3_SQL="$EQL_V3_DIR/release/cipherstash-encrypt.sql" +fi + +# The v3 bundle is self-contained (schemas eql_v3 / eql_v3_internal) and +# coexists with the v2.3 install — setup-db and setup-db-v3 target the same +# database. Skip the install if eql_v3 is already present; the bundle is not +# idempotent (bare CREATE FUNCTION / CREATE DOMAIN). To reinstall after a +# bundle change, run the v3 uninstaller first (release/ +# cipherstash-encrypt-uninstall.sql — drops only the eql_v3 schemas). +if psql -U postgres -d postgres -tAc "SELECT 1 FROM pg_namespace WHERE nspname = 'eql_v3'" | grep -q 1; then + echo "eql_v3 schema already installed — skipping bundle install." +else + echo "Installing EQL v3 from $EQL_V3_SQL..." + psql -v ON_ERROR_STOP=1 -U postgres -d postgres < "$EQL_V3_SQL" +fi + +echo "Creating v3 bench tables..." +psql -v ON_ERROR_STOP=1 -U postgres -d postgres < sql/schema_v3.sql + +echo "Creating plaintext baseline tables..." +psql -v ON_ERROR_STOP=1 -U postgres -d postgres < sql/plaintext_baselines.sql + +echo "EQL v3 database setup complete!" +""" + [tasks."bench:build"] description = "Build all binaries in release mode" run = """ @@ -209,6 +264,49 @@ echo "Combining results..." echo "Benchmark complete! Results written to results/ingest/${BENCH_NAME}_combined.json" """ +[tasks."bench:v3:ingest:_run"] +description = "Internal: run a v3 ingest benchmark and move results under results/ingest/v3/" +run = """ +#!/usr/bin/env bash +set -e +BENCH_NAME="$1" +TABLE_NAME="$2" +mise run bench:ingest:_run "$BENCH_NAME" "$TABLE_NAME" "${3:-500,1000,10000}" +mkdir -p results/ingest/v3 +mv "results/ingest/${BENCH_NAME}_combined.json" "results/ingest/v3/${BENCH_NAME}_combined.json" +echo "Moved results to results/ingest/v3/${BENCH_NAME}_combined.json" +""" + +[tasks."bench:v3:ingest:encrypt_string"] +description = "Run encrypt_string_v3 ingest benchmark (eql_v3.text_search)" +run = "mise run bench:v3:ingest:_run encrypt_string_v3 string_encrypted_v3" + +[tasks."bench:v3:ingest:encrypt_int"] +description = "Run encrypt_int_v3 ingest benchmark (eql_v3.integer_ord)" +run = "mise run bench:v3:ingest:_run encrypt_int_v3 integer_encrypted_v3" + +[tasks."bench:v3:ingest:encrypt_int_ope"] +description = "Run encrypt_int_ope_v3 ingest benchmark (eql_v3.integer_ord_ope, synthetic op)" +run = "mise run bench:v3:ingest:_run encrypt_int_ope_v3 integer_encrypted_ope_v3" + +[tasks."bench:v3:ingest:encrypt_category"] +description = "Run encrypt_category_v3 ingest benchmark (eql_v3.text_eq)" +run = "mise run bench:v3:ingest:_run encrypt_category_v3 category_encrypted_v3" + +[tasks."bench:v3:ingest:json-ste-vec-small"] +description = "Run encrypt_ste_vec_small_v3 ingest benchmark (eql_v3.json)" +run = "mise run bench:v3:ingest:_run encrypt_ste_vec_small_v3 json_ste_vec_small_encrypted_v3" + +[tasks."bench:v3:ingest"] +description = "Run all v3 ingest benchmarks" +depends = [ + "bench:v3:ingest:encrypt_int", + "bench:v3:ingest:encrypt_string", + "bench:v3:ingest:encrypt_int_ope", + "bench:v3:ingest:encrypt_category", + "bench:v3:ingest:json-ste-vec-small", +] + [tasks."bench:ingest:encrypt_int"] description = "Run encrypt_int ingest benchmark and combine results" run = "mise run bench:ingest:_run encrypt_int integer_encrypted" @@ -352,7 +450,17 @@ while true; do done echo "Creating indexes..." +# Time the index build and append to the build-times log — index build cost +# is a first-class result for the v3 comparison (the v3 ORE btree opclass +# has a plpgsql comparator; build time at 1M is a named risk to measure). +INDEX_BUILD_START=$(date +%s) psql -U postgres -d postgres < sql/indexes/${TABLE_NAME}_up.sql +INDEX_BUILD_SECS=$(( $(date +%s) - INDEX_BUILD_START )) +mkdir -p results/ingest +# NB: single-quoted printf format — this script lives in a TOML basic +# multiline string, which consumes backslash escapes before bash sees them. +printf '{"table": "%s", "build_seconds": %s, "recorded_at": "%s"}\n' "$TABLE_NAME" "$INDEX_BUILD_SECS" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> results/ingest/index_build_times.jsonl +echo "Index build took ${INDEX_BUILD_SECS}s (logged to results/ingest/index_build_times.jsonl)" # ANALYZE the freshly-loaded table so the planner has accurate # selectivity stats for the functional indexes. Without this, the @@ -387,6 +495,57 @@ run = "mise run prepare:_table category_encrypted encrypt_category $1" description = "Prepare combo_encrypted_ table with three encrypted columns (name + age + category)" run = "mise run prepare:_table combo_encrypted encrypt_combo $1" +[tasks."prepare:string_encrypted_v3"] +description = "Prepare string_encrypted_v3 table (eql_v3.text_search) with target row count" +run = "mise run prepare:_table string_encrypted_v3 encrypt_string_v3 $1" + +[tasks."prepare:integer_encrypted_v3"] +description = "Prepare integer_encrypted_v3 table (eql_v3.integer_ord) with target row count" +run = "mise run prepare:_table integer_encrypted_v3 encrypt_int_v3 $1" + +[tasks."prepare:integer_encrypted_ope_v3"] +description = "Prepare integer_encrypted_ope_v3 table (eql_v3.integer_ord_ope, synthetic op term) with target row count" +run = "mise run prepare:_table integer_encrypted_ope_v3 encrypt_int_ope_v3 $1" + +[tasks."prepare:category_encrypted_v3"] +description = "Prepare category_encrypted_v3 table (eql_v3.text_eq) with target row count" +run = "mise run prepare:_table category_encrypted_v3 encrypt_category_v3 $1" + +[tasks."prepare:combo_encrypted_v3"] +description = "Prepare combo_encrypted_v3 table (three eql_v3 columns) with target row count" +run = "mise run prepare:_table combo_encrypted_v3 encrypt_combo_v3 $1" + +[tasks."prepare:json_ste_vec_small_v3"] +description = "Prepare json_ste_vec_small_encrypted_v3 table (eql_v3.json) with target row count" +run = "mise run prepare:_table json_ste_vec_small_encrypted_v3 encrypt_ste_vec_small_v3 $1" + +[tasks."prepare:scalar_smoke_v3"] +description = "Populate the scalar_smoke_v3 new-family smoke table (10k rows, no tier variants)" +depends = ["postgres", "bench:build"] +run = """ +#!/usr/bin/env bash +set -e +CURRENT=$(psql -U postgres -d postgres -tAc "SELECT COUNT(*) FROM scalar_smoke_v3;") +TARGET="${1:-10000}" +if [ "$CURRENT" -ge "$TARGET" ]; then + echo "scalar_smoke_v3 already has $CURRENT rows (>= $TARGET)." + exit 0 +fi +NUM_RECORDS=$((TARGET - CURRENT)) ./target/release/encrypt_scalar_smoke_v3 +""" + +[tasks."prepare:v3:all"] +description = "Prepare every v3 encrypted table at the given tier (default 10000)" +run = """ +#!/usr/bin/env bash +set -e +TIER="${1:-10000}" +for t in string_encrypted_v3 integer_encrypted_v3 integer_encrypted_ope_v3 category_encrypted_v3 combo_encrypted_v3 json_ste_vec_small_v3; do + mise run "prepare:$t" "$TIER" +done +mise run prepare:scalar_smoke_v3 +""" + [tasks."prepare:category_plaintext"] description = "Prepare category_plaintext_ table with ~250 low-cardinality categorical strings (CAT_001..CAT_250, uniform random)" depends = ["postgres"] @@ -443,6 +602,73 @@ FINAL_ROWS=$(psql -U postgres -d postgres -t -c "SELECT COUNT(*) FROM $TABLE_NAM echo "Preparation complete! Final row count: $FINAL_ROWS" """ +[tasks."prepare:integer_plaintext"] +description = "Prepare integer_plaintext_ table (random ints, btree) — plaintext baseline for ORE/OPE range scenarios" +depends = ["postgres"] +run = """ +#!/usr/bin/env bash +set -e +TARGET_ROWS="$1" +case "$TARGET_ROWS" in + 10000|100000|1000000|10000000) ;; + *) echo "Error: TARGET_ROWS must be one of: 10000, 100000, 1000000, 10000000"; exit 1 ;; +esac +TABLE_NAME="integer_plaintext_${TARGET_ROWS}" +until pg_isready -U postgres > /dev/null 2>&1; do sleep 1; done +CURRENT_ROWS=$(psql -U postgres -d postgres -tAc "SELECT COUNT(*) FROM $TABLE_NAME;") +if [ "$CURRENT_ROWS" -ge "$TARGET_ROWS" ]; then + echo "$TABLE_NAME already has $CURRENT_ROWS rows (>= $TARGET_ROWS)." + psql -U postgres -d postgres -c "CREATE INDEX IF NOT EXISTS ${TABLE_NAME}_value_idx ON $TABLE_NAME (value);" + exit 0 +fi +ROWS_TO_INSERT=$((TARGET_ROWS - CURRENT_ROWS)) +echo "Inserting $ROWS_TO_INSERT rows into $TABLE_NAME..." +# Same distribution as the encrypted integer bench: uniform over the full +# i32 range (Faker.fake::()). +psql -v ON_ERROR_STOP=1 -U postgres -d postgres -c " + INSERT INTO $TABLE_NAME (value) + SELECT (floor(random() * 4294967296) - 2147483648)::int + FROM generate_series(1, $ROWS_TO_INSERT);" +psql -U postgres -d postgres -c "CREATE INDEX IF NOT EXISTS ${TABLE_NAME}_value_idx ON $TABLE_NAME (value);" +psql -U postgres -d postgres -c "ANALYZE $TABLE_NAME;" +echo "Preparation complete." +""" + +[tasks."prepare:json_small_plaintext"] +description = "Prepare json_small_plaintext_ table (FakeJsonSmall-shaped jsonb, jsonb_path_ops GIN) — plaintext baseline for JSON scenarios" +depends = ["postgres"] +run = """ +#!/usr/bin/env bash +set -e +TARGET_ROWS="$1" +case "$TARGET_ROWS" in + 10000|100000|1000000|10000000) ;; + *) echo "Error: TARGET_ROWS must be one of: 10000, 100000, 1000000, 10000000"; exit 1 ;; +esac +TABLE_NAME="json_small_plaintext_${TARGET_ROWS}" +until pg_isready -U postgres > /dev/null 2>&1; do sleep 1; done +CURRENT_ROWS=$(psql -U postgres -d postgres -tAc "SELECT COUNT(*) FROM $TABLE_NAME;") +if [ "$CURRENT_ROWS" -ge "$TARGET_ROWS" ]; then + echo "$TABLE_NAME already has $CURRENT_ROWS rows (>= $TARGET_ROWS)." + psql -U postgres -d postgres -c "CREATE INDEX IF NOT EXISTS ${TABLE_NAME}_gin_idx ON $TABLE_NAME USING GIN (value jsonb_path_ops);" + exit 0 +fi +ROWS_TO_INSERT=$((TARGET_ROWS - CURRENT_ROWS)) +echo "Inserting $ROWS_TO_INSERT rows into $TABLE_NAME..." +psql -v ON_ERROR_STOP=1 -U postgres -d postgres -c " + INSERT INTO $TABLE_NAME (value) + SELECT jsonb_build_object( + 'first_name', md5(random()::text), + 'last_name', md5(random()::text), + 'age', (18 + floor(random() * 82))::int, + 'email', md5(random()::text) || '@example.com' + ) + FROM generate_series(1, $ROWS_TO_INSERT);" +psql -U postgres -d postgres -c "CREATE INDEX IF NOT EXISTS ${TABLE_NAME}_gin_idx ON $TABLE_NAME USING GIN (value jsonb_path_ops);" +psql -U postgres -d postgres -c "ANALYZE $TABLE_NAME;" +echo "Preparation complete." +""" + [tasks."prepare:string_plaintext"] description = "Prepare string_plaintext_ table with random high-cardinality strings (md5)" depends = ["postgres"] @@ -480,7 +706,11 @@ echo "Current rows in $TABLE_NAME: $CURRENT_ROWS" echo "Target rows: $TARGET_ROWS" if [ "$CURRENT_ROWS" -ge "$TARGET_ROWS" ]; then - echo "Table already has $CURRENT_ROWS rows (>= $TARGET_ROWS). No action needed." + echo "Table already has $CURRENT_ROWS rows (>= $TARGET_ROWS)." + # Still ensure the value index exists — older populations predate it, and + # the early exit must not skip it (that's how the 1M plaintext baseline + # ran unindexed the first time). + psql -U postgres -d postgres -c "CREATE INDEX IF NOT EXISTS ${TABLE_NAME}_value_idx ON $TABLE_NAME (value);" exit 0 fi @@ -496,6 +726,13 @@ psql -v ON_ERROR_STOP=1 -U postgres -d postgres -c " FROM generate_series(1, $ROWS_TO_INSERT) g; " +# btree on value: the v3 plaintext baseline bench (benches/plaintext_v3.rs) +# runs `WHERE value = $1` against this table, and the encrypted counterpart +# is btree-indexed — an unindexed plaintext baseline would overstate EQL's +# relative performance. Idempotent; harmless for other consumers. +psql -U postgres -d postgres -c "CREATE INDEX IF NOT EXISTS ${TABLE_NAME}_value_idx ON $TABLE_NAME (value);" +psql -U postgres -d postgres -c "ANALYZE $TABLE_NAME;" + FINAL_ROWS=$(psql -U postgres -d postgres -t -c "SELECT COUNT(*) FROM $TABLE_NAME;" | tr -d ' ') echo "Preparation complete! Final row count: $FINAL_ROWS" """ @@ -701,6 +938,121 @@ TARGET_ROWS="$TARGET_ROWS" cargo criterion --bench json --message-format json > echo "Benchmark complete! Results written to $OUTPUT_FILE" """ +[tasks."bench:v3:query:_run"] +description = "Internal: run one v3 query bench (prepare deps, clean criterion group, write results under results/query/v3/)" +run = """ +#!/usr/bin/env bash +set -e + +# Signature: [prepare tasks...] +# TARGET_ROWS comes LAST: the per-family wrapper tasks are single-line +# commands, and mise appends caller args to those — so the tier the user +# passes lands at the end of the arg list. +BENCH_NAME="$1" # cargo bench target, e.g. exact_v3 +GROUP="$2" # criterion group to clean, e.g. EXACT +shift 2 + +# All-but-last args are prepare task names; the last is the tier. +PREPS="" +while [ $# -gt 1 ]; do + PREPS="$PREPS $1" + shift +done +TARGET_ROWS="${1:-}" + +if [ -z "$BENCH_NAME" ] || [ -z "$GROUP" ] || [ -z "$TARGET_ROWS" ]; then + echo "Usage: mise run bench:v3:query:_run [prepare tasks...] " + exit 1 +fi +if ! [[ "$TARGET_ROWS" =~ ^[0-9]+$ ]]; then + echo "Error: target row count must be a positive integer" + exit 1 +fi + +for prep in $PREPS; do + echo "Preparing via prepare:$prep..." + mise run "prepare:$prep" "$TARGET_ROWS" +done + +echo "Cleaning old criterion data for $GROUP..." +rm -rf "target/criterion/data/main/$GROUP" "target/criterion/reports/$GROUP" + +echo "Running $BENCH_NAME query benchmark..." +mkdir -p results/query/v3 +OUTPUT_FILE="results/query/v3/${BENCH_NAME%_v3}_rows_${TARGET_ROWS}.json" +TARGET_ROWS="$TARGET_ROWS" cargo criterion --bench "$BENCH_NAME" --message-format json > "$OUTPUT_FILE" + +echo "Benchmark complete! Results written to $OUTPUT_FILE" +""" + +[tasks."bench:v3:query:exact"] +description = "Run EXACT v3 query benchmark (eql_v3.text_search)" +run = "mise run bench:v3:query:_run exact_v3 EXACT string_encrypted_v3" + +[tasks."bench:v3:query:ore"] +description = "Run ORE v3 query benchmark (eql_v3.integer_ord)" +run = "mise run bench:v3:query:_run ore_v3 ORE integer_encrypted_v3" + +[tasks."bench:v3:query:group_by"] +description = "Run GROUP BY v3 query benchmark (eql_v3.text_eq + shared plaintext baseline)" +run = "mise run bench:v3:query:_run group_by_v3 GROUP_BY category_encrypted_v3 category_plaintext" + +[tasks."bench:v3:query:match"] +description = "Run MATCH v3 query benchmark (bloom containment; incl. no-index variants at 10k)" +run = "mise run bench:v3:query:_run match_v3 MATCH string_encrypted_v3" + +[tasks."bench:v3:query:ope"] +description = "Run OPE v3 query benchmark (eql_v3.integer_ord_ope, synthetic op term)" +run = "mise run bench:v3:query:_run ope_v3 OPE integer_encrypted_ope_v3" + +[tasks."bench:v3:query:combo"] +description = "Run COMBO v3 query benchmark (composite predicates)" +run = "mise run bench:v3:query:_run combo_v3 COMBO combo_encrypted_v3" + +[tasks."bench:v3:query:json"] +description = "Run JSON v3 query benchmark (eql_v3.json SteVec)" +run = "mise run bench:v3:query:_run json_v3 JSON json_ste_vec_small_v3" + +[tasks."bench:v3:query:smoke"] +description = "Run the new-family scalar smoke bench (fixed 10k table)" +depends = ["postgres", "bench:build"] +run = """ +#!/usr/bin/env bash +set -e +mise run prepare:scalar_smoke_v3 +rm -rf target/criterion/data/main/SMOKE_V3 target/criterion/reports/SMOKE_V3 +mkdir -p results/query/v3 +cargo criterion --bench scalar_smoke_v3 --message-format json > results/query/v3/scalar_smoke_rows_10000.json +echo "Benchmark complete! Results written to results/query/v3/scalar_smoke_rows_10000.json" +""" + +[tasks."bench:v3:query:plaintext"] +description = "Run the plaintext baseline bench (string/integer/json plaintext tables)" +run = "mise run bench:v3:query:_run plaintext_v3 PLAINTEXT string_plaintext integer_plaintext json_small_plaintext" + +[tasks."bench:v3:query:all"] +description = "Run all v3 query benchmarks across tiers up to max_rows (default 1000000)" +run = """ +#!/usr/bin/env bash +set -e +MAX_ROWS="${1:-1000000}" +for rows in 10000 100000 1000000; do + if [ "$rows" -gt "$MAX_ROWS" ]; then break; fi + for fam in exact match ore ope group_by combo json plaintext; do + mise run "bench:v3:query:$fam" "$rows" + done +done +mise run bench:v3:query:smoke +""" + +[tasks."report:v3-compare"] +description = "Generate the EQL v3 vs v2 comparison report + charts (report/V3_COMPARISON.md, report/v3/*)" +run = """ +#!/usr/bin/env bash +set -e +python3 report_v3_compare.py --threshold "${1:-10}" +""" + [tasks."bench:query:all"] description = "Run all query benchmarks across row-count tiers (10k, 100k, 1M, 10M). Optional arg caps the largest tier, e.g. `mise run bench:query:all 1000000` stops at 1M." run = """ diff --git a/report/V3_COMPARISON.md b/report/V3_COMPARISON.md new file mode 100644 index 0000000..1e2a71e --- /dev/null +++ b/report/V3_COMPARISON.md @@ -0,0 +1,359 @@ +# EQL v3 vs v2.3 — Benchmark Comparison + +Auto-generated by `report_v3_compare.py` (`mise run report:v3-compare`). + +Regression threshold: v3 slower by more than **10%** on a semantics-equivalent scenario. + +Methodology notes: + +- v3 payloads are produced by `eql-bindings::from_v2` over the pinned cipherstash-client's v2.3 output (the supported migration path); conversion cost is inside the measured ingest path. +- v3 query parameters are stored-shape payloads (no v3 scalar query wire shape exists); server-side timings are unaffected. +- The v3 string column (`text_search`) carries an ORE term v2's unique+match config did not — v3 string rows are wider. +- OPE ciphertexts are synthetic (fixed-width order-preserving hex) until a client emits real CLLW-OPE terms; plan shapes and comparison counts are faithful, ciphertext size is approximate. +- v2 numbers are the committed baseline results (`results/query/`, `results/ingest/`); v3 numbers come from `results/query/v3/`, `results/ingest/v3/`. + +## Query scenarios: v3 vs v2 + +96 comparable scenario/tier pairs — **14 regressions**, 21 improvements beyond ±10%. + +| Scenario | Tier | v2 median | v3 median | Δ | Flag | Note | +|---|---|---|---|---|---|---| +| JSON/json/field_eq/extractor | 1000000 | 463.3 µs | 82.27 ms | +17658.4% | ⚠️ semantics changed | generic-plan GIN + every-row needle artifact (see report notes) | +| JSON/json/field_eq/extractor | 100000 | 466.7 µs | 5.77 ms | +1137.3% | ⚠️ semantics changed | generic-plan GIN + every-row needle artifact (see report notes) | +| JSON/json/field_eq/extractor | 10000 | 496.8 µs | 1.42 ms | +185.9% | ⚠️ semantics changed | generic-plan GIN + every-row needle artifact (see report notes) | +| JSON/json/contains/functional | 10000 | 237.4 µs | 465.1 µs | +95.9% | ⚠️ semantics changed | v2 jsonb_array recipe → v3 to_ste_vec_query GIN recipe | +| COMBO/combo/bloom_ore_order_limit | 10000 | 286.9 µs | 524.6 µs | +82.8% | ⚠️ semantics changed | v2 LIKE → v3 @> | +| JSON/json/field_order/functional | 100000 | 306.2 µs | 525.6 µs | +71.6% | 🔴 REGRESSION | | +| JSON/json/contains/functional | 100000 | 279.9 µs | 477.0 µs | +70.4% | ⚠️ semantics changed | v2 jsonb_array recipe → v3 to_ste_vec_query GIN recipe | +| JSON/json/field_order/functional | 10000 | 317.9 µs | 515.3 µs | +62.1% | 🔴 REGRESSION | | +| ORE/ore/range_lt_ordered_10 | 10000 | 480.9 µs | 759.6 µs | +57.9% | 🔴 REGRESSION | | +| ORE/ore/range_lt_ordered_10 | 100000 | 502.4 µs | 735.3 µs | +46.3% | 🔴 REGRESSION | | +| JSON/json/field_order/functional | 1000000 | 356.7 µs | 515.2 µs | +44.4% | 🔴 REGRESSION | | +| ORE/ore/range_lt_ordered_10 | 1000000 | 513.2 µs | 735.5 µs | +43.3% | 🔴 REGRESSION | | +| COMBO/combo/bloom_ore_order_limit | 1000000 | 16.64 ms | 22.70 ms | +36.4% | ⚠️ semantics changed | v2 LIKE → v3 @> | +| COMBO/combo/bloom_ore_order_limit | 100000 | 2.08 ms | 2.78 ms | +33.5% | ⚠️ semantics changed | v2 LIKE → v3 @> | +| COMBO/combo/top_n_filtered_group_by | 10000 | 178.4 µs | 230.9 µs | +29.4% | ⚠️ semantics changed | v2 LIKE → v3 @> | +| COMBO/combo/filtered_group_by | 10000 | 178.5 µs | 230.5 µs | +29.1% | ⚠️ semantics changed | v2 LIKE → v3 @> | +| MATCH/match/eql_cast_firstname | 10000 | 152.3 µs | 194.2 µs | +27.5% | ⚠️ semantics changed | v2 LIKE → v3 @> (no LIKE operator in v3; same bloom semantics) | +| JSON/json/field_eq/functional | 10000 | 104.8 µs | 126.4 µs | +20.6% | 🔴 REGRESSION | same btree plan as v2 (needle matches every row in both versions) | +| EXACT/exact/eql_hash | 100000 | 102.9 µs | 123.6 µs | +20.1% | ⚠️ semantics changed | index type changed: v2 hash → v3 btree on eq_term | +| JSON/json/field_eq/functional | 1000000 | 105.8 µs | 126.4 µs | +19.5% | 🔴 REGRESSION | same btree plan as v2 (needle matches every row in both versions) | +| MATCH/match/eql_bloom | 10000 | 405.4 µs | 483.4 µs | +19.2% | 🔴 REGRESSION | v3 GIN uses native array_ops on the bloom term (no shipped opclass) | +| JSON/json/field_eq/functional | 100000 | 107.4 µs | 127.6 µs | +18.9% | 🔴 REGRESSION | same btree plan as v2 (needle matches every row in both versions) | +| JSON/json/field_eq/bare | 100000 | 106.8 µs | 125.5 µs | +17.5% | 🔴 REGRESSION | same btree plan as v2 (needle matches every row in both versions) | +| JSON/json/field_eq/bare | 10000 | 109.3 µs | 125.7 µs | +15.0% | 🔴 REGRESSION | same btree plan as v2 (needle matches every row in both versions) | +| EXACT/exact/eql_hash | 10000 | 108.7 µs | 124.2 µs | +14.2% | ⚠️ semantics changed | index type changed: v2 hash → v3 btree on eq_term | +| JSON/json/field_eq/bare | 1000000 | 109.0 µs | 124.2 µs | +14.0% | 🔴 REGRESSION | same btree plan as v2 (needle matches every row in both versions) | +| COMBO/combo/filtered_group_by | 100000 | 665.2 µs | 749.5 µs | +12.7% | ⚠️ semantics changed | v2 LIKE → v3 @> | +| JSON/json/contains/functional | 1000000 | 431.5 µs | 485.3 µs | +12.5% | ⚠️ semantics changed | v2 jsonb_array recipe → v3 to_ste_vec_query GIN recipe | +| MATCH/match/eql_cast_lastname | 10000 | 439.7 µs | 488.4 µs | +11.1% | ⚠️ semantics changed | v2 LIKE → v3 @> (no LIKE operator in v3; same bloom semantics) | +| EXACT/exact/eql_hash | 1000000 | 104.6 µs | 116.1 µs | +11.0% | ⚠️ semantics changed | index type changed: v2 hash → v3 btree on eq_term | +| MATCH/match_decrypt/eql_bloom | 100000 | 25.70 ms | 28.38 ms | +10.4% | 🔴 REGRESSION | | +| MATCH/match/eql_bloom | 100000 | 1.75 ms | 1.89 ms | +7.7% | | v3 GIN uses native array_ops on the bloom term (no shipped opclass) | +| MATCH/match/eql_cast_lastname | 1000000 | 14.40 ms | 15.42 ms | +7.0% | ⚠️ semantics changed | v2 LIKE → v3 @> (no LIKE operator in v3; same bloom semantics) | +| EXACT/exact/eql_cast | 100000 | 113.5 µs | 121.0 µs | +6.6% | | v3 string rows are wider (text_search adds an ORE term v2 didn't carry) | +| MATCH/match/eql_bloom | 1000000 | 14.47 ms | 15.41 ms | +6.5% | | v3 GIN uses native array_ops on the bloom term (no shipped opclass) | +| EXACT/exact_decrypt/eql_hash | 100000 | 23.94 ms | 25.19 ms | +5.2% | ⚠️ semantics changed | index type changed: v2 hash → v3 btree on eq_term | +| GROUP_BY/group_by/low_cardinality_groups_plaintext | 100000 | 9.03 ms | 9.47 ms | +4.9% | | | +| GROUP_BY/group_by/top_n_groups_plaintext | 100000 | 9.43 ms | 9.89 ms | +4.9% | | | +| EXACT/exact_decrypt/eql_hash | 1000000 | 23.64 ms | 24.78 ms | +4.8% | ⚠️ semantics changed | index type changed: v2 hash → v3 btree on eq_term | +| EXACT/exact_decrypt/eql_cast | 1000000 | 23.88 ms | 24.82 ms | +3.9% | | | +| EXACT/exact/eql_cast | 10000 | 119.3 µs | 123.4 µs | +3.4% | | v3 string rows are wider (text_search adds an ORE term v2 didn't carry) | +| MATCH/match/eql_cast_lastname | 100000 | 1.79 ms | 1.85 ms | +3.3% | ⚠️ semantics changed | v2 LIKE → v3 @> (no LIKE operator in v3; same bloom semantics) | +| EXACT/exact_decrypt/eql_cast | 100000 | 24.08 ms | 24.72 ms | +2.6% | | | +| MATCH/match_decrypt/eql_cast_firstname | 10000 | 24.26 ms | 24.87 ms | +2.5% | ⚠️ semantics changed | v2 LIKE → v3 @> | +| MATCH/match_decrypt/eql_cast_firstname | 1000000 | 30.42 ms | 31.08 ms | +2.2% | ⚠️ semantics changed | v2 LIKE → v3 @> | +| EXACT/exact_decrypt/eql_hash | 10000 | 23.96 ms | 24.32 ms | +1.5% | ⚠️ semantics changed | index type changed: v2 hash → v3 btree on eq_term | +| EXACT/exact_decrypt/eql_cast | 10000 | 24.06 ms | 24.32 ms | +1.1% | | | +| MATCH/match_decrypt/eql_cast_lastname | 100000 | 27.84 ms | 28.14 ms | +1.1% | ⚠️ semantics changed | v2 LIKE → v3 @> | +| COMBO/combo/top_n_filtered_group_by | 1000000 | 5.29 ms | 5.34 ms | +1.0% | ⚠️ semantics changed | v2 LIKE → v3 @> | +| ORE/ore_decrypt/range_gt_10 | 100000 | 25.72 ms | 25.90 ms | +0.7% | | | +| GROUP_BY/group_by/top_n_groups_plaintext | 10000 | 1.20 ms | 1.20 ms | +0.5% | | | +| MATCH/match_decrypt/eql_cast_lastname | 10000 | 26.13 ms | 26.22 ms | +0.4% | ⚠️ semantics changed | v2 LIKE → v3 @> | +| EXACT/exact/eql_cast | 1000000 | 115.1 µs | 115.2 µs | +0.1% | | v3 string rows are wider (text_search adds an ORE term v2 didn't carry) | +| GROUP_BY/group_by/top_n_groups_encrypted | 1000000 | 93.01 ms | 92.54 ms | -0.5% | | | +| GROUP_BY/group_by/low_cardinality_groups_plaintext | 10000 | 1.16 ms | 1.14 ms | -1.9% | | | +| MATCH/match_decrypt/eql_bloom | 10000 | 26.41 ms | 25.89 ms | -1.9% | | | +| MATCH/match_decrypt/eql_cast_firstname | 100000 | 26.41 ms | 25.86 ms | -2.1% | ⚠️ semantics changed | v2 LIKE → v3 @> | +| MATCH/match_decrypt/eql_cast_lastname | 1000000 | 39.48 ms | 38.34 ms | -2.9% | ⚠️ semantics changed | v2 LIKE → v3 @> | +| MATCH/match_decrypt/eql_bloom | 1000000 | 39.93 ms | 38.76 ms | -2.9% | | | +| ORE/ore_decrypt/range_lt_ordered_10 | 1000000 | 26.86 ms | 25.88 ms | -3.7% | | | +| ORE/ore/range_gt_10 | 100000 | 573.5 µs | 550.2 µs | -4.1% | | | +| GROUP_BY/group_by/low_cardinality_groups_encrypted | 1000000 | 92.57 ms | 88.45 ms | -4.5% | | | +| GROUP_BY/group_by/top_n_groups_plaintext | 1000000 | 40.21 ms | 38.37 ms | -4.6% | | | +| ORE/ore_decrypt/range_gt_10 | 1000000 | 27.04 ms | 25.69 ms | -5.0% | | | +| GROUP_BY/group_by/low_cardinality_groups_plaintext | 1000000 | 39.05 ms | 37.06 ms | -5.1% | | | +| ORE/ore_decrypt/range_lt_10 | 1000000 | 26.97 ms | 25.56 ms | -5.2% | | | +| MATCH/match/eql_cast_firstname | 100000 | 633.3 µs | 596.5 µs | -5.8% | ⚠️ semantics changed | v2 LIKE → v3 @> (no LIKE operator in v3; same bloom semantics) | +| ORE/ore_decrypt/range_lt_ordered_10 | 10000 | 28.42 ms | 26.21 ms | -7.8% | | | +| ORE/ore/range_lt_10 | 10000 | 595.5 µs | 546.0 µs | -8.3% | | | +| ORE/ore_decrypt/range_lt_10 | 10000 | 28.88 ms | 26.34 ms | -8.8% | | | +| ORE/ore/range_lt_10 | 1000000 | 577.0 µs | 521.6 µs | -9.6% | | | +| ORE/ore_decrypt/range_gt_10 | 10000 | 29.35 ms | 26.42 ms | -10.0% | | | +| MATCH/match/eql_cast_firstname | 1000000 | 3.68 ms | 3.29 ms | -10.6% | ⚠️ semantics changed | v2 LIKE → v3 @> (no LIKE operator in v3; same bloom semantics) | +| GROUP_BY/group_by/top_n_groups_encrypted | 100000 | 20.28 ms | 18.07 ms | -10.9% | 🟢 improvement | | +| GROUP_BY/group_by/low_cardinality_groups_encrypted | 100000 | 20.24 ms | 18.01 ms | -11.0% | 🟢 improvement | | +| ORE/ore_decrypt/range_lt_ordered_10 | 100000 | 29.32 ms | 25.93 ms | -11.6% | 🟢 improvement | | +| ORE/ore_decrypt/range_lt_10 | 100000 | 29.44 ms | 25.98 ms | -11.8% | 🟢 improvement | | +| GROUP_BY/group_by/low_cardinality_groups_encrypted | 10000 | 2.22 ms | 1.95 ms | -12.4% | 🟢 improvement | | +| ORE/ore_decrypt/range_gt_100 | 100000 | 42.13 ms | 36.67 ms | -12.9% | 🟢 improvement | | +| GROUP_BY/group_by/top_n_groups_encrypted | 10000 | 2.30 ms | 1.99 ms | -13.4% | 🟢 improvement | | +| ORE/ore/range_gt_10 | 10000 | 624.6 µs | 538.8 µs | -13.7% | 🟢 improvement | | +| ORE/ore/range_gt_10 | 1000000 | 694.1 µs | 578.6 µs | -16.6% | 🟢 improvement | | +| COMBO/combo/filtered_group_by | 1000000 | 6.40 ms | 5.30 ms | -17.1% | ⚠️ semantics changed | v2 LIKE → v3 @> | +| ORE/ore/range_lt_10 | 100000 | 655.1 µs | 536.8 µs | -18.1% | 🟢 improvement | | +| ORE/ore_decrypt/range_lt_100 | 1000000 | 45.47 ms | 37.13 ms | -18.4% | 🟢 improvement | | +| ORE/ore_decrypt/range_lt_100 | 100000 | 46.19 ms | 37.02 ms | -19.9% | 🟢 improvement | | +| ORE/ore_decrypt/range_lt_100 | 10000 | 46.05 ms | 36.26 ms | -21.2% | 🟢 improvement | | +| ORE/ore_decrypt/range_gt_100 | 10000 | 46.26 ms | 35.99 ms | -22.2% | 🟢 improvement | | +| ORE/ore_decrypt/range_gt_100 | 1000000 | 47.06 ms | 35.00 ms | -25.6% | 🟢 improvement | | +| COMBO/combo/top_n_filtered_group_by | 100000 | 1.03 ms | 718.8 µs | -30.5% | ⚠️ semantics changed | v2 LIKE → v3 @> | +| ORE/ore/range_lt_100 | 10000 | 3.93 ms | 988.9 µs | -74.8% | 🟢 improvement | | +| ORE/ore/range_lt_100 | 100000 | 3.87 ms | 970.2 µs | -74.9% | 🟢 improvement | | +| ORE/ore/range_gt_100 | 1000000 | 4.10 ms | 1.01 ms | -75.4% | 🟢 improvement | | +| ORE/ore/range_lt_100 | 1000000 | 4.06 ms | 990.4 µs | -75.6% | 🟢 improvement | | +| ORE/ore/range_gt_100 | 100000 | 4.16 ms | 1.01 ms | -75.8% | 🟢 improvement | | +| ORE/ore/range_gt_100 | 10000 | 4.04 ms | 962.2 µs | -76.2% | 🟢 improvement | | + +## v3-only scenarios (no v2 counterpart) + +| Scenario | Median | +|---|---| +| MATCH/match/eql_bloom_noindex/10000 | 55.14 ms | +| MATCH/match/eql_cast_firstname_noindex/10000 | 186.96 ms | +| MATCH/match/eql_cast_lastname_noindex/10000 | 55.28 ms | +| OPE/ope/range_gt_10/10000 | 118.1 µs | +| OPE/ope/range_gt_10/100000 | 116.9 µs | +| OPE/ope/range_gt_10/1000000 | 119.3 µs | +| OPE/ope/range_gt_100/10000 | 351.7 µs | +| OPE/ope/range_gt_100/100000 | 334.0 µs | +| OPE/ope/range_gt_100/1000000 | 323.1 µs | +| OPE/ope/range_lt_10/10000 | 116.8 µs | +| OPE/ope/range_lt_10/100000 | 116.2 µs | +| OPE/ope/range_lt_10/1000000 | 115.2 µs | +| OPE/ope/range_lt_100/10000 | 336.4 µs | +| OPE/ope/range_lt_100/100000 | 346.0 µs | +| OPE/ope/range_lt_100/1000000 | 347.6 µs | +| OPE/ope/range_lt_ordered_10/10000 | 118.2 µs | +| OPE/ope/range_lt_ordered_10/100000 | 118.9 µs | +| OPE/ope/range_lt_ordered_10/1000000 | 117.8 µs | +| OPE/ope_decrypt/range_gt_10/10000 | 26.26 ms | +| OPE/ope_decrypt/range_gt_10/100000 | 25.84 ms | +| OPE/ope_decrypt/range_gt_10/1000000 | 25.80 ms | +| OPE/ope_decrypt/range_gt_100/10000 | 35.05 ms | +| OPE/ope_decrypt/range_gt_100/100000 | 36.32 ms | +| OPE/ope_decrypt/range_gt_100/1000000 | 36.39 ms | +| OPE/ope_decrypt/range_lt_10/10000 | 25.76 ms | +| OPE/ope_decrypt/range_lt_10/100000 | 25.67 ms | +| OPE/ope_decrypt/range_lt_10/1000000 | 25.85 ms | +| OPE/ope_decrypt/range_lt_100/10000 | 35.31 ms | +| OPE/ope_decrypt/range_lt_100/100000 | 36.80 ms | +| OPE/ope_decrypt/range_lt_100/1000000 | 36.79 ms | +| OPE/ope_decrypt/range_lt_ordered_10/10000 | 26.50 ms | +| OPE/ope_decrypt/range_lt_ordered_10/100000 | 25.70 ms | +| OPE/ope_decrypt/range_lt_ordered_10/1000000 | 25.69 ms | +| PLAINTEXT/plaintext/exact_eq/10000 | 88.3 µs | +| PLAINTEXT/plaintext/exact_eq/100000 | 90.9 µs | +| PLAINTEXT/plaintext/exact_eq/1000000 | 91.4 µs | +| PLAINTEXT/plaintext/json_contains/10000 | 104.2 µs | +| PLAINTEXT/plaintext/json_contains/100000 | 228.4 µs | +| PLAINTEXT/plaintext/json_contains/1000000 | 230.4 µs | +| PLAINTEXT/plaintext/json_field_eq/10000 | 205.3 µs | +| PLAINTEXT/plaintext/json_field_eq/100000 | 218.6 µs | +| PLAINTEXT/plaintext/json_field_eq/1000000 | 378.0 µs | +| PLAINTEXT/plaintext/range_gt_10/10000 | 89.0 µs | +| PLAINTEXT/plaintext/range_gt_10/100000 | 87.3 µs | +| PLAINTEXT/plaintext/range_gt_10/1000000 | 88.0 µs | +| PLAINTEXT/plaintext/range_lt_ordered_10/10000 | 96.9 µs | +| PLAINTEXT/plaintext/range_lt_ordered_10/100000 | 98.0 µs | +| PLAINTEXT/plaintext/range_lt_ordered_10/1000000 | 98.8 µs | +| SMOKE_V3/smoke/bigint_ord/range_gt_10/10000 | 1.23 ms | +| SMOKE_V3/smoke/bigint_ord/range_gt_ordered_10/10000 | 712.3 µs | +| SMOKE_V3/smoke/boolean/select_back/10000 | 103.2 µs | +| SMOKE_V3/smoke/date_ord/range_gt_10/10000 | 1.83 ms | +| SMOKE_V3/smoke/date_ord/range_gt_ordered_10/10000 | 1.54 ms | +| SMOKE_V3/smoke/double_ord/range_gt_10/10000 | 1.88 ms | +| SMOKE_V3/smoke/double_ord/range_gt_ordered_10/10000 | 787.8 µs | +| SMOKE_V3/smoke/numeric_ord/range_gt_10/10000 | 1.61 ms | +| SMOKE_V3/smoke/numeric_ord/range_gt_ordered_10/10000 | 880.1 µs | +| SMOKE_V3/smoke/timestamp_ord/range_gt_10/10000 | 1.52 ms | +| SMOKE_V3/smoke/timestamp_ord/range_gt_ordered_10/10000 | 846.8 µs | + +## Index engagement audit (v3 plans) + +Scenarios matching an expected-index rule must show a non-empty `indexes_used` in their EXPLAIN capture. + +| Scenario | Indexes used | Rows | Status | +|---|---|---|---| +| COMBO/combo/bloom_ore_order_limit/10000 | combo_encrypted_v3_10000_name_match_gin_index | 4 | | +| COMBO/combo/bloom_ore_order_limit/100000 | combo_encrypted_v3_100000_name_match_gin_index | 10 | | +| COMBO/combo/bloom_ore_order_limit/1000000 | combo_encrypted_v3_1000000_name_match_gin_index | 10 | | +| COMBO/combo/filtered_group_by/10000 | combo_encrypted_v3_10000_name_match_gin_index | 4 | | +| COMBO/combo/filtered_group_by/100000 | combo_encrypted_v3_100000_name_match_gin_index | 51 | | +| COMBO/combo/filtered_group_by/1000000 | combo_encrypted_v3_1000000_name_match_gin_index | 237 | | +| COMBO/combo/top_n_filtered_group_by/10000 | combo_encrypted_v3_10000_name_match_gin_index | 4 | | +| COMBO/combo/top_n_filtered_group_by/100000 | combo_encrypted_v3_100000_name_match_gin_index | 10 | | +| COMBO/combo/top_n_filtered_group_by/1000000 | combo_encrypted_v3_1000000_name_match_gin_index | 10 | | +| EXACT/exact/eql_cast/10000 | string_encrypted_v3_10000_eq_btree_index | 1 | ✅ | +| EXACT/exact/eql_cast/100000 | string_encrypted_v3_100000_eq_btree_index | 1 | ✅ | +| EXACT/exact/eql_cast/1000000 | string_encrypted_v3_1000000_eq_btree_index | 1 | ✅ | +| EXACT/exact/eql_hash/10000 | string_encrypted_v3_10000_eq_btree_index | 1 | ✅ | +| EXACT/exact/eql_hash/100000 | string_encrypted_v3_100000_eq_btree_index | 1 | ✅ | +| EXACT/exact/eql_hash/1000000 | string_encrypted_v3_1000000_eq_btree_index | 1 | ✅ | +| GROUP_BY/group_by/low_cardinality_groups_encrypted/10000 | — | 1 | | +| GROUP_BY/group_by/low_cardinality_groups_encrypted/100000 | — | 1 | | +| GROUP_BY/group_by/low_cardinality_groups_encrypted/1000000 | — | 1 | | +| GROUP_BY/group_by/low_cardinality_groups_plaintext/10000 | — | 1 | | +| GROUP_BY/group_by/low_cardinality_groups_plaintext/100000 | — | 1 | | +| GROUP_BY/group_by/low_cardinality_groups_plaintext/1000000 | — | 1 | | +| GROUP_BY/group_by/top_n_groups_encrypted/10000 | — | 10 | | +| GROUP_BY/group_by/top_n_groups_encrypted/100000 | — | 10 | | +| GROUP_BY/group_by/top_n_groups_encrypted/1000000 | — | 10 | | +| GROUP_BY/group_by/top_n_groups_plaintext/10000 | — | 10 | | +| GROUP_BY/group_by/top_n_groups_plaintext/100000 | — | 10 | | +| GROUP_BY/group_by/top_n_groups_plaintext/1000000 | — | 10 | | +| JSON/json/contains/functional/10000 | json_ste_vec_small_encrypted_v3_10000_stevec_query_index | 1 | ✅ | +| JSON/json/contains/functional/100000 | json_ste_vec_small_encrypted_v3_100000_stevec_query_index | 1 | ✅ | +| JSON/json/contains/functional/1000000 | json_ste_vec_small_encrypted_v3_1000000_stevec_query_index | 1 | ✅ | +| JSON/json/field_eq/bare/10000 | json_ste_vec_small_encrypted_v3_10000_field_eq_idx | 10 | ✅ | +| JSON/json/field_eq/bare/100000 | json_ste_vec_small_encrypted_v3_100000_field_eq_idx | 10 | ✅ | +| JSON/json/field_eq/bare/1000000 | json_ste_vec_small_encrypted_v3_1000000_field_eq_idx | 10 | ✅ | +| JSON/json/field_eq/extractor/10000 | — | 10 | | +| JSON/json/field_eq/extractor/100000 | — | 10 | | +| JSON/json/field_eq/extractor/1000000 | — | 10 | | +| JSON/json/field_eq/functional/10000 | json_ste_vec_small_encrypted_v3_10000_field_eq_idx | 10 | ✅ | +| JSON/json/field_eq/functional/100000 | json_ste_vec_small_encrypted_v3_100000_field_eq_idx | 10 | ✅ | +| JSON/json/field_eq/functional/1000000 | json_ste_vec_small_encrypted_v3_1000000_field_eq_idx | 10 | ✅ | +| JSON/json/field_order/functional/10000 | json_ste_vec_small_encrypted_v3_10000_field_order_idx | 10 | ✅ | +| JSON/json/field_order/functional/100000 | json_ste_vec_small_encrypted_v3_100000_field_order_idx | 10 | ✅ | +| JSON/json/field_order/functional/1000000 | json_ste_vec_small_encrypted_v3_1000000_field_order_idx | 10 | ✅ | +| MATCH/match/eql_bloom/10000 | string_encrypted_v3_10000_match_gin_index | 10 | ✅ | +| MATCH/match/eql_bloom/100000 | string_encrypted_v3_100000_match_gin_index | 10 | ✅ | +| MATCH/match/eql_bloom/1000000 | string_encrypted_v3_1000000_match_gin_index | 10 | ✅ | +| MATCH/match/eql_bloom_noindex/10000 | — | 10 | | +| MATCH/match/eql_cast_firstname/10000 | string_encrypted_v3_10000_match_gin_index | 4 | ✅ | +| MATCH/match/eql_cast_firstname/100000 | string_encrypted_v3_100000_match_gin_index | 10 | ✅ | +| MATCH/match/eql_cast_firstname/1000000 | string_encrypted_v3_1000000_match_gin_index | 10 | ✅ | +| MATCH/match/eql_cast_firstname_noindex/10000 | — | 4 | | +| MATCH/match/eql_cast_lastname/10000 | string_encrypted_v3_10000_match_gin_index | 10 | ✅ | +| MATCH/match/eql_cast_lastname/100000 | string_encrypted_v3_100000_match_gin_index | 10 | ✅ | +| MATCH/match/eql_cast_lastname/1000000 | string_encrypted_v3_1000000_match_gin_index | 10 | ✅ | +| MATCH/match/eql_cast_lastname_noindex/10000 | — | 10 | | +| OPE/ope/range_gt_10/10000 | — | 10 | | +| OPE/ope/range_gt_10/100000 | — | 10 | | +| OPE/ope/range_gt_10/1000000 | — | 10 | | +| OPE/ope/range_gt_100/10000 | — | 100 | | +| OPE/ope/range_gt_100/100000 | — | 100 | | +| OPE/ope/range_gt_100/1000000 | — | 100 | | +| OPE/ope/range_lt_10/10000 | — | 10 | | +| OPE/ope/range_lt_10/100000 | — | 10 | | +| OPE/ope/range_lt_10/1000000 | — | 10 | | +| OPE/ope/range_lt_100/10000 | — | 100 | | +| OPE/ope/range_lt_100/100000 | — | 100 | | +| OPE/ope/range_lt_100/1000000 | — | 100 | | +| OPE/ope/range_lt_ordered_10/10000 | integer_encrypted_ope_v3_10000_ope_index | 10 | ✅ | +| OPE/ope/range_lt_ordered_10/100000 | integer_encrypted_ope_v3_100000_ope_index | 10 | ✅ | +| OPE/ope/range_lt_ordered_10/1000000 | integer_encrypted_ope_v3_1000000_ope_index | 10 | ✅ | +| ORE/ore/range_gt_10/10000 | integer_encrypted_v3_10000_ord_index | 10 | | +| ORE/ore/range_gt_10/100000 | integer_encrypted_v3_100000_ord_index | 10 | | +| ORE/ore/range_gt_10/1000000 | integer_encrypted_v3_1000000_ord_index | 10 | | +| ORE/ore/range_gt_100/10000 | integer_encrypted_v3_10000_ord_index | 100 | | +| ORE/ore/range_gt_100/100000 | integer_encrypted_v3_100000_ord_index | 100 | | +| ORE/ore/range_gt_100/1000000 | integer_encrypted_v3_1000000_ord_index | 100 | | +| ORE/ore/range_lt_10/10000 | integer_encrypted_v3_10000_ord_index | 10 | | +| ORE/ore/range_lt_10/100000 | integer_encrypted_v3_100000_ord_index | 10 | | +| ORE/ore/range_lt_10/1000000 | integer_encrypted_v3_1000000_ord_index | 10 | | +| ORE/ore/range_lt_100/10000 | integer_encrypted_v3_10000_ord_index | 100 | | +| ORE/ore/range_lt_100/100000 | integer_encrypted_v3_100000_ord_index | 100 | | +| ORE/ore/range_lt_100/1000000 | integer_encrypted_v3_1000000_ord_index | 100 | | +| ORE/ore/range_lt_ordered_10/10000 | integer_encrypted_v3_10000_ord_index | 10 | ✅ | +| ORE/ore/range_lt_ordered_10/100000 | integer_encrypted_v3_100000_ord_index | 10 | ✅ | +| ORE/ore/range_lt_ordered_10/1000000 | integer_encrypted_v3_1000000_ord_index | 10 | ✅ | +| PLAINTEXT/plaintext/exact_eq/10000 | string_plaintext_10000_value_idx | 1 | | +| PLAINTEXT/plaintext/exact_eq/100000 | string_plaintext_100000_value_idx | 1 | | +| PLAINTEXT/plaintext/exact_eq/1000000 | string_plaintext_1000000_value_idx | 1 | | +| PLAINTEXT/plaintext/json_contains/10000 | json_small_plaintext_10000_gin_idx | 10 | | +| PLAINTEXT/plaintext/json_contains/100000 | — | 10 | | +| PLAINTEXT/plaintext/json_contains/1000000 | — | 10 | | +| PLAINTEXT/plaintext/json_field_eq/10000 | — | 10 | | +| PLAINTEXT/plaintext/json_field_eq/100000 | — | 10 | | +| PLAINTEXT/plaintext/json_field_eq/1000000 | — | 10 | | +| PLAINTEXT/plaintext/range_gt_10/10000 | — | 10 | | +| PLAINTEXT/plaintext/range_gt_10/100000 | — | 10 | | +| PLAINTEXT/plaintext/range_gt_10/1000000 | — | 10 | | +| PLAINTEXT/plaintext/range_lt_ordered_10/10000 | integer_plaintext_10000_value_idx | 10 | | +| PLAINTEXT/plaintext/range_lt_ordered_10/100000 | integer_plaintext_100000_value_idx | 10 | | +| PLAINTEXT/plaintext/range_lt_ordered_10/1000000 | integer_plaintext_1000000_value_idx | 10 | | +| SMOKE_V3/smoke/bigint_ord/range_gt_10/10000 | — | 10 | | +| SMOKE_V3/smoke/bigint_ord/range_gt_ordered_10/10000 | scalar_smoke_v3_bigint_val_ord_idx | 10 | ✅ | +| SMOKE_V3/smoke/date_ord/range_gt_10/10000 | — | 10 | | +| SMOKE_V3/smoke/date_ord/range_gt_ordered_10/10000 | scalar_smoke_v3_date_val_ord_idx | 10 | ✅ | +| SMOKE_V3/smoke/double_ord/range_gt_10/10000 | — | 10 | | +| SMOKE_V3/smoke/double_ord/range_gt_ordered_10/10000 | scalar_smoke_v3_double_val_ord_idx | 10 | ✅ | +| SMOKE_V3/smoke/numeric_ord/range_gt_10/10000 | — | 10 | | +| SMOKE_V3/smoke/numeric_ord/range_gt_ordered_10/10000 | scalar_smoke_v3_numeric_val_ord_idx | 10 | ✅ | +| SMOKE_V3/smoke/timestamp_ord/range_gt_10/10000 | — | 10 | | +| SMOKE_V3/smoke/timestamp_ord/range_gt_ordered_10/10000 | scalar_smoke_v3_timestamp_val_ord_idx | 10 | ✅ | + +## Ingest throughput: v3 vs v2 + +| Bench | Records | v2 rec/s | v3 rec/s | Δ | Note | +|---|---|---|---|---|---| +| encrypt_category_v3 | 500 | — | 1,155 | | | +| encrypt_category_v3 | 1,000 | — | 3,834 | | | +| encrypt_category_v3 | 10,000 | — | 12,738 | | | +| encrypt_int_ope_v3 | 500 | — | 1,227 | | synthetic op term; client-side cost excludes real CLLW-OPE generation (no client emits it yet) | +| encrypt_int_ope_v3 | 1,000 | — | 4,000 | | | +| encrypt_int_ope_v3 | 10,000 | — | 12,550 | | | +| encrypt_int_v3 | 500 | 699 | 708 | +1.3% | | +| encrypt_int_v3 | 1,000 | 1,587 | 1,551 | -2.2% | | +| encrypt_int_v3 | 10,000 | 2,127 | 2,129 | +0.1% | | +| encrypt_ste_vec_small_v3 | 500 | — | 267 | | | +| encrypt_ste_vec_small_v3 | 1,000 | — | 2,704 | | | +| encrypt_ste_vec_small_v3 | 10,000 | 4,032 | 4,604 | +14.2% | | +| encrypt_string_v3 | 500 | 966 | 656 | -32.2% | NOT a conversion regression: v3's only eq+match text domain (text_search) also REQUIRES the ORE term, so the config adds Index::new_ore() that v2's unique+match didn't carry — string ingest is capped at ORE-generation speed. A v3 hm+bf-only domain would restore v2 throughput. | +| encrypt_string_v3 | 1,000 | 3,989 | 1,033 | -74.1% | | +| encrypt_string_v3 | 10,000 | 9,649 | 1,278 | -86.8% | | + +## Index build times + +| Table | Build seconds | Recorded | +|---|---|---| +| string_encrypted_v3_10000 | 1 | 2026-07-03T13:10:14Z | +| integer_encrypted_v3_10000 | 1 | 2026-07-03T13:10:22Z | +| integer_encrypted_ope_v3_10000 | 0 | 2026-07-03T13:10:25Z | +| category_encrypted_v3_10000 | 0 | 2026-07-03T13:10:27Z | +| combo_encrypted_v3_10000 | 1 | 2026-07-03T13:10:42Z | +| json_ste_vec_small_encrypted_v3_10000 | 1 | 2026-07-03T13:10:47Z | +| string_encrypted_v3_100000 | 8 | 2026-07-03T13:36:20Z | +| integer_encrypted_v3_100000 | 4 | 2026-07-03T13:38:54Z | +| integer_encrypted_ope_v3_100000 | 0 | 2026-07-03T13:40:47Z | +| category_encrypted_v3_100000 | 1 | 2026-07-03T13:42:37Z | +| combo_encrypted_v3_100000 | 7 | 2026-07-03T13:45:31Z | +| json_ste_vec_small_encrypted_v3_100000 | 6 | 2026-07-03T13:46:31Z | +| string_encrypted_v3_1000000 | 78 | 2026-07-03T14:05:03Z | +| integer_encrypted_v3_1000000 | 44 | 2026-07-03T14:15:06Z | +| integer_encrypted_ope_v3_1000000 | 1 | 2026-07-03T14:16:48Z | +| category_encrypted_v3_1000000 | 1 | 2026-07-03T14:19:32Z | +| combo_encrypted_v3_1000000 | 67 | 2026-07-03T14:42:01Z | +| json_ste_vec_small_encrypted_v3_1000000 | 55 | 2026-07-03T14:46:55Z | + +## Charts + +![v3_vs_v2_10000](v3/v3_vs_v2_10000.png) + +![overhead_vs_plaintext_10000](v3/overhead_vs_plaintext_10000.png) + +![ore_vs_ope_10000](v3/ore_vs_ope_10000.png) + +![v3_vs_v2_100000](v3/v3_vs_v2_100000.png) + +![overhead_vs_plaintext_100000](v3/overhead_vs_plaintext_100000.png) + +![ore_vs_ope_100000](v3/ore_vs_ope_100000.png) + +![v3_vs_v2_1000000](v3/v3_vs_v2_1000000.png) + +![overhead_vs_plaintext_1000000](v3/overhead_vs_plaintext_1000000.png) + +![ore_vs_ope_1000000](v3/ore_vs_ope_1000000.png) diff --git a/report/v3/ore_vs_ope_10000.png b/report/v3/ore_vs_ope_10000.png new file mode 100644 index 0000000000000000000000000000000000000000..8c189ff811a08935a3b9d0f9110aa16c99701c21 GIT binary patch literal 53285 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