Skip to content

Commit 2dbc42f

Browse files
authored
Merge pull request #301 from cipherstash/eql_v3_scale_index_engagement
test(v3): scaled, cost-chosen index-engagement tests for encrypted-domain surface
2 parents a2a31f6 + 4af413b commit 2dbc42f

5 files changed

Lines changed: 266 additions & 40 deletions

File tree

.github/workflows/bench-eql.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,17 @@ jobs:
6161
mise run postgres:up postgres-${POSTGRES_VERSION} --extra-args "--detach --wait"
6262
6363
- name: Run bench tests
64+
# CS_* scoped to THIS step only (least privilege): test:bench -> test:sqlx:prep
65+
# -> fixture:generate:all encrypts via cipherstash-client and needs BOTH a
66+
# ZeroKMS auth credential (CS_CLIENT_ACCESS_KEY + CS_WORKSPACE_CRN) AND a client
67+
# key (CS_CLIENT_ID + CS_CLIENT_KEY); without them it fails "Auth strategy error:
68+
# Not authenticated". Kept off job scope so checkout/mise/rust-cache actions
69+
# never see them.
70+
env:
71+
CS_CLIENT_ACCESS_KEY: ${{ secrets.CS_CLIENT_ACCESS_KEY }}
72+
CS_WORKSPACE_CRN: ${{ secrets.CS_WORKSPACE_CRN }}
73+
CS_CLIENT_ID: ${{ secrets.CS_CLIENT_ID }}
74+
CS_CLIENT_KEY: ${{ secrets.CS_CLIENT_KEY }}
6475
run: |
6576
export active_rust_toolchain=$(rustup show active-toolchain | cut -d' ' -f1)
6677
rustup component add --toolchain ${active_rust_toolchain} rustfmt clippy

tasks/test/bench.sh

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,15 @@ echo "=========================================="
1515

1616
"$(dirname "$0")/../postgres/check_container.sh" "${POSTGRES_VERSION}"
1717

18-
echo "Building EQL..."
19-
mise run --output prefix --force build
20-
21-
echo "Updating SQLx migrations with built EQL..."
22-
cp release/cipherstash-encrypt.sql tests/sqlx/migrations/001_install_eql.sql
23-
24-
echo "Running SQLx migrations..."
25-
(cd tests/sqlx && sqlx migrate run)
18+
# Prep the SQLx test DB exactly like the standard suite (test:sqlx): build EQL,
19+
# copy it into migrations, migrate, AND regenerate the gitignored per-type
20+
# fixtures. The fixtures are include_str!'d into the test binary at COMPILE time
21+
# by #[sqlx::test(fixtures(...))], so they MUST exist on disk before `cargo test`
22+
# compiles. This script previously hand-rolled build+cp+migrate but omitted
23+
# fixture generation; once fixtures became generated/gitignored the bench binary
24+
# stopped compiling (couldn't read tests/sqlx/fixtures/eql_v2_*.sql). Reusing
25+
# prep keeps bench in lockstep with test:sqlx and prevents that drift recurring.
26+
mise run --output prefix test:sqlx:prep
2627

2728
echo "Running bench tests (cargo test --features bench)..."
2829
(cd tests/sqlx && cargo test --features bench)

tests/sqlx/Cargo.toml

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -36,16 +36,19 @@ workspace = true
3636
[features]
3737
default = []
3838
# Opt-in to slow benchmark / regression / scale tests. Without this feature
39-
# they're #[ignore]'d so PR CI stays fast. The `bench-eql` workflow enables
40-
# it on push to main and on a nightly schedule. Run locally with:
41-
# mise run test:bench
42-
bench = []
43-
# Opt-in to the matrix's per-(variant, index) scale tests. Each builds
44-
# ~5000 rows of filler plus a single selective pivot and asserts the
45-
# planner *prefers* the functional index with `enable_seqscan` left on.
46-
# The default index tests force seqscan off and only prove the index is
47-
# *usable*. Off by default to keep `mise run test` fast; CI runs with
48-
# `--features scale`.
39+
# they're #[ignore]'d or #[cfg]'d out so PR CI stays fast. Enabling `bench`
40+
# transitively enables `scale` (below), so the `bench-eql` workflow — push to
41+
# main + nightly, via `tasks/test/bench.sh` (`cargo test --features bench`) —
42+
# is the runner that exercises the per-combo scale-preference matrix tests.
43+
# Run locally with: mise run test:bench
44+
bench = ["scale"]
45+
# The matrix's per-(variant, index) scale-preference tests
46+
# (`#[cfg(feature = "scale")]`). Each replicates ONE real fixture payload to
47+
# ~5000 rows plus a selective pivot and asserts the planner *prefers* the
48+
# functional index with `enable_seqscan` left ON. The `*_index_engages_*` arms
49+
# force seqscan off and only prove the index is *usable*. Off in fast PR CI
50+
# (`mise run test`); activated transitively by `bench` (above), so the
51+
# `bench-eql` workflow runs them. Not enabled directly anywhere else.
4952
scale = []
5053
# Opt-in to the e2e property suite (CIP-3141). It generates fresh random
5154
# plaintexts each run and encrypts them end-to-end through ZeroKMS via

tests/sqlx/src/matrix.rs

Lines changed: 117 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1587,42 +1587,137 @@ macro_rules! __scalar_matrix_scale_case {
15871587
);
15881588

15891589
let values: &[$scalar] = <$scalar as ScalarType>::fixture_values();
1590-
anyhow::ensure!(values.len() >= 2,
1591-
"scale test requires >= 2 fixture rows for distinct filler/pivot");
1592-
let filler = values[0].clone();
1593-
let pivot = values[values.len() / 2].clone();
1594-
let filler_payload =
1595-
$crate::scalar_domains::fetch_fixture_payload::<$scalar>(&pool, filler).await?;
1596-
let pivot_payload =
1597-
$crate::scalar_domains::fetch_fixture_payload::<$scalar>(&pool, pivot).await?;
1590+
// Distinct, sorted fixture values so MIN / MID / MAX are well
1591+
// defined regardless of fixture order. ONE data shape serves
1592+
// every op-class a combo can carry — equality combos hold `=`;
1593+
// the ordered combos hold `=` plus `<`/`<=`/`>`/`>=`, all sharing
1594+
// a single extractor (so one functional index serves them):
1595+
//
1596+
// 5000 identical MID rows (the bulk) + ONE MIN row + ONE MAX
1597+
// row = 5002 rows.
1598+
//
1599+
// Each op then anchors its predicate so EXACTLY ONE row matches,
1600+
// making the predicate ~1/5002 selective and the functional index
1601+
// the cheap plan with `enable_seqscan` left ON (Fact 4). A single
1602+
// MIN-bulk table cannot do this for both range directions at once
1603+
// (`value > MIN` would match every non-MIN row); a MID bulk with
1604+
// one MIN and one MAX pivot makes every op single-row-selective:
1605+
// `=` anchor MIN -> the single MIN row (bulk is MID)
1606+
// `<` anchor MID -> the single MIN row (MID < MID is false)
1607+
// `<=` anchor MIN -> the single MIN row
1608+
// `>` anchor MID -> the single MAX row
1609+
// `>=` anchor MAX -> the single MAX row
1610+
let mut sorted: Vec<$scalar> = values.to_vec();
1611+
sorted.sort();
1612+
sorted.dedup();
1613+
anyhow::ensure!(sorted.len() >= 3,
1614+
"scale test requires >= 3 distinct fixture values for \
1615+
min/mid/max single-row selectivity");
1616+
let min_v = sorted[0].clone();
1617+
let max_v = sorted[sorted.len() - 1].clone();
1618+
let mid_v = sorted[sorted.len() / 2].clone();
1619+
1620+
let min_payload =
1621+
$crate::scalar_domains::fetch_fixture_payload::<$scalar>(&pool, min_v).await?;
1622+
let mid_payload =
1623+
$crate::scalar_domains::fetch_fixture_payload::<$scalar>(&pool, mid_v).await?;
1624+
let max_payload =
1625+
$crate::scalar_domains::fetch_fixture_payload::<$scalar>(&pool, max_v).await?;
15981626

15991627
let mut tx = pool.begin().await?;
16001628
sqlx::query(&format!(
16011629
"CREATE TEMP TABLE {table} (value {d}) ON COMMIT DROP",
16021630
)).execute(&mut *tx).await?;
1631+
// The bulk: 5000 identical MID rows.
16031632
sqlx::query(&format!(
16041633
"INSERT INTO {table}(value) \
16051634
SELECT $1::jsonb::{d} FROM generate_series(1, 5000)",
1606-
)).bind(&filler_payload).execute(&mut *tx).await?;
1635+
)).bind(&mid_payload).execute(&mut *tx).await?;
1636+
// The two selective pivots: exactly one MIN row and one MAX row.
16071637
sqlx::query(&format!(
1608-
"INSERT INTO {table}(value) VALUES ($1::jsonb::{d})",
1609-
)).bind(&pivot_payload).execute(&mut *tx).await?;
1638+
"INSERT INTO {table}(value) VALUES ($1::jsonb::{d}), ($2::jsonb::{d})",
1639+
)).bind(&min_payload).bind(&max_payload).execute(&mut *tx).await?;
16101640
sqlx::query(&format!(
16111641
"CREATE INDEX {index} ON {table} USING {using} ({extractor}(value))", using = $using, extractor = extractor,
16121642
)).execute(&mut *tx).await?;
16131643
sqlx::query(&format!("ANALYZE {table}"))
16141644
.execute(&mut *tx).await?;
1615-
1616-
let lit = pivot_payload.replace('\'', "''");
1617-
$crate::matrix::assert_index_scan_uses(
1618-
&mut *tx,
1619-
&format!("SELECT * FROM {table} WHERE value = '{lit}'::jsonb::{d}"),
1620-
index,
1621-
&format!(
1622-
"with seqscan enabled the planner must prefer the {extractor} {using} index for a selective =",
1623-
extractor = extractor, using = $using,
1624-
),
1625-
).await?;
1645+
// enable_seqscan LEFT ON — this is the cost-PREFERENCE proof, not
1646+
// the usability proof (the sibling `*_index_engages_*` arm forces
1647+
// seqscan off over the ~17-row fixture). See Fact 1 / Fact 4.
1648+
1649+
// Both RHS forms (`::{domain}` and bare `::jsonb`) and BOTH the
1650+
// natural operator form and the explicit extractor form are
1651+
// asserted per op, mirroring the validity arm
1652+
// (`__scalar_matrix_index_case!`) minus the forced seqscan-off.
1653+
let rhs_casts = [format!("::{d}", d = d), String::new()];
1654+
$(
1655+
// `<>` is never index-selective over 5000 rows and is not a
1656+
// member of any index combo; guard it out defensively.
1657+
if $op != "<>" {
1658+
// Per-op anchor giving a single-row match against the
1659+
// bulk-MID / one-MIN / one-MAX table (see the header).
1660+
let anchor: &str = match $op {
1661+
"=" => &min_payload,
1662+
"<" => &mid_payload,
1663+
"<=" => &min_payload,
1664+
">" => &mid_payload,
1665+
">=" => &max_payload,
1666+
_ => &min_payload,
1667+
};
1668+
let lit = anchor.replace('\'', "''");
1669+
for rhs_cast in &rhs_casts {
1670+
// Natural bare-operator form: `value {op} <lit>`. This
1671+
// is the inlinability tripwire — a broken inline flips
1672+
// it to Seq Scan.
1673+
let natural = format!(
1674+
"SELECT * FROM {table} WHERE value {op} '{lit}'::jsonb{cast}",
1675+
op = $op, cast = rhs_cast,
1676+
);
1677+
$crate::matrix::assert_index_scan_uses(
1678+
&mut *tx, &natural, index,
1679+
&format!(
1680+
"scale: natural-form `{op}` (rhs {cast:?}) must PREFER the \
1681+
{extractor} {using} index for a single-row predicate (seqscan ON)",
1682+
op = $op, cast = rhs_cast,
1683+
extractor = extractor, using = $using,
1684+
),
1685+
).await?;
1686+
1687+
// Explicit extractor form: `{extractor}(value) {op}
1688+
// {extractor}(<lit>)`. Complements the natural form;
1689+
// a divergence between the two surfaces an inlining
1690+
// break.
1691+
//
1692+
// ONLY the domain-cast RHS (`::{d}`) — never bare
1693+
// `::jsonb`. A standalone `eq_term`/`ord_term` call on
1694+
// a bare-jsonb argument is ambiguous: the extractor is
1695+
// overloaded across the domain family, and bare jsonb
1696+
// implicitly casts to several of them, so Postgres
1697+
// raises `function eql_v3.<extractor>(jsonb) is not
1698+
// unique`. The natural operator form above already
1699+
// exercises the bare-jsonb RHS path (the operator
1700+
// signature pins the domain), so skipping it here loses
1701+
// no coverage.
1702+
if !rhs_cast.is_empty() {
1703+
let extracted = format!(
1704+
"SELECT * FROM {table} \
1705+
WHERE {extractor}(value) {op} {extractor}('{lit}'::jsonb{cast})",
1706+
extractor = extractor, op = $op, cast = rhs_cast,
1707+
);
1708+
$crate::matrix::assert_index_scan_uses(
1709+
&mut *tx, &extracted, index,
1710+
&format!(
1711+
"scale: extractor-form `{op}` (rhs {cast:?}) must PREFER the \
1712+
{extractor} {using} index for a single-row predicate (seqscan ON)",
1713+
op = $op, cast = rhs_cast,
1714+
extractor = extractor, using = $using,
1715+
),
1716+
).await?;
1717+
}
1718+
}
1719+
}
1720+
)+
16261721

