feat(safegres): perf rule X9 (RLS quals re-evaluated per row) - #1583
Open
pyramation wants to merge 2 commits into
Open
feat(safegres): perf rule X9 (RLS quals re-evaluated per row)#1583pyramation wants to merge 2 commits into
pyramation wants to merge 2 commits into
Conversation
STABLE only promises a function's result is fixed within the statement; it does not make the planner call it once. A policy qual of the form `tenant_id = current_tenant()` therefore executes the function for every row the scan considers. Wrapped as `(SELECT current_tenant())` the expression references no column and is hoisted into an InitPlan: one call per query, and a constant the index can be probed with. X9 detects the unwrapped shape structurally — non-IMMUTABLE call, no argument referencing a column of the row, not already inside an uncorrelated scalar sub-select — so it needs no list of known identity functions and flags a bare current_setting() as well. VOLATILE calls are excluded (per-row is their contract; P1 covers them), and an EXISTS sub-select does not count as hoisted because it is correlated with the outer row.
Benchmarked on 200k rows with a policy function counting its own calls: 200,000 invocations / 424 ms unwrapped in a Filter position, 1 / 22 ms via the InitPlan. Notes the caveat the measurement exposed \u2014 the same unwrapped call is evaluated once per scan when the planner turns it into an index condition, so the penalty is plan-dependent and wrapping is what makes it unconditional.
Contributor
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
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
STABLEpromises a function's result won't change within the statement. It does not make the planner call it once. So a policy qual of the shapetenant_id = current_tenant()can be executed for every row the scan considers, and safegres — which already parses policy ASTs — said nothing about it. X9 is that rule. It is Supabase'sauth_rls_initplanlint, done on the AST rather than on a regex overpg_policy.Measured on 200k rows with a policy function that increments a sequence so its invocations are counted rather than inferred:
other_id = bench.current_principal_id()other_id = (SELECT bench.current_principal_id())The caveat the benchmark exposed, and which the rule text states rather than hides: the penalty is plan-dependent. When the planner turns the unwrapped call into an index condition it evaluates it once per scan anyway, so the identical policy costs 1 call on an indexed column and 200,000 the moment the qual lands in a Filter — an unindexed column, a join, an OR branch, a plan flip after
ANALYZE. Wrapping is what makes "once per query" unconditional.Detection is structural, not a list of known identity functions
Three consequences worth stating:
current_setting('jwt.claims.x')is flagged too — it is itselfSTABLE, so removing the wrapper function does not avoid the per-row call.isHoisted()refuses to accept anEXISTSsub-select as a defence — that subquery is correlated with the outer row, so it runs per row and takes the call with it. OnlyEXPR_SUBLINKwith an emptyfromClausecounts.Result against constructive-db
Under the
constructivepreset (24 schemas, 487 exposed tables), X9 fires 1,011 times for one function,jwt_public.current_principal_id, and the perf score drops 30.4 (F) → 7.4 (F),X9 −4044. Security is untouched at 100 (A+); a plainsafegres auditis byte-identical to before, since X9 only runs under--perf. That is the intended direction: the latency bug existed before this PR and scored zero, and the fix belongs one layer down, in theast_helpers.rls_fnemit site that generates all 1,011 of them.Findings carry
context.function, which the perf baseline already keys on, so they ratchet like every other perf rule.21 tests in
perf.test.ts(150 in the package) pass against the newx9-initplan.sqlfixture, which covers the wrapped, correlated-EXISTS, row-dependent-argument, VOLATILE, IMMUTABLE, raw-current_setting, andWITH CHECKcases.Link to Devin session: https://app.devin.ai/sessions/ec06ef6eabae4872ae5ec3926f037c85
Requested by: @pyramation