Skip to content

perf(eql, stack-encrypt): store the json_tree projection, walk by parent map, budget the subtree - #1325

Draft
coderdan wants to merge 3 commits into
feat/json-index-stack-encryptfrom
perf/json-tree-walk-and-projection
Draft

coderdan wants to merge 3 commits into
feat/json-index-stack-encryptfrom
perf/json-tree-walk-and-projection

Conversation

@coderdan

@coderdan coderdan commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Stacked on #1288. The json_tree family (EQL v4's encrypted JSON document type, written by Stack Encrypt's JSON index) was benchmarked at 10,000 rows against the v3 SteVec type and native jsonb. On a flat five-field document it is on par with v3; on a nested document of about 220 nodes the shipped SQL was one to four orders of magnitude behind native jsonb. Three mechanisms accounted for all of it, and this PR fixes them. One is a wire change, which is why it must land before the family is released: a stored document now carries its own containment projection.

In user terms: a containment query (@>) on a nested document goes from about 70 ms to the low milliseconds and the GIN index builds in seconds rather than minutes per ten thousand rows; fetching a subtree by token goes from ten seconds to tens of milliseconds, or single digits when the server hands back the sealed root instead; and a range comparison keeps its index under a prepared statement.

Changes

EQL SQL (packages/eql/src/v3/json_tree/)

  • types.sql: the stored document requires q, its containment projection, validated as a group by the same validator the needle uses (moved ahead of the document validator so it is defined first).
  • functions.sql: json_tree_query(doc) reads doc -> 'q' and returns jsonb (a domain cast would run the needle's recursive CHECK per recheck row); it is plpgsql at COST 1000 rather than an inlinable SQL function, because inlined to value -> 'q' the planner costed a sequential scan over the TOASTed nested table below the GIN bitmap scan and chose it (1.5 s for a one-row match at 10k rows, 17 s at 100k; 0.7 ms through the index). json_tree_group is removed. The walk (json_tree_children, json_tree_child_count, json_tree_subtree, json_tree_subtree_at) builds one map of entries keyed by parent tag (json_tree_by_parent) and looks computed tags up in it, so a level costs its fan-out in hashes rather than fan-out times document size. json_tree_subtree and json_tree_subtree_at take a budget (default 64): past it, when the root is sealed, they return the root entry alone with d = 0. json_tree_path_query_first is COST 1000.

stack-encrypt (packages/stack-encrypt/src/json/)

  • JsonDocument writes q on the wire (its projection()) and ignores it on the way in.
  • JsonPath::select evaluates a path over a value (keys, positions, wildcards).
  • JsonSubtree::at(path), path(), is_sealed_root(); opening a sealed-root fallback takes the path over the whole document (one value for Value, one per node reached for Vec<Value>), and is refused without it.

eql-bindings (packages/eql/crates/eql-bindings/src/v4/json_tree.rs)

  • JsonTreeDocument gains q; JsonTreeSubtree is now { rows, path } with new and at.

Tests and docs

  • packages/eql/tests/encryption/tests/json_tree.rs: the budget at, under and off; the fallback opens at the path for both walks; a wrong token still yields nothing.
  • packages/stack-encrypt/tests/json_index.rs: q on the wire equals the needle of the whole document; the fallback's openings and refusals.
  • Changeset for @cipherstash/eql; the design document's "Implementation notes (2026-10-21, Phase 3)".

Verification

  • mise x --env test -- cargo nextest run -p stack-encrypt --all-features: 734 passed.
  • cargo test -p eql-bindings --features stack-encrypt,targets in packages/eql: all passed.
  • EQL_TEST_DATABASE_URL=… mise run test:encryption:postgres in packages/eql against a fresh PostgreSQL 17 database: every suite passed, including both json_tree tests (containment oracle against jsonb @> with the GIN index shown in EXPLAIN, the walks, the budget and the fallback).
  • mise run test:lint, test:schema, test:public_identifiers, test:symbol_order_v3, test:v4_isolation, test:installer_complete in packages/eql: passed. docs:validate passed its coverage and tags steps; its documented-sql step needs the Docker database on port 7432, which was not running locally, so CI is what runs it here.
  • Clippy with -D warnings on stack-encrypt, eql-bindings (stack-encrypt feature) and the encryption test crate: clean. cargo fmt --check: clean.
  • Benchmarked in cipherstash/benches (branch feat/json-tree-benches, commit 57abcf9, report/JSON_TREE_COMPARISON.md) at 10,000 and 100,000 rows against the v3 SteVec type and native jsonb, every containment scenario checked against jsonb @> over a plaintext twin before timing. Nested corpus (about 215 nodes per document), 10k rows, first run → this branch → native: @> deep object LIMIT 10, 68 ms → 3.2 ms → 0.36 ms; @> one array element, 12.7 ms → 0.70 ms → 0.10 ms; equality at a nested path, 12.9 ms → 0.50 ms → 0.10 ms; subtree of 6 entries, 292 ms → 2.8 ms; subtree of 244 entries, 10.2 s → 9.5 ms with the sealed root returned, 23 ms walked out; children, 7.1 ms → 1.6 ms; GIN build per 10k rows, 44 s → 5 s. Flat corpus against v3: equality at a path 33% faster at 10k and 82% at 100k, range at a path 87% faster at both tiers (the generic-plan fix), containment index a tenth the size; ORDER BY at a path and bare containment of a whole small document remain behind v3 (json_tree: the smaller performance findings from the first benchmarks #1324). The 100k tier holds the same shape throughout.

Related

Refs #1288 (the family), #1324 (the smaller findings left open).

Review notes

Start with functions.sql: the projection section and the walk section are each small and self-contained. The wire change is one field; q is the selectors already in sv regrouped, so it reveals nothing the entries do not. The fallback's client side is derive.rs (open_fallback) and the two DecryptInto impls above it.

https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu

…ent map, budget the subtree

The first measurements of the json_tree family (cipherstash/benches,
branch feat/json-tree-benches) put it on par with the v3 SteVec on a flat
corpus and one to four orders of magnitude behind native jsonb on a nested
one: containment 70-80 ms a query, a 244-entry subtree 10 s, the GIN index
44 s to build on 10,000 rows. Three mechanisms accounted for all of it.

The containment projection was rebuilt from the group tags by a recursive
plpgsql function on every GIN recheck and again in the Filter, about 3 ms
per 220-entry document. The client already derives that shape for the
needle, so the document now writes it to the wire as `q` beside `sv`, the
domain CHECK validates it as a group, and `eql_v4.json_tree_query(doc)`
reads `doc -> 'q'`. It returns jsonb rather than the needle domain, since
a domain cast runs the needle's recursive CHECK and the recheck would pay
it per row. `q` is the selectors already in `sv`, regrouped; nothing new
is revealed, and the field is required (the family is unreleased).

The walk evaluated the parent tag inside a LATERAL scan of `sv`, once per
(node, ordinal, entry): ~670k SHA-256 calls for the 244-entry subtree. It
now builds one jsonb map of the chained entries keyed by `p` and looks
each computed tag up, so a level costs its fan-out in hashes, which is the
design's W x F as written. 10 s becomes ~50 ms; children and child_count
7 ms become under 3.

And with the root sealed, the root's ciphertext is the whole document at
about 1.3 times the plaintext, where the walk returns ~440 characters per
entry and pays the fan-out per node. So `json_tree_subtree` and
`json_tree_subtree_at` take a `budget` (default 64) and, past it, return
the root entry alone with `d = 0`; the client opens it whole and takes the
walked path over it (`JsonSubtree::at`, `JsonTreeSubtree::new(rows).at`,
over the new `JsonPath::select`). Budget 0 never falls back, a token that
names nothing still yields nothing, an unsealed root is always walked out.

Also `json_tree_path_query_first` is costed at 1000: under a prepared
statement's generic plan the default cost chose a sequential scan with the
extraction as a Filter over the functional btree (17 ms against 0.12 ms).

Measured in the benches repository; the Postgres oracle
(`mise run test:encryption:postgres`) covers the budget, the fallback's
opening at the path, and containment against `jsonb @>` with the stored
projection. The design document's implementation notes record Phase 3.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
@coderabbitai

coderabbitai Bot commented Oct 21, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Mutation testing (cargo-mutants, --in-diff, stack-auth + stack-encrypt)

caught missed unviable timeout
9 0 5 0

Every mutant in the changed lines was caught by a test.

…IN index wins over a TOASTed seq scan

Re-measured after the projection moved to the wire, the nested corpus's
containment queries ran as sequential scans: 1.5 s for a one-row match on
10,000 rows, 17 s on 100,000, with the GIN index present and usable
(`enable_seqscan = off` took it at 0.8 ms). Inlined to `value -> 'q'`, the
filter is costed as one operator per row, and a column of 97 KB documents
is TOASTed out of the heap, so the main relation is a few hundred pages
and the sequential scan is costed below the bitmap scan. The planner does
not cost the decompression that reading `q` from each row actually pays.

So `eql_v4.json_tree_query` is now plpgsql, which the planner cannot
inline, with `COST 1000`. The index expression still matches the call, the
call keeps its cost, and the planner takes the index on every nested
containment scenario at both tiers. The per-row call is a few
microseconds. The oracle suite passes against a fresh install.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
@changeset-bot

changeset-bot Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8ca825b

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 12 packages
Name Type
@cipherstash/eql Minor
stash Patch
@cipherstash/stack-prisma Patch
@cipherstash/basic-example Patch
@cipherstash/e2e Patch
@cipherstash/prisma-example Patch
@cipherstash/stack-drizzle Patch
@cipherstash/stack-supabase Patch
@cipherstash/stack Patch
@cipherstash/wizard Patch
@cipherstash/bench Patch
@cipherstash/test-kit Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

coderdan added a commit to cipherstash/benches that referenced this pull request Oct 21, 2026
…st the walk-and-projection branch

The second measurement, against cipherstash/stack#1325. Every nested
operation is now index-served and within an order of magnitude of native
jsonb except the subtree walks, which are per-node hashing on the server
and sit at 3 to 13 ms per row (23 ms walked out in full). The report
records the four mechanisms the two runs found and what each measures
now, with the one this run found itself: a TOASTed column makes a
sequential scan look free to the planner, so the projection reader has to
stay out of its inlining to keep the GIN index.

Also the v3 SteVec and plaintext baselines at 100k rows, re-measured
today on the same cluster.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…e fallback's refusal messages

CI found two gaps. The DB-free round-trip test still asserted the stored
document's keys as `h, i, sv, v`; `q` is one of them now. And the in-diff
mutants gate deleted the fallback opening's `0 =>` arm and survived: both
refusals are `Error::OpenedType`, and the test checked only the variant.
It now asserts each refusal's `expected` text, so a path that reaches
nothing and a wildcard that reaches two nodes are told apart.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant