Repository navigation
tooling: add an inlinability lint for EQL functions and operators #194
Description
Summary
Build a lint tool — modelled on Supabase's Splinter — that flags EQL functions and operators which the Postgres planner cannot inline. Non-inlinable functions in operator implementations silently degrade encrypted-query performance: the documented functional indexes (hmac_256, bloom_filter, ste_vec) never engage, and customers seq-scan at scale without a clear diagnostic.
Motivation
Three real issues we've shipped or hit recently are all detectable by a static lint:
- perf: eql_v2.like / eql_v2.ilike are VOLATILE — blocks planner inlining and bloom_filter index match #189 —
eql_v2.like/eql_v2.ilikewereLANGUAGE sql VOLATILE. Volatile blocks inlining;bloom_filterindex never engaged. - perf: Drizzle eq/ne/inArray emit bare equality, bypassing hmac functional indexes stack#421 — Drizzle's bare-equality emit relied on
=operator dispatching to a plpgsql function that can't inline. Same root cause. - perf: encryptedSupabase emits bare-column query forms that don't engage Supabase functional indexes stack#420 — encryptedSupabase's bare
=/LIKEqueries seq-scan on Supabase because the implementation function isn't inlinable. Currently in flight as a fix in this repo (Phase 1 of the predicate/extractor RFC).
Today we discover these by running EXPLAIN on a representative bench fixture and noticing a Seq Scan where there shouldn't be one. That's slow, manual, easy to miss in code review, and only catches what the bench happens to exercise. A lint runs at PR time over the full operator surface and catches the issue at definition time.
Postgres SQL function inlining rules
The lint is straightforward because the rules are deterministic and queryable from pg_catalog:
| Property | pg_proc column |
Required for inlining |
|---|---|---|
| Language | prolang::regtype |
must be sql |
| Volatility | provolatile |
i (immutable) or s (stable) |
| Body shape | prosrc |
single SELECT statement |
SET clauses |
proconfig |
must be NULL |
| Security | prosecdef |
must be false |
Any operator implementation function that fails one of these is non-inlinable. The lint can produce a row per violation, machine-readable for CI gating + human-readable for developer diagnosis.
Initial scope (v1)
eql_v2.lints()— a single SQL function returning(severity text, category text, object_name text, message text).- Direct check on operator implementations — for every operator on
eql_v2_encrypted(and the supporting types likeeql_v2.bloom_filter,eql_v2.ore_block_*), confirm the implementation function is inlinable. - Transitive check — when an inlinable SQL function calls a non-inlinable function, the chain breaks. Walk one level via
pg_depend(or string-match inprosrcas a fallback). - Test fixture — assert the lint reports zero violations on a clean install (post-Phase 1).
Future scope (out of v1)
- Index-size budget — given an index expression
f(col), estimate the row size fromf's return type and flag if it could exceed the 2704-byte btree limit. Would have caught thebench_text_eql_idxfailure in perf(bench): add @cipherstash/bench for index-engagement validation stack#424. - Operator coverage — every documented operator pair on encrypted types is registered with a commutator/negator where applicable.
- STRICT extractors — every
_sem-style extractor (per the predicate/extractor RFC) is declaredSTRICT. - Build-time static analyzer — parse the
.sqlsource files in this repo before install. Easier to integrate with the doxygen / comment-annotation work already in flight.
Exemption mechanism
Some functions are intentionally non-inlinable (e.g. eql_v2.compare is a multi-method dispatcher by design). The lint should support an opt-out, ideally via a Doxygen-style comment annotation (consistent with the doc-generation work already underway): --! @lint-allow non-inlinable: dispatcher by design. Annotation lives next to the code it exempts.
Delivery
- Phase 1 (this issue): install-time SQL function
eql_v2.lints(). Runs against any installed EQL. Splinter-style. - Phase 2 (follow-up): integrate into EQL CI as a gate — every PR runs
SELECT * FROM eql_v2.lints() WHERE severity = 'error'and fails the build on non-empty. - Phase 3 (follow-up): build-time static analyzer for definition-level checks that don't need a running DB.
Validation plan
The first PR for the lint stacks ahead of #193 (Phase 1 operator inlining). The lint identifies what #193 needs to fix; #193 fixes it; the lint is then clean. That's the regression-test loop in microcosm — the lint catches the problem the fix addresses.
Related
- perf: eql_v2.like / eql_v2.ilike are VOLATILE — blocks planner inlining and bloom_filter index match #189 —
like/ilikevolatility flip (closed; would have been caught by this lint) - perf: flip eql_v2_encrypted infix operator implementations to inlinable SQL (RFC Phase 1) #193 — Phase 1 operator inlining (the test target for v1 of this lint)
- perf: encryptedSupabase emits bare-column query forms that don't engage Supabase functional indexes stack#420, #421, #426 — downstream issues that would have been pre-empted by lint coverage
docs/plans/uniform-predicate-extractor-pairs-rfc.md— broader predicate/extractor RFC (this lint is complementary tooling)
Activity
perf: flip eql_v2_encrypted infix operator implementations to inlinab…
feat: add eql_v2.lints() inlinability lint (#194)
perf: flip eql_v2_encrypted infix operator implementations to inlinab…
perf: flip eql_v2_encrypted infix operator implementations to inlinab…
Merge pull request #195 from cipherstash/dan/lint-inlinability
perf: flip eql_v2_encrypted infix operator implementations to inlinab…
feat: add eql_v2.lints() inlinability lint (#194)
Merge pull request #195 from cipherstash/dan/lint-inlinability
perf: flip eql_v2_encrypted infix operator implementations to inlinab…