Skip to content

fix(v3): convert ore_block_256 opclass-path helpers to plpgsql (+43% ordered-scan regression) - #357

Merged
coderdan merged 2 commits into
eql_v3from
dan/cip-3380-v3-opclass-path-helper-functions-are-language-sql-cannot
Jul 4, 2026
Merged

fix(v3): convert ore_block_256 opclass-path helpers to plpgsql (+43% ordered-scan regression)#357
coderdan merged 2 commits into
eql_v3from
dan/cip-3380-v3-opclass-path-helper-functions-are-language-sql-cannot

Conversation

@coderdan

@coderdan coderdan commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Closes #353 (Linear: CIP-3380).

What

Language-only conversion of eql_v3_internal.jsonb_array_to_bytea_array and eql_v3_internal.jsonb_array_to_ore_block_256 from LANGUAGE sql to plpgsql, with the doc comments and eql-inline-critical markers updated to the corrected rationale. No logic changes.

Why

The LANGUAGE sql choice was made for inlineability — but these helpers' only caller chain is ore_block_256(val) (plpgsql) feeding the btree operator class, and neither plpgsql callers nor opclass support contexts can ever inline a SQL function. Every compared value paid the per-call SQL-function executor instead: 3.5× the per-call cost of the logic-identical plpgsql form (track_functions attribution), and end-to-end in the v3 release benchmarks:

Scenario (1M rows) EQL 2.3 eql_v3 before eql_v3 with this fix (predicted from A→B→A)
ORE ordered index scan 0.513 ms 0.736 ms (+43%) 0.553 ms
bloom + ORE-order composite 16.64 ms 22.70 ms (+36%) 13.97 ms (16% faster than v2)

The "with fix" numbers are from applying this exact change to a live 1M-row bench database (A→B→A, restored after measurement); non-ordering control scenarios moved ±2.6%. Notably the plpgsql comparator is NOT the problem — its per-query cost is identical to v2's and the 1M index build times match (42 s v2 / 44 s v3). Full attribution data: cipherstash/benches#23, v3-regressions-report.md.

Semantics preserved (probed on an installed bundle)

  • NULL / JSON null / non-array scalar → NULL (the documented v3 divergence from v2's raise is kept)
  • Empty ob array → composite with an empty (not NULL) terms array, sorting before every non-empty term — the Empty-string ("") handling for ordered encrypted text is undefined / inconsistent between eql_v2 and eql_v3 #262 COALESCE is retained
  • Both functions install as plpgsql and remain unpinned through the pin_search_path pass (marker prefix kept; a SET search_path clause on plpgsql would force per-call configuration switching in the same hot path)
  • plpgsql pitfall avoided: SELECT <composite> INTO <composite-var> assigns field-wise, so the row constructor is returned directly

Follow-ups (separate issues)

#354 (jsonb_entry CHECK function — same non-inlinable-context pattern, third instance), and the structural option of a C-level decode+compare; per-query attribution splits ~0.19 ms comparator / ~0.26 ms term re-decoding, so caching the decoded representation is the next win beyond this fix.

Verification suggestion

After merge, cipherstash/benches#23's suite re-runs against a rebuilt bundle (mise run setup-db-v3 after uninstall) — report:v3-compare should show ORE/range_lt_ordered_10 and COMBO/bloom_ore_order_limit drop out of the regression list.

https://claude.ai/code/session_01StQnoycoFXMDdSpQ6zKDav

jsonb_array_to_bytea_array and jsonb_array_to_ore_block_256 were
LANGUAGE sql for inlineability, but their only caller chain —
ore_block_256(val) (plpgsql) feeding the btree operator class — can
never inline SQL functions. Every compared value instead paid the
per-call SQL-function executor: 3.5x the per-call cost of the
logic-identical plpgsql form, +43% end-to-end on ORE ordered index
scans vs EQL 2.3 (0.513 -> 0.736 ms at 1M rows) and +36% on the
composite bloom+ORE-order shape (16.6 -> 22.7 ms).

Language-only change; semantics preserved and probed on an installed
bundle: NULL / JSON null / non-array inputs still return NULL, and the
empty-`ob` COALESCE (#262) still yields an empty (not NULL) terms
array, sorting before every non-empty term. The eql-inline-critical
markers are kept so pin_search_path leaves both functions unpinned —
SET search_path on plpgsql forces per-call configuration switching in
the same hot path (verified unpinned post-install).

Validated A->B->A on a live 1M-row bench database before this patch
was authored: with the plpgsql form, ORE/range_lt_ordered_10 returns
to 0.553 ms (within 8% of v2) and COMBO/bloom_ore_order_limit to
13.97 ms (16% faster than v2), with non-ordering controls unmoved.
Attribution data: cipherstash/benches#23, v3-regressions-report.md.

plpgsql note for reviewers: `SELECT <composite> INTO <composite-var>`
assigns field-wise — the row constructor is returned directly.

Closes #353

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

coderabbitai Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4e7d1dfc-77dd-4791-a256-5740cceb653f

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch dan/cip-3380-v3-opclass-path-helper-functions-are-language-sql-cannot

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

The two failing CI tests encoded the old assumption that
jsonb_array_to_bytea_array / jsonb_array_to_ore_block_256 must stay
inlinable LANGUAGE sql — the premise the parent commit corrects (their
only caller chain is plpgsql + opclass contexts, where SQL functions
never inline; issue #353).

- Remove both helpers from the inlinable-SQL sets of
  eql_v3_sem_inline_critical_functions_are_unpinned and
  eql_v3_sem_inline_critical_helpers_carry_marker.
- Add a dedicated guard,
  eql_v3_ore_block_256_opclass_helpers_are_plpgsql_and_unpinned,
  asserting the corrected invariant: plpgsql (a revert to LANGUAGE sql
  reintroduces the +43% regression), IMMUTABLE, marker present, and
  unpinned (SET search_path on plpgsql adds per-call overhead in the
  same hot path).

All three guard predicates verified by hand against a freshly built and
installed bundle (0 offenders each).

Claude-Session: https://claude.ai/code/session_01StQnoycoFXMDdSpQ6zKDav
@coderdan
coderdan requested a review from tobyhede July 4, 2026 02:59
coderdan added a commit that referenced this pull request Jul 4, 2026
The jsonb_entry / jsonb_query domain doc blocks are public API docs
(Doxygen renders src/ with INTERNAL_DOCS at its default NO), and the
issue-#354 performance rationale added there is an internal concern —
wrap it in @internal/@endinternal so the published docs keep only the
user-facing contract. The #357-style notes on the internal validator
functions were already excluded via their blocks' @internal tags.

Claude-Session: https://claude.ai/code/session_01StQnoycoFXMDdSpQ6zKDav
@coderdan
coderdan merged commit cd28c82 into eql_v3 Jul 4, 2026
18 checks passed
@coderdan
coderdan deleted the dan/cip-3380-v3-opclass-path-helper-functions-are-language-sql-cannot branch July 4, 2026 04:21
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