From c2e76f38de1fdcd7c7c9986dfb6d836a02ac7ed3 Mon Sep 17 00:00:00 2001 From: James Sadler Date: Thu, 2 Jul 2026 16:41:50 +1000 Subject: [PATCH 1/9] feat(lib): add EQL v3 conversion support and sidecar version axis Adds a v3 module to the bench library, built on the eql-bindings from_v2 converter (path dependency off the EQL repo's feat/eql-bindings-from-v2 branch; TODO switch to crates.io 0.2.0 once published): - v2_store_to_v3 / ciphertext_to_v3: convert cipherstash-client 0.38 storage payloads into eql_v3 domain payloads (terms the target does not require are dropped, bf reinterpreted as signed smallint[], k removed). - v3_scalar_to_v2_envelope / v3_scalar_to_ciphertext: rebuild the v2 ct envelope from a v3 scalar row for client-side decryption (decryption reads only c + i, never the index terms). - sample_plaintext_string_v3 + EncryptedQueryBuilderV3 / EncryptedQueryV3: v3 twins of the query-bench machinery. Scalar QUERY conversion is unsupported upstream, so probes are encrypted as STORAGE payloads and converted; results decode as plain jsonb (v3 domains are jsonb-backed, no composite wrapper). - IngestOptions.convert_to_v3(target): v3 ingest path converting each storage payload before INSERT. - ScenarioMetadata.version (2|3): the version axis for the metadata sidecars. Reporters treat an absent field as 2, existing v2 filenames and payloads are unchanged. --- Cargo.lock | 105 ++++++++++ Cargo.toml | 5 + benches/combo.rs | 1 + benches/exact.rs | 1 + benches/group_by.rs | 1 + benches/json.rs | 1 + benches/match.rs | 1 + benches/ore.rs | 2 + src/lib.rs | 452 ++++++++++++++++++++++++++++++++++++++++++-- 9 files changed, 555 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fa93666..dd2ac62 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1128,6 +1128,7 @@ dependencies = [ "cipherstash-client", "criterion", "criterion-table", + "eql-bindings", "fake 4.4.0", "hex", "hex-literal", @@ -1297,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" @@ -1318,6 +1325,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "eql-bindings" +version = "0.1.0" +dependencies = [ + "schemars", + "serde", + "serde_json", + "ts-rs", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -3276,6 +3293,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" @@ -3638,6 +3675,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" @@ -3742,6 +3804,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" @@ -4288,6 +4361,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" @@ -4684,6 +4766,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 3d3a2b8..8f13f34 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,11 @@ rand = "0.8.5" # mise.toml's setup-db task stays at eql-2.3.0. Bump both together. cipherstash-client = { version = "=0.38.0", features = ["tokio"] } stack-profile = "=0.38.0" +# EQL v2.3 → v3 wire conversion (`from_v2`) + canonical v3 payload types. +# Consumed as a path dependency off the EQL repo's `feat/eql-bindings-from-v2` +# branch while the crate is unreleased. +# TODO(crates.io): switch to `eql-bindings = "0.2.0"` once 0.2.0 is published. +eql-bindings = { path = "../encrypt-query-language/crates/eql-bindings" } anyhow = "1.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/benches/combo.rs b/benches/combo.rs index 5edf279..dac8ebb 100644 --- a/benches/combo.rs +++ b/benches/combo.rs @@ -155,6 +155,7 @@ fn criterion_benchmark(c: &mut Criterion) { explain, indexes_used, rows_returned, + version: 2, }); } out diff --git a/benches/exact.rs b/benches/exact.rs index 13b371a..a546a2f 100644 --- a/benches/exact.rs +++ b/benches/exact.rs @@ -134,6 +134,7 @@ fn criterion_benchmark(c: &mut Criterion) { explain, indexes_used, rows_returned, + version: 2, }); } out diff --git a/benches/group_by.rs b/benches/group_by.rs index 758da1e..534dd10 100644 --- a/benches/group_by.rs +++ b/benches/group_by.rs @@ -124,6 +124,7 @@ fn criterion_benchmark(c: &mut Criterion) { explain, indexes_used, rows_returned, + version: 2, }); } out diff --git a/benches/json.rs b/benches/json.rs index 6761a61..c8e38a3 100644 --- a/benches/json.rs +++ b/benches/json.rs @@ -491,6 +491,7 @@ fn criterion_benchmark(c: &mut Criterion) { explain, indexes_used, rows_returned: rows.len() as u64, + version: 2, } } diff --git a/benches/match.rs b/benches/match.rs index 25542e2..c5c5141 100644 --- a/benches/match.rs +++ b/benches/match.rs @@ -105,6 +105,7 @@ fn criterion_benchmark(c: &mut Criterion) { explain, indexes_used, rows_returned, + version: 2, }); } out diff --git a/benches/ore.rs b/benches/ore.rs index b217551..5363b4a 100644 --- a/benches/ore.rs +++ b/benches/ore.rs @@ -264,6 +264,7 @@ fn criterion_benchmark(c: &mut Criterion) { explain, indexes_used, rows_returned, + version: 2, }); } @@ -288,6 +289,7 @@ fn criterion_benchmark(c: &mut Criterion) { explain, indexes_used, rows_returned, + version: 2, }); } out diff --git a/src/lib.rs b/src/lib.rs index 50af3e2..94da37c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -39,6 +39,246 @@ use std::env; use std::fmt::Debug; use std::sync::Arc; +/// EQL v3 support: wire conversion (v2.3 → v3 via `eql_bindings::from_v2`) +/// plus the v3 twins of the query-bench machinery. +/// +/// cipherstash-client 0.38 emits EQL v2.3 payloads only, so every v3 bench +/// path encrypts through the existing v2 pipeline and converts the STORED +/// payload with [`eql_bindings::from_v2::from_v2`]. Scalar QUERY conversion +/// is unsupported upstream (`FromV2Error::UnsupportedQueryTarget` — no v3 +/// scalar query wire shape exists), so v3 query benches derive probe terms +/// from converted stored payloads and compare via the `eql_v3.*_term` +/// extractor functions. +pub mod v3 { + use super::*; + pub use eql_bindings::from_v2::{from_v2, from_v2_query, FromV2Error, TargetDomain}; + + /// Convert a serialised EQL v2.3 STORED payload into the v3 payload for + /// `target`. Thin context-adding wrapper over + /// [`eql_bindings::from_v2::from_v2`] — see the module docs there for + /// the conversion rules (terms the target doesn't require are dropped, + /// `bf` is reinterpreted into signed `smallint[]`, `k` is removed). + pub fn v2_store_to_v3( + v2: &serde_json::Value, + target: TargetDomain, + ) -> Result { + from_v2(v2, target) + .map_err(anyhow::Error::new) + .context("v2→v3 conversion failed") + } + + /// Convert a cipherstash-client storage ciphertext into the v3 payload + /// for `target`. Serialises the payload to its v2.3 wire form first — + /// `from_v2` operates on the wire shape, not the Rust type. + pub fn ciphertext_to_v3( + ciphertext: &EqlCiphertext, + target: TargetDomain, + ) -> Result { + let v2 = serde_json::to_value(ciphertext) + .context("failed to serialise v2 ciphertext to its wire form")?; + v2_store_to_v3(&v2, target) + } + + /// Rebuild the v2 `ct` envelope from a v3 SCALAR payload, for + /// decryption. v3 scalar payloads drop the `k` discriminator but keep + /// the record ciphertext (`c`) and identifier (`i`) verbatim, so the + /// envelope cipherstash-client's decrypt path needs is recoverable + /// without a reverse term conversion (decryption never reads the index + /// terms — and could not: v3's `bf` is signed, v2's is unsigned). + pub fn v3_scalar_to_v2_envelope(v3: &serde_json::Value) -> Result { + let obj = v3.as_object().context("v3 payload must be a JSON object")?; + let c = obj + .get("c") + .context("expected a v3 scalar payload carrying `c` — SteVec documents (`sv`) have no scalar envelope")?; + let i = obj.get("i").context("v3 payload missing `i` identifier")?; + Ok(json!({ + "v": 2, + "k": "ct", + "i": i, + "c": c, + })) + } + + /// Parse a v3 SCALAR payload back into an [`EqlCiphertext`] for + /// client-side decryption via `decrypt_eql`. + pub fn v3_scalar_to_ciphertext(v3: &serde_json::Value) -> Result { + let envelope = v3_scalar_to_v2_envelope(v3)?; + serde_json::from_value(envelope) + .context("rebuilt v2 envelope did not parse as EqlCiphertext") + } + + /// Sample a single plaintext string from a v3 encrypted table by + /// decrypting the first row. The v3 twin of + /// [`super::sample_plaintext_string`]: v3 columns are jsonb domains, so + /// the row decodes as plain jsonb (`value::jsonb` — no composite + /// wrapper) and decryption goes through the rebuilt v2 `ct` envelope. + pub async fn sample_plaintext_string_v3( + pool: &sqlx::PgPool, + cipher: Arc>, + table_name: &str, + ) -> Result { + let row: (Json,) = + sqlx::query_as(&format!("SELECT value::jsonb FROM {} LIMIT 1", table_name)) + .fetch_one(pool) + .await + .with_context(|| format!("sample query failed against {}", table_name))?; + + let ciphertext = v3_scalar_to_ciphertext(&row.0 .0)?; + let decrypted = decrypt_eql(cipher, vec![ciphertext], &Default::default()) + .await + .context("sample decrypt failed")?; + + let pt = decrypted + .into_iter() + .next() + .ok_or_else(|| anyhow::anyhow!("decrypt_eql returned empty Vec for {}", table_name))?; + match &pt { + Plaintext::Text(Some(s)) => Ok(s.clone()), + other => anyhow::bail!("expected Text sample from {}, got {:?}", table_name, other), + } + } + + /// v3 twin of [`super::EncryptedQueryBuilder`]. Scalar QUERY conversion + /// is unsupported upstream, so the probe is encrypted as a STORAGE + /// payload (`EqlOperation::Store`) through the existing v2 pipeline and + /// converted with `from_v2` — the SQL then compares via the + /// `eql_v3.*_term` extractor functions (or the inlinable operators, + /// which reduce to the same extractor expressions). + pub struct EncryptedQueryBuilderV3 { + pub column_config: ColumnConfig, + pub identifier: Identifier, + pub target: TargetDomain, + pub statement: Option, + } + + impl EncryptedQueryBuilderV3 { + pub fn new( + column_config: ColumnConfig, + identifier: Identifier, + target: TargetDomain, + ) -> Self { + Self { + column_config, + identifier, + target, + statement: None, + } + } + + pub fn statement(mut self, statement: impl Into) -> Self { + self.statement = Some(statement.into()); + self + } + + pub async fn build_query( + self, + plaintext: T, + cipher: Arc>, + ) -> Result + where + T: Into + Send + Debug, + { + let prepared = PreparedPlaintext::new( + Cow::Owned(self.column_config), + self.identifier.clone(), + plaintext.into(), + EqlOperation::Store, + ); + + let mut out = + encrypt_eql(Arc::clone(&cipher), vec![prepared], &Default::default()).await?; + + // Store operations always yield EqlOutput::Store — same + // invariant as the v2 ingest path in `IngestOptions::ingest`. + let EqlOutput::Store(ciphertext) = out.remove(0) else { + unreachable!("storage probe must yield EqlOutput::Store"); + }; + + let param = ciphertext_to_v3(&ciphertext, self.target) + .context("probe payload failed v2→v3 conversion")?; + + Ok(EncryptedQueryV3 { + param, + statement: self.statement.context("statement must be set")?, + scoped_cipher: cipher, + }) + } + } + + /// A bound v3 bench query: SQL statement + the converted v3 probe + /// payload. The probe binds as jsonb (`Json<serde_json::Value>`) and the + /// SQL casts it to the target domain (`$1::eql_v3.text_search`, …) so + /// the encrypted operators / extractors resolve instead of native jsonb. + pub struct EncryptedQueryV3 { + pub param: serde_json::Value, + pub statement: String, + scoped_cipher: Arc<ScopedCipher<AutoStrategy>>, + } + + impl EncryptedQueryV3 { + /// Execute and decode `(id, value)` rows. v3 encrypted columns are + /// jsonb-backed domains, so the value decodes as plain jsonb — the + /// bench SQL projects `value::jsonb` explicitly (sqlx cannot decode + /// a bare domain-typed column as `Json<Value>`, and no v3 scenario + /// puts the raw column in an ORDER BY, so the historic v2 sort-key + /// folding trap does not apply). + pub async fn execute( + &self, + pool: &sqlx::PgPool, + ) -> Result<Vec<(i32, Json<serde_json::Value>)>> { + let results: Vec<(i32, Json<serde_json::Value>)> = sqlx::query_as(&self.statement) + .bind(Json(&self.param)) + .fetch_all(pool) + .await?; + Ok(results) + } + + /// Execute, then decrypt the result payloads client-side by + /// rebuilding the v2 `ct` envelope per row (see + /// [`v3_scalar_to_ciphertext`]). + pub async fn execute_and_decrypt<T>(&self, pool: &sqlx::PgPool) -> Result<Vec<T>> + where + T: TryFrom<Plaintext>, + <T as TryFrom<Plaintext>>::Error: Debug, + { + let results = self.execute(pool).await?; + + let ciphertexts = results + .into_iter() + .map(|(_, value)| v3_scalar_to_ciphertext(&value.0)) + .collect::<Result<Vec<_>>>()?; + + let decrypted = decrypt_eql( + Arc::clone(&self.scoped_cipher), + ciphertexts, + &Default::default(), + ) + .await? + .into_iter() + .map(|pt| T::try_from(pt).expect("failed to convert plaintext")) + .collect(); + + Ok(decrypted) + } + + /// Run `EXPLAIN (FORMAT JSON)` on the bound query — v3 twin of + /// [`super::EncryptedQuery::explain`]. + pub async fn explain(&self, pool: &sqlx::PgPool) -> Result<serde_json::Value> { + let explain_sql = format!("EXPLAIN (FORMAT JSON) {}", self.statement); + let row: (Json<serde_json::Value>,) = sqlx::query_as(&explain_sql) + .bind(Json(&self.param)) + .fetch_one(pool) + .await?; + Ok(row.0 .0) + } + + /// The bound v3 parameter for metadata logging. + pub fn parameter_json(&self) -> serde_json::Value { + self.param.clone() + } + } +} + /// Generator for low-cardinality categorical strings of the form `CAT_001` /// .. `CAT_250`, uniform random over 250 distinct values. Used by the /// `category_encrypted_*` and `category_plaintext_*` tables that drive the @@ -151,6 +391,10 @@ pub struct IngestOptions { pub batch_size: usize, pub identifier: Identifier, pub column_config: ColumnConfig, + /// When set, every storage payload is converted v2→v3 for this target + /// domain (via `eql_bindings::from_v2`) before the INSERT — the v3 + /// ingest path. `None` binds the v2 ciphertext unchanged. + pub eql_target: Option<v3::TargetDomain>, } pub struct IngestOptionsBuilder { @@ -159,6 +403,7 @@ pub struct IngestOptionsBuilder { batch_size: Option<usize>, identifier: Option<Identifier>, column_config: Option<ColumnConfig>, + eql_target: Option<v3::TargetDomain>, } impl IngestOptionsBuilder { @@ -172,6 +417,7 @@ impl IngestOptionsBuilder { batch_size: None, identifier: None, column_config: None, + eql_target: None, } } @@ -195,6 +441,13 @@ impl IngestOptionsBuilder { self } + /// Convert every storage payload v2→v3 for `target` before the INSERT + /// (the v3 ingest path). See [`IngestOptions::eql_target`]. + pub fn convert_to_v3(mut self, target: v3::TargetDomain) -> Self { + self.eql_target = Some(target); + self + } + pub fn build(self) -> Result<IngestOptions> { Ok(IngestOptions { bench_name: self.bench_name, @@ -202,6 +455,7 @@ impl IngestOptionsBuilder { batch_size: self.batch_size.unwrap_or(Self::DEFAULT_BATCH_SIZE), identifier: self.identifier.context("identifier is required")?, column_config: self.column_config.context("column_config is required")?, + eql_target: self.eql_target, }) } } @@ -261,20 +515,48 @@ impl IngestOptions { let out = encrypt_eql(scoped_cipher.clone(), prepared, &Default::default()).await?; - QueryBuilder::new(format!("INSERT INTO {} (value) ", self.identifier.table())) - .push_values(out, |mut b, v| { - // Every PreparedPlaintext above used EqlOperation::Store, so - // encrypt_eql yields only EqlOutput::Store. cipherstash-client - // splits the storage and query payload shapes (since - // 0.34.1-alpha.9) — unwrap the storage ciphertext for binding. - let EqlOutput::Store(ciphertext) = v else { - unreachable!("storage batch must yield EqlOutput::Store"); - }; - b.push_bind(Json(ciphertext)); - }) - .build() - .execute(&pool) - .await?; + match self.eql_target { + None => { + QueryBuilder::new(format!("INSERT INTO {} (value) ", self.identifier.table())) + .push_values(out, |mut b, v| { + // Every PreparedPlaintext above used EqlOperation::Store, so + // encrypt_eql yields only EqlOutput::Store. cipherstash-client + // splits the storage and query payload shapes (since + // 0.34.1-alpha.9) — unwrap the storage ciphertext for binding. + let EqlOutput::Store(ciphertext) = v else { + unreachable!("storage batch must yield EqlOutput::Store"); + }; + b.push_bind(Json(ciphertext)); + }) + .build() + .execute(&pool) + .await?; + } + Some(target) => { + // v3 path: convert each storage payload before binding. + // The converted payload binds as jsonb; PostgreSQL's + // assignment cast to the column's eql_v3 domain runs the + // domain CHECK on INSERT (defense in depth — from_v2 + // already strict-validated the payload client-side). + let converted = out + .into_iter() + .map(|v| { + let EqlOutput::Store(ciphertext) = v else { + unreachable!("storage batch must yield EqlOutput::Store"); + }; + v3::ciphertext_to_v3(&ciphertext, target) + }) + .collect::<Result<Vec<_>>>()?; + + QueryBuilder::new(format!("INSERT INTO {} (value) ", self.identifier.table())) + .push_values(converted, |mut b, v| { + b.push_bind(Json(v)); + }) + .build() + .execute(&pool) + .await?; + } + } } let result = json!({ @@ -574,6 +856,11 @@ pub struct ScenarioMetadata { /// trivial relative to criterion's warmup phase, and gives us a real /// number rather than the planner's estimate from `Plan Rows`. pub rows_returned: u64, + /// EQL wire/SQL-surface version the scenario ran against: `2` for the + /// original `eql_v2` scenarios, `3` for the `_v3` twins. The Python + /// reporters treat an absent field (pre-version sidecars) as 2, so the + /// v2 filenames and payload shapes stay backwards-compatible. + pub version: u8, } /// Walk an `EXPLAIN (FORMAT JSON)` tree and collect every `Index Name`. @@ -709,3 +996,140 @@ pub fn write_metadata_file( eprintln!("bench metadata written to {}", path.display()); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::v3::{v2_store_to_v3, v3_scalar_to_v2_envelope}; + use eql_bindings::from_v2::TargetDomain; + use serde_json::json; + + /// A representative EQL v2.3 STORED scalar payload as cipherstash-client + /// emits it for a text column configured with unique + match + ore + /// indexes: `k: "ct"` envelope carrying all three term keys. The `c` + /// ciphertext is opaque to the conversion layer (copied verbatim), so a + /// placeholder string is a faithful fixture. + fn v2_text_store_payload() -> serde_json::Value { + json!({ + "v": 2, + "k": "ct", + "i": {"t": "string_encrypted_v3", "c": "value"}, + "c": "mBbLGB85%OPAQUE-RECORD", + "hm": "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90", + "bf": [1, 77, 40000], + "ob": ["deadbeef", "cafef00d"], + }) + } + + #[test] + fn v2_store_to_v3_keeps_required_terms_and_drops_the_rest() { + let target = TargetDomain::parse("text_eq").unwrap(); + let v3 = v2_store_to_v3(&v2_text_store_payload(), target).unwrap(); + + assert_eq!(v3["v"], json!(3)); + assert_eq!(v3["i"], json!({"t": "string_encrypted_v3", "c": "value"})); + assert_eq!(v3["c"], json!("mBbLGB85%OPAQUE-RECORD")); + assert_eq!( + v3["hm"], + json!("a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90") + ); + // v3 payloads carry no `k` discriminator, and text_eq requires only + // `hm` — the bloom and ORE terms must be dropped. + let obj = v3.as_object().unwrap(); + assert!(!obj.contains_key("k")); + assert!(!obj.contains_key("bf")); + assert!(!obj.contains_key("ob")); + } + + #[test] + fn v2_store_to_v3_reinterprets_bloom_bits_as_signed_smallints() { + let target = TargetDomain::parse("text_match").unwrap(); + let v3 = v2_store_to_v3(&v2_text_store_payload(), target).unwrap(); + + // v2 emits unsigned u16 bit positions; the v3 `bloom_filter` domain + // is `smallint[]`, so the upper half wraps negative (two's + // complement). 40000 - 65536 = -25536. + assert_eq!(v3["bf"], json!([1, 77, -25536])); + } + + #[test] + fn v2_store_to_v3_fails_closed_when_a_required_term_is_missing() { + // An integer payload with only the ORE term cannot convert to a + // target that requires `hm`. + let v2 = json!({ + "v": 2, + "k": "ct", + "i": {"t": "integer_encrypted_v3", "c": "value"}, + "c": "OPAQUE", + "ob": ["deadbeef"], + }); + let target = TargetDomain::parse("int4_eq").unwrap(); + let err = v2_store_to_v3(&v2, target).unwrap_err(); + let msg = format!("{:#}", err); + assert!( + msg.contains("hm"), + "error should name the missing term: {msg}" + ); + assert!( + msg.contains("int4_eq"), + "error should name the target domain: {msg}" + ); + } + + #[test] + fn v3_scalar_to_v2_envelope_rebuilds_the_ct_shape_for_decryption() { + let v3 = json!({ + "v": 3, + "i": {"t": "string_encrypted_v3", "c": "value"}, + "c": "mBbLGB85%OPAQUE-RECORD", + "hm": "a1b2", + "bf": [1, -25536], + }); + let envelope = v3_scalar_to_v2_envelope(&v3).unwrap(); + // Exactly the v2 `ct` envelope cipherstash-client's decrypt path + // needs — terms are NOT carried over (v2 `bf` is unsigned; a v3 + // signed bloom would fail the round-trip, and decryption only needs + // `c` + `i`). + assert_eq!( + envelope, + json!({ + "v": 2, + "k": "ct", + "i": {"t": "string_encrypted_v3", "c": "value"}, + "c": "mBbLGB85%OPAQUE-RECORD", + }) + ); + } + + #[test] + fn v3_scalar_to_v2_envelope_rejects_ste_vec_documents() { + let v3_doc = json!({ + "v": 3, + "i": {"t": "json_ste_vec_small_encrypted_v3", "c": "value"}, + "sv": [{"s": "abc", "c": "OPAQUE", "hm": "a1"}], + }); + let err = v3_scalar_to_v2_envelope(&v3_doc).unwrap_err(); + let msg = format!("{:#}", err); + assert!( + msg.contains("scalar"), + "error should say a scalar payload was expected: {msg}" + ); + } + + #[test] + fn scenario_metadata_serialises_the_version_axis() { + let metadata = ScenarioMetadata { + id: "EXACT_V3/exact/eql_hash/10000".to_string(), + query: "SELECT 1".to_string(), + parameters: vec![], + explain: json!([]), + indexes_used: vec![], + rows_returned: 1, + version: 3, + }; + let value = serde_json::to_value(&metadata).unwrap(); + // The Python reporters key v2/v3 grouping off this field; absent + // (pre-version sidecars) means 2. + assert_eq!(value["version"], json!(3)); + } +} From 6febc1854f7f93bb43e2100c40dfd57f46e539ab Mon Sep 17 00:00:00 2001 From: James Sadler <james@cipherstash.com> Date: Thu, 2 Jul 2026 16:44:16 +1000 Subject: [PATCH 2/9] feat(sql): add EQL v3 twin tables and functional index scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sql/schema-v3.sql defines the _v3 twins of the benchmarked tables, typed as the per-capability eql_v3 jsonb domains: string_encrypted_v3 value eql_v3.text_search (hm+ob+bf) — the only single-column v3 domain serving both the EXACT and MATCH families that share the v2 string table; the extra ob term vs v2 is documented. integer_encrypted_v3 value eql_v3.int4_ord_ore (v2 uses ColumnType::Int / i32 → int4, not int8) category_encrypted_v3 value eql_v3.text_eq combo_encrypted_v3 name text_match / age int4_ord_ore / category text_eq json_ste_vec_*_v3 value eql_v3.json Plaintext baseline tables are shared with the v2 schema (not duplicated). A TODO scaffold documents the _ord_ope path blocked on cipherstash-client emitting the op term (CIP-3280, unreleased). sql/indexes/v3/ mirrors the v2 per-tier index intents through the v3 extractors: hash(eq_term), btree(ord_term), GIN(match_term), and the canonical GIN((to_ste_vec_query(value)::jsonb) jsonb_path_ops) for containment. All files verified to apply and roll back cleanly against a scratch Postgres 16 with the built v3 installer. --- .../category_encrypted_v3_10000000_down.sql | 1 + .../v3/category_encrypted_v3_10000000_up.sql | 10 ++ .../v3/category_encrypted_v3_1000000_down.sql | 1 + .../v3/category_encrypted_v3_1000000_up.sql | 10 ++ .../v3/category_encrypted_v3_100000_down.sql | 1 + .../v3/category_encrypted_v3_100000_up.sql | 10 ++ .../v3/category_encrypted_v3_10000_down.sql | 1 + .../v3/category_encrypted_v3_10000_up.sql | 10 ++ .../v3/combo_encrypted_v3_10000000_down.sql | 3 + .../v3/combo_encrypted_v3_10000000_up.sql | 24 +++ .../v3/combo_encrypted_v3_1000000_down.sql | 3 + .../v3/combo_encrypted_v3_1000000_up.sql | 24 +++ .../v3/combo_encrypted_v3_100000_down.sql | 3 + .../v3/combo_encrypted_v3_100000_up.sql | 24 +++ .../v3/combo_encrypted_v3_10000_down.sql | 3 + .../v3/combo_encrypted_v3_10000_up.sql | 24 +++ .../v3/integer_encrypted_v3_10000000_down.sql | 1 + .../v3/integer_encrypted_v3_10000000_up.sql | 13 ++ .../v3/integer_encrypted_v3_1000000_down.sql | 1 + .../v3/integer_encrypted_v3_1000000_up.sql | 13 ++ .../v3/integer_encrypted_v3_100000_down.sql | 1 + .../v3/integer_encrypted_v3_100000_up.sql | 13 ++ .../v3/integer_encrypted_v3_10000_down.sql | 1 + .../v3/integer_encrypted_v3_10000_up.sql | 13 ++ ...e_vec_small_encrypted_v3_10000000_down.sql | 1 + ...ste_vec_small_encrypted_v3_10000000_up.sql | 20 +++ ...te_vec_small_encrypted_v3_1000000_down.sql | 1 + ..._ste_vec_small_encrypted_v3_1000000_up.sql | 20 +++ ...ste_vec_small_encrypted_v3_100000_down.sql | 1 + ...n_ste_vec_small_encrypted_v3_100000_up.sql | 20 +++ ..._ste_vec_small_encrypted_v3_10000_down.sql | 1 + ...on_ste_vec_small_encrypted_v3_10000_up.sql | 20 +++ .../v3/string_encrypted_v3_10000000_down.sql | 2 + .../v3/string_encrypted_v3_10000000_up.sql | 24 +++ .../v3/string_encrypted_v3_1000000_down.sql | 2 + .../v3/string_encrypted_v3_1000000_up.sql | 24 +++ .../v3/string_encrypted_v3_100000_down.sql | 2 + .../v3/string_encrypted_v3_100000_up.sql | 24 +++ .../v3/string_encrypted_v3_10000_down.sql | 2 + .../v3/string_encrypted_v3_10000_up.sql | 24 +++ sql/schema-v3.sql | 169 ++++++++++++++++++ 41 files changed, 565 insertions(+) create mode 100644 sql/indexes/v3/category_encrypted_v3_10000000_down.sql create mode 100644 sql/indexes/v3/category_encrypted_v3_10000000_up.sql create mode 100644 sql/indexes/v3/category_encrypted_v3_1000000_down.sql create mode 100644 sql/indexes/v3/category_encrypted_v3_1000000_up.sql create mode 100644 sql/indexes/v3/category_encrypted_v3_100000_down.sql create mode 100644 sql/indexes/v3/category_encrypted_v3_100000_up.sql create mode 100644 sql/indexes/v3/category_encrypted_v3_10000_down.sql create mode 100644 sql/indexes/v3/category_encrypted_v3_10000_up.sql create mode 100644 sql/indexes/v3/combo_encrypted_v3_10000000_down.sql create mode 100644 sql/indexes/v3/combo_encrypted_v3_10000000_up.sql create mode 100644 sql/indexes/v3/combo_encrypted_v3_1000000_down.sql create mode 100644 sql/indexes/v3/combo_encrypted_v3_1000000_up.sql create mode 100644 sql/indexes/v3/combo_encrypted_v3_100000_down.sql create mode 100644 sql/indexes/v3/combo_encrypted_v3_100000_up.sql create mode 100644 sql/indexes/v3/combo_encrypted_v3_10000_down.sql create mode 100644 sql/indexes/v3/combo_encrypted_v3_10000_up.sql create mode 100644 sql/indexes/v3/integer_encrypted_v3_10000000_down.sql create mode 100644 sql/indexes/v3/integer_encrypted_v3_10000000_up.sql create mode 100644 sql/indexes/v3/integer_encrypted_v3_1000000_down.sql create mode 100644 sql/indexes/v3/integer_encrypted_v3_1000000_up.sql create mode 100644 sql/indexes/v3/integer_encrypted_v3_100000_down.sql create mode 100644 sql/indexes/v3/integer_encrypted_v3_100000_up.sql create mode 100644 sql/indexes/v3/integer_encrypted_v3_10000_down.sql create mode 100644 sql/indexes/v3/integer_encrypted_v3_10000_up.sql create mode 100644 sql/indexes/v3/json_ste_vec_small_encrypted_v3_10000000_down.sql create mode 100644 sql/indexes/v3/json_ste_vec_small_encrypted_v3_10000000_up.sql create mode 100644 sql/indexes/v3/json_ste_vec_small_encrypted_v3_1000000_down.sql create mode 100644 sql/indexes/v3/json_ste_vec_small_encrypted_v3_1000000_up.sql create mode 100644 sql/indexes/v3/json_ste_vec_small_encrypted_v3_100000_down.sql create mode 100644 sql/indexes/v3/json_ste_vec_small_encrypted_v3_100000_up.sql create mode 100644 sql/indexes/v3/json_ste_vec_small_encrypted_v3_10000_down.sql create mode 100644 sql/indexes/v3/json_ste_vec_small_encrypted_v3_10000_up.sql create mode 100644 sql/indexes/v3/string_encrypted_v3_10000000_down.sql create mode 100644 sql/indexes/v3/string_encrypted_v3_10000000_up.sql create mode 100644 sql/indexes/v3/string_encrypted_v3_1000000_down.sql create mode 100644 sql/indexes/v3/string_encrypted_v3_1000000_up.sql create mode 100644 sql/indexes/v3/string_encrypted_v3_100000_down.sql create mode 100644 sql/indexes/v3/string_encrypted_v3_100000_up.sql create mode 100644 sql/indexes/v3/string_encrypted_v3_10000_down.sql create mode 100644 sql/indexes/v3/string_encrypted_v3_10000_up.sql create mode 100644 sql/schema-v3.sql diff --git a/sql/indexes/v3/category_encrypted_v3_10000000_down.sql b/sql/indexes/v3/category_encrypted_v3_10000000_down.sql new file mode 100644 index 0000000..b4c8175 --- /dev/null +++ b/sql/indexes/v3/category_encrypted_v3_10000000_down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS category_encrypted_v3_10000000_eq_term_index; diff --git a/sql/indexes/v3/category_encrypted_v3_10000000_up.sql b/sql/indexes/v3/category_encrypted_v3_10000000_up.sql new file mode 100644 index 0000000..607c3c9 --- /dev/null +++ b/sql/indexes/v3/category_encrypted_v3_10000000_up.sql @@ -0,0 +1,10 @@ +-- eql_v3 functional index for category_encrypted_v3_10000000 +-- (column typed eql_v3.text_eq — hm). The GROUP_BY_V3 scenarios group on +-- eql_v3.eq_term(value); like the v2 hmac hash index, this index exists for +-- equality lookups — GROUP BY itself is served by HashAggregate. + +CREATE INDEX +category_encrypted_v3_10000000_eq_term_index +ON category_encrypted_v3_10000000 USING hash ( + eql_v3.eq_term(value) +); diff --git a/sql/indexes/v3/category_encrypted_v3_1000000_down.sql b/sql/indexes/v3/category_encrypted_v3_1000000_down.sql new file mode 100644 index 0000000..b165056 --- /dev/null +++ b/sql/indexes/v3/category_encrypted_v3_1000000_down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS category_encrypted_v3_1000000_eq_term_index; diff --git a/sql/indexes/v3/category_encrypted_v3_1000000_up.sql b/sql/indexes/v3/category_encrypted_v3_1000000_up.sql new file mode 100644 index 0000000..7db9655 --- /dev/null +++ b/sql/indexes/v3/category_encrypted_v3_1000000_up.sql @@ -0,0 +1,10 @@ +-- eql_v3 functional index for category_encrypted_v3_1000000 +-- (column typed eql_v3.text_eq — hm). The GROUP_BY_V3 scenarios group on +-- eql_v3.eq_term(value); like the v2 hmac hash index, this index exists for +-- equality lookups — GROUP BY itself is served by HashAggregate. + +CREATE INDEX +category_encrypted_v3_1000000_eq_term_index +ON category_encrypted_v3_1000000 USING hash ( + eql_v3.eq_term(value) +); diff --git a/sql/indexes/v3/category_encrypted_v3_100000_down.sql b/sql/indexes/v3/category_encrypted_v3_100000_down.sql new file mode 100644 index 0000000..cf971c4 --- /dev/null +++ b/sql/indexes/v3/category_encrypted_v3_100000_down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS category_encrypted_v3_100000_eq_term_index; diff --git a/sql/indexes/v3/category_encrypted_v3_100000_up.sql b/sql/indexes/v3/category_encrypted_v3_100000_up.sql new file mode 100644 index 0000000..41cebe8 --- /dev/null +++ b/sql/indexes/v3/category_encrypted_v3_100000_up.sql @@ -0,0 +1,10 @@ +-- eql_v3 functional index for category_encrypted_v3_100000 +-- (column typed eql_v3.text_eq — hm). The GROUP_BY_V3 scenarios group on +-- eql_v3.eq_term(value); like the v2 hmac hash index, this index exists for +-- equality lookups — GROUP BY itself is served by HashAggregate. + +CREATE INDEX +category_encrypted_v3_100000_eq_term_index +ON category_encrypted_v3_100000 USING hash ( + eql_v3.eq_term(value) +); diff --git a/sql/indexes/v3/category_encrypted_v3_10000_down.sql b/sql/indexes/v3/category_encrypted_v3_10000_down.sql new file mode 100644 index 0000000..9068a4f --- /dev/null +++ b/sql/indexes/v3/category_encrypted_v3_10000_down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS category_encrypted_v3_10000_eq_term_index; diff --git a/sql/indexes/v3/category_encrypted_v3_10000_up.sql b/sql/indexes/v3/category_encrypted_v3_10000_up.sql new file mode 100644 index 0000000..834987b --- /dev/null +++ b/sql/indexes/v3/category_encrypted_v3_10000_up.sql @@ -0,0 +1,10 @@ +-- eql_v3 functional index for category_encrypted_v3_10000 +-- (column typed eql_v3.text_eq — hm). The GROUP_BY_V3 scenarios group on +-- eql_v3.eq_term(value); like the v2 hmac hash index, this index exists for +-- equality lookups — GROUP BY itself is served by HashAggregate. + +CREATE INDEX +category_encrypted_v3_10000_eq_term_index +ON category_encrypted_v3_10000 USING hash ( + eql_v3.eq_term(value) +); diff --git a/sql/indexes/v3/combo_encrypted_v3_10000000_down.sql b/sql/indexes/v3/combo_encrypted_v3_10000000_down.sql new file mode 100644 index 0000000..ef6d173 --- /dev/null +++ b/sql/indexes/v3/combo_encrypted_v3_10000000_down.sql @@ -0,0 +1,3 @@ +DROP INDEX IF EXISTS combo_encrypted_v3_10000000_name_match_term_index; +DROP INDEX IF EXISTS combo_encrypted_v3_10000000_age_ord_term_index; +DROP INDEX IF EXISTS combo_encrypted_v3_10000000_category_eq_term_index; diff --git a/sql/indexes/v3/combo_encrypted_v3_10000000_up.sql b/sql/indexes/v3/combo_encrypted_v3_10000000_up.sql new file mode 100644 index 0000000..bb50902 --- /dev/null +++ b/sql/indexes/v3/combo_encrypted_v3_10000000_up.sql @@ -0,0 +1,24 @@ +-- eql_v3 functional indexes for combo_encrypted_v3_10000000 — per-column +-- capability twins of the v2 combo indexes: +-- name eql_v3.text_match → GIN on match_term (bloom containment; +-- replaces the v2 bloom_filter GIN + LIKE) +-- age eql_v3.int4_ord_ore → btree on ord_term (ORE ordering) +-- category eql_v3.text_eq → hash on eq_term (hmac equality) + +CREATE INDEX +combo_encrypted_v3_10000000_name_match_term_index +ON combo_encrypted_v3_10000000 USING GIN ( + eql_v3.match_term(name) +); + +CREATE INDEX +combo_encrypted_v3_10000000_age_ord_term_index +ON combo_encrypted_v3_10000000 USING btree ( + eql_v3.ord_term(age) +); + +CREATE INDEX +combo_encrypted_v3_10000000_category_eq_term_index +ON combo_encrypted_v3_10000000 USING hash ( + eql_v3.eq_term(category) +); diff --git a/sql/indexes/v3/combo_encrypted_v3_1000000_down.sql b/sql/indexes/v3/combo_encrypted_v3_1000000_down.sql new file mode 100644 index 0000000..243b1a4 --- /dev/null +++ b/sql/indexes/v3/combo_encrypted_v3_1000000_down.sql @@ -0,0 +1,3 @@ +DROP INDEX IF EXISTS combo_encrypted_v3_1000000_name_match_term_index; +DROP INDEX IF EXISTS combo_encrypted_v3_1000000_age_ord_term_index; +DROP INDEX IF EXISTS combo_encrypted_v3_1000000_category_eq_term_index; diff --git a/sql/indexes/v3/combo_encrypted_v3_1000000_up.sql b/sql/indexes/v3/combo_encrypted_v3_1000000_up.sql new file mode 100644 index 0000000..528874b --- /dev/null +++ b/sql/indexes/v3/combo_encrypted_v3_1000000_up.sql @@ -0,0 +1,24 @@ +-- eql_v3 functional indexes for combo_encrypted_v3_1000000 — per-column +-- capability twins of the v2 combo indexes: +-- name eql_v3.text_match → GIN on match_term (bloom containment; +-- replaces the v2 bloom_filter GIN + LIKE) +-- age eql_v3.int4_ord_ore → btree on ord_term (ORE ordering) +-- category eql_v3.text_eq → hash on eq_term (hmac equality) + +CREATE INDEX +combo_encrypted_v3_1000000_name_match_term_index +ON combo_encrypted_v3_1000000 USING GIN ( + eql_v3.match_term(name) +); + +CREATE INDEX +combo_encrypted_v3_1000000_age_ord_term_index +ON combo_encrypted_v3_1000000 USING btree ( + eql_v3.ord_term(age) +); + +CREATE INDEX +combo_encrypted_v3_1000000_category_eq_term_index +ON combo_encrypted_v3_1000000 USING hash ( + eql_v3.eq_term(category) +); diff --git a/sql/indexes/v3/combo_encrypted_v3_100000_down.sql b/sql/indexes/v3/combo_encrypted_v3_100000_down.sql new file mode 100644 index 0000000..9f6b329 --- /dev/null +++ b/sql/indexes/v3/combo_encrypted_v3_100000_down.sql @@ -0,0 +1,3 @@ +DROP INDEX IF EXISTS combo_encrypted_v3_100000_name_match_term_index; +DROP INDEX IF EXISTS combo_encrypted_v3_100000_age_ord_term_index; +DROP INDEX IF EXISTS combo_encrypted_v3_100000_category_eq_term_index; diff --git a/sql/indexes/v3/combo_encrypted_v3_100000_up.sql b/sql/indexes/v3/combo_encrypted_v3_100000_up.sql new file mode 100644 index 0000000..906575c --- /dev/null +++ b/sql/indexes/v3/combo_encrypted_v3_100000_up.sql @@ -0,0 +1,24 @@ +-- eql_v3 functional indexes for combo_encrypted_v3_100000 — per-column +-- capability twins of the v2 combo indexes: +-- name eql_v3.text_match → GIN on match_term (bloom containment; +-- replaces the v2 bloom_filter GIN + LIKE) +-- age eql_v3.int4_ord_ore → btree on ord_term (ORE ordering) +-- category eql_v3.text_eq → hash on eq_term (hmac equality) + +CREATE INDEX +combo_encrypted_v3_100000_name_match_term_index +ON combo_encrypted_v3_100000 USING GIN ( + eql_v3.match_term(name) +); + +CREATE INDEX +combo_encrypted_v3_100000_age_ord_term_index +ON combo_encrypted_v3_100000 USING btree ( + eql_v3.ord_term(age) +); + +CREATE INDEX +combo_encrypted_v3_100000_category_eq_term_index +ON combo_encrypted_v3_100000 USING hash ( + eql_v3.eq_term(category) +); diff --git a/sql/indexes/v3/combo_encrypted_v3_10000_down.sql b/sql/indexes/v3/combo_encrypted_v3_10000_down.sql new file mode 100644 index 0000000..5aa743e --- /dev/null +++ b/sql/indexes/v3/combo_encrypted_v3_10000_down.sql @@ -0,0 +1,3 @@ +DROP INDEX IF EXISTS combo_encrypted_v3_10000_name_match_term_index; +DROP INDEX IF EXISTS combo_encrypted_v3_10000_age_ord_term_index; +DROP INDEX IF EXISTS combo_encrypted_v3_10000_category_eq_term_index; diff --git a/sql/indexes/v3/combo_encrypted_v3_10000_up.sql b/sql/indexes/v3/combo_encrypted_v3_10000_up.sql new file mode 100644 index 0000000..e9e653b --- /dev/null +++ b/sql/indexes/v3/combo_encrypted_v3_10000_up.sql @@ -0,0 +1,24 @@ +-- eql_v3 functional indexes for combo_encrypted_v3_10000 — per-column +-- capability twins of the v2 combo indexes: +-- name eql_v3.text_match → GIN on match_term (bloom containment; +-- replaces the v2 bloom_filter GIN + LIKE) +-- age eql_v3.int4_ord_ore → btree on ord_term (ORE ordering) +-- category eql_v3.text_eq → hash on eq_term (hmac equality) + +CREATE INDEX +combo_encrypted_v3_10000_name_match_term_index +ON combo_encrypted_v3_10000 USING GIN ( + eql_v3.match_term(name) +); + +CREATE INDEX +combo_encrypted_v3_10000_age_ord_term_index +ON combo_encrypted_v3_10000 USING btree ( + eql_v3.ord_term(age) +); + +CREATE INDEX +combo_encrypted_v3_10000_category_eq_term_index +ON combo_encrypted_v3_10000 USING hash ( + eql_v3.eq_term(category) +); diff --git a/sql/indexes/v3/integer_encrypted_v3_10000000_down.sql b/sql/indexes/v3/integer_encrypted_v3_10000000_down.sql new file mode 100644 index 0000000..330656d --- /dev/null +++ b/sql/indexes/v3/integer_encrypted_v3_10000000_down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS integer_encrypted_v3_10000000_ord_term_index; diff --git a/sql/indexes/v3/integer_encrypted_v3_10000000_up.sql b/sql/indexes/v3/integer_encrypted_v3_10000000_up.sql new file mode 100644 index 0000000..c7b15d2 --- /dev/null +++ b/sql/indexes/v3/integer_encrypted_v3_10000000_up.sql @@ -0,0 +1,13 @@ +-- eql_v3 functional index for integer_encrypted_v3_10000000 +-- (column typed eql_v3.int4_ord_ore — ob). +-- +-- ord_term returns eql_v3.ore_block_256, whose btree operator class is the +-- DEFAULT for the type — bare-form range predicates (`value > $1`) inline +-- to eql_v3.ord_term(value) > eql_v3.ord_term($1) and match this index; +-- extractor-form ORDER BY streams rows out of it already sorted. + +CREATE INDEX +integer_encrypted_v3_10000000_ord_term_index +ON integer_encrypted_v3_10000000 USING btree ( + eql_v3.ord_term(value) +); diff --git a/sql/indexes/v3/integer_encrypted_v3_1000000_down.sql b/sql/indexes/v3/integer_encrypted_v3_1000000_down.sql new file mode 100644 index 0000000..32f6c18 --- /dev/null +++ b/sql/indexes/v3/integer_encrypted_v3_1000000_down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS integer_encrypted_v3_1000000_ord_term_index; diff --git a/sql/indexes/v3/integer_encrypted_v3_1000000_up.sql b/sql/indexes/v3/integer_encrypted_v3_1000000_up.sql new file mode 100644 index 0000000..eac7a7c --- /dev/null +++ b/sql/indexes/v3/integer_encrypted_v3_1000000_up.sql @@ -0,0 +1,13 @@ +-- eql_v3 functional index for integer_encrypted_v3_1000000 +-- (column typed eql_v3.int4_ord_ore — ob). +-- +-- ord_term returns eql_v3.ore_block_256, whose btree operator class is the +-- DEFAULT for the type — bare-form range predicates (`value > $1`) inline +-- to eql_v3.ord_term(value) > eql_v3.ord_term($1) and match this index; +-- extractor-form ORDER BY streams rows out of it already sorted. + +CREATE INDEX +integer_encrypted_v3_1000000_ord_term_index +ON integer_encrypted_v3_1000000 USING btree ( + eql_v3.ord_term(value) +); diff --git a/sql/indexes/v3/integer_encrypted_v3_100000_down.sql b/sql/indexes/v3/integer_encrypted_v3_100000_down.sql new file mode 100644 index 0000000..9776a2d --- /dev/null +++ b/sql/indexes/v3/integer_encrypted_v3_100000_down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS integer_encrypted_v3_100000_ord_term_index; diff --git a/sql/indexes/v3/integer_encrypted_v3_100000_up.sql b/sql/indexes/v3/integer_encrypted_v3_100000_up.sql new file mode 100644 index 0000000..cac6a4a --- /dev/null +++ b/sql/indexes/v3/integer_encrypted_v3_100000_up.sql @@ -0,0 +1,13 @@ +-- eql_v3 functional index for integer_encrypted_v3_100000 +-- (column typed eql_v3.int4_ord_ore — ob). +-- +-- ord_term returns eql_v3.ore_block_256, whose btree operator class is the +-- DEFAULT for the type — bare-form range predicates (`value > $1`) inline +-- to eql_v3.ord_term(value) > eql_v3.ord_term($1) and match this index; +-- extractor-form ORDER BY streams rows out of it already sorted. + +CREATE INDEX +integer_encrypted_v3_100000_ord_term_index +ON integer_encrypted_v3_100000 USING btree ( + eql_v3.ord_term(value) +); diff --git a/sql/indexes/v3/integer_encrypted_v3_10000_down.sql b/sql/indexes/v3/integer_encrypted_v3_10000_down.sql new file mode 100644 index 0000000..945832c --- /dev/null +++ b/sql/indexes/v3/integer_encrypted_v3_10000_down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS integer_encrypted_v3_10000_ord_term_index; diff --git a/sql/indexes/v3/integer_encrypted_v3_10000_up.sql b/sql/indexes/v3/integer_encrypted_v3_10000_up.sql new file mode 100644 index 0000000..5585c47 --- /dev/null +++ b/sql/indexes/v3/integer_encrypted_v3_10000_up.sql @@ -0,0 +1,13 @@ +-- eql_v3 functional index for integer_encrypted_v3_10000 +-- (column typed eql_v3.int4_ord_ore — ob). +-- +-- ord_term returns eql_v3.ore_block_256, whose btree operator class is the +-- DEFAULT for the type — bare-form range predicates (`value > $1`) inline +-- to eql_v3.ord_term(value) > eql_v3.ord_term($1) and match this index; +-- extractor-form ORDER BY streams rows out of it already sorted. + +CREATE INDEX +integer_encrypted_v3_10000_ord_term_index +ON integer_encrypted_v3_10000 USING btree ( + eql_v3.ord_term(value) +); diff --git a/sql/indexes/v3/json_ste_vec_small_encrypted_v3_10000000_down.sql b/sql/indexes/v3/json_ste_vec_small_encrypted_v3_10000000_down.sql new file mode 100644 index 0000000..19b9800 --- /dev/null +++ b/sql/indexes/v3/json_ste_vec_small_encrypted_v3_10000000_down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS json_ste_vec_small_encrypted_v3_10000000_ste_vec_query_index; diff --git a/sql/indexes/v3/json_ste_vec_small_encrypted_v3_10000000_up.sql b/sql/indexes/v3/json_ste_vec_small_encrypted_v3_10000000_up.sql new file mode 100644 index 0000000..7cd9b16 --- /dev/null +++ b/sql/indexes/v3/json_ste_vec_small_encrypted_v3_10000000_up.sql @@ -0,0 +1,20 @@ +-- eql_v3 GIN index for json_ste_vec_small_encrypted_v3_10000000 +-- (column typed eql_v3.json). +-- +-- The canonical v3 containment recipe: the typed +-- `@>(eql_v3.json, eql_v3.jsonb_query)` operator inlines to a native +-- `jsonb @>` over eql_v3.to_ste_vec_query(value)::jsonb, so a functional +-- GIN on the same expression engages. One index serves whole-document +-- containment AND field-level needle containment (the needle is +-- `{"sv":[{s, hm|oc}]}`), replacing the two v2 GINs (jsonb_array + +-- to_stevec_query). +-- +-- The per-selector field_eq / field_order functional indexes are built by +-- benches/json_v3.rs at startup — the selector hash is only known once the +-- bench has sampled a row, so they cannot live in this static file. + +CREATE INDEX +json_ste_vec_small_encrypted_v3_10000000_ste_vec_query_index +ON json_ste_vec_small_encrypted_v3_10000000 USING GIN ( + (eql_v3.to_ste_vec_query(value)::jsonb) jsonb_path_ops +); diff --git a/sql/indexes/v3/json_ste_vec_small_encrypted_v3_1000000_down.sql b/sql/indexes/v3/json_ste_vec_small_encrypted_v3_1000000_down.sql new file mode 100644 index 0000000..b97fde4 --- /dev/null +++ b/sql/indexes/v3/json_ste_vec_small_encrypted_v3_1000000_down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS json_ste_vec_small_encrypted_v3_1000000_ste_vec_query_index; diff --git a/sql/indexes/v3/json_ste_vec_small_encrypted_v3_1000000_up.sql b/sql/indexes/v3/json_ste_vec_small_encrypted_v3_1000000_up.sql new file mode 100644 index 0000000..364ac24 --- /dev/null +++ b/sql/indexes/v3/json_ste_vec_small_encrypted_v3_1000000_up.sql @@ -0,0 +1,20 @@ +-- eql_v3 GIN index for json_ste_vec_small_encrypted_v3_1000000 +-- (column typed eql_v3.json). +-- +-- The canonical v3 containment recipe: the typed +-- `@>(eql_v3.json, eql_v3.jsonb_query)` operator inlines to a native +-- `jsonb @>` over eql_v3.to_ste_vec_query(value)::jsonb, so a functional +-- GIN on the same expression engages. One index serves whole-document +-- containment AND field-level needle containment (the needle is +-- `{"sv":[{s, hm|oc}]}`), replacing the two v2 GINs (jsonb_array + +-- to_stevec_query). +-- +-- The per-selector field_eq / field_order functional indexes are built by +-- benches/json_v3.rs at startup — the selector hash is only known once the +-- bench has sampled a row, so they cannot live in this static file. + +CREATE INDEX +json_ste_vec_small_encrypted_v3_1000000_ste_vec_query_index +ON json_ste_vec_small_encrypted_v3_1000000 USING GIN ( + (eql_v3.to_ste_vec_query(value)::jsonb) jsonb_path_ops +); diff --git a/sql/indexes/v3/json_ste_vec_small_encrypted_v3_100000_down.sql b/sql/indexes/v3/json_ste_vec_small_encrypted_v3_100000_down.sql new file mode 100644 index 0000000..3b8b3d6 --- /dev/null +++ b/sql/indexes/v3/json_ste_vec_small_encrypted_v3_100000_down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS json_ste_vec_small_encrypted_v3_100000_ste_vec_query_index; diff --git a/sql/indexes/v3/json_ste_vec_small_encrypted_v3_100000_up.sql b/sql/indexes/v3/json_ste_vec_small_encrypted_v3_100000_up.sql new file mode 100644 index 0000000..24ec990 --- /dev/null +++ b/sql/indexes/v3/json_ste_vec_small_encrypted_v3_100000_up.sql @@ -0,0 +1,20 @@ +-- eql_v3 GIN index for json_ste_vec_small_encrypted_v3_100000 +-- (column typed eql_v3.json). +-- +-- The canonical v3 containment recipe: the typed +-- `@>(eql_v3.json, eql_v3.jsonb_query)` operator inlines to a native +-- `jsonb @>` over eql_v3.to_ste_vec_query(value)::jsonb, so a functional +-- GIN on the same expression engages. One index serves whole-document +-- containment AND field-level needle containment (the needle is +-- `{"sv":[{s, hm|oc}]}`), replacing the two v2 GINs (jsonb_array + +-- to_stevec_query). +-- +-- The per-selector field_eq / field_order functional indexes are built by +-- benches/json_v3.rs at startup — the selector hash is only known once the +-- bench has sampled a row, so they cannot live in this static file. + +CREATE INDEX +json_ste_vec_small_encrypted_v3_100000_ste_vec_query_index +ON json_ste_vec_small_encrypted_v3_100000 USING GIN ( + (eql_v3.to_ste_vec_query(value)::jsonb) jsonb_path_ops +); diff --git a/sql/indexes/v3/json_ste_vec_small_encrypted_v3_10000_down.sql b/sql/indexes/v3/json_ste_vec_small_encrypted_v3_10000_down.sql new file mode 100644 index 0000000..6708cc0 --- /dev/null +++ b/sql/indexes/v3/json_ste_vec_small_encrypted_v3_10000_down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS json_ste_vec_small_encrypted_v3_10000_ste_vec_query_index; diff --git a/sql/indexes/v3/json_ste_vec_small_encrypted_v3_10000_up.sql b/sql/indexes/v3/json_ste_vec_small_encrypted_v3_10000_up.sql new file mode 100644 index 0000000..7c197c4 --- /dev/null +++ b/sql/indexes/v3/json_ste_vec_small_encrypted_v3_10000_up.sql @@ -0,0 +1,20 @@ +-- eql_v3 GIN index for json_ste_vec_small_encrypted_v3_10000 +-- (column typed eql_v3.json). +-- +-- The canonical v3 containment recipe: the typed +-- `@>(eql_v3.json, eql_v3.jsonb_query)` operator inlines to a native +-- `jsonb @>` over eql_v3.to_ste_vec_query(value)::jsonb, so a functional +-- GIN on the same expression engages. One index serves whole-document +-- containment AND field-level needle containment (the needle is +-- `{"sv":[{s, hm|oc}]}`), replacing the two v2 GINs (jsonb_array + +-- to_stevec_query). +-- +-- The per-selector field_eq / field_order functional indexes are built by +-- benches/json_v3.rs at startup — the selector hash is only known once the +-- bench has sampled a row, so they cannot live in this static file. + +CREATE INDEX +json_ste_vec_small_encrypted_v3_10000_ste_vec_query_index +ON json_ste_vec_small_encrypted_v3_10000 USING GIN ( + (eql_v3.to_ste_vec_query(value)::jsonb) jsonb_path_ops +); diff --git a/sql/indexes/v3/string_encrypted_v3_10000000_down.sql b/sql/indexes/v3/string_encrypted_v3_10000000_down.sql new file mode 100644 index 0000000..87b5e45 --- /dev/null +++ b/sql/indexes/v3/string_encrypted_v3_10000000_down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS string_encrypted_v3_10000000_eq_term_index; +DROP INDEX IF EXISTS string_encrypted_v3_10000000_match_term_index; diff --git a/sql/indexes/v3/string_encrypted_v3_10000000_up.sql b/sql/indexes/v3/string_encrypted_v3_10000000_up.sql new file mode 100644 index 0000000..eae831e --- /dev/null +++ b/sql/indexes/v3/string_encrypted_v3_10000000_up.sql @@ -0,0 +1,24 @@ +-- eql_v3 functional indexes for string_encrypted_v3_10000000 +-- (column typed eql_v3.text_search — hm + ob + bf). +-- +-- eq_term — hash index; engages for equality (EXACT_V3 scenarios). The +-- inlinable `=` operator and the explicit extractor form both +-- reduce to eql_v3.eq_term(value) = eql_v3.eq_term($1). +-- match_term — GIN over eql_v3.bloom_filter (smallint[]); engages for the +-- bloom containment `@>` (MATCH_V3). v3 removes LIKE/ILIKE. +-- +-- No ord_term index: no v3 string scenario orders or range-scans the +-- column (the ob term is present because text_search requires it, not +-- because a scenario uses it). + +CREATE INDEX +string_encrypted_v3_10000000_eq_term_index +ON string_encrypted_v3_10000000 USING hash ( + eql_v3.eq_term(value) +); + +CREATE INDEX +string_encrypted_v3_10000000_match_term_index +ON string_encrypted_v3_10000000 USING GIN ( + eql_v3.match_term(value) +); diff --git a/sql/indexes/v3/string_encrypted_v3_1000000_down.sql b/sql/indexes/v3/string_encrypted_v3_1000000_down.sql new file mode 100644 index 0000000..7eaac93 --- /dev/null +++ b/sql/indexes/v3/string_encrypted_v3_1000000_down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS string_encrypted_v3_1000000_eq_term_index; +DROP INDEX IF EXISTS string_encrypted_v3_1000000_match_term_index; diff --git a/sql/indexes/v3/string_encrypted_v3_1000000_up.sql b/sql/indexes/v3/string_encrypted_v3_1000000_up.sql new file mode 100644 index 0000000..ce98999 --- /dev/null +++ b/sql/indexes/v3/string_encrypted_v3_1000000_up.sql @@ -0,0 +1,24 @@ +-- eql_v3 functional indexes for string_encrypted_v3_1000000 +-- (column typed eql_v3.text_search — hm + ob + bf). +-- +-- eq_term — hash index; engages for equality (EXACT_V3 scenarios). The +-- inlinable `=` operator and the explicit extractor form both +-- reduce to eql_v3.eq_term(value) = eql_v3.eq_term($1). +-- match_term — GIN over eql_v3.bloom_filter (smallint[]); engages for the +-- bloom containment `@>` (MATCH_V3). v3 removes LIKE/ILIKE. +-- +-- No ord_term index: no v3 string scenario orders or range-scans the +-- column (the ob term is present because text_search requires it, not +-- because a scenario uses it). + +CREATE INDEX +string_encrypted_v3_1000000_eq_term_index +ON string_encrypted_v3_1000000 USING hash ( + eql_v3.eq_term(value) +); + +CREATE INDEX +string_encrypted_v3_1000000_match_term_index +ON string_encrypted_v3_1000000 USING GIN ( + eql_v3.match_term(value) +); diff --git a/sql/indexes/v3/string_encrypted_v3_100000_down.sql b/sql/indexes/v3/string_encrypted_v3_100000_down.sql new file mode 100644 index 0000000..69e8479 --- /dev/null +++ b/sql/indexes/v3/string_encrypted_v3_100000_down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS string_encrypted_v3_100000_eq_term_index; +DROP INDEX IF EXISTS string_encrypted_v3_100000_match_term_index; diff --git a/sql/indexes/v3/string_encrypted_v3_100000_up.sql b/sql/indexes/v3/string_encrypted_v3_100000_up.sql new file mode 100644 index 0000000..f21f5b8 --- /dev/null +++ b/sql/indexes/v3/string_encrypted_v3_100000_up.sql @@ -0,0 +1,24 @@ +-- eql_v3 functional indexes for string_encrypted_v3_100000 +-- (column typed eql_v3.text_search — hm + ob + bf). +-- +-- eq_term — hash index; engages for equality (EXACT_V3 scenarios). The +-- inlinable `=` operator and the explicit extractor form both +-- reduce to eql_v3.eq_term(value) = eql_v3.eq_term($1). +-- match_term — GIN over eql_v3.bloom_filter (smallint[]); engages for the +-- bloom containment `@>` (MATCH_V3). v3 removes LIKE/ILIKE. +-- +-- No ord_term index: no v3 string scenario orders or range-scans the +-- column (the ob term is present because text_search requires it, not +-- because a scenario uses it). + +CREATE INDEX +string_encrypted_v3_100000_eq_term_index +ON string_encrypted_v3_100000 USING hash ( + eql_v3.eq_term(value) +); + +CREATE INDEX +string_encrypted_v3_100000_match_term_index +ON string_encrypted_v3_100000 USING GIN ( + eql_v3.match_term(value) +); diff --git a/sql/indexes/v3/string_encrypted_v3_10000_down.sql b/sql/indexes/v3/string_encrypted_v3_10000_down.sql new file mode 100644 index 0000000..85df154 --- /dev/null +++ b/sql/indexes/v3/string_encrypted_v3_10000_down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS string_encrypted_v3_10000_eq_term_index; +DROP INDEX IF EXISTS string_encrypted_v3_10000_match_term_index; diff --git a/sql/indexes/v3/string_encrypted_v3_10000_up.sql b/sql/indexes/v3/string_encrypted_v3_10000_up.sql new file mode 100644 index 0000000..94d4426 --- /dev/null +++ b/sql/indexes/v3/string_encrypted_v3_10000_up.sql @@ -0,0 +1,24 @@ +-- eql_v3 functional indexes for string_encrypted_v3_10000 +-- (column typed eql_v3.text_search — hm + ob + bf). +-- +-- eq_term — hash index; engages for equality (EXACT_V3 scenarios). The +-- inlinable `=` operator and the explicit extractor form both +-- reduce to eql_v3.eq_term(value) = eql_v3.eq_term($1). +-- match_term — GIN over eql_v3.bloom_filter (smallint[]); engages for the +-- bloom containment `@>` (MATCH_V3). v3 removes LIKE/ILIKE. +-- +-- No ord_term index: no v3 string scenario orders or range-scans the +-- column (the ob term is present because text_search requires it, not +-- because a scenario uses it). + +CREATE INDEX +string_encrypted_v3_10000_eq_term_index +ON string_encrypted_v3_10000 USING hash ( + eql_v3.eq_term(value) +); + +CREATE INDEX +string_encrypted_v3_10000_match_term_index +ON string_encrypted_v3_10000 USING GIN ( + eql_v3.match_term(value) +); diff --git a/sql/schema-v3.sql b/sql/schema-v3.sql new file mode 100644 index 0000000..6b0777a --- /dev/null +++ b/sql/schema-v3.sql @@ -0,0 +1,169 @@ +-- EQL v3 twins of the benchmarked tables. Applied by `mise run setup-db-v3` +-- AFTER the eql_v3 SQL installer (built from the EQL repo via `mise run +-- build`; there is no released v3 artifact yet). +-- +-- v3 has no generic `eql_v2_encrypted`-style envelope type: each column is +-- typed as the per-scalar-per-capability jsonb domain that carries exactly +-- the index terms its bench scenarios need. Mapping from the v2 tables: +-- +-- string_encrypted → eql_v3.text_search (hm + ob + bf) +-- The v2 string table serves BOTH the EXACT (hmac equality) and MATCH +-- (bloom containment) scenario families from one column. No single v3 +-- domain carries hm + bf without ob, so `text_search` — the "all text +-- capabilities" domain — is the only single-column twin that supports +-- both families. Cost of that choice: the v3 string ingest additionally +-- encrypts an ORE term (`ob`) that v2's encrypt_string does not, which +-- is visible in the encrypt_string_v3 ingest numbers (documented in +-- the bin and README). +-- integer_encrypted → eql_v3.int4_ord_ore (ob) +-- The v2 int scenarios encrypt `i32` via ColumnType::Int (int4, not +-- int8) with an ORE index only. +-- category_encrypted → eql_v3.text_eq (hm) +-- Equality/GROUP BY only. +-- combo_encrypted → name eql_v3.text_match / age eql_v3.int4_ord_ore / +-- category eql_v3.text_eq +-- Per-column capability match for the composite-predicate scenarios +-- (bloom containment + ORE order + hmac GROUP BY). v3 removes LIKE, so +-- `name` needs only the bloom term. +-- json_ste_vec_* → eql_v3.json (ste_vec document domain) +-- +-- Plaintext baseline tables (string_plaintext_*, category_plaintext_*, ...) +-- are version-independent and shared with the v2 schema — not duplicated +-- here. +-- +-- TODO(CIP-3280): `_ord_ope` scenario scaffold. eql_v3 ships `_ord_ope` +-- domains (wire key `op`, extractor `eql_v3.ord_ope_term`, OPE-CLLW ordering +-- via native bytea comparison — the Supabase-friendly ordered path), but +-- cipherstash-client 0.38.0 does NOT emit the `op` term (CIP-3280 is +-- unreleased). Once a client release emits `op`: +-- 1. add `integer_ope_encrypted_v3*` tables typed eql_v3.int4_ord_ope, +-- 2. add btree indexes on eql_v3.ord_ope_term(value) under sql/indexes/v3/, +-- 3. enable the ope scenario stub in benches/ore_v3.rs. + +-- Base (un-tiered) tables used by the ingest throughput benches. + +CREATE TABLE IF NOT EXISTS string_encrypted_v3 ( + id SERIAL PRIMARY KEY, + value eql_v3.text_search NOT NULL +); + +CREATE TABLE IF NOT EXISTS integer_encrypted_v3 ( + id SERIAL PRIMARY KEY, + value eql_v3.int4_ord_ore NOT NULL +); + +CREATE TABLE IF NOT EXISTS json_ste_vec_small_encrypted_v3 ( + id SERIAL PRIMARY KEY, + value eql_v3.json NOT NULL +); + +-- Row-count-tier tables used by the query benches +-- (`<base>_v3_<tier>`, populated by the prepare:v3:* tasks). + +CREATE TABLE IF NOT EXISTS string_encrypted_v3_10000 ( + id SERIAL PRIMARY KEY, + value eql_v3.text_search NOT NULL +); + +CREATE TABLE IF NOT EXISTS string_encrypted_v3_100000 ( + id SERIAL PRIMARY KEY, + value eql_v3.text_search NOT NULL +); + +CREATE TABLE IF NOT EXISTS string_encrypted_v3_1000000 ( + id SERIAL PRIMARY KEY, + value eql_v3.text_search NOT NULL +); + +CREATE TABLE IF NOT EXISTS string_encrypted_v3_10000000 ( + id SERIAL PRIMARY KEY, + value eql_v3.text_search NOT NULL +); + +CREATE TABLE IF NOT EXISTS integer_encrypted_v3_10000 ( + id SERIAL PRIMARY KEY, + value eql_v3.int4_ord_ore NOT NULL +); + +CREATE TABLE IF NOT EXISTS integer_encrypted_v3_100000 ( + id SERIAL PRIMARY KEY, + value eql_v3.int4_ord_ore NOT NULL +); + +CREATE TABLE IF NOT EXISTS integer_encrypted_v3_1000000 ( + id SERIAL PRIMARY KEY, + value eql_v3.int4_ord_ore NOT NULL +); + +CREATE TABLE IF NOT EXISTS integer_encrypted_v3_10000000 ( + id SERIAL PRIMARY KEY, + value eql_v3.int4_ord_ore NOT NULL +); + +CREATE TABLE IF NOT EXISTS category_encrypted_v3_10000 ( + id SERIAL PRIMARY KEY, + value eql_v3.text_eq NOT NULL +); + +CREATE TABLE IF NOT EXISTS category_encrypted_v3_100000 ( + id SERIAL PRIMARY KEY, + value eql_v3.text_eq NOT NULL +); + +CREATE TABLE IF NOT EXISTS category_encrypted_v3_1000000 ( + id SERIAL PRIMARY KEY, + value eql_v3.text_eq NOT NULL +); + +CREATE TABLE IF NOT EXISTS category_encrypted_v3_10000000 ( + id SERIAL PRIMARY KEY, + value eql_v3.text_eq NOT NULL +); + +CREATE TABLE IF NOT EXISTS combo_encrypted_v3_10000 ( + id SERIAL PRIMARY KEY, + name eql_v3.text_match NOT NULL, + age eql_v3.int4_ord_ore NOT NULL, + category eql_v3.text_eq NOT NULL +); + +CREATE TABLE IF NOT EXISTS combo_encrypted_v3_100000 ( + id SERIAL PRIMARY KEY, + name eql_v3.text_match NOT NULL, + age eql_v3.int4_ord_ore NOT NULL, + category eql_v3.text_eq NOT NULL +); + +CREATE TABLE IF NOT EXISTS combo_encrypted_v3_1000000 ( + id SERIAL PRIMARY KEY, + name eql_v3.text_match NOT NULL, + age eql_v3.int4_ord_ore NOT NULL, + category eql_v3.text_eq NOT NULL +); + +CREATE TABLE IF NOT EXISTS combo_encrypted_v3_10000000 ( + id SERIAL PRIMARY KEY, + name eql_v3.text_match NOT NULL, + age eql_v3.int4_ord_ore NOT NULL, + category eql_v3.text_eq NOT NULL +); + +CREATE TABLE IF NOT EXISTS json_ste_vec_small_encrypted_v3_10000 ( + id SERIAL PRIMARY KEY, + value eql_v3.json NOT NULL +); + +CREATE TABLE IF NOT EXISTS json_ste_vec_small_encrypted_v3_100000 ( + id SERIAL PRIMARY KEY, + value eql_v3.json NOT NULL +); + +CREATE TABLE IF NOT EXISTS json_ste_vec_small_encrypted_v3_1000000 ( + id SERIAL PRIMARY KEY, + value eql_v3.json NOT NULL +); + +CREATE TABLE IF NOT EXISTS json_ste_vec_small_encrypted_v3_10000000 ( + id SERIAL PRIMARY KEY, + value eql_v3.json NOT NULL +); From b0f2b4be4aa245dfa26ab8941251a38e7e1299d7 Mon Sep 17 00:00:00 2001 From: James Sadler <james@cipherstash.com> Date: Thu, 2 Jul 2026 16:46:40 +1000 Subject: [PATCH 3/9] feat(ingest): add EQL v3 ingest bins and conversion-overhead scenario MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v3 twins of the ingest bins encrypt through the unchanged cipherstash-client (v2 wire) pipeline, convert each storage payload with from_v2, and insert into the _v3 tables: encrypt_string_v3 → text_search (adds an ORE index to the column config — text_search requires ob — so throughput is not directly comparable to v2's encrypt_string; documented) encrypt_int_v3 → int4_ord_ore (identical encrypt workload) encrypt_category_v3 → text_eq (identical encrypt workload) encrypt_combo_v3 → per-column text_match / int4_ord_ore / text_eq encrypt_ste_vec_small_v3 → json (SteVec Standard mode) convert_overhead is the dedicated conversion-cost scenario: the same encrypt workload with CONVERT_MODE=encrypt_only vs encrypt_convert, no database writes, reported as its own ingest family — the delta between the two hyperfine families is pure from_v2 cost. --- Cargo.toml | 26 +++++ src/bin/convert_overhead.rs | 121 +++++++++++++++++++++++ src/bin/encrypt_category_v3.rs | 47 +++++++++ src/bin/encrypt_combo_v3.rs | 146 ++++++++++++++++++++++++++++ src/bin/encrypt_int_v3.rs | 47 +++++++++ src/bin/encrypt_ste_vec_small_v3.rs | 67 +++++++++++++ src/bin/encrypt_string_v3.rs | 56 +++++++++++ 7 files changed, 510 insertions(+) create mode 100644 src/bin/convert_overhead.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_v3.rs create mode 100644 src/bin/encrypt_ste_vec_small_v3.rs create mode 100644 src/bin/encrypt_string_v3.rs diff --git a/Cargo.toml b/Cargo.toml index 8f13f34..97e6339 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -81,6 +81,32 @@ path = "src/bin/encrypt_category.rs" name = "encrypt_combo" path = "src/bin/encrypt_combo.rs" +# --- EQL v3 ingest bins (encrypt via v2 pipeline, convert with from_v2) --- + +[[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_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 = "convert_overhead" +path = "src/bin/convert_overhead.rs" + [[bench]] name = "ore" harness = false diff --git a/src/bin/convert_overhead.rs b/src/bin/convert_overhead.rs new file mode 100644 index 0000000..12826cb --- /dev/null +++ b/src/bin/convert_overhead.rs @@ -0,0 +1,121 @@ +//! Conversion-overhead ingest scenario: quantifies what +//! `eql_bindings::from_v2` adds on top of encryption. +//! +//! Runs the SAME workload in two modes selected by `CONVERT_MODE`: +//! +//! * `encrypt_only` — generate fake names and encrypt them as storage +//! payloads (the shared cost floor). +//! * `encrypt_convert` — the same, plus a v2→v3 `from_v2` conversion of +//! every payload (target `eql_v3.text_search`, matching the +//! encrypt_string_v3 ingest path). +//! +//! The column config matches `encrypt_string_v3` (unique + match + ore → +//! hm + bf + ob) so the conversion input is exactly what the real v3 +//! string ingest converts. No database writes in either mode — the delta +//! between the two hyperfine families is pure client-side conversion cost +//! (JSON re-shaping + strict validation), free of INSERT noise. +//! +//! Reported as its own ingest family: `convert_overhead_encrypt_only` / +//! `convert_overhead_encrypt_convert` (see the mise task +//! `bench:ingest:convert-overhead`). +//! +//! Environment variables: +//! - CONVERT_MODE: `encrypt_only` | `encrypt_convert` (required) +//! - NUM_RECORDS: number of values to process (default: 10000) +//! - HYPERFINE_ITERATION: set by hyperfine; keys the validation sidecar +//! - CS_CLIENT_ID / CS_CLIENT_KEY / CS_WORKSPACE_CRN: CipherStash creds + +use anyhow::{bail, Context, Result}; +use cipherstash_client::{ + encryption::Plaintext, + eql::{encrypt_eql, EqlOperation, EqlOutput, Identifier, PreparedPlaintext}, + schema::{column::Index, ColumnConfig, ColumnType}, +}; +use dbbenches::{ + init_scoped_cipher, init_tracing, + v3::{ciphertext_to_v3, TargetDomain}, +}; +use fake::{faker::name::raw::Name, locales::EN, Fake}; +use serde_json::json; +use std::borrow::Cow; +use std::env; +use std::hint::black_box; + +#[tokio::main] +async fn main() -> Result<()> { + init_tracing(); + + let mode = env::var("CONVERT_MODE").context("CONVERT_MODE must be set")?; + let convert = match mode.as_str() { + "encrypt_only" => false, + "encrypt_convert" => true, + other => bail!("CONVERT_MODE must be `encrypt_only` or `encrypt_convert`, got `{other}`"), + }; + + let num_records: i32 = env::var("NUM_RECORDS") + .unwrap_or_else(|_| "10000".to_string()) + .parse() + .expect("NUM_RECORDS must be a valid integer"); + + let hf_iteration: i32 = env::var("HYPERFINE_ITERATION") + .unwrap_or_else(|_| "0".to_string()) + .parse() + .expect("HYPERFINE_ITERATION must be a valid integer"); + + let batch_size: usize = 1000; + + let scoped_cipher = init_scoped_cipher().await?; + + // Match encrypt_string_v3's config exactly — the conversion input must + // be the hm+bf+ob payload the real v3 string ingest converts. + 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("string_encrypted_v3", "value"); + let target = TargetDomain::parse("text_search").expect("text_search is a v3 domain"); + + let mut processed: i64 = 0; + for batch_start in (0..num_records).step_by(batch_size) { + let batch_end = (batch_start + batch_size as i32).min(num_records); + let batch_count = batch_end - batch_start; + + let prepared = (0..batch_count) + .map(|_| { + let name: String = Name(EN).fake(); + PreparedPlaintext::new( + Cow::Borrowed(&column_config), + identifier.clone(), + Plaintext::new(name), + EqlOperation::Store, + ) + }) + .collect::<Vec<_>>(); + + let out = encrypt_eql(scoped_cipher.clone(), prepared, &Default::default()).await?; + + for output in out { + let EqlOutput::Store(ciphertext) = output else { + unreachable!("storage batch must yield EqlOutput::Store"); + }; + if convert { + // black_box keeps the release optimiser from eliding the + // conversion whose cost this scenario exists to measure. + black_box(ciphertext_to_v3(&ciphertext, target)?); + } else { + black_box(&ciphertext); + } + processed += 1; + } + } + + // Validation sidecar consumed by combine_benchmark — same contract as + // IngestOptions::ingest (`inserted` here means "values processed"; + // this bench deliberately never touches the database). + let result = json!({ "inserted": processed }); + let filename = format!("target/convert_overhead_{mode}-{num_records}_{hf_iteration}.json"); + std::fs::write(&filename, serde_json::to_string(&result)?)?; + + Ok(()) +} diff --git a/src/bin/encrypt_category_v3.rs b/src/bin/encrypt_category_v3.rs new file mode 100644 index 0000000..e28d5ac --- /dev/null +++ b/src/bin/encrypt_category_v3.rs @@ -0,0 +1,47 @@ +//! EQL v3 twin of `encrypt_category`: encrypts low-cardinality categorical +//! values (`CAT_001`..`CAT_250`) via the existing cipherstash-client (v2 +//! wire) pipeline, converts each storage payload with +//! `eql_bindings::from_v2`, and inserts into `category_encrypted_v3_*`. +//! +//! Target domain: `eql_v3.text_eq` (hm) — same unique-index configuration +//! as the v2 bin, so the encryption workload is identical and the ingest +//! numbers differ only by the from_v2 conversion. Drives the GROUP_BY_V3 +//! scenarios in `benches/group_by_v3.rs`. +//! +//! Environment variables: DATABASE_URL, NUM_RECORDS (default 10000), +//! TABLE_SUFFIX, CS_CLIENT_ID / CS_CLIENT_KEY / CS_WORKSPACE_CRN. + +use anyhow::Result; +use cipherstash_client::{ + eql::Identifier, + schema::{column::Index, ColumnConfig, ColumnType}, +}; +use dbbenches::{v3::TargetDomain, FakeCategory, IngestOptionsBuilder}; +use std::env; + +#[tokio::main] +async fn main() -> Result<()> { + let num_records: i32 = env::var("NUM_RECORDS") + .unwrap_or_else(|_| "10000".to_string()) + .parse() + .expect("NUM_RECORDS must be a valid integer"); + + let table_suffix = env::var("TABLE_SUFFIX").unwrap_or_default(); + let table_name = format!("category_encrypted_v3{}", table_suffix); + + IngestOptionsBuilder::new("encrypt_category_v3") + .num_records(num_records) + .batch_size(1000) + .identifier(Identifier::new(&table_name, "value")) + .column_config( + ColumnConfig::build("value") + .casts_as(ColumnType::Text) + .add_index(Index::new_unique()), + ) + .convert_to_v3(TargetDomain::parse("text_eq").expect("text_eq is a v3 domain")) + .build()? + .ingest::<String, FakeCategory>(FakeCategory) + .await?; + + Ok(()) +} diff --git a/src/bin/encrypt_combo_v3.rs b/src/bin/encrypt_combo_v3.rs new file mode 100644 index 0000000..fab8b4e --- /dev/null +++ b/src/bin/encrypt_combo_v3.rs @@ -0,0 +1,146 @@ +//! EQL v3 twin of `encrypt_combo`: encrypts three-column combo rows +//! (`name`, `age`, `category`) via the existing cipherstash-client (v2 +//! wire) pipeline, converts each storage payload with +//! `eql_bindings::from_v2` for its per-column target domain, and inserts +//! into the `combo_encrypted_v3_*` tables. Used by `benches/combo_v3.rs`. +//! +//! Per-column target domains (matching the scenario capabilities): +//! * `name` → `eql_v3.text_match` (bf — bloom containment; v3 has +//! no LIKE, so the v2 unique+match config's `hm` term is dropped by +//! the conversion) +//! * `age` → `eql_v3.int4_ord_ore` (ob — ORE ordering) +//! * `category` → `eql_v3.text_eq` (hm — hmac equality / GROUP BY) +//! +//! Environment variables: DATABASE_URL, NUM_RECORDS (default 10000), +//! TABLE_SUFFIX, CS_CLIENT_ID / CS_CLIENT_KEY / CS_WORKSPACE_CRN. + +use anyhow::{Context, Result}; +use cipherstash_client::{ + encryption::Plaintext, + eql::{encrypt_eql, EqlCiphertext, EqlOperation, EqlOutput, Identifier, PreparedPlaintext}, + schema::{column::Index, ColumnConfig, ColumnType}, +}; +use dbbenches::{ + init_scoped_cipher, init_tracing, + v3::{ciphertext_to_v3, TargetDomain}, + FakeCategory, +}; +use fake::{faker::name::raw::Name, locales::EN, Fake}; +use sqlx::{postgres::PgPoolOptions, types::Json, QueryBuilder}; +use std::borrow::Cow; +use std::env; + +#[tokio::main] +async fn main() -> Result<()> { + init_tracing(); + + let database_url = + env::var("DATABASE_URL").context("DATABASE_URL environment variable must be set")?; + let num_records: i32 = env::var("NUM_RECORDS") + .unwrap_or_else(|_| "10000".to_string()) + .parse() + .expect("NUM_RECORDS must be a valid integer"); + let table_suffix = env::var("TABLE_SUFFIX").unwrap_or_default(); + let table_name = format!("combo_encrypted_v3{}", table_suffix); + let batch_size: usize = 1000; + + let pool = PgPoolOptions::new() + .max_connections(5) + .connect(&database_url) + .await?; + + // Init once and reuse for the binary lifetime — see lib.rs::ingest + // for the rationale. + let scoped_cipher = init_scoped_cipher().await?; + + // Same column configs as the v2 encrypt_combo bin — the conversion + // step (not the encryption step) narrows each payload to its target + // domain's terms, keeping the encryption workload identical to v2. + let name_config = ColumnConfig::build("name") + .casts_as(ColumnType::Text) + .add_index(Index::new_unique()) + .add_index(Index::new_match()); + let age_config = ColumnConfig::build("age") + .casts_as(ColumnType::Int) + .add_index(Index::new_ore()); + let category_config = ColumnConfig::build("category") + .casts_as(ColumnType::Text) + .add_index(Index::new_unique()); + + let name_ident = Identifier::new(&table_name, "name"); + let age_ident = Identifier::new(&table_name, "age"); + let category_ident = Identifier::new(&table_name, "category"); + + let name_target = TargetDomain::parse("text_match").expect("text_match is a v3 domain"); + let age_target = TargetDomain::parse("int4_ord_ore").expect("int4_ord_ore is a v3 domain"); + let category_target = TargetDomain::parse("text_eq").expect("text_eq is a v3 domain"); + + for batch_start in (0..num_records).step_by(batch_size) { + let batch_end = (batch_start + batch_size as i32).min(num_records); + let batch_count = batch_end - batch_start; + + let mut prepared = Vec::with_capacity((batch_count * 3) as usize); + for _ in 0..batch_count { + let name: String = Name(EN).fake(); + // Same distribution as the v2 bin — uniform 18..=90. + let age: i32 = (18..=90).fake(); + let category: String = FakeCategory.fake(); + + prepared.push(PreparedPlaintext::new( + Cow::Borrowed(&name_config), + name_ident.clone(), + Plaintext::new(name), + EqlOperation::Store, + )); + prepared.push(PreparedPlaintext::new( + Cow::Borrowed(&age_config), + age_ident.clone(), + Plaintext::new(age), + EqlOperation::Store, + )); + prepared.push(PreparedPlaintext::new( + Cow::Borrowed(&category_config), + category_ident.clone(), + Plaintext::new(category), + EqlOperation::Store, + )); + } + + let out = encrypt_eql(scoped_cipher.clone(), prepared, &Default::default()).await?; + + let ciphertexts: Vec<EqlCiphertext> = out + .into_iter() + .map(|o| match o { + EqlOutput::Store(ct) => ct, + EqlOutput::Query(_) => { + unreachable!("storage batch must yield EqlOutput::Store") + } + }) + .collect(); + + // encrypt_eql preserves input order; chunks of 3 reassemble per-row + // (name, age, category) tuples, converted per-column to v3. + let rows = ciphertexts + .chunks_exact(3) + .map(|c| { + Ok(( + ciphertext_to_v3(&c[0], name_target).context("name payload")?, + ciphertext_to_v3(&c[1], age_target).context("age payload")?, + ciphertext_to_v3(&c[2], category_target).context("category payload")?, + )) + }) + .collect::<Result<Vec<_>>>()?; + + QueryBuilder::new(format!("INSERT INTO {} (name, age, category) ", table_name)) + .push_values(rows, |mut b, (name, age, category)| { + b.push_bind(Json(name)); + b.push_bind(Json(age)); + b.push_bind(Json(category)); + }) + .build() + .execute(&pool) + .await?; + } + + Ok(()) +} diff --git a/src/bin/encrypt_int_v3.rs b/src/bin/encrypt_int_v3.rs new file mode 100644 index 0000000..946cc96 --- /dev/null +++ b/src/bin/encrypt_int_v3.rs @@ -0,0 +1,47 @@ +//! EQL v3 twin of `encrypt_int`: encrypts random `i32` values via the +//! existing cipherstash-client (v2 wire) pipeline, converts each storage +//! payload with `eql_bindings::from_v2`, and inserts into +//! `integer_encrypted_v3` (or a `TABLE_SUFFIX` variant). +//! +//! Target domain: `eql_v3.int4_ord_ore` (ob) — same ColumnType::Int / ORE +//! index configuration as the v2 bin, so the encryption workload is +//! identical and the ingest numbers differ only by the from_v2 conversion. +//! +//! Environment variables: DATABASE_URL, NUM_RECORDS (default 10000), +//! TABLE_SUFFIX, CS_CLIENT_ID / CS_CLIENT_KEY / CS_WORKSPACE_CRN. + +use anyhow::Result; +use cipherstash_client::{ + eql::Identifier, + schema::{column::Index, ColumnConfig, ColumnType}, +}; +use dbbenches::{v3::TargetDomain, IngestOptionsBuilder}; +use fake::Faker; +use std::env; + +#[tokio::main] +async fn main() -> Result<()> { + let num_records: i32 = env::var("NUM_RECORDS") + .unwrap_or_else(|_| "10000".to_string()) + .parse() + .expect("NUM_RECORDS must be a valid integer"); + + let table_suffix = env::var("TABLE_SUFFIX").unwrap_or_default(); + let table_name = format!("integer_encrypted_v3{}", table_suffix); + + IngestOptionsBuilder::new("encrypt_int_v3") + .num_records(num_records) + .batch_size(1000) + .identifier(Identifier::new(&table_name, "value")) + .column_config( + ColumnConfig::build("value") + .casts_as(ColumnType::Int) + .add_index(Index::new_ore()), + ) + .convert_to_v3(TargetDomain::parse("int4_ord_ore").expect("int4_ord_ore is a v3 domain")) + .build()? + .ingest::<i32, _>(Faker) + .await?; + + Ok(()) +} diff --git a/src/bin/encrypt_ste_vec_small_v3.rs b/src/bin/encrypt_ste_vec_small_v3.rs new file mode 100644 index 0000000..42acff2 --- /dev/null +++ b/src/bin/encrypt_ste_vec_small_v3.rs @@ -0,0 +1,67 @@ +//! EQL v3 twin of `encrypt_ste_vec_small`: encrypts small JSON documents +//! (first_name / last_name / age / email) with SteVec indexing via the +//! existing cipherstash-client (v2 wire) pipeline, converts each `k: "sv"` +//! payload with `eql_bindings::from_v2`, and inserts into +//! `json_ste_vec_small_encrypted_v3` (or a `TABLE_SUFFIX` variant). +//! +//! Target domain: `eql_v3.json` — the SteVec document domain +//! (`{v: 3, i, sv: [...]}`, per-entry `s` + `c` + exactly one of `hm` XOR +//! `oc`). Standard SteVec mode matches the v2 bin, so the encryption +//! workload is identical and the ingest numbers differ only by the from_v2 +//! conversion. `sv` entry order is preserved by the converter — `sv[0]` is +//! the decryption root. +//! +//! Environment variables: DATABASE_URL, NUM_RECORDS (default 10000), +//! BATCH_SIZE (default 1000), TABLE_SUFFIX, +//! CS_CLIENT_ID / CS_CLIENT_KEY / CS_WORKSPACE_CRN. + +use anyhow::Result; +use cipherstash_client::{ + eql::Identifier, + schema::{ + column::{ArrayIndexMode, Index, IndexType, SteVecMode}, + ColumnConfig, ColumnType, + }, +}; +use dbbenches::{v3::TargetDomain, FakeJsonSmall, IngestOptionsBuilder, WrappedJson}; +use std::env; + +#[tokio::main] +async fn main() -> Result<()> { + let num_records: i32 = env::var("NUM_RECORDS") + .unwrap_or_else(|_| "10000".to_string()) + .parse() + .expect("NUM_RECORDS must be a valid integer"); + + let batch_size: usize = env::var("BATCH_SIZE") + .unwrap_or_else(|_| "1000".to_string()) + .parse() + .expect("BATCH_SIZE must be a valid integer"); + + let table_suffix = env::var("TABLE_SUFFIX").unwrap_or_default(); + let table_name = format!("json_ste_vec_small_encrypted_v3{}", table_suffix); + + IngestOptionsBuilder::new("encrypt_ste_vec_small_v3") + .num_records(num_records) + .batch_size(batch_size) + .identifier(Identifier::new(&table_name, "value")) + .column_config( + ColumnConfig::build("value") + .casts_as(ColumnType::Json) + .add_index(Index::new(IndexType::SteVec { + prefix: "value".to_string(), + term_filters: Default::default(), + array_index_mode: ArrayIndexMode::default(), + // Standard mode emits `oc` (ORE CLLW) for orderable + // terms — the term the v3 jsonb entry contract (`hm` + // XOR `oc`) and the field_order scenarios expect. + mode: SteVecMode::Standard, + })), + ) + .convert_to_v3(TargetDomain::parse("json").expect("json is the v3 SteVec domain")) + .build()? + .ingest::<WrappedJson, _>(FakeJsonSmall) + .await?; + + Ok(()) +} diff --git a/src/bin/encrypt_string_v3.rs b/src/bin/encrypt_string_v3.rs new file mode 100644 index 0000000..7772210 --- /dev/null +++ b/src/bin/encrypt_string_v3.rs @@ -0,0 +1,56 @@ +//! EQL v3 twin of `encrypt_string`: encrypts generated names via the +//! existing cipherstash-client (v2 wire) pipeline, converts each storage +//! payload with `eql_bindings::from_v2`, and inserts into +//! `string_encrypted_v3` (or a `TABLE_SUFFIX` variant). +//! +//! Target domain: `eql_v3.text_search` (hm + ob + bf) — the only +//! single-column v3 domain that serves both the EXACT_V3 (hmac equality) +//! and MATCH_V3 (bloom containment) scenario families, mirroring how the +//! v2 `string_encrypted` table is shared by exact.rs and match.rs. To +//! satisfy `text_search`'s required `ob` term the column config adds an +//! ORE index that v2's `encrypt_string` does not have — this bin therefore +//! encrypts one extra term per value, and its ingest throughput is NOT +//! directly comparable to `encrypt_string`'s (use the dedicated +//! `convert_overhead` family to quantify pure conversion cost). +//! +//! Environment variables: DATABASE_URL, NUM_RECORDS (default 10000), +//! TABLE_SUFFIX, CS_CLIENT_ID / CS_CLIENT_KEY / CS_WORKSPACE_CRN. + +use anyhow::Result; +use cipherstash_client::{ + eql::Identifier, + schema::{column::Index, ColumnConfig, ColumnType}, +}; +use dbbenches::{v3::TargetDomain, IngestOptionsBuilder}; +use fake::{faker::name::raw::Name, locales::EN}; +use std::env; + +#[tokio::main] +async fn main() -> Result<()> { + let num_records: i32 = env::var("NUM_RECORDS") + .unwrap_or_else(|_| "10000".to_string()) + .parse() + .expect("NUM_RECORDS must be a valid integer"); + + let table_suffix = env::var("TABLE_SUFFIX").unwrap_or_default(); + let table_name = format!("string_encrypted_v3{}", table_suffix); + + IngestOptionsBuilder::new("encrypt_string_v3") + .num_records(num_records) + .batch_size(1000) + .identifier(Identifier::new(&table_name, "value")) + .column_config( + ColumnConfig::build("value") + .casts_as(ColumnType::Text) + .add_index(Index::new_unique()) + .add_index(Index::new_match()) + // text_search requires `ob` — see the module docs above. + .add_index(Index::new_ore()), + ) + .convert_to_v3(TargetDomain::parse("text_search").expect("text_search is a v3 domain")) + .build()? + .ingest::<String, Name<EN>>(Name(EN)) + .await?; + + Ok(()) +} From 649472be331138ddddf3cd0885c53db2a8e411ba Mon Sep 17 00:00:00 2001 From: James Sadler <james@cipherstash.com> Date: Thu, 2 Jul 2026 16:52:55 +1000 Subject: [PATCH 4/9] feat(bench): add EQL v3 query-bench twins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six criterion benches (EXACT_V3, MATCH_V3, ORE_V3, GROUP_BY_V3, COMBO_V3, JSON_V3) mirroring the v2 scenario families against the _v3 tables, with sidecar version: 3 and _v3-prefixed result/metadata filenames. All probe payloads are storage-payload conversions (no v3 scalar query wire shape exists); every query shape was validated against a live eql_v3 install on a scratch Postgres 16, including functional-index engagement for the ordered-range plan. Scenario notes: - exact: eql_cast (bare inlinable =) + eql_hash (eq_term extractor). - match: bloom containment only — the two v2 LIKE scenarios are dropped (v3 removes LIKE/ILIKE; documented in the bench header). Adds eql_bloom_bare to price the typed @> operator inlining. - ore: four range baselines + extractor-ordered range; selective scenarios stay disabled (EQL #230 applies unchanged); commented OPE scenario stub documents the CIP-3280 blocker. - group_by: encrypted scenarios only — plaintext baselines are version-independent and stay in the v2 family. - combo: LIKE filter replaced by the match_term @> recipe. - json: canonical to_ste_vec_query GIN containment recipe; field_eq bare now index-capable (v3 -> is inlinable, unlike v2's plpgsql); per-selector functional indexes built at bench startup. --- Cargo.toml | 26 ++ benches/combo_v3.rs | 180 ++++++++++++++ benches/exact_v3.rs | 185 +++++++++++++++ benches/group_by_v3.rs | 132 +++++++++++ benches/json_v3.rs | 521 +++++++++++++++++++++++++++++++++++++++++ benches/match_v3.rs | 176 ++++++++++++++ benches/ore_v3.rs | 222 ++++++++++++++++++ 7 files changed, 1442 insertions(+) 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/ore_v3.rs diff --git a/Cargo.toml b/Cargo.toml index 97e6339..2807662 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -129,4 +129,30 @@ harness = false [[bench]] name = "combo" +harness = false + +# --- EQL v3 query-bench twins --- + +[[bench]] +name = "ore_v3" +harness = false + +[[bench]] +name = "match_v3" +harness = false + +[[bench]] +name = "exact_v3" +harness = false + +[[bench]] +name = "group_by_v3" +harness = false + +[[bench]] +name = "json_v3" +harness = false + +[[bench]] +name = "combo_v3" harness = false \ No newline at end of file diff --git a/benches/combo_v3.rs b/benches/combo_v3.rs new file mode 100644 index 0000000..f5c85c5 --- /dev/null +++ b/benches/combo_v3.rs @@ -0,0 +1,180 @@ +//! EQL v3 twin of `benches/combo.rs` — composite-predicate scenarios over +//! `combo_encrypted_v3_<N>` (name `eql_v3.text_match`, age +//! `eql_v3.int4_ord_ore`, category `eql_v3.text_eq`). +//! +//! The v2 scenarios filter with `name LIKE $1`; v3 removes LIKE, so every +//! scenario filters with the bloom containment recipe instead: +//! `eql_v3.match_term(name) @> eql_v3.match_term($1::eql_v3.text_match)`, +//! which engages the `GIN (eql_v3.match_term(name))` index. The rest of +//! each shape mirrors v2 with the v3 extractors (`eql_v3.ord_term(age)` +//! for the ORE ORDER BY, `eql_v3.eq_term(category)` for the GROUP BY key). +//! +//! One bound encrypted parameter per scenario (the name probe), same as +//! v2. Probe flow: storage-payload conversion (target `text_match`) — see +//! benches/exact_v3.rs for why query-payload conversion is not possible. + +use cipherstash_client::{ + encryption::ScopedCipher, + eql::Identifier, + schema::{column::Index, ColumnConfig, ColumnType}, + AutoStrategy, +}; +use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use dbbenches::{ + bench_assert, extract_indexes_used, init_scoped_cipher, + v3::{EncryptedQueryBuilderV3, EncryptedQueryV3, TargetDomain}, + write_metadata_file, 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 eql_v3.match_term(name) @> eql_v3.match_term($1::eql_v3.text_match) \ + ORDER BY eql_v3.ord_term(age) LIMIT 10", + "bloom_ore_order_limit", + ), + ( + "SELECT eql_v3.eq_term(category), count(*) FROM {TABLE} \ + WHERE eql_v3.match_term(name) @> eql_v3.match_term($1::eql_v3.text_match) \ + GROUP BY 1", + "filtered_group_by", + ), + ( + "SELECT eql_v3.eq_term(category), count(*) FROM {TABLE} \ + WHERE eql_v3.match_term(name) @> eql_v3.match_term($1::eql_v3.text_match) \ + GROUP BY 1 ORDER BY count(*) DESC LIMIT 10", + "top_n_filtered_group_by", + ), +]; + +async fn build_query( + cipher: Arc<ScopedCipher<AutoStrategy>>, + query: &str, + pattern: &str, + table_name: &str, +) -> EncryptedQueryV3 { + // Same `name` column config as encrypt_combo_v3 (unique + match); the + // text_match conversion keeps only the bloom term the scenarios need. + let column_config = ColumnConfig::build("name") + .casts_as(ColumnType::Text) + .add_index(Index::new_unique()) + .add_index(Index::new_match()); + + let identifier = Identifier::new(table_name, "name"); + let target = TargetDomain::parse("text_match").expect("text_match is a v3 domain"); + + EncryptedQueryBuilderV3::new(column_config, identifier, target) + .statement(query) + .build_query(pattern.to_string(), cipher) + .await + .expect("Failed to build encrypted v3 query") +} + +fn criterion_benchmark(c: &mut Criterion) { + 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) + }); + + // "Bob" matches the v2 combo bench's probe. Note the semantics shift + // slightly with the LIKE removal: v2's `LIKE 'Bob'` requires the bloom + // ngrams of the pattern; the v3 containment does the same bloom-side + // work, so the filtered set is comparable. + let pattern = "Bob"; + + let queries = 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 query = build_query(Arc::clone(&cipher), &query_str, pattern, &table_name).await; + queries.push(query); + } + queries + }); + + // Combo scenarios return shapes incompatible with + // EncryptedQueryV3::execute (typed for `(i32, Json<Value>)` rows), so + // metadata capture and the iter loop below use raw sqlx — same + // structure as the v2 combo bench. + 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_V3/combo/{}/{}", scenario, target_rows); + let explain = query.explain(&pool).await.expect("EXPLAIN failed"); + let indexes_used = extract_indexes_used(&explain); + let parameters = vec![query.parameter_json()]; + let rows = sqlx::query(&query.statement) + .bind(Json(&query.param)) + .fetch_all(&pool) + .await + .expect("execute for row-count failed"); + let rows_returned = rows.len() as u64; + out.push(ScenarioMetadata { + id: bench_id, + query: query.statement.clone(), + parameters, + explain, + indexes_used, + rows_returned, + version: 3, + }); + } + out + }); + write_metadata_file("combo_v3", &target_rows, metadata) + .expect("failed to write bench metadata sidecar"); + + let mut group = c.benchmark_group("COMBO_V3"); + group.sample_size(10); + + for (i, query) in queries.into_iter().enumerate() { + let (_, scenario) = QUERY_TEMPLATES[i]; + let exec_id = format!("COMBO_V3/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..b4965de --- /dev/null +++ b/benches/exact_v3.rs @@ -0,0 +1,185 @@ +//! EQL v3 twin of `benches/exact.rs` — equality lookups against +//! `string_encrypted_v3_<N>` (column typed `eql_v3.text_search`). +//! +//! Probe flow: cipherstash-client 0.38 cannot emit a v3 scalar QUERY +//! payload (`from_v2_query` fails closed with UnsupportedQueryTarget), so +//! the probe value is encrypted as a STORAGE payload and converted with +//! `from_v2` (target `text_search`). The stored-shape probe carries the +//! same `hm` term a query payload would, and the SQL compares via the +//! `eql_v3.eq_term` extractor (`eql_hash`) or the inlinable `=` operator +//! (`eql_cast`), both of which reduce to +//! `eql_v3.eq_term(value) = eql_v3.eq_term($1)` and engage the +//! `hash (eql_v3.eq_term(value))` index. +//! +//! v3 columns are jsonb domains (no composite wrapper), so scenarios +//! project `value::jsonb` — safe here because no v3 scenario puts the raw +//! column in an ORDER BY (the v2 projection-pushdown sort-key trap needs +//! an `ORDER BY value` to bite). + +use cipherstash_client::{ + encryption::ScopedCipher, + eql::Identifier, + schema::{column::Index, ColumnConfig, ColumnType}, + AutoStrategy, +}; +use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use dbbenches::{ + bench_assert, extract_indexes_used, init_scoped_cipher, + v3::{sample_plaintext_string_v3, EncryptedQueryBuilderV3, EncryptedQueryV3, TargetDomain}, + write_metadata_file, ScenarioMetadata, +}; +use sqlx::postgres::PgPoolOptions; +use std::sync::Arc; +use tokio::runtime::Runtime; + +// (sql_template, scenario_name). Scenario names mirror the v2 exact bench +// so the reporters line the two versions up side by side. +static QUERY_TEMPLATES: &[(&str, &str)] = &[ + ( + "SELECT id, value::jsonb FROM {TABLE} WHERE value = $1::eql_v3.text_search LIMIT 1", + "eql_cast", + ), + ( + "SELECT id, value::jsonb FROM {TABLE} \ + WHERE eql_v3.eq_term(value) = eql_v3.eq_term($1::eql_v3.text_search) LIMIT 1", + "eql_hash", + ), +]; + +async fn build_query( + cipher: Arc<ScopedCipher<AutoStrategy>>, + query: &str, + x: &str, + table_name: &str, +) -> EncryptedQueryV3 { + // Same column config as encrypt_string_v3 — the probe must carry every + // term text_search requires (hm + bf + ob) to pass the conversion. + 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 target = TargetDomain::parse("text_search").expect("text_search is a v3 domain"); + + EncryptedQueryBuilderV3::new(column_config, identifier, target) + .statement(query) + .build_query(x.to_string(), cipher) + .await + .expect("Failed to build encrypted v3 query") +} + +fn criterion_benchmark(c: &mut Criterion) { + 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) + }); + + // Derive the search term from a real row so the bench measures actual + // equality-query cost — same rationale as the v2 exact 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 + ); + + let queries = 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 query = + build_query(Arc::clone(&cipher), &query_str, &search_term, &table_name).await; + queries.push(query); + } + 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_V3/exact/{}/{}", scenario, target_rows); + let explain = query.explain(&pool).await.expect("EXPLAIN failed"); + let indexes_used = extract_indexes_used(&explain); + let parameters = vec![query.parameter_json()]; + let rows = query + .execute(&pool) + .await + .expect("execute for row-count failed"); + let rows_returned = rows.len() as u64; + out.push(ScenarioMetadata { + id: bench_id, + query: query.statement.clone(), + parameters, + explain, + indexes_used, + rows_returned, + version: 3, + }); + } + out + }); + write_metadata_file("exact_v3", &target_rows, metadata) + .expect("failed to write bench metadata sidecar"); + + let mut group = c.benchmark_group("EXACT_V3"); + group.sample_size(10); + + for (i, query) in queries.into_iter().enumerate() { + let (_, scenario) = QUERY_TEMPLATES[i]; + let exec_id = format!("EXACT_V3/exact/{}/{}", scenario, target_rows); + let decrypt_id = format!("EXACT_V3/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 { + // Decryption rebuilds the v2 `ct` envelope per row (see + // dbbenches::v3::v3_scalar_to_ciphertext) — same client-side + // decrypt work as v2 plus the envelope rebuild. + let _r: Vec<String> = 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..d38bcb2 --- /dev/null +++ b/benches/group_by_v3.rs @@ -0,0 +1,132 @@ +//! EQL v3 twin of `benches/group_by.rs` — realistic-cardinality GROUP BY +//! against `category_encrypted_v3_<N>` (column typed `eql_v3.text_eq`, +//! ~250 distinct buckets). +//! +//! Scenarios mirror the v2 encrypted shapes with `eql_v3.eq_term` in place +//! of `eql_v2.hmac_256`: +//! +//! * `low_cardinality_groups_encrypted` — count(*)-wrapped extractor +//! GROUP BY (isolates HashAggregate cost from emit cost). +//! * `top_n_groups_encrypted` — top-10-categories dashboard shape. +//! +//! The plaintext baselines (`*_plaintext`) are NOT duplicated here: the +//! `category_plaintext_<N>` tables are version-independent and the v2 +//! group_by bench already measures them — compare the v3 encrypted numbers +//! against those same baselines in the report. + +use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use dbbenches::{bench_assert, extract_indexes_used, write_metadata_file, ScenarioMetadata}; +use sqlx::postgres::PgPoolOptions; +use sqlx::types::Json; +use sqlx::Row; +use tokio::runtime::Runtime; + +// (sql_template, scenario_name, base_table_name). `{TABLE}` is replaced +// with `<base_table_name>_<TARGET_ROWS>`. +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 eql_v3.eq_term(value), count(*) FROM {TABLE} \ + GROUP BY 1 ORDER BY count(*) DESC LIMIT 10", + "top_n_groups_encrypted", + "category_encrypted_v3", + ), +]; + +fn criterion_benchmark(c: &mut Criterion) { + 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_V3/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<serde_json::Value>,) = 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, + version: 3, + }); + } + out + }); + + write_metadata_file("group_by_v3", &target_rows, metadata) + .expect("failed to write bench metadata sidecar"); + + let mut group = c.benchmark_group("GROUP_BY_V3"); + group.sample_size(10); + + for (bench_id, query_str) in scenarios { + let function_name = bench_id + .strip_prefix("GROUP_BY_V3/") + .expect("bench_id missing GROUP_BY_V3/ 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); + // Drain results to force aggregation to materialise — the + // count-wrapped scenario returns a single i64; top-N returns + // up to 10 (group key, count) rows. + if rows.len() == 1 { + black_box(rows[0].get::<i64, _>(0)); + } else { + black_box(rows.iter().map(|r| r.get::<i64, _>(1)).sum::<i64>()); + } + }) + }); + } + + 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..6d99775 --- /dev/null +++ b/benches/json_v3.rs @@ -0,0 +1,521 @@ +//! EQL v3 twin of `benches/json.rs` — JSON / ste_vec query benches against +//! `json_ste_vec_small_encrypted_v3_<N>` (column typed `eql_v3.json`). +//! +//! Scenario mapping from v2 (same names, v3 recipes): +//! +//! 1. `contains/functional` — whole-document containment. v2 used +//! `eql_v2.jsonb_array(a) @> eql_v2.jsonb_array(b)`; the canonical v3 +//! recipe is the typed needle form +//! `WHERE value @> $1::jsonb::eql_v3.jsonb_query`, which inlines to +//! a native `jsonb @>` over +//! `eql_v3.to_ste_vec_query(value)::jsonb` and engages the single +//! `GIN ((to_ste_vec_query(value))::jsonb jsonb_path_ops)` index. The +//! needle is the sampled row's own normalized query shape +//! (`SELECT eql_v3.to_ste_vec_query(value)`), so it matches at least +//! the source row. +//! +//! 2. `field_eq/bare` — `(value -> '<sel>'::text) = $1::jsonb::eql_v3.jsonb_entry`. +//! Unlike v2 (where `->` was plpgsql and unmatchable), the v3 `->` and +//! `=` are inlinable SQL: the predicate reduces to +//! `eql_v3.eq_term(value -> '<sel>') = eql_v3.eq_term($1)` and engages +//! the per-selector btree built at startup (see create_field_indexes). +//! `field_eq/extractor` — single-field needle `{"sv":[{s,hm}]}` +//! through the same `@> $1::jsonb::eql_v3.jsonb_query` GIN recipe as +//! containment (one index covers every selector, hm- and oc-bearing). +//! `field_eq/functional` — the explicit extractor form of bare. +//! +//! 3. `field_order/functional` — `ORDER BY eql_v3.ore_cllw(value -> +//! '<sel>'::text) LIMIT 10`, engaging a per-selector btree (the +//! `eql_v3.ore_cllw_ops` opclass is DEFAULT for the type). No bare +//! ORDER BY scenario, same reasoning as v2: sort keys are never +//! rewritten by the planner. +//! +//! The needle picker mirrors v2: one `hm`-bearing sv element drives +//! field_eq/*, one orderable element drives field_order/*. In v3 the only +//! per-entry orderable tag is `oc` (ORE CLLW) — the root-scalar `ob` tag +//! does not exist at sv-entry level (the from_v2 conversion enforces the +//! `hm` XOR `oc` entry contract). + +use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use dbbenches::{bench_assert, extract_indexes_used, write_metadata_file, ScenarioMetadata}; +use serde_json::Value as JsonValue; +use sqlx::postgres::PgPoolOptions; +use sqlx::types::Json; +use sqlx::Row; +use tokio::runtime::Runtime; + +/// Sampled needles, picked once at startup from the target v3 table. +#[derive(Debug)] +struct Needles { + /// The sampled row's normalized containment needle + /// (`eql_v3.to_ste_vec_query(value)::jsonb`) — every sv element reduced + /// to `s` + its `hm`/`oc` term. + containment_needle: JsonValue, + /// Selector + payloads for the field_eq/* scenarios (None when no sv + /// element carries `hm`). + hm_pick: Option<HmPick>, + /// Selector for the field_order/* scenarios (None when no sv element + /// carries `oc`). + ore_pick: Option<OrePick>, +} + +#[derive(Debug)] +struct HmPick { + /// Deterministic selector hash (inlined into SQL — the planner needs a + /// literal for functional-index matching). + selector: String, + /// `(value -> '<selector>')::jsonb` — an entry payload for the + /// field_eq bare / functional needles (`::eql_v3.jsonb_entry`). + sample_field_value: JsonValue, + /// `{"sv":[{"s":"<sel>","hm":"<hash>"}]}` — the field_eq/extractor + /// needle (`::eql_v3.jsonb_query`). + hmac_needle: String, +} + +#[derive(Debug)] +struct OrePick { + /// Deterministic selector hash for the `oc`-bearing field. + selector: String, +} + +async fn sample_needles(pool: &sqlx::PgPool, table: &str) -> Needles { + // The containment needle is sampled pre-normalized via the same + // to_ste_vec_query the GIN index and @> operator use. + let needle_row = sqlx::query(&format!( + "SELECT eql_v3.to_ste_vec_query(value)::jsonb AS needle FROM {table} LIMIT 1" + )) + .fetch_one(pool) + .await + .expect("containment needle sample failed — is the table populated?"); + let containment_needle: Json<JsonValue> = needle_row.get("needle"); + + // Scan the first row's sv array for one hm-bearing and one oc-bearing + // element. `value::jsonb` downcasts the eql_v3.json domain so the + // native jsonb operators apply (the v3 `->` selector operator would + // otherwise capture a typed text RHS). + let rows = sqlx::query(&format!( + "SELECT elem ->> 's' AS sel, + elem ->> 'hm' AS hmac, + elem ->> 'oc' AS oc, + ord + 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"); + } + + let mut hm_pick: Option<HmPick> = None; + let mut ore_pick: Option<OrePick> = None; + + for row in &rows { + let sel: String = row.get("sel"); + let hmac: Option<String> = row.get("hmac"); + let oc: Option<String> = row.get("oc"); + + if hm_pick.is_none() { + if let Some(h) = hmac { + // `->` with a typed text selector resolves the v3 operator + // and returns the matching entry (root meta merged in); + // cast to jsonb for decoding. + 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<JsonValue> = field_row.get("sample_field_value"); + hm_pick = Some(HmPick { + selector: sel.clone(), + sample_field_value: sample_field_value.0, + hmac_needle: format!(r#"{{"sv":[{{"s":"{}","hm":"{}"}}]}}"#, sel, h), + }); + } + } + + if ore_pick.is_none() && oc.is_some() { + ore_pick = Some(OrePick { selector: sel }); + } + + if hm_pick.is_some() && ore_pick.is_some() { + break; + } + } + + Needles { + containment_needle: containment_needle.0, + hm_pick, + ore_pick, + } +} + +/// Build the per-selector functional indexes the `field_eq/*` and +/// `field_order/functional` scenarios measure. Per-selector expressions +/// embed the sampled selector hash, so — as in the v2 bench — they cannot +/// live in the static sql/indexes/v3/*.sql files. +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"); + // btree, not hash — same build-scalability reasoning as the v2 + // bench (hash index builds degrade badly at the 10M tier). + 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) { + 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("<none>"), + needles + .ore_pick + .as_ref() + .map(|p| p.selector.as_str()) + .unwrap_or("<none>"), + ); + + create_field_indexes(&pool, &table_name, &needles).await; + + (pool, needles) + }); + + // Needles bind as text ($1) and are cast in SQL + // (::jsonb::eql_v3.jsonb_query / ::jsonb::eql_v3.jsonb_entry). + let containment_needle_json = + serde_json::to_string(&needles.containment_needle).expect("serialise containment needle"); + 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" + ) + }); + + // Same SQL shape as contains/functional — the scenario differs by + // needle (single {s,hm} element vs the whole document's terms). + 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<ScenarioMetadata> = Vec::with_capacity(5); + + 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<JsonValue>,) = 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<sqlx::postgres::PgRow> = 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, + version: 3, + } + } + + out.push( + capture( + &pool, + format!("JSON_V3/json/contains/functional/{}", target_rows), + &q_contains_functional, + Some(&containment_needle_json), + ) + .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_V3/json/field_eq/bare/{}", target_rows), + q_bare, + Some(hm_field), + ) + .await, + ); + + out.push( + capture( + &pool, + format!("JSON_V3/json/field_eq/extractor/{}", target_rows), + q_extractor, + Some(hm_pick.hmac_needle.as_str()), + ) + .await, + ); + + out.push( + capture( + &pool, + format!("JSON_V3/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 has_ore { + if let Some(q) = q_field_order_functional.as_deref() { + out.push( + capture( + &pool, + format!("JSON_V3/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("json_v3", &target_rows, metadata) + .expect("failed to write bench metadata sidecar"); + + // --- Bench loop --- + + let mut group = c.benchmark_group("JSON_V3"); + group.sample_size(10); + + { + let id = format!("JSON_V3/json/contains/functional/{}", target_rows); + let q = q_contains_functional.clone(); + let needle = containment_needle_json.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_needle = needles + .hm_pick + .as_ref() + .expect("hm_pick present when has_hm") + .hmac_needle + .clone(); + + if let Some(q) = q_field_eq_bare.clone() { + let id = format!("JSON_V3/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_V3/json/field_eq/extractor/{}", target_rows); + let needle = hmac_needle.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_V3/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_V3/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..43a5fb4 --- /dev/null +++ b/benches/match_v3.rs @@ -0,0 +1,176 @@ +//! EQL v3 twin of `benches/match.rs` — pattern-style matching against +//! `string_encrypted_v3_<N>` (column typed `eql_v3.text_search`). +//! +//! **Dropped scenarios vs v2:** `eql_cast_firstname` and `eql_cast_lastname` +//! (both `WHERE value LIKE $1`) have no v3 equivalent — EQL v3 removes +//! `LIKE` / `ILIKE` entirely (the operators route to blocker functions that +//! RAISE). Encrypted text matching in v3 is bloom-filter token containment +//! via `@>`, so this bench carries the `eql_bloom` scenario only, in two +//! forms: +//! +//! * `eql_bloom` — extractor form, the direct v2 analog: +//! `eql_v3.match_term(value) @> eql_v3.match_term($1)`. Engages the +//! `GIN (eql_v3.match_term(value))` index. +//! * `eql_bloom_bare` — bare operator form `value @> $1::eql_v3.text_search`, +//! the shape an ORM caller writes. The typed `@>` inlines to the same +//! match_term expression, so it should engage the same GIN — the +//! scenario exists to verify (and price) exactly that inlining. +//! +//! Probe flow: storage-payload conversion (target `text_search`) — see +//! `benches/exact_v3.rs` for why query-payload conversion is not possible. + +use cipherstash_client::{ + encryption::ScopedCipher, + eql::Identifier, + schema::{column::Index, ColumnConfig, ColumnType}, + AutoStrategy, +}; +use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use dbbenches::{ + bench_assert, extract_indexes_used, init_scoped_cipher, + v3::{EncryptedQueryBuilderV3, EncryptedQueryV3, TargetDomain}, + write_metadata_file, ScenarioMetadata, +}; +use sqlx::postgres::PgPoolOptions; +use std::sync::Arc; +use tokio::runtime::Runtime; + +// (sql_template, probe_value, scenario_name). "Johnson" matches the v2 +// eql_bloom scenario's probe so the two versions measure the same +// containment workload. +static QUERY_TEMPLATES: &[(&str, &str, &str)] = &[ + ( + "SELECT id, value::jsonb FROM {TABLE} \ + WHERE eql_v3.match_term(value) @> eql_v3.match_term($1::eql_v3.text_search) LIMIT 10", + "Johnson", + "eql_bloom", + ), + ( + "SELECT id, value::jsonb FROM {TABLE} \ + WHERE value @> $1::eql_v3.text_search LIMIT 10", + "Johnson", + "eql_bloom_bare", + ), +]; + +async fn build_query( + cipher: Arc<ScopedCipher<AutoStrategy>>, + query: &str, + x: &str, + table_name: &str, +) -> EncryptedQueryV3 { + // Same column config as encrypt_string_v3 — the probe must carry every + // term text_search requires (hm + bf + ob) to pass the conversion. + 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 target = TargetDomain::parse("text_search").expect("text_search is a v3 domain"); + + EncryptedQueryBuilderV3::new(column_config, identifier, target) + .statement(query) + .build_query(x.to_string(), cipher) + .await + .expect("Failed to build encrypted v3 query") +} + +fn criterion_benchmark(c: &mut Criterion) { + 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) + }); + + let queries = 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 query = build_query(Arc::clone(&cipher), &query_str, x, &table_name).await; + queries.push(query); + } + 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!("MATCH_V3/match/{}/{}", scenario, target_rows); + let explain = query.explain(&pool).await.expect("EXPLAIN failed"); + let indexes_used = extract_indexes_used(&explain); + let parameters = vec![query.parameter_json()]; + let rows = query + .execute(&pool) + .await + .expect("execute for row-count failed"); + let rows_returned = rows.len() as u64; + out.push(ScenarioMetadata { + id: bench_id, + query: query.statement.clone(), + parameters, + explain, + indexes_used, + rows_returned, + version: 3, + }); + } + out + }); + write_metadata_file("match_v3", &target_rows, metadata) + .expect("failed to write bench metadata sidecar"); + + let mut group = c.benchmark_group("MATCH_V3"); + group.sample_size(10); + + for (i, query) in queries.into_iter().enumerate() { + let (_, _, scenario) = QUERY_TEMPLATES[i]; + let exec_id = format!("MATCH_V3/match/{}/{}", scenario, target_rows); + let decrypt_id = format!("MATCH_V3/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<String> = 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..520b03e --- /dev/null +++ b/benches/ore_v3.rs @@ -0,0 +1,222 @@ +//! EQL v3 twin of `benches/ore.rs` — range + ordered-range queries against +//! `integer_encrypted_v3_<N>` (column typed `eql_v3.int4_ord_ore`). +//! +//! Scenario parity with v2: +//! +//! * The four non-selective range baselines (`range_gt_10/100`, +//! `range_lt_10/100`) carry over unchanged in intent — bare-form +//! `value <op> $1::eql_v3.int4_ord_ore` inlines to +//! `eql_v3.ord_term(a) <op> eql_v3.ord_term(b)` and structurally +//! matches the `btree (eql_v3.ord_term(value))` index. Whether the +//! planner uses it is a selectivity question, exactly as in v2 (see +//! the long discussion in benches/ore.rs). +//! * `range_lt_ordered_10` keeps the extractor ORDER BY +//! (`ORDER BY eql_v3.ord_term(value)`) so rows stream out of the index +//! already sorted. The natural-form `ORDER BY value` anti-pattern +//! stays excluded, as in v2. +//! * The selective scenarios remain disabled for the same planner +//! selectivity mis-estimate (EQL issue #230) — the bound-parameter +//! operand hides selectivity from the planner in v3 exactly as in v2. +//! +//! Probe flow: the threshold (5000) is encrypted as a STORAGE payload and +//! converted (target `int4_ord_ore`) because no v3 scalar QUERY wire shape +//! exists — see benches/exact_v3.rs. +//! +//! TODO(CIP-3280) — `_ord_ope` scenario stub. eql_v3 ships `int4_ord_ope` +//! (wire key `op`, extractor `eql_v3.ord_ope_term`, ordering by native +//! bytea comparison of the OPE-CLLW ciphertext — no per-row plpgsql +//! compare). cipherstash-client 0.38.0 does not emit the `op` term +//! (CIP-3280 unreleased), so the scenario cannot be driven yet. When a +//! client release emits `op`: +//! 1. create the `integer_ope_encrypted_v3*` tables (schema-v3.sql TODO), +//! 2. add an `encrypt_int_ope_v3` ingest bin targeting `int4_ord_ope`, +//! 3. enable OPE_QUERY_TEMPLATES below against those tables with a +//! `btree (eql_v3.ord_ope_term(value))` index. +// +// static OPE_QUERY_TEMPLATES: &[(&str, i32, &str)] = &[ +// ( +// "SELECT id, value::jsonb FROM {OPE_TABLE} \ +// WHERE value > $1::eql_v3.int4_ord_ope LIMIT 10", +// 5000, +// "ope_range_gt_10", +// ), +// ( +// "SELECT id, value::jsonb FROM {OPE_TABLE} \ +// WHERE value < $1::eql_v3.int4_ord_ope \ +// ORDER BY eql_v3.ord_ope_term(value) LIMIT 10", +// 5000, +// "ope_range_lt_ordered_10", +// ), +// ]; + +use cipherstash_client::{ + encryption::ScopedCipher, + eql::Identifier, + schema::{column::Index, ColumnConfig, ColumnType}, + AutoStrategy, +}; +use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use dbbenches::{ + bench_assert, extract_indexes_used, init_scoped_cipher, init_tracing, + v3::{EncryptedQueryBuilderV3, EncryptedQueryV3, TargetDomain}, + write_metadata_file, ScenarioMetadata, +}; +use sqlx::postgres::PgPoolOptions; +use std::sync::Arc; +use tokio::runtime::Runtime; + +// (sql_template, probe_value, scenario_name). Thresholds and scenario names +// mirror benches/ore.rs so the reporters line the versions up. +static QUERY_TEMPLATES: &[(&str, i32, &str)] = &[ + ( + "SELECT id, value::jsonb FROM {TABLE} \ + WHERE value > $1::eql_v3.int4_ord_ore LIMIT 10", + 5000, + "range_gt_10", + ), + ( + "SELECT id, value::jsonb FROM {TABLE} \ + WHERE value > $1::eql_v3.int4_ord_ore LIMIT 100", + 5000, + "range_gt_100", + ), + ( + "SELECT id, value::jsonb FROM {TABLE} \ + WHERE value < $1::eql_v3.int4_ord_ore LIMIT 10", + 5000, + "range_lt_10", + ), + ( + "SELECT id, value::jsonb FROM {TABLE} \ + WHERE value < $1::eql_v3.int4_ord_ore LIMIT 100", + 5000, + "range_lt_100", + ), + ( + "SELECT id, value::jsonb FROM {TABLE} \ + WHERE value < $1::eql_v3.int4_ord_ore \ + ORDER BY eql_v3.ord_term(value) LIMIT 10", + 5000, + "range_lt_ordered_10", + ), +]; + +async fn build_query( + cipher: Arc<ScopedCipher<AutoStrategy>>, + query: &str, + x: i32, + table_name: &str, +) -> EncryptedQueryV3 { + let column_config = ColumnConfig::build("value") + .casts_as(ColumnType::Int) + .add_index(Index::new_ore()); + + let identifier = Identifier::new(table_name, "value"); + let target = TargetDomain::parse("int4_ord_ore").expect("int4_ord_ore is a v3 domain"); + + EncryptedQueryBuilderV3::new(column_config, identifier, target) + .statement(query) + .build_query(x, cipher) + .await + .expect("Failed to build encrypted v3 query") +} + +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 queries = 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 query = build_query(Arc::clone(&cipher), &query_str, *x, &table_name).await; + queries.push(query); + } + 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_V3/ore/{}/{}", scenario, target_rows); + let explain = query.explain(&pool).await.expect("EXPLAIN failed"); + let indexes_used = extract_indexes_used(&explain); + let parameters = vec![query.parameter_json()]; + let rows = query + .execute(&pool) + .await + .expect("execute for row-count failed"); + let rows_returned = rows.len() as u64; + out.push(ScenarioMetadata { + id: bench_id, + query: query.statement.clone(), + parameters, + explain, + indexes_used, + rows_returned, + version: 3, + }); + } + out + }); + write_metadata_file("ore_v3", &target_rows, metadata) + .expect("failed to write bench metadata sidecar"); + + let mut group = c.benchmark_group("ORE_V3"); + group.sample_size(10); + + for (i, query) in queries.into_iter().enumerate() { + let (_, _, scenario) = QUERY_TEMPLATES[i]; + let exec_id = format!("ORE_V3/ore/{}/{}", scenario, target_rows); + let decrypt_id = format!("ORE_V3/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<i32> = black_box(bench_assert( + query.execute_and_decrypt(&pool).await, + &decrypt_id_inner, + )); + }) + }); + } + + group.finish(); +} + +criterion_group!(benches, criterion_benchmark); +criterion_main!(benches); From 4e59a50531d7ac165306f53bfb475f27d51c1afc Mon Sep 17 00:00:00 2001 From: James Sadler <james@cipherstash.com> Date: Thu, 2 Jul 2026 16:55:33 +1000 Subject: [PATCH 5/9] feat(mise): add setup-db-v3, prepare:v3:*, bench:*:v3 and convert-overhead tasks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Additive twins of the v2 tasks — no existing task is modified: - setup-db-v3 installs the locally-built eql_v3 SQL (EQL_V3_SQL env override, defaulting to the sibling EQL checkout's release/ artifact — there is no released v3 installer yet) plus sql/schema-v3.sql. - prepare:v3:_table mirrors prepare:_table with index scripts from sql/indexes/v3/, plus per-table prepare:v3:* wrappers. - bench:query:{exact,match,ore,group_by,combo,json}:v3 (via a shared bench:query:_run_v3 helper) write results/query/<family>_v3_rows_N files; bench:query:all:v3 sweeps the tiers. - bench:ingest:{encrypt_int,encrypt_string,json-ste-vec-small}:v3 reuse the generic bench:ingest:_run. - bench:ingest:convert-overhead runs the encrypt_only vs encrypt_convert hyperfine pair (no DB writes) whose delta is pure from_v2 conversion cost. --- mise.toml | 324 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 324 insertions(+) diff --git a/mise.toml b/mise.toml index ec3fb6d..97cf4b5 100644 --- a/mise.toml +++ b/mise.toml @@ -169,6 +169,41 @@ 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 SQL (eql_v3 schema) and create the _v3 bench tables. Additive: run after (or independently of) setup-db — v2 objects are untouched." +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 + +# There is no released v3 artifact yet — the installer comes from a local +# EQL repo build (`mise run build` writes release/cipherstash-encrypt.sql). +# EQL_V3_SQL overrides the default sibling-checkout location, mirroring the +# EQL_SQL override on setup-db: +# EQL_V3_SQL=/path/to/cipherstash-encrypt.sql mise run setup-db-v3 +EQL_V3_SQL="${EQL_V3_SQL:-../encrypt-query-language/release/cipherstash-encrypt.sql}" + +if [ ! -f "$EQL_V3_SQL" ]; then + echo "Error: EQL v3 installer not found at $EQL_V3_SQL" + echo "Build it in the EQL repo first: mise run build" + echo "or point EQL_V3_SQL at an existing cipherstash-encrypt.sql (v3 build)." + exit 1 +fi + +echo "Installing EQL v3 from $EQL_V3_SQL ..." +psql -v ON_ERROR_STOP=1 -U postgres -d postgres < "$EQL_V3_SQL" + +echo "Creating _v3 tables..." +psql -v ON_ERROR_STOP=1 -U postgres -d postgres < sql/schema-v3.sql + +echo "EQL v3 database setup complete!" +""" + [tasks."bench:build"] description = "Build all binaries in release mode" run = """ @@ -367,6 +402,111 @@ FINAL_ROWS=$(psql -U postgres -d postgres -t -c "SELECT COUNT(*) FROM $TABLE_NAM echo "Preparation complete! Final row count: $FINAL_ROWS" """ +[tasks."prepare:v3:_table"] +description = "Internal: Prepare a _v3 table with target row count (v3 twin of prepare:_table — index scripts come from sql/indexes/v3/)" +depends = ["postgres", "bench:build"] +run = """ +#!/usr/bin/env bash +set -e + +BASE_TABLE="$1" +BINARY_NAME="$2" +TARGET_ROWS="$3" + +if [ -z "$BASE_TABLE" ] || [ -z "$BINARY_NAME" ] || [ -z "$TARGET_ROWS" ]; then + echo "Error: BASE_TABLE, BINARY_NAME, and TARGET_ROWS arguments required" + echo "Usage: mise run prepare:v3:_table <base_table> <binary_name> <target_rows>" + exit 1 +fi + +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="${BASE_TABLE}_${TARGET_ROWS}" + +echo "Waiting for PostgreSQL to be ready..." +until pg_isready -U postgres > /dev/null 2>&1; do + sleep 1 +done + +echo "Counting rows in $TABLE_NAME table..." +CURRENT_ROWS=$(psql -U postgres -d postgres -t -c "SELECT COUNT(*) FROM $TABLE_NAME;" | tr -d ' ') + +echo "Current rows: $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." + exit 0 +fi + +echo "Dropping indexes..." +psql -U postgres -d postgres < sql/indexes/v3/${TABLE_NAME}_down.sql + +# Same ZeroKMS-transient retry loop as prepare:_table — the v3 bins write +# rows transactionally per batch, so a re-run picks up from the current +# row count. +MAX_ATTEMPTS=30 +attempt=1 +while true; do + CURRENT_ROWS=$(psql -U postgres -d postgres -t -c "SELECT COUNT(*) FROM $TABLE_NAME;" | tr -d ' ') + if [ "$CURRENT_ROWS" -ge "$TARGET_ROWS" ]; then + echo "Table reached $CURRENT_ROWS rows (>= $TARGET_ROWS)." + break + fi + if [ "$attempt" -gt "$MAX_ATTEMPTS" ]; then + echo "Error: exceeded $MAX_ATTEMPTS attempts, table has $CURRENT_ROWS / $TARGET_ROWS rows." + exit 1 + fi + ROWS_TO_INSERT=$((TARGET_ROWS - CURRENT_ROWS)) + echo "Attempt $attempt: inserting $ROWS_TO_INSERT additional rows..." + if NUM_RECORDS=$ROWS_TO_INSERT TABLE_SUFFIX="_${TARGET_ROWS}" ./target/release/$BINARY_NAME; then + echo "Attempt $attempt: succeeded." + else + echo "Attempt $attempt: failed (likely ZeroKMS transient), retrying after backoff..." + sleep 5 + fi + attempt=$((attempt + 1)) +done + +echo "Creating indexes..." +psql -U postgres -d postgres < sql/indexes/v3/${TABLE_NAME}_up.sql + +# ANALYZE for accurate functional-index selectivity stats — see the +# rationale on prepare:_table. +echo "Running ANALYZE..." +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" +""" + +[tasks."prepare:v3:string_encrypted"] +description = "Prepare string_encrypted_v3 table with target row count" +run = "mise run prepare:v3:_table string_encrypted_v3 encrypt_string_v3 $1" + +[tasks."prepare:v3:integer_encrypted"] +description = "Prepare integer_encrypted_v3 table with target row count" +run = "mise run prepare:v3:_table integer_encrypted_v3 encrypt_int_v3 $1" + +[tasks."prepare:v3:category_encrypted"] +description = "Prepare category_encrypted_v3_<size> table with ~250 low-cardinality categorical values" +run = "mise run prepare:v3:_table category_encrypted_v3 encrypt_category_v3 $1" + +[tasks."prepare:v3:combo_encrypted"] +description = "Prepare combo_encrypted_v3_<size> table with three encrypted columns (name + age + category)" +run = "mise run prepare:v3:_table combo_encrypted_v3 encrypt_combo_v3 $1" + +[tasks."prepare:v3:json_ste_vec_small"] +description = "Prepare json_ste_vec_small_encrypted_v3 table with target row count" +run = "mise run prepare:v3:_table json_ste_vec_small_encrypted_v3 encrypt_ste_vec_small_v3 $1" + [tasks."prepare:string_encrypted"] description = "Prepare string_encrypted table with target row count" run = "mise run prepare:_table string_encrypted encrypt_string $1" @@ -828,3 +968,187 @@ run = """ #!/usr/bin/env bash python3 report_ingest.py "$@" """ + +# --------------------------------------------------------------------------- +# EQL v3 benchmark tasks. Additive twins of the v2 tasks above — nothing in +# the v2 path changes. Requires `mise run setup-db-v3` (installs the eql_v3 +# schema + _v3 tables) on top of the usual setup-db. +# --------------------------------------------------------------------------- + +[tasks."bench:query:_run_v3"] +description = "Internal: Run one v3 query benchmark (prepare + criterion + result file)" +run = """ +#!/usr/bin/env bash +set -e + +PREPARE_TASK="$1" # e.g. prepare:v3:string_encrypted +BENCH_NAME="$2" # e.g. exact_v3 (criterion bench target) +GROUP_NAME="$3" # e.g. EXACT_V3 (criterion group dir) +TARGET_ROWS="$4" + +if [ -z "$PREPARE_TASK" ] || [ -z "$BENCH_NAME" ] || [ -z "$GROUP_NAME" ] || [ -z "$TARGET_ROWS" ]; then + echo "Error: PREPARE_TASK, BENCH_NAME, GROUP_NAME and TARGET_ROWS arguments required" + echo "Usage: mise run bench:query:_run_v3 <prepare_task> <bench_name> <group_name> <target_rows>" + exit 1 +fi + +if ! [[ "$TARGET_ROWS" =~ ^[0-9]+$ ]]; then + echo "Error: target row count must be a positive integer" + exit 1 +fi + +echo "Preparing table via $PREPARE_TASK with $TARGET_ROWS rows..." +mise run "$PREPARE_TASK" "$TARGET_ROWS" + +echo "Cleaning old benchmark data..." +rm -rf "target/criterion/data/main/$GROUP_NAME" "target/criterion/reports/$GROUP_NAME" + +echo "Running $BENCH_NAME query benchmark..." +mkdir -p results/query +OUTPUT_FILE="results/query/${BENCH_NAME}_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:query:exact:v3"] +description = "Run EXACT_V3 query benchmark (string_encrypted_v3, eql_v3.text_search)" +run = "mise run bench:query:_run_v3 prepare:v3:string_encrypted exact_v3 EXACT_V3 $1" + +[tasks."bench:query:match:v3"] +description = "Run MATCH_V3 query benchmark (bloom containment only — v3 removes LIKE)" +run = "mise run bench:query:_run_v3 prepare:v3:string_encrypted match_v3 MATCH_V3 $1" + +[tasks."bench:query:ore:v3"] +description = "Run ORE_V3 query benchmark (integer_encrypted_v3, eql_v3.int4_ord_ore)" +run = "mise run bench:query:_run_v3 prepare:v3:integer_encrypted ore_v3 ORE_V3 $1" + +[tasks."bench:query:group_by:v3"] +description = "Run GROUP_BY_V3 query benchmark (encrypted scenarios only — plaintext baselines stay in the v2 family)" +run = "mise run bench:query:_run_v3 prepare:v3:category_encrypted group_by_v3 GROUP_BY_V3 $1" + +[tasks."bench:query:combo:v3"] +description = "Run COMBO_V3 query benchmark (composite predicates on combo_encrypted_v3)" +run = "mise run bench:query:_run_v3 prepare:v3:combo_encrypted combo_v3 COMBO_V3 $1" + +[tasks."bench:query:json:v3"] +description = "Run JSON_V3 / ste_vec query benchmark (containment + selector scenarios)" +run = "mise run bench:query:_run_v3 prepare:v3:json_ste_vec_small json_v3 JSON_V3 $1" + +[tasks."bench:query:all:v3"] +description = "Run all v3 query benchmarks across row-count tiers (10k, 100k, 1M, 10M). Optional arg caps the largest tier." +run = """ +#!/usr/bin/env bash +set -e + +MAX_ROWS="${1:-}" + +if [ -n "$MAX_ROWS" ] && ! [[ "$MAX_ROWS" =~ ^[0-9]+$ ]]; then + echo "Error: max row count must be a positive integer" + echo "Usage: mise run bench:query:all:v3 [max_rows]" + exit 1 +fi + +ALL_ROW_COUNTS=(10000 100000 1000000 10000000) +ROW_COUNTS=() +for ROWS in "${ALL_ROW_COUNTS[@]}"; do + if [ -z "$MAX_ROWS" ] || [ "$ROWS" -le "$MAX_ROWS" ]; then + ROW_COUNTS+=("$ROWS") + fi +done + +if [ -z "${ROW_COUNTS[*]}" ]; then + echo "Error: max_rows=$MAX_ROWS excludes every tier (smallest is 10000)" + exit 1 +fi + +echo "========================================" +echo "Starting v3 benchmark suite" +echo "Row counts: ${ROW_COUNTS[*]}" +echo "Benchmarks: exact_v3, match_v3, ore_v3, group_by_v3, combo_v3, json_v3" +echo "========================================" +echo "" + +START_TIME=$(date +%s) + +for ROWS in "${ROW_COUNTS[@]}"; do + echo "========================================" + echo "Running v3 benchmarks with $ROWS rows" + echo "========================================" + echo "" + + echo "[1/6] Running EXACT_V3 benchmark with $ROWS rows..." + mise run bench:query:exact:v3 "$ROWS" + echo "" + + echo "[2/6] Running MATCH_V3 benchmark with $ROWS rows..." + mise run bench:query:match:v3 "$ROWS" + echo "" + + echo "[3/6] Running ORE_V3 benchmark with $ROWS rows..." + mise run bench:query:ore:v3 "$ROWS" + echo "" + + echo "[4/6] Running GROUP_BY_V3 benchmark with $ROWS rows..." + mise run bench:query:group_by:v3 "$ROWS" + echo "" + + echo "[5/6] Running COMBO_V3 benchmark with $ROWS rows..." + mise run bench:query:combo:v3 "$ROWS" + echo "" + + echo "[6/6] Running JSON_V3 benchmark with $ROWS rows..." + mise run bench:query:json:v3 "$ROWS" + echo "" + + echo "Completed v3 benchmarks for $ROWS rows" + echo "" +done + +END_TIME=$(date +%s) +ELAPSED=$((END_TIME - START_TIME)) +HOURS=$((ELAPSED / 3600)) +MINUTES=$(((ELAPSED % 3600) / 60)) +SECONDS=$((ELAPSED % 60)) + +echo "========================================" +echo "All v3 benchmarks complete!" +echo "Total time: ${HOURS}h ${MINUTES}m ${SECONDS}s" +echo "Results saved in results/query/" +echo "========================================" +""" + +[tasks."bench:ingest:encrypt_int:v3"] +description = "Run encrypt_int_v3 ingest benchmark (encrypt + from_v2 convert + insert) and combine results" +run = "mise run bench:ingest:_run encrypt_int_v3 integer_encrypted_v3" + +[tasks."bench:ingest:encrypt_string:v3"] +description = "Run encrypt_string_v3 ingest benchmark (encrypt + from_v2 convert + insert; note the extra ORE term vs v2 — see the bin docs)" +run = "mise run bench:ingest:_run encrypt_string_v3 string_encrypted_v3" + +[tasks."bench:ingest:json-ste-vec-small:v3"] +description = "Run encrypt_ste_vec_small_v3 (SteVec → eql_v3.json) ingest benchmark" +run = "mise run bench:ingest:_run encrypt_ste_vec_small_v3 json_ste_vec_small_encrypted_v3 $1" + +[tasks."bench:ingest:convert-overhead"] +description = "Run the conversion-overhead scenario: identical encrypt workload with and without the from_v2 v2→v3 conversion, no database writes. The delta between the two families is pure conversion cost." +depends = ["bench:build"] +run = """ +#!/usr/bin/env bash +set -e +[ -f .env ] && set -a && source .env && set +a + +NUM_RECORDS="${1:-500,1000,10000}" + +for MODE in encrypt_only encrypt_convert; do + BENCH_NAME="convert_overhead_${MODE}" + echo "Running $BENCH_NAME benchmark..." + mise x -- hyperfine --export-json "target/${BENCH_NAME}_hyperfine.json" \ + -L num_records "${NUM_RECORDS}" --runs 2 \ + "NUM_RECORDS={num_records} CONVERT_MODE=${MODE} ./target/release/convert_overhead" + + echo "Combining results..." + ./target/release/combine_benchmark "$BENCH_NAME" + echo "Results written to results/ingest/${BENCH_NAME}_combined.json" +done +""" From 0dfaa739b8afacb483bff712924b52c839dbdabf Mon Sep 17 00:00:00 2001 From: James Sadler <james@cipherstash.com> Date: Thu, 2 Jul 2026 17:00:00 +1000 Subject: [PATCH 6/9] feat(report): thread the EQL version axis through the Python reporters report_benchmarks.py: - discover ingest families by globbing results/ingest/*_combined.json (picks up encrypt_*_v3 and convert_overhead_* alongside the hardcoded v2 list, which is retired) - parse sidecar version (absent = 2) into ScenarioMetadata; derive the query-family version from the _V3 type suffix - overview table gains an EQL column; families sort so each _V3 twin sits next to its v2 counterpart for side-by-side comparison - static SQL/description maps and table/index-path resolution extended for the _V3 families (index scripts under sql/indexes/v3/) report_ingest.py: adds an eql version column (bench names ending _v3, plus convert_overhead_encrypt_convert, are v3; everything else including pre-existing files is v2). find_slow_queries.py needs no changes (bench-id keyed). Verified: both reporters render the existing v2-only results unchanged, and a mixed results dir (real v2 + synthetic v3 fixtures) renders both versions side by side. --- report_benchmarks.py | 378 +++++++++++++++++++++++++++++++++++++++---- report_ingest.py | 18 ++- 2 files changed, 361 insertions(+), 35 deletions(-) diff --git a/report_benchmarks.py b/report_benchmarks.py index 1e8c543..a9b8443 100755 --- a/report_benchmarks.py +++ b/report_benchmarks.py @@ -36,15 +36,23 @@ class IngestResult: avg_memory_mb: float +def eql_version_for(query_type: str) -> int: + """EQL version axis for a query-type family: the `_V3` families run + against the eql_v3 schema; everything else (including pre-version + result files) is v2.""" + return 3 if query_type.endswith("_V3") else 2 + + @dataclass class QueryResult: """Results from a query benchmark""" - query_type: str # e.g., "EXACT", "MATCH", "ORE" + query_type: str # e.g., "EXACT", "MATCH", "ORE", "EXACT_V3" query_name: str # e.g., "eql_cast", "range_gt_10" row_count: int decrypt: bool mean_ns: float median_ns: float + version: int = 2 # EQL version axis (2 | 3) @dataclass @@ -67,6 +75,9 @@ class ScenarioMetadata: # sidecar predates the actual-rows capture and we're falling back to # the planner estimate from `explain[0]["Plan"]["Plan Rows"]`. rows_returned: Optional[int] = None + # EQL version axis (2 | 3). Sidecars written before the axis existed + # carry no `version` field and are v2. + version: int = 2 class BenchmarkReporter: @@ -82,19 +93,31 @@ def __init__(self, results_dir: Path, output_file: Path, sql_dir: Optional[Path] self.index_cache: Dict[str, str] = {} # Cache for index SQL def load_ingest_results(self): - """Load ingest benchmark results""" + """Load ingest benchmark results. + + Discovers every `*_combined.json` under results/ingest (rather than + a hardcoded family list) so the `encrypt_*_v3` and + `convert_overhead_*` families are picked up alongside the v2 ones. + The bench type is the filename minus a leading `encrypt_` and the + trailing `_combined` — v2 names are unchanged (`int`, `string`, …) + and the v3 twins sort adjacent to them (`int_v3`, `string_v3`, …) + for side-by-side reading. + """ ingest_dir = self.results_dir / "ingest" - - for bench_type in ["int", "json_small", "json_large", "string", "ste_vec_small", "ste_vec_large"]: - file_path = ingest_dir / f"encrypt_{bench_type}_combined.json" - - if not file_path.exists(): - print(f"Warning: {file_path} not found, skipping", file=sys.stderr) - continue - + if not ingest_dir.is_dir(): + print(f"Warning: {ingest_dir} not found, skipping ingest results", file=sys.stderr) + return + + for file_path in sorted(ingest_dir.glob("*_combined.json")): + bench_type = file_path.stem + if bench_type.endswith("_combined"): + bench_type = bench_type[:-len("_combined")] + if bench_type.startswith("encrypt_"): + bench_type = bench_type[len("encrypt_"):] + with open(file_path) as f: data = json.load(f) - + for result in data.get("results", []): self.ingest_results.append(IngestResult( bench_type=bench_type, @@ -161,7 +184,8 @@ def load_query_results(self): row_count=row_count, decrypt=decrypt, mean_ns=mean_ns, - median_ns=median_ns + median_ns=median_ns, + version=eql_version_for(query_type) )) def load_query_metadata(self): @@ -197,6 +221,8 @@ def load_query_metadata(self): explain=s.get("explain", []), indexes_used=s.get("indexes_used", []), rows_returned=s.get("rows_returned"), + # Absent field = pre-version sidecar = v2. + version=s.get("version", 2), ) def format_time(self, ns: float, include_indicator: bool = True) -> str: @@ -386,6 +412,123 @@ def get_query_sql_and_param(self, query_type: str, query_name: str) -> Tuple[str "ORDER BY <ore_extractor>(value -> '<selector-hash>'::text) LIMIT 10", "" ) + }, + # --- EQL v3 twins. Probe parameters are STORED-shape payloads + # converted with from_v2 (no v3 scalar query wire shape exists); + # the SQL compares via the eql_v3.*_term extractors or the + # inlinable typed operators. + "EXACT_V3": { + "eql_cast": ( + "SELECT id, value::jsonb FROM {TABLE} " + "WHERE value = $1::eql_v3.text_search LIMIT 1", + "<sampled row plaintext>" + ), + "eql_hash": ( + "SELECT id, value::jsonb FROM {TABLE} " + "WHERE eql_v3.eq_term(value) = eql_v3.eq_term($1::eql_v3.text_search) LIMIT 1", + "<sampled row plaintext>" + ) + }, + "MATCH_V3": { + "eql_bloom": ( + "SELECT id, value::jsonb FROM {TABLE} " + "WHERE eql_v3.match_term(value) @> eql_v3.match_term($1::eql_v3.text_search) LIMIT 10", + "Johnson" + ), + "eql_bloom_bare": ( + "SELECT id, value::jsonb FROM {TABLE} " + "WHERE value @> $1::eql_v3.text_search LIMIT 10", + "Johnson" + ) + }, + "ORE_V3": { + "range_gt_10": ( + "SELECT id, value::jsonb FROM {TABLE} " + "WHERE value > $1::eql_v3.int4_ord_ore LIMIT 10", + "5000" + ), + "range_gt_100": ( + "SELECT id, value::jsonb FROM {TABLE} " + "WHERE value > $1::eql_v3.int4_ord_ore LIMIT 100", + "5000" + ), + "range_lt_10": ( + "SELECT id, value::jsonb FROM {TABLE} " + "WHERE value < $1::eql_v3.int4_ord_ore LIMIT 10", + "5000" + ), + "range_lt_100": ( + "SELECT id, value::jsonb FROM {TABLE} " + "WHERE value < $1::eql_v3.int4_ord_ore LIMIT 100", + "5000" + ), + "range_lt_ordered_10": ( + "SELECT id, value::jsonb FROM {TABLE} " + "WHERE value < $1::eql_v3.int4_ord_ore " + "ORDER BY eql_v3.ord_term(value) LIMIT 10", + "5000" + ) + }, + "GROUP_BY_V3": { + "low_cardinality_groups_encrypted": ( + "SELECT count(*) FROM " + "(SELECT 1 FROM {TABLE} GROUP BY eql_v3.eq_term(value)) g", + "" + ), + "top_n_groups_encrypted": ( + "SELECT eql_v3.eq_term(value), count(*) FROM {TABLE} " + "GROUP BY 1 ORDER BY count(*) DESC LIMIT 10", + "" + ) + }, + "COMBO_V3": { + "bloom_ore_order_limit": ( + "SELECT id FROM {TABLE} " + "WHERE eql_v3.match_term(name) @> eql_v3.match_term($1::eql_v3.text_match) " + "ORDER BY eql_v3.ord_term(age) LIMIT 10", + "Bob" + ), + "filtered_group_by": ( + "SELECT eql_v3.eq_term(category), count(*) FROM {TABLE} " + "WHERE eql_v3.match_term(name) @> eql_v3.match_term($1::eql_v3.text_match) " + "GROUP BY 1", + "Bob" + ), + "top_n_filtered_group_by": ( + "SELECT eql_v3.eq_term(category), count(*) FROM {TABLE} " + "WHERE eql_v3.match_term(name) @> eql_v3.match_term($1::eql_v3.text_match) " + "GROUP BY 1 ORDER BY count(*) DESC LIMIT 10", + "Bob" + ) + }, + "JSON_V3": { + "contains/functional": ( + "SELECT id FROM {TABLE} " + "WHERE value @> $1::jsonb::eql_v3.jsonb_query LIMIT 10", + "<sampled row via eql_v3.to_ste_vec_query>" + ), + "field_eq/bare": ( + "SELECT id FROM {TABLE} " + "WHERE (value -> '<selector-hash>'::text) = $1::jsonb::eql_v3.jsonb_entry " + "LIMIT 10", + "<sampled sv entry as jsonb>" + ), + "field_eq/extractor": ( + "SELECT id FROM {TABLE} " + "WHERE value @> $1::jsonb::eql_v3.jsonb_query LIMIT 10", + "{\"sv\":[{\"s\":\"<selector-hash>\",\"hm\":\"<hmac>\"}]}" + ), + "field_eq/functional": ( + "SELECT id FROM {TABLE} " + "WHERE eql_v3.eq_term(value -> '<selector-hash>'::text) " + "= eql_v3.eq_term($1::jsonb::eql_v3.jsonb_entry) LIMIT 10", + "<sampled sv entry as jsonb>" + ), + "field_order/functional": ( + "SELECT id FROM {TABLE} " + "ORDER BY eql_v3.ore_cllw(value -> '<selector-hash>'::text) LIMIT 10", + "" + ) } } @@ -665,6 +808,139 @@ def get_query_description(self, query_type: str, query_name: str) -> Tuple[str, "LIMIT (no Sort node). When absent (older bench run / index not yet " "rebuilt), falls back to Seq Scan + Top-N sort." ) + }, + # --- EQL v3 twins --- + "EXACT_V3": { + "eql_cast": ( + "EQL v3 exact match using the inlinable `=` operator", + "Table: `string_encrypted_v3_{rows}` (column `eql_v3.text_search`). " + "Index: `hash (eql_v3.eq_term(value))`. The typed `=` inlines to " + "`eql_v3.eq_term(a) = eql_v3.eq_term(b)` and engages the index." + ), + "eql_hash": ( + "EQL v3 exact match using the `eql_v3.eq_term` extractor", + "Table: `string_encrypted_v3_{rows}` (column `eql_v3.text_search`). " + "Index: `hash (eql_v3.eq_term(value))` — the explicit extractor form " + "of `eql_cast`, structurally identical after operator inlining." + ) + }, + "MATCH_V3": { + "eql_bloom": ( + "EQL v3 bloom-filter token containment via the `eql_v3.match_term` extractor", + "Table: `string_encrypted_v3_{rows}` (column `eql_v3.text_search`). " + "Index: `GIN (eql_v3.match_term(value))`. v3 removes LIKE/ILIKE — the " + "two v2 `eql_cast_*` LIKE scenarios have no v3 twin; bloom containment " + "is the only encrypted text-matching surface." + ), + "eql_bloom_bare": ( + "EQL v3 bloom containment via the bare typed `@>` operator", + "Table: `string_encrypted_v3_{rows}` (column `eql_v3.text_search`). " + "The ORM-shaped form: `value @> $1::eql_v3.text_search` inlines to the " + "same match_term expression as `eql_bloom` and should engage the same " + "GIN — the scenario prices exactly that inlining." + ) + }, + "ORE_V3": { + "range_gt_10": ( + "EQL v3 range query (greater than) returning 10 results", + "Table: `integer_encrypted_v3_{rows}` (column `eql_v3.int4_ord_ore`). " + "Index: `btree (eql_v3.ord_term(value))`. Bare-form range operators " + "inline to ord_term comparisons and match the index; planner usage is " + "a selectivity question exactly as in the v2 family." + ), + "range_gt_100": ( + "EQL v3 range query (greater than) returning 100 results", + "Table: `integer_encrypted_v3_{rows}` (column `eql_v3.int4_ord_ore`). " + "Index: `btree (eql_v3.ord_term(value))`." + ), + "range_lt_10": ( + "EQL v3 range query (less than) returning 10 results", + "Table: `integer_encrypted_v3_{rows}` (column `eql_v3.int4_ord_ore`). " + "Index: `btree (eql_v3.ord_term(value))`." + ), + "range_lt_100": ( + "EQL v3 range query (less than) returning 100 results", + "Table: `integer_encrypted_v3_{rows}` (column `eql_v3.int4_ord_ore`). " + "Index: `btree (eql_v3.ord_term(value))`." + ), + "range_lt_ordered_10": ( + "EQL v3 ordered range query (extractor ORDER BY)", + "Table: `integer_encrypted_v3_{rows}` (column `eql_v3.int4_ord_ore`). " + "Index: `btree (eql_v3.ord_term(value))`. `ORDER BY eql_v3.ord_term(value)` " + "matches the index expression, so rows stream out already sorted — no " + "Sort node. Same sort-key rule as v2." + ) + }, + "GROUP_BY_V3": { + "low_cardinality_groups_encrypted": ( + "EQL v3 low-cardinality GROUP BY (~250 buckets) on `eql_v3.eq_term(value)`", + "Table: `category_encrypted_v3_{rows}` (column `eql_v3.text_eq`, " + "same CAT_001..CAT_250 distribution as the v2 family). HashAggregate " + "keyed on the small deterministic eq_term. Compare against the shared " + "`low_cardinality_groups_plaintext` baseline in the v2 GROUP_BY family " + "— the plaintext tables are version-independent and not re-run for v3." + ), + "top_n_groups_encrypted": ( + "EQL v3 dashboard analytic: top 10 categories by frequency", + "Table: `category_encrypted_v3_{rows}` (column `eql_v3.text_eq`). " + "Same shape as the v2 scenario with `eql_v3.eq_term` as the group key. " + "Plaintext baseline lives in the v2 GROUP_BY family." + ) + }, + "COMBO_V3": { + "bloom_ore_order_limit": ( + "EQL v3 composite: bloom containment filter + ORE ORDER BY + LIMIT", + "Table: `combo_encrypted_v3_{rows}` (name `eql_v3.text_match`, age " + "`eql_v3.int4_ord_ore`, category `eql_v3.text_eq`). The v2 LIKE filter " + "becomes `eql_v3.match_term(name) @> eql_v3.match_term($1)` (v3 removes " + "LIKE); ORDER BY uses `eql_v3.ord_term(age)`." + ), + "filtered_group_by": ( + "EQL v3 composite: bloom containment filter + GROUP BY category", + "Table: `combo_encrypted_v3_{rows}`. Bloom GIN narrows the input; " + "HashAggregate groups by `eql_v3.eq_term(category)`." + ), + "top_n_filtered_group_by": ( + "EQL v3 dashboard analytic: top 10 categories for a bloom-filtered set", + "Table: `combo_encrypted_v3_{rows}`. Same as filtered_group_by plus " + "`ORDER BY count(*) DESC LIMIT 10`." + ) + }, + "JSON_V3": { + "contains/functional": ( + "EQL v3 whole-document containment via the typed `@>` + jsonb_query needle", + "Table: `json_ste_vec_small_encrypted_v3_{rows}` (column `eql_v3.json`). " + "Index: `GIN ((eql_v3.to_ste_vec_query(value))::jsonb jsonb_path_ops)`. " + "The typed `@>` inlines to a native jsonb `@>` over the same expression. " + "Replaces the v2 `eql_v2.jsonb_array(...) @> ...` recipe; the needle is " + "the sampled row's own normalized query shape." + ), + "field_eq/bare": ( + "EQL v3 field-level equality via `value -> 'sel' = $1` (inlinable)", + "Table: `json_ste_vec_small_encrypted_v3_{rows}`. Unlike v2 (plpgsql " + "`->`, unmatchable by the planner), the v3 `->` and `=` are inlinable — " + "the predicate reduces to eq_term comparisons and can engage the " + "per-selector `btree (eql_v3.eq_term(value -> '<sel>'::text))` index " + "the bench builds at startup." + ), + "field_eq/extractor": ( + "EQL v3 field-level equality via the jsonb_query containment needle", + "Table: `json_ste_vec_small_encrypted_v3_{rows}`. Single-field needle " + "`{\"sv\":[{s,hm}]}` through the same to_ste_vec_query GIN as " + "contains/functional — one index covers every selector." + ), + "field_eq/functional": ( + "EQL v3 field-level equality via the explicit `eql_v3.eq_term` form", + "Table: `json_ste_vec_small_encrypted_v3_{rows}`. The explicit extractor " + "spelling of field_eq/bare; engages the same per-selector btree." + ), + "field_order/functional": ( + "EQL v3 field-level ORDER BY via `eql_v3.ore_cllw` on the extracted entry", + "Table: `json_ste_vec_small_encrypted_v3_{rows}`. Index: per-selector " + "`btree (eql_v3.ore_cllw(value -> '<sel>'::text))` built at bench " + "startup (the `eql_v3.ore_cllw_ops` opclass is DEFAULT for the type). " + "In v3 the only per-entry orderable tag is `oc` — no ob/op variants." + ) } } @@ -716,14 +992,19 @@ def get_table_indexes(self, table_name: str) -> Optional[str]: if table_name in self.index_cache: return self.index_cache[table_name] + # v3 tables keep their index scripts under sql/indexes/v3/. + index_dir = self.sql_dir / "indexes" + if "_v3" in table_name: + index_dir = index_dir / "v3" + # Try to find the index file - index_file = self.sql_dir / "indexes" / f"{table_name}_up.sql" - + index_file = index_dir / f"{table_name}_up.sql" + if not index_file.exists(): # Try without row count suffix (base table) # e.g., string_encrypted_10000 -> string_encrypted base_table = re.sub(r'_(\d+)$', '', table_name) - index_file = self.sql_dir / "indexes" / f"{base_table}_up.sql" + index_file = index_dir / f"{base_table}_up.sql" if not index_file.exists(): return None @@ -810,6 +1091,27 @@ def _write_ingest_section(self, f): "string": "Tests insertion of encrypted string values.", "ste_vec_small": "Tests insertion of small JSON objects with SteVec (searchable encrypted vector) indexing.", "ste_vec_large": "Tests insertion of large JSON objects with SteVec (searchable encrypted vector) indexing.", + "int_v3": "EQL v3 twin of `int`: same encrypt workload plus a from_v2 " + "v2→v3 conversion per payload, inserting into " + "`integer_encrypted_v3` (eql_v3.int4_ord_ore).", + "string_v3": "EQL v3 twin of `string`, inserting into `string_encrypted_v3` " + "(eql_v3.text_search). NOTE: not directly comparable to " + "`string` — text_search requires the `ob` term, so this " + "workload encrypts an additional ORE index that v2's " + "encrypt_string does not.", + "ste_vec_small_v3": "EQL v3 twin of `ste_vec_small`: same SteVec encrypt " + "workload plus the from_v2 document conversion, " + "inserting into `json_ste_vec_small_encrypted_v3` " + "(eql_v3.json).", + "convert_overhead_encrypt_only": "Conversion-overhead baseline: encrypt the " + "string_v3 workload (hm+bf+ob) with NO " + "conversion and NO database writes.", + "convert_overhead_encrypt_convert": "Conversion-overhead treatment: identical " + "workload to `convert_overhead_encrypt_only` " + "plus a from_v2 v2→v3 conversion per payload " + "(still no database writes). The delta " + "between the two families is pure from_v2 " + "cost.", } if bench_type in descriptions: @@ -999,13 +1301,17 @@ def _write_query_overview(self, f, scenario_pages: Dict[str, str]): f.write("## Query Performance\n\n") f.write("Per-query-type detail is broken out into separate pages — click into a " "scenario family for the SQL, per-tier timings, the indexes the planner " - "picked, and the EXPLAIN plan tree.\n\n") - f.write("| Query Type | Scenarios | Tiers | Largest-tier median (no decrypt) | Detail |\n") - f.write("|-|-|-|-|-|\n") + "picked, and the EXPLAIN plan tree. The EQL column is the version axis: " + "`_V3` families run the same scenario intents against the `eql_v3` " + "schema, and sort next to their v2 counterparts for side-by-side " + "comparison.\n\n") + f.write("| Query Type | EQL | Scenarios | Tiers | Largest-tier median (no decrypt) | Detail |\n") + f.write("|-|-|-|-|-|-|\n") for qt in sorted(scenario_pages.keys()): type_results = [r for r in self.query_results if r.query_type == qt] if not type_results: continue + version = eql_version_for(qt) scenarios = sorted(set(r.query_name for r in type_results)) tiers = sorted(set(r.row_count for r in type_results)) tiers_str = ", ".join(f"{t:,}" for t in tiers) @@ -1023,7 +1329,7 @@ def _write_query_overview(self, f, scenario_pages: Dict[str, str]): else: med_str = "—" page = scenario_pages[qt] - f.write(f"| {qt} | {scenarios_str} | {tiers_str} | {med_str} | [open]({page}) |\n") + f.write(f"| {qt} | v{version} | {scenarios_str} | {tiers_str} | {med_str} | [open]({page}) |\n") f.write("\n") def _write_query_type_page_content(self, f, query_type: str): @@ -1068,26 +1374,34 @@ def _write_query_subsection(self, f, query_type: str, query_name: str, # Add index information for one of the row counts (they all use same indexes) if results: - # Determine table name based on query type / scenario. + # Determine table name based on query type / scenario. The _V3 + # families map to the same base tables with a `_v3` infix + # (e.g. string_encrypted_v3_10000). sample_row_count = results[0].row_count - if query_type == "GROUP_BY" and query_name.endswith("_plaintext"): + base_type = query_type + v3_infix = "" + if query_type.endswith("_V3"): + base_type = query_type[:-len("_V3")] + v3_infix = "_v3" + if base_type == "GROUP_BY" and query_name.endswith("_plaintext"): # Plaintext baselines run against a plain TEXT column — no # functional EQL indexes; lookup will return None and the - # Indexes block will be skipped. + # Indexes block will be skipped. (v2 only — the v3 family + # has no plaintext scenarios.) table_name = f"category_plaintext_{sample_row_count}" - elif query_type == "GROUP_BY": + elif base_type == "GROUP_BY": # Encrypted GROUP BY scenarios run against the categorical # 250-bucket table family. - table_name = f"category_encrypted_{sample_row_count}" - elif query_type in ["EXACT", "MATCH"]: + table_name = f"category_encrypted{v3_infix}_{sample_row_count}" + elif base_type in ["EXACT", "MATCH"]: # String-encrypted scenarios. - table_name = f"string_encrypted_{sample_row_count}" - elif query_type == "ORE": - table_name = f"integer_encrypted_{sample_row_count}" - elif query_type == "JSON": - table_name = f"json_ste_vec_small_encrypted_{sample_row_count}" - elif query_type == "COMBO": - table_name = f"combo_encrypted_{sample_row_count}" + table_name = f"string_encrypted{v3_infix}_{sample_row_count}" + elif base_type == "ORE": + table_name = f"integer_encrypted{v3_infix}_{sample_row_count}" + elif base_type == "JSON": + table_name = f"json_ste_vec_small_encrypted{v3_infix}_{sample_row_count}" + elif base_type == "COMBO": + table_name = f"combo_encrypted{v3_infix}_{sample_row_count}" else: table_name = "" diff --git a/report_ingest.py b/report_ingest.py index 98c53fe..169c24f 100644 --- a/report_ingest.py +++ b/report_ingest.py @@ -54,6 +54,17 @@ def format_age(mtime: float, now: float) -> str: return f"{delta // (86400 * 7)}w ago" +def bench_version(bench: str) -> int: + """EQL version axis for an ingest bench name. `_v3`-suffixed benches + write v3 payloads; `convert_overhead_encrypt_convert` performs the + v2→v3 conversion (its `encrypt_only` twin is the v2-shaped baseline). + Everything else — including all pre-existing result files — is v2. + """ + if bench.endswith("_v3") or bench == "convert_overhead_encrypt_convert": + return 3 + return 2 + + def collect_ingest(results_dir, prefix): """Walk `results_dir/ingest/*_combined.json` and flatten the inner results. @@ -119,11 +130,12 @@ def main(): print() now = time.time() - print(f"{'throughput':>16} {'records':>11} {'time':>9} {'last run':>9} bench") - print(f"{'-' * 16} {'-' * 11} {'-' * 9} {'-' * 9} {'-' * 40}") + print(f"{'throughput':>16} {'records':>11} {'time':>9} {'last run':>9} {'eql':>3} bench") + print(f"{'-' * 16} {'-' * 11} {'-' * 9} {'-' * 9} {'-' * 3} {'-' * 40}") for throughput, records, total_time, mtime, bench in rows: print(f"{format_throughput(throughput)} {format_records(records)} " - f"{format_seconds(total_time)} {format_age(mtime, now):>9} {bench}") + f"{format_seconds(total_time)} {format_age(mtime, now):>9} " + f"{'v' + str(bench_version(bench)):>3} {bench}") if __name__ == "__main__": From bcbfce8224774a06dedf94e143745fac2dbf39bc Mon Sep 17 00:00:00 2001 From: James Sadler <james@cipherstash.com> Date: Thu, 2 Jul 2026 17:00:35 +1000 Subject: [PATCH 7/9] docs(readme): document the EQL v3 scenario suite and version axis --- README.md | 80 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/README.md b/README.md index 9a6c38e..c301852 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,86 @@ Three categories of queries are benchmarked: Each query is tested with and without decryption of results. +## 🆕 EQL v3 Scenarios + +The suite carries an **EQL version axis**: every v2 scenario family has an +additive v3 twin that runs the same scenario intent against the `eql_v3` +schema (per-scalar-per-capability jsonb domains, term extractor functions, +no generic envelope type). Nothing in the v2 path changes — v2 filenames, +tables and tasks are untouched. + +### How v3 payloads are produced + +cipherstash-client 0.38 emits EQL v2.3 wire payloads only. The v3 paths +encrypt through the unchanged v2 pipeline and convert each STORED payload +with [`eql-bindings`](https://github.com/cipherstash/encrypt-query-language)'s +`from_v2` (currently a path dependency on the EQL repo checkout). Scalar +QUERY conversion is unsupported upstream (no v3 scalar query wire shape +exists), so the v3 query benches encrypt each probe value as a storage +payload, convert it, and compare via the `eql_v3.*_term` extractors (or the +inlinable typed operators, which reduce to the same expressions), e.g.: + +```sql +WHERE eql_v3.eq_term(value) = eql_v3.eq_term($1::eql_v3.text_search) +``` + +### Table / domain mapping + +| v2 table | v3 twin | Domain | Notes | +|---|---|---|---| +| `string_encrypted` | `string_encrypted_v3` | `eql_v3.text_search` | Only single-column v3 domain serving both EXACT (hm) and MATCH (bf); requires an extra `ob` ORE term the v2 string ingest doesn't encrypt — `encrypt_string_v3` throughput is therefore not directly comparable to `encrypt_string`. | +| `integer_encrypted` | `integer_encrypted_v3` | `eql_v3.int4_ord_ore` | v2 encrypts `i32` (int4). | +| `category_encrypted` | `category_encrypted_v3` | `eql_v3.text_eq` | | +| `combo_encrypted` | `combo_encrypted_v3` | `text_match` / `int4_ord_ore` / `text_eq` | Per-column capability match. | +| `json_ste_vec_small_encrypted` | `json_ste_vec_small_encrypted_v3` | `eql_v3.json` | SteVec document domain. | +| plaintext baselines | *(shared)* | — | Version-independent; not duplicated. | + +### Scenario changes vs v2 + +- **MATCH**: v3 removes `LIKE` / `ILIKE` — the two v2 `eql_cast_*` LIKE + scenarios have no twin. Bloom containment (`@>`) is the only encrypted + text-matching surface; the v3 bench adds an `eql_bloom_bare` scenario to + price the typed-operator inlining. +- **GROUP BY**: v3 runs encrypted scenarios only; compare against the + shared plaintext baselines in the v2 family. +- **JSON**: containment uses the canonical v3 recipe + (`value @> $1::eql_v3.jsonb_query` over a + `GIN ((eql_v3.to_ste_vec_query(value))::jsonb jsonb_path_ops)` index); + `field_eq/bare` becomes index-capable (the v3 `->` is inlinable SQL, + unlike v2's plpgsql). +- **ORE (OPE)**: the `eql_v3.*_ord_ope` domains (OPE-CLLW ordering, wire + key `op`) are scaffolded but **disabled** — cipherstash-client 0.38.0 + does not emit `op` (CIP-3280 unreleased). See the TODOs in + `sql/schema-v3.sql` and `benches/ore_v3.rs`. +- A dedicated **conversion-overhead** ingest family + (`convert_overhead_encrypt_only` vs `convert_overhead_encrypt_convert`) + quantifies the pure `from_v2` cost: identical encrypt workloads, no + database writes, delta = conversion. + +### Version axis in results and reports + +v3 result files carry a `_v3` family prefix (`exact_v3_rows_10000.json`, +`exact_v3_metadata_10000.json`) and every sidecar scenario records +`"version": 3` (absent / `2` = v2, so old files parse unchanged). The +report generators group by version and sort each `_V3` family next to its +v2 counterpart for side-by-side comparison. + +### Running the v3 suite + +```bash +# Build the v3 SQL installer in the EQL repo (no released artifact yet): +# (cd ../encrypt-query-language && mise run build) +mise run setup-db-v3 # install eql_v3 + create the _v3 tables + +# Query benches (per family / tier, or the full sweep) +mise run bench:query:exact:v3 10000 +mise run bench:query:all:v3 1000000 + +# Ingest benches + the conversion-overhead scenario +mise run bench:ingest:encrypt_string:v3 +mise run bench:ingest:convert-overhead +``` + ## 🚀 Running Benchmarks ### Prerequisites From 0e9ff028cce424b82eba70f312bd8bb7c798d47e Mon Sep 17 00:00:00 2001 From: James Sadler <james@cipherstash.com> Date: Thu, 2 Jul 2026 17:16:55 +1000 Subject: [PATCH 8/9] fix: address code review feedback on the v3 bench suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - v3_scalar_to_v2_envelope now fails closed on non-v3 payloads (a v2 ct payload also carries c + i and would silently re-wrap) — guard plus a failing-first test. - group_by drain logic (both v2 and v3 twins, kept in sync) discriminates scenarios by column count instead of row count: a top-N result containing exactly one group would misdecode the group-key column as i64 under the row-count heuristic. - report_benchmarks.py overview prefers the sidecar-recorded version (source of truth) when a family's sidecars agree, falling back to the name-derived QueryResult version — both parsed fields are now read. - README project-structure tree covers the v3 files; Cargo.toml gets its trailing newline. Not changed, with reasons: the eql-bindings path dependency stays (the plan mandates consuming the unreleased crate via path dep; the TODO(crates.io 0.2.0) marker already documents the swap); encrypt_ste_vec_small_gin needs no ingest description (its task exports *_gin_hyperfine.json, never *_combined.json, so the glob cannot surface it); the six-bench scaffolding duplication mirrors the established v2 idiom deliberately — a shared-scaffold refactor spans both generations and is follow-up work. --- .gitignore | 1 + Cargo.toml | 2 +- README.md | 12 +++++++++--- benches/group_by.rs | 21 +++++++++++++-------- benches/group_by_v3.rs | 20 +++++++++++++------- report_benchmarks.py | 11 ++++++++++- src/lib.rs | 21 +++++++++++++++++++++ 7 files changed, 68 insertions(+), 20 deletions(-) diff --git a/.gitignore b/.gitignore index b1b15ce..dec4911 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ .DS_Store .env *.pem +__pycache__/ diff --git a/Cargo.toml b/Cargo.toml index 2807662..675be24 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -155,4 +155,4 @@ harness = false [[bench]] name = "combo_v3" -harness = false \ No newline at end of file +harness = false diff --git a/README.md b/README.md index c301852..23f3d44 100644 --- a/README.md +++ b/README.md @@ -323,16 +323,22 @@ ore-benches/ ├── benches/ # Criterion benchmark definitions │ ├── exact.rs # EXACT query benchmarks │ ├── match.rs # MATCH query benchmarks -│ └── ore.rs # ORE range query benchmarks +│ ├── ore.rs # ORE range query benchmarks +│ └── *_v3.rs # EQL v3 twins (exact_v3, match_v3, ore_v3, +│ # group_by_v3, combo_v3, json_v3) ├── src/ │ ├── bin/ # Binary utilities │ │ ├── encrypt_int.rs │ │ ├── encrypt_string.rs +│ │ ├── encrypt_*_v3.rs # EQL v3 ingest twins +│ │ ├── convert_overhead.rs # from_v2 conversion-cost scenario │ │ └── combine_benchmark.rs -│ └── lib.rs # Shared benchmark code +│ └── lib.rs # Shared benchmark code (incl. the v3 module) ├── sql/ -│ ├── schema.sql # Database schema +│ ├── schema.sql # Database schema (EQL v2 tables) +│ ├── schema-v3.sql # EQL v3 twin tables (eql_v3 domains) │ └── indexes/ # Index creation scripts +│ └── v3/ # EQL v3 functional index scripts ├── results/ # Benchmark results (JSON) │ ├── ingest/ # Ingest throughput results │ └── query/ # Query performance results diff --git a/benches/group_by.rs b/benches/group_by.rs index 534dd10..3174937 100644 --- a/benches/group_by.rs +++ b/benches/group_by.rs @@ -150,14 +150,19 @@ fn criterion_benchmark(c: &mut Criterion) { b.to_async(&rt).iter(|| async { let rows = bench_assert(sqlx::query(&query_str).fetch_all(&pool).await, &scenario_id); - // Drain results to force aggregation to materialise. The - // count-wrapped scenarios return a single i64; the top-N - // scenarios return up to 10 (group-key bytes, count) rows - // and we just sum the counts. - if rows.len() == 1 { - black_box(rows[0].get::<i64, _>(0)); - } else { - black_box(rows.iter().map(|r| r.get::<i64, _>(1)).sum::<i64>()); + // Drain results to force aggregation to materialise. + // Discriminate by column count, not row count: the + // count-wrapped scenarios return one 1-column i64 row, the + // top-N scenarios return (group key, count) 2-column rows — + // a top-N result that happened to contain exactly one group + // would misdecode under a row-count heuristic. + match rows.first() { + Some(first) if first.columns().len() == 1 => { + black_box(first.get::<i64, _>(0)); + } + _ => { + black_box(rows.iter().map(|r| r.get::<i64, _>(1)).sum::<i64>()); + } } }) }); diff --git a/benches/group_by_v3.rs b/benches/group_by_v3.rs index d38bcb2..18c2927 100644 --- a/benches/group_by_v3.rs +++ b/benches/group_by_v3.rs @@ -113,13 +113,19 @@ fn criterion_benchmark(c: &mut Criterion) { b.to_async(&rt).iter(|| async { let rows = bench_assert(sqlx::query(&query_str).fetch_all(&pool).await, &scenario_id); - // Drain results to force aggregation to materialise — the - // count-wrapped scenario returns a single i64; top-N returns - // up to 10 (group key, count) rows. - if rows.len() == 1 { - black_box(rows[0].get::<i64, _>(0)); - } else { - black_box(rows.iter().map(|r| r.get::<i64, _>(1)).sum::<i64>()); + // Drain results to force aggregation to materialise. + // Discriminate by column count, not row count: the + // count-wrapped scenario returns one 1-column i64 row, the + // top-N scenarios return (group key, count) 2-column rows — + // a top-N result that happened to contain exactly one group + // would misdecode under a row-count heuristic. + match rows.first() { + Some(first) if first.columns().len() == 1 => { + black_box(first.get::<i64, _>(0)); + } + _ => { + black_box(rows.iter().map(|r| r.get::<i64, _>(1)).sum::<i64>()); + } } }) }); diff --git a/report_benchmarks.py b/report_benchmarks.py index a9b8443..04542ff 100755 --- a/report_benchmarks.py +++ b/report_benchmarks.py @@ -1311,7 +1311,16 @@ def _write_query_overview(self, f, scenario_pages: Dict[str, str]): type_results = [r for r in self.query_results if r.query_type == qt] if not type_results: continue - version = eql_version_for(qt) + # The sidecar-recorded version is the source of truth when the + # family's sidecars agree; fall back to the (name-derived) + # QueryResult version for families without metadata — criterion's + # NDJSON carries no version field. + meta_versions = {m.version for m in self.metadata.values() + if m.query_type == qt} + if len(meta_versions) == 1: + version = meta_versions.pop() + else: + version = type_results[0].version scenarios = sorted(set(r.query_name for r in type_results)) tiers = sorted(set(r.row_count for r in type_results)) tiers_str = ", ".join(f"{t:,}" for t in tiers) diff --git a/src/lib.rs b/src/lib.rs index 94da37c..083f772 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -87,6 +87,13 @@ pub mod v3 { /// terms — and could not: v3's `bf` is signed, v2's is unsigned). pub fn v3_scalar_to_v2_envelope(v3: &serde_json::Value) -> Result<serde_json::Value> { let obj = v3.as_object().context("v3 payload must be a JSON object")?; + // Fail closed on anything that isn't a v3 payload — a v2 `ct` + // payload also carries `c` + `i` and would otherwise silently + // re-wrap. + let version = obj.get("v").and_then(serde_json::Value::as_u64); + if version != Some(3) { + anyhow::bail!("expected EQL payload version 3, found {:?}", version); + } let c = obj .get("c") .context("expected a v3 scalar payload carrying `c` — SteVec documents (`sv`) have no scalar envelope")?; @@ -1101,6 +1108,20 @@ mod tests { ); } + #[test] + fn v3_scalar_to_v2_envelope_rejects_non_v3_payloads() { + // A v2 `ct` payload also carries `c` + `i` — without a version + // check it would silently re-wrap. Fail closed instead, consistent + // with the module's conversion design. + let v2 = v2_text_store_payload(); + let err = v3_scalar_to_v2_envelope(&v2).unwrap_err(); + let msg = format!("{:#}", err); + assert!( + msg.contains("version"), + "error should name the version mismatch: {msg}" + ); + } + #[test] fn v3_scalar_to_v2_envelope_rejects_ste_vec_documents() { let v3_doc = json!({ From f01ff78e0434bc0c033a9a60b0e7b329dfce66a7 Mon Sep 17 00:00:00 2001 From: James Sadler <james@cipherstash.com> Date: Thu, 2 Jul 2026 22:59:51 +1000 Subject: [PATCH 9/9] docs(v3): SteVec documents carry k:"sv" on the wire; align fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rebased eql-bindings (upstream #336 final form) added the k:"sv" form discriminator to v3 SteVec/jsonb documents (from_v2 emits it; the eql_v3.json domain CHECK requires it). Scalar v3 payloads still carry no k. Nothing broke here — documents are produced via from_v2, so they gained k automatically — but: - correct the v2_store_to_v3 doc and the text_eq test comment, which overgeneralised "k is removed" to all v3 payloads (scalars only); - add k:"sv" to the synthetic v3 SteVec fixture in v3_scalar_to_v2_envelope_rejects_ste_vec_documents so it mirrors the real wire shape; - add k:"sv" to the eql_v3.json document shape in the encrypt_ste_vec_small_v3 module docs. --- src/bin/encrypt_ste_vec_small_v3.rs | 4 ++-- src/lib.rs | 11 ++++++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/bin/encrypt_ste_vec_small_v3.rs b/src/bin/encrypt_ste_vec_small_v3.rs index 42acff2..20bbdcc 100644 --- a/src/bin/encrypt_ste_vec_small_v3.rs +++ b/src/bin/encrypt_ste_vec_small_v3.rs @@ -5,8 +5,8 @@ //! `json_ste_vec_small_encrypted_v3` (or a `TABLE_SUFFIX` variant). //! //! Target domain: `eql_v3.json` — the SteVec document domain -//! (`{v: 3, i, sv: [...]}`, per-entry `s` + `c` + exactly one of `hm` XOR -//! `oc`). Standard SteVec mode matches the v2 bin, so the encryption +//! (`{v: 3, k: "sv", i, sv: [...]}`, per-entry `s` + `c` + exactly one of +//! `hm` XOR `oc`). Standard SteVec mode matches the v2 bin, so the encryption //! workload is identical and the ingest numbers differ only by the from_v2 //! conversion. `sv` entry order is preserved by the converter — `sv[0]` is //! the decryption root. diff --git a/src/lib.rs b/src/lib.rs index 083f772..a3a0e3d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -57,7 +57,8 @@ pub mod v3 { /// `target`. Thin context-adding wrapper over /// [`eql_bindings::from_v2::from_v2`] — see the module docs there for /// the conversion rules (terms the target doesn't require are dropped, - /// `bf` is reinterpreted into signed `smallint[]`, `k` is removed). + /// `bf` is reinterpreted into signed `smallint[]`, the scalar `k: "ct"` + /// discriminator is removed while SteVec documents keep `k: "sv"`). pub fn v2_store_to_v3( v2: &serde_json::Value, target: TargetDomain, @@ -1040,8 +1041,9 @@ mod tests { v3["hm"], json!("a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90") ); - // v3 payloads carry no `k` discriminator, and text_eq requires only - // `hm` — the bloom and ORE terms must be dropped. + // v3 SCALAR payloads carry no `k` discriminator (only SteVec + // documents keep `k: "sv"`), and text_eq requires only `hm` — the + // bloom and ORE terms must be dropped. let obj = v3.as_object().unwrap(); assert!(!obj.contains_key("k")); assert!(!obj.contains_key("bf")); @@ -1124,8 +1126,11 @@ mod tests { #[test] fn v3_scalar_to_v2_envelope_rejects_ste_vec_documents() { + // Real v3 SteVec documents carry the k:"sv" form discriminator (v3 + // scalars have no k) — mirror the actual wire shape. let v3_doc = json!({ "v": 3, + "k": "sv", "i": {"t": "json_ste_vec_small_encrypted_v3", "c": "value"}, "sv": [{"s": "abc", "c": "OPAQUE", "hm": "a1"}], });