Repository navigation
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
Draft
perf(eql, stack-encrypt): store the json_tree projection, walk by parent map, budget the subtree#1325coderdan wants to merge 3 commits into
coderdan wants to merge 3 commits into
Conversation
…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
Contributor
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: true
Comment |
Contributor
Mutation testing (cargo-mutants,
|
| 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 detectedLatest commit: 8ca825b The changes in this PR will be included in the next version bump. This PR includes changesets to release 12 packages
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Stacked on #1288. The
json_treefamily (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 nativejsonb. 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 nativejsonb. 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 requiresq, 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)readsdoc -> 'q'and returnsjsonb(a domain cast would run the needle's recursive CHECK per recheck row); it is plpgsql atCOST 1000rather than an inlinable SQL function, because inlined tovalue -> '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_groupis 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_subtreeandjson_tree_subtree_attake abudget(default 64): past it, when the root is sealed, they return the root entry alone withd = 0.json_tree_path_query_firstisCOST 1000.stack-encrypt (
packages/stack-encrypt/src/json/)JsonDocumentwritesqon the wire (itsprojection()) and ignores it on the way in.JsonPath::selectevaluates 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 forValue, one per node reached forVec<Value>), and is refused without it.eql-bindings (
packages/eql/crates/eql-bindings/src/v4/json_tree.rs)JsonTreeDocumentgainsq;JsonTreeSubtreeis now{ rows, path }withnewandat.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:qon the wire equals the needle of the whole document; the fallback's openings and refusals.@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,targetsinpackages/eql: all passed.EQL_TEST_DATABASE_URL=… mise run test:encryption:postgresinpackages/eqlagainst a fresh PostgreSQL 17 database: every suite passed, including bothjson_treetests (containment oracle againstjsonb @>with the GIN index shown inEXPLAIN, 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_completeinpackages/eql: passed.docs:validatepassed its coverage and tags steps; itsdocumented-sqlstep needs the Docker database on port 7432, which was not running locally, so CI is what runs it here.-D warningsonstack-encrypt,eql-bindings(stack-encryptfeature) and the encryption test crate: clean.cargo fmt --check: clean.feat/json-tree-benches, commit 57abcf9,report/JSON_TREE_COMPARISON.md) at 10,000 and 100,000 rows against the v3 SteVec type and nativejsonb, every containment scenario checked againstjsonb @>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 BYat 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;qis the selectors already insvregrouped, so it reveals nothing the entries do not. The fallback's client side isderive.rs(open_fallback) and the twoDecryptIntoimpls above it.https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu