Skip to content

tooling: add an inlinability lint for EQL functions and operators #194

Description

@coderdan

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:

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 like eql_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 in prosrc as 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 from f's return type and flag if it could exceed the 2704-byte btree limit. Would have caught the bench_text_eql_idx failure 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 declared STRICT.
  • Build-time static analyzer — parse the .sql source 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

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions