Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/empty-bloom-match-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@cipherstash/eql': patch
---

**A fuzzy-match query whose term carries an empty bloom filter no longer matches every row.** `eql_v3.matches` (the `@@` operator on the `public.eql_v3_text_match` / `public.eql_v3_text_search` / `public.eql_v3_text_search_ore` domains) reduces to bloom array-containment `match_term(a) @> match_term(b)`, and an empty needle bloom (`{}`) is contained by every value — so a search string with no n-gram tokens (e.g. a sub-trigram value below the tokeniser floor) silently returned the entire table instead of nothing. The wrapper now guards the empty-needle case with `LIKE`-shaped semantics — an empty needle matches only a value whose own bloom is also empty (`'' LIKE ''` is true; `'catty' LIKE ''` is false) — so a tokenless query matches at most the empty-bloom rows, never everything. **Why.** This is the server-side backstop for an invariant the SDK already guards client-side; it holds regardless of which client built the term. The fix preserves the documented functional GIN index recipe (`GIN (eql_v3.match_term(col))`) for real queries: the top-level `match_term(col) @> needle` conjunct is retained as an indexable qualifier, so `WHERE col @@ $1` (a bind parameter, or a literal) still inlines and engages a Bitmap Index Scan, with the guard riding along as a cheap recheck filter. (The wrapper is now non-`STRICT` — a `STRICT` SQL function whose body contains the guard's `AND`/`OR` will not inline — and it references the needle twice, so a needle supplied as an *uncorrelated subquery* no longer inlines and falls back to a sequence scan; parameters and literals, the normal shapes, are unaffected.) Stored empty blooms (legitimate sub-trigram values) are unaffected: `matches` and `NOT matches` still partition the non-NULL rows exactly as before. See upgrade note U-011.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ tests/sqlx/fixtures/v3_json_storage.sql
tests/sqlx/fixtures/v3_doc_integer.sql
tests/sqlx/fixtures/v3_numeric_collision.sql
tests/sqlx/fixtures/v3_text_empty.sql
tests/sqlx/fixtures/v3_text_empty_bloom.sql

# Large generated test data files
tests/ste_vec_vast.sql
Expand Down
5 changes: 5 additions & 0 deletions crates/eql-codegen/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,10 @@ pub enum FnEntry {
args: [SqlParam; 2],
call_a: String, // e.g. eql_v3.eq_term(a) (embeds extract_arg cast logic)
call_b: String, // e.g. eql_v3.eq_term(b::public.eql_v3_integer_eq)
// True only for the `@@` bloom-match wrapper: appends the empty-needle
// guard to the body so an empty needle bloom does not match every row.
// See `Operator::needs_empty_bloom_guard`.
empty_bloom_guard: bool,
},
Unsupported {
operator_lit: String, // sql_str(op), escaped content for the RAISE literal
Expand Down Expand Up @@ -280,6 +284,7 @@ pub fn wrapper_entry(
],
call_a: extract_arg(arg_a, extractor, dom, "a"),
call_b: extract_arg(arg_b, extractor, dom, "b"),
empty_bloom_guard: op.needs_empty_bloom_guard(),
}
}

Expand Down
24 changes: 22 additions & 2 deletions crates/eql-codegen/src/generate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1535,11 +1535,31 @@ mod tests {
let fns = render_functions_file(s.name, domain(s, "match"));
// The supported `@@` overloads are `eql_v3.matches` wrappers whose body
// reduces to bloom array-containment `@>` on the extracted terms (so a
// functional GIN index on `eql_v3.match_term(col)` engages).
// functional GIN index on `eql_v3.match_term(col)` engages) guarded by an
// empty-needle clause: an empty needle bloom (`{}`) must match only a
// value whose own bloom is also empty, never every row. The
// top-level `@>` conjunct is preserved so the GIN index still engages;
// in the normal non-empty-needle case the guard folds to a constant TRUE
// and drops out.
assert!(fns.contains(
"CREATE FUNCTION eql_v3.matches(a public.eql_v3_text_match, b public.eql_v3_text_match)"
));
assert!(fns.contains("SELECT eql_v3.match_term(a) @> eql_v3.match_term(b)"));
assert!(fns.contains(
"SELECT eql_v3.match_term(a) @> eql_v3.match_term(b) \
AND (cardinality(eql_v3.match_term(b)) > 0 OR cardinality(eql_v3.match_term(a)) = 0)"
));
// The guarded match wrapper is NOT STRICT: a STRICT SQL function with a
// non-strict body (the guard's AND/OR) would stop inlining, losing the
// functional GIN index. Its body propagates NULL on its own. The bare
// (non-guarded) wrappers keep STRICT.
assert!(fns.contains(
"CREATE FUNCTION eql_v3.matches(a public.eql_v3_text_match, b public.eql_v3_text_match)\n\
RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE\n"
));
assert!(!fns.contains(
"CREATE FUNCTION eql_v3.matches(a public.eql_v3_text_match, b public.eql_v3_text_match)\n\
RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE\n"
));
assert!(!fns.contains("eql_v3.contains("));
assert!(!fns.contains("eql_v3.contained_by("));
// `@>` / `<@` are now blockers on the match domain.
Expand Down
29 changes: 29 additions & 0 deletions crates/eql-codegen/src/operator_surface.rs
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,35 @@ impl Operator {
| OpSymbol::Concat => self.symbol.as_str(),
}
}

/// Whether the wrapper body must carry the empty-needle guard.
///
/// True only for `@@`, whose body is bloom array-containment (`@>`). Bare
/// `match_term(a) @> match_term(b)` is vacuously TRUE whenever the needle
/// bloom `b` is empty (`{}` is `@>` by everything), so a query term with no
/// n-gram tokens would match every row. The wrapper renderer appends
/// `AND (cardinality(match_term(b)) > 0 OR cardinality(match_term(a)) = 0)`,
/// giving `LIKE`-shaped semantics: an empty needle matches only a value whose
/// own bloom is also empty. The top-level `@>` conjunct is preserved so the
/// functional GIN index on `match_term(col)` still engages; for a non-empty
/// needle the guard folds to a constant `TRUE` and drops out at plan time.
///
/// Every other operator's body is a single comparison with no such
/// degenerate-empty case, so this is exhaustively `Match`-only.
///
/// The guarded wrapper is rendered **without `STRICT`** (the bare wrappers
/// keep it). PostgreSQL refuses to inline a `STRICT` SQL function whose body
/// contains non-strict constructs, and the guard introduces top-level
/// `AND`/`OR` (both non-strict: `false AND NULL` is `false`, `true OR NULL`
/// is `true`). A `STRICT` guarded wrapper would therefore stop inlining, and
/// `col @@ needle` would no longer fold to `match_term(col) @> match_term(
/// needle)` — losing the functional GIN index. The body propagates `NULL`
/// on a `NULL` operand on its own (`NULL @> y` is `NULL`, and the guard's
/// `OR`/`AND` carry that `NULL` through), so dropping `STRICT` preserves the
/// wrapper's NULL semantics while restoring inlinability.
pub fn needs_empty_bloom_guard(&self) -> bool {
matches!(self.symbol, OpSymbol::Match)
}
}

/// The native-jsonb operator symbols that every encrypted domain blocks, in
Expand Down
4 changes: 2 additions & 2 deletions crates/eql-codegen/templates/functions/wrapper.sql.j2
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@
--! @param {{ e.args[1].name }} {{ e.args[1].ty }}
--! @return boolean
CREATE FUNCTION {{ schema }}.{{ e.function_name }}({{ e.args[0].name }} {{ e.args[0].ty }}, {{ e.args[1].name }} {{ e.args[1].ty }})
RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE
AS $$ SELECT {{ e.call_a }} {{ e.op }} {{ e.call_b }} $$;
RETURNS boolean LANGUAGE sql IMMUTABLE{% if not e.empty_bloom_guard %} STRICT{% endif %} PARALLEL SAFE
AS $$ SELECT {{ e.call_a }} {{ e.op }} {{ e.call_b }}{% if e.empty_bloom_guard %} AND (cardinality({{ e.call_b }}) > 0 OR cardinality({{ e.call_a }}) = 0){% endif %} $$;
78 changes: 78 additions & 0 deletions docs/upgrading/v3.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ release is prepared.
8. **Text bloom fuzzy match is `@@` / `eql_v3.matches`, not `@>` / `eql_v3.contains`** ([U-008](#u-008-text-bloom-fuzzy-match-is-renamed-to---eql_v3matches)). On `public.eql_v3_text_match` / `text_search` / `text_search_ore` the fuzzy match is now the single directional operator `@@` (backed by `eql_v3.matches`); `@>` / `<@` (and `eql_v3.contains` / `contained_by`) now **raise** on these domains. It is n-gram token matching, not containment. The GIN index recipe is unchanged. Genuine JSON containment keeps `@>` / `<@`.
9. **Uninstalling can exceed the stock lock budget** ([U-009](#u-009-the-uninstaller-can-exceed-postgress-default-lock-budget)). The single-transaction uninstaller drops ~6,400+ objects, each taking one lock — right at the capacity a default `max_locks_per_transaction = 64` cluster affords. A lone uninstall on a quiet cluster normally succeeds; under concurrent load it can fail with `out of shared memory`. Raise `max_locks_per_transaction` (needs a restart) or uninstall during a quiet window. Installation is unaffected.
10. **SteVec exact match is value-selector presence, and the document wire format is restructured** ([U-010](#u-010-stevec-value-inclusive-selectors-and-the-envelope-wire-format)). Encrypted-JSON field equality now works exactly for *every* type (including `text` / `bigint` / `numeric`) via document containment on a value-inclusive selector — spell it `col @> $1::eql_v3.query_json`; the per-value `hm` term is retired, and extract-surface equality (`-> 'sel' = …`) is blocked (ranges stay). The document now carries a once-per-document key header `h` (no root `c`); each entry's `c` is raw AEAD output with a selector-derived nonce; `eql_v3.jsonb_array_elements_text` is removed. **Re-encryption required** — there is no mechanical conversion.
11. **An empty-bloom fuzzy-match term no longer matches every row** ([U-011](#u-011-an-empty-bloom-fuzzy-match-term-no-longer-matches-every-row)). `col @@ $1` (`eql_v3.matches`) on the text match/search domains matched the *entire table* when the query term carried an empty bloom filter (a search string with no n-gram tokens — e.g. a value below the tokeniser's trigram floor), because empty-array containment is vacuously true. It now uses `LIKE ''` semantics: an empty needle matches only a value whose own bloom is also empty. Normal (non-empty needle) queries are unchanged and still index-accelerated. No re-encryption, no schema change.

## Compatibility

Expand Down Expand Up @@ -45,6 +46,7 @@ release is prepared.
| Encrypted-JSON domains (`public.eql_v3_json` searchable, `public.eql_v3_jsonb_entry` — 3.0.0 pre-releases only) | **Renamed.** Searchable document → `public.eql_v3_json_search`, entry → `public.eql_v3_json_entry`; the bare `public.eql_v3_json` is now a new storage-only domain — see U-007. |
| `eql_v3.ore_cllw(entry)` / `eql_v3.has_ore_cllw(entry)` / `eql_v3_internal.ore_cllw` (+ comparator, operators, opclass) | **Removed.** Entry ordering is `eql_v3.ord_term(entry)` — see U-004. |
| Text bloom fuzzy match (`public.eql_v3_text_match` / `text_search` / `text_search_ore`) | **Changed.** `@>` / `<@` (`eql_v3.contains` / `contained_by`) → the single directional `@@` (`eql_v3.matches`); `@>` / `<@` now raise on these domains — see U-008. |
| Empty-bloom fuzzy-match needle (`@@` / `eql_v3.matches`) | **Changed.** An empty needle bloom matched every row (vacuous containment); now `LIKE ''`-shaped — matches only empty-bloom values. Normal queries and the GIN index recipe unchanged — see U-011. |
| Uninstaller lock footprint | **Grown.** The single-transaction uninstall takes ~6,400+ locks — at/over the default `max_locks_per_transaction = 64` cluster budget under concurrent load — see U-009. Installation unaffected. |
| JSON containment (`public.eql_v3_json_search` `@>` / `<@`, `eql_v3.ste_vec_contains`) | **Unchanged.** Genuine containment keeps the containment vocabulary — see U-008. |
| Legacy `eql_v2` wire documentation (`docs/reference/schema/eql-payload-v2.*.schema.json`) | **Unchanged.** Stays `v: 2`. |
Expand Down Expand Up @@ -773,3 +775,79 @@ SELECT eql_v3.jsonb_array_elements_text('{}'::jsonb);
**Rollback.** Not applicable within 3.0.0 — the wire format is part of the
breaking release. Downgrading to a v2-emitting client and the `eql_v2` surface
is the v2.x → v3 rollback path (a full re-encryption in reverse).

### U-011: An empty-bloom fuzzy-match term no longer matches every row

**What changed.** `eql_v3.matches` — the `@@` fuzzy-match operator on
`public.eql_v3_text_match`, `public.eql_v3_text_search`, and
`public.eql_v3_text_search_ore` — reduces to bloom array-containment
`match_term(a) @> match_term(b)` on the extracted `bf` terms. An **empty** needle
bloom (`{}`) is contained by every value (`'[1,2,3]'::jsonb @> '[]'::jsonb` is
`true`), so a query term with no n-gram tokens matched **every row in the table**,
silently and with no error. A term has no tokens when its plaintext is below the
tokeniser's floor (e.g. a 2-character search string, which produces no trigrams
and encrypts to `bf: []`).

The wrapper now guards the empty-needle case with `LIKE`-shaped semantics:

| stored value bloom | needle bloom | `LIKE` analogue | result |
| --- | --- | --- | --- |
| empty | empty | `'' LIKE ''` | **true** |
| non-empty | empty | `'catty' LIKE ''` | **false** (was `true`) |
| empty | non-empty | `'' LIKE 'cat'` | false |
| non-empty | non-empty | — | bloom containment |

Only the second row changed. An empty needle now matches **only** a value whose
own bloom is also empty, instead of everything.

**Why.** This is the server-side backstop for an invariant the SDK already
enforces client-side ("short needles are rejected rather than silently matching
every row"). It holds regardless of which client constructed the term.

**Who is affected.** Anyone issuing a fuzzy match (`col @@ $1`, or the by-name
`eql_v3.matches(col, $1)`) where the query term can be a sub-floor value with no
n-gram tokens. In practice this surfaced as a free-text search over an encrypted
column returning the entire table (capped only by the query's `LIMIT`) where the
plaintext baseline returned a handful of rows.

**What to do.** Nothing — the fix is transparent for well-formed queries. No
re-encryption, no schema change, no query rewrite. In particular:

- **Normal queries still engage a `Bitmap Index Scan`** on the documented
functional index (`GIN (eql_v3.match_term(col))`): the top-level
`match_term(col) @> needle` conjunct is preserved as an indexable qualifier, so
`WHERE col @@ $1` (a bind parameter, or a literal needle) inlines as before and
the guard rides along as a cheap recheck filter.
- **One narrow exception:** `eql_v3.matches` is now non-`STRICT` (a `STRICT` SQL
function whose body carries the guard's `AND`/`OR` will not inline) and
references the needle twice, so a needle supplied as an **uncorrelated
subquery** — `WHERE col @@ (SELECT …)` — no longer inlines and falls back to a
sequence scan. The result is still correct; only the index acceleration is
lost. Supply the needle as a bind parameter or a literal (the normal shapes) to
keep the index.
- **Stored empty blooms** (legitimate sub-floor values already in a column) are
unaffected: they never matched a populated needle before and still don't, and
`matches` / `NOT matches` continue to partition the non-NULL rows exactly as
before.

**Verification.**

```sql
-- Internal reproduction of the bug (a populated value @> an empty needle):
SELECT eql_v3_internal.bloom_filter('{"bf":[10,20,30]}'::jsonb)
@> eql_v3_internal.bloom_filter('{"bf":[]}'::jsonb); -- true (raw containment, unchanged)

-- But the guarded operator no longer matches:
SELECT eql_v3.matches(
'{"v":"3","i":{},"c":"x","bf":[10,20,30]}'::jsonb::public.eql_v3_text_match,
'{"v":"3","i":{},"c":"x","bf":[]}'::jsonb::public.eql_v3_text_match); -- false (was true)

-- An empty needle still matches an empty value ('' LIKE ''):
SELECT eql_v3.matches(
'{"v":"3","i":{},"c":"x","bf":[]}'::jsonb::public.eql_v3_text_match,
'{"v":"3","i":{},"c":"x","bf":[]}'::jsonb::public.eql_v3_text_match); -- true
```

**Rollback.** Not applicable — the change only narrows a degenerate empty-needle
result set that was never intentional. A prior release is the only way back to
the match-everything behaviour, which is the bug being fixed.
8 changes: 4 additions & 4 deletions src/v3/scalars/text/query_text_match_functions.sql

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 4 additions & 4 deletions src/v3/scalars/text/query_text_search_functions.sql

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 4 additions & 4 deletions src/v3/scalars/text/query_text_search_ore_functions.sql

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading