Skip to content

docs(0011): document the SQL function inlining trade-off - #192

Open
sidgaikwad wants to merge 1 commit into
supabase:mainfrom
sidgaikwad:docs/0011-inlining-tradeoff
Open

sidgaikwad wants to merge 1 commit into
supabase:mainfrom
sidgaikwad:docs/0011-inlining-tradeoff

Conversation

@sidgaikwad

Copy link
Copy Markdown

What does this PR do?

Adds two sections to 0011_function_search_path_mutable documenting a cost of the remediation this lint recommends, and when it is worth caring about.

Addresses supabase/supabase#33131, which reports that fixing function_search_path_mutable makes functions dramatically slower. It does, for one specific class of function, and the doc currently says nothing about it.

The problem

PostgreSQL will not inline a language sql function that carries a SET clause. Inlining is what lets the planner fold a simple SQL function into the calling query and push the caller's where clause into it. Adding set search_path = '' turns that call into a Function Scan: the function runs to completion and the caller filters the result afterwards.

Measured on PostgreSQL 17.6, 200,000-row table, index on the filtered column, two functions whose bodies are identical and fully qualified so the SET clause is the only difference:

plan shared buffers temp blocks
no SET clause Bitmap Index Scan 203 0
set search_path = '' Function Scan, Rows Removed by Filter: 199800 1748 1219 read / 1219 written

~8.6x the shared buffers plus 1,219 temporary blocks the inlined plan never touches, and it widens with table size. Wall-clock varied a lot run to run, so the doc leads with the plan and buffer counts rather than a ratio.

It is the presence of a SET clause, not the empty value — set search_path = 'some_schema' loses inlining identically — so there is no formulation of the fix that avoids it.

Why the doc does not just say "this fix is slow"

The blast radius is much narrower than the issue makes it sound, and that seemed like the more useful thing to write down:

  • PL/pgSQL functions are unaffected. PostgreSQL never inlines them, so pinning the search path costs nothing. Measured: 24.88 ms unpinned vs 25.16 ms pinned, both already Function Scan. Most functions people write against this lint are PL/pgSQL, and for those the answer is simply "apply the fix".
  • SQL functions off a hot path pay a cost nobody notices.

So the doc scopes the warning to the case that actually pays: an argument-less language sql function called inside a larger query that relies on predicate pushdown. For that case it suggests a view, which produces the identical plan (Bitmap Index Scan, 203 buffers) and, because a view stores resolved references rather than names, does not depend on the caller's search path at all — verified by re-running the plan under set search_path = ''.

Two caveats are called out with it, since recommending the rewrite without them would be its own bug: a view cannot take arguments, and a view runs with its owner's privileges unless created with security_invoker = true (cross-linked to 0010).

Deliberately not changed

No lint logic. 0011 matches on proconfig alone and does not look at prosecdef, so a SECURITY INVOKER SQL function is flagged the same as a SECURITY DEFINER one even though the escalation vector is weaker. Whether that distinction should change the lint is a policy call for maintainers, not something a docs PR should decide.

Verification

Every SQL snippet and every query plan in the new sections was re-run in a clean schema built from scratch. The create function / create view statements execute verbatim, and the quoted plans reproduce exactly, including Rows Removed by Filter: 199800 and Buffers: shared hit=1748, temp read=1219 written=1219.

bin/compile.py and bin/check_lints.py both pass with no changes to splinter.sql; the cross-link uses the same [name](NNNN_name.md) form as the existing lint-to-lint links, which the docs site rewrites to ?lint=.

PostgreSQL will not inline a `language sql` function that carries a `SET`
clause, so the remediation this lint recommends turns an inlinable function
into a `Function Scan`: the function runs to completion and the caller filters
afterwards, instead of the caller's predicate reaching the index.

On a 200,000-row table with an index on the filtered column, and two functions
whose bodies are identical and fully qualified, the pinned call reads ~8.6x the
shared buffers (203 -> 1748) and spills 1,219 temporary blocks the inlined plan
never touches. The gap widens with table size.

It is the presence of a `SET` clause rather than the empty value, so no
formulation of the fix avoids it.

Document when this is worth caring about, which is narrower than it first
looks: PL/pgSQL functions are never inlined, so pinning costs them nothing, and
a SQL function outside a hot path will not notice. For an argument-less SQL
function that does rely on predicate pushdown, a view gives the same plan and
does not depend on the caller's search path at all -- with the two caveats that
a view cannot take arguments and defaults to the owner's privileges.

Addresses supabase/supabase#33131
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