16271722
tx.commit().await?;
16281723
Ok(())

tests/sqlx/tests/v3_jsonb_tests.rs

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1148,6 +1148,122 @@ async fn v3_jsonb_index_to_ste_vec_query_gin_engages(pool: PgPool) -> anyhow::Re
11481148
Ok(())
11491149
}
11501150

1151+
// ============================================================================
1152+
// D11-scale — jsonb containment GIN is COST-CHOSEN at scale (seqscan ON).
1153+
//
1154+
// The sibling `v3_jsonb_index_to_ste_vec_query_gin_engages` (above) forces
1155+
// `enable_seqscan = off` over the 10-row fixture: it proves the GIN index is
1156+
// USABLE, not that the planner PREFERS it. This test replicates ONE real
1157+
// fixture document to 5000 rows (the bulk) plus a single DISTINCT pivot
1158+
// document and, leaving `enable_seqscan` ON, asserts the planner CHOOSES the
1159+
// GIN index for a single-row-selective containment needle. Same pattern as the
1160+
// scalar `*_scale_preference_*` arms, and `#[cfg(feature = "scale")]` so it
1161+
// rides the bench workflow, not fast PR CI (matches the scalar scale arms).
1162+
//
1163+
// Real ciphertext only: both documents come from the generated `v3_ste_vec`
1164+
// fixture, replicated via generate_series — no new fixture, no static blob.
1165+
// Selectivity comes from the distinct-per-row `$.hello` oc leaf
1166+
// (`SEL_HELLO_OC`, whose load-bearing distinctness is asserted by
1167+
// `v3_jsonb_containment_oc_only` / `v3_jsonb_fixture_structural_invariants`):
1168+
// the pivot's own oc term matches ONLY the pivot row, never the 5000 bulk rows
1169+
// (whose oc term is the filler document's, a different value). A precondition
1170+
// check below fails loudly if the two leaves ever collide.
1171+
// ============================================================================
1172+
1173+
#[cfg(feature = "scale")]
1174+
#[sqlx::test(fixtures(path = "../fixtures", scripts("v3_ste_vec")))]
1175+
async fn v3_jsonb_to_ste_vec_query_gin_is_cost_chosen(pool: PgPool) -> anyhow::Result<()> {
1176+
// Two DISTINCT real fixture rows: the filler (bulk) and the pivot. Their
1177+
// `$.hello` oc leaves differ (distinct per row), so a needle for the
1178+
// pivot's oc isolates exactly the single pivot row.
1179+
let filler_payload: String = sqlx::query_scalar(
1180+
"SELECT payload::jsonb::text FROM fixtures.v3_ste_vec ORDER BY id ASC LIMIT 1",
1181+
)
1182+
.fetch_one(&pool)
1183+
.await?;
1184+
let pivot_payload: String = sqlx::query_scalar(
1185+
"SELECT payload::jsonb::text FROM fixtures.v3_ste_vec ORDER BY id DESC LIMIT 1",
1186+
)
1187+
.fetch_one(&pool)
1188+
.await?;
1189+
1190+
// The pivot's own `$.hello` oc term — the same extraction the oc-containment
1191+
// oracle (`v3_jsonb_containment_oc_only`) uses — which the needle searches
1192+
// for. The filler's oc term is extracted only to assert the two differ.
1193+
let pivot_oc: String = sqlx::query_scalar(&format!(
1194+
"SELECT (payload ->> '{SEL_HELLO_OC}'::text)::jsonb ->> 'oc' \
1195+
FROM fixtures.v3_ste_vec ORDER BY id DESC LIMIT 1"
1196+
))
1197+
.fetch_one(&pool)
1198+
.await?;
1199+
let filler_oc: String = sqlx::query_scalar(&format!(
1200+
"SELECT (payload ->> '{SEL_HELLO_OC}'::text)::jsonb ->> 'oc' \
1201+
FROM fixtures.v3_ste_vec ORDER BY id ASC LIMIT 1"
1202+
))
1203+
.fetch_one(&pool)
1204+
.await?;
1205+
anyhow::ensure!(
1206+
filler_oc != pivot_oc,
1207+
"fixture precondition: filler and pivot rows must have distinct $.hello oc \
1208+
leaves for single-row selectivity (distinct-per-row oc is the load-bearing \
1209+
W1 invariant); got identical terms"
1210+
);
1211+
1212+
let mut tx = pool.begin().await?;
1213+
sqlx::query("CREATE TEMP TABLE v3_jsonb_scale (payload eql_v3.json) ON COMMIT DROP")
1214+
.execute(&mut *tx)
1215+
.await?;
1216+
// The bulk: 5000 copies of the filler document.
1217+
sqlx::query(
1218+
"INSERT INTO v3_jsonb_scale(payload) \
1219+
SELECT $1::jsonb::eql_v3.json FROM generate_series(1, 5000)",
1220+
)
1221+
.bind(&filler_payload)
1222+
.execute(&mut *tx)
1223+
.await?;
1224+
// The single selective pivot document.
1225+
sqlx::query("INSERT INTO v3_jsonb_scale(payload) VALUES ($1::jsonb::eql_v3.json)")
1226+
.bind(&pivot_payload)
1227+
.execute(&mut *tx)
1228+
.await?;
1229+
sqlx::query(
1230+
"CREATE INDEX v3_jsonb_scale_gin_idx ON v3_jsonb_scale \
1231+
USING gin ((eql_v3.to_ste_vec_query(payload)::jsonb) jsonb_path_ops)",
1232+
)
1233+
.execute(&mut *tx)
1234+
.await?;
1235+
sqlx::query("ANALYZE v3_jsonb_scale")
1236+
.execute(&mut *tx)
1237+
.await?;
1238+
// enable_seqscan LEFT ON — this is the cost-PREFERENCE proof, not the
1239+
// usability proof (the sibling `*_gin_engages` arm forces seqscan off).
1240+
1241+
// Selective needle: the pivot's own `$.hello` oc leaf. With distinct-per-row
1242+
// oc, exactly the single pivot row contains it.
1243+
let n = needle(&[(SEL_HELLO_OC, "oc", &pivot_oc)]);
1244+
let query =
1245+
format!("SELECT count(*) FROM v3_jsonb_scale WHERE payload @> '{n}'::eql_v3.ste_vec_query");
1246+
assert_index_scan_uses(
1247+
&mut *tx,
1248+
&query,
1249+
"v3_jsonb_scale_gin_idx",
1250+
"jsonb containment `@>` must PREFER the to_ste_vec_query GIN index at scale (seqscan ON)",
1251+
)
1252+
.await?;
1253+
1254+
// Row floor + selectivity: exactly the single pivot row matches (not zero —
1255+
// which would make the index-scan-over-nothing pass vacuously — and not the
1256+
// bulk, which would mean the needle was not selective).
1257+
let matched: i64 = sqlx::query_scalar(&query).fetch_one(&mut *tx).await?;
1258+
assert_eq!(
1259+
matched, 1,
1260+
"the GIN-engaged containment needle must match exactly the single pivot row"
1261+
);
1262+
1263+
tx.rollback().await?;
1264+
Ok(())
1265+
}
1266+
11511267
#[sqlx::test(fixtures(path = "../fixtures", scripts("v3_ste_vec")))]
11521268
async fn v3_jsonb_index_ore_cllw_btree_engages(pool: PgPool) -> anyhow::Result<()> {
11531269
let mut tx = pool.begin().await?;

0 commit comments

Comments
 (0)