Skip to content

feat(safegres): perf rule X9 (RLS quals re-evaluated per row) - #1583

Open
pyramation wants to merge 2 commits into
mainfrom
feat/safegres-initplan-rule
Open

feat(safegres): perf rule X9 (RLS quals re-evaluated per row)#1583
pyramation wants to merge 2 commits into
mainfrom
feat/safegres-initplan-rule

Conversation

@pyramation

Copy link
Copy Markdown
Contributor

Summary

STABLE promises 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 shape tenant_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's auth_rls_initplan lint, done on the AST rather than on a regex over pg_policy.

Measured on 200k rows with a policy function that increments a sequence so its invocations are counted rather than inferred:

Policy qual Function calls Time
other_id = bench.current_principal_id() 200,000 424 ms
other_id = (SELECT bench.current_principal_id()) 1 22 ms
->  Seq Scan on rows (actual rows=200000 loops=1)
      Filter: (other_id = bench.current_principal_id())        -- 200k calls

InitPlan 1
  ->  Result (actual rows=1 loops=1)
->  Seq Scan on rows (actual rows=200000 loops=1)
      Filter: (other_id = (InitPlan 1).col1)                   -- 1 call

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

FuncCall in a policy qual
  ∧ provolatile = 's'                       -- IMMUTABLE is folded at plan time
  ∧ no argument references a column          -- else there is nothing to hoist
  ∧ not already inside an uncorrelated (SELECT …)
  → X9

Three consequences worth stating:

  • A GUC-reading helper someone adds next year is caught with no configuration.
  • A bare current_setting('jwt.claims.x') is flagged too — it is itself STABLE, so removing the wrapper function does not avoid the per-row call.
  • VOLATILE is deliberately out of scope: per-row evaluation is its contract, and hoisting one would change semantics. P1 already covers those.

isHoisted() refuses to accept an EXISTS sub-select as a defence — that subquery is correlated with the outer row, so it runs per row and takes the call with it. Only EXPR_SUBLINK with an empty fromClause counts.

Result against constructive-db

Under the constructive preset (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 plain safegres audit is 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 the ast_helpers.rls_fn emit 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 new x9-initplan.sql fixture, which covers the wrapped, correlated-EXISTS, row-dependent-argument, VOLATILE, IMMUTABLE, raw-current_setting, and WITH CHECK cases.

Link to Devin session: https://app.devin.ai/sessions/ec06ef6eabae4872ae5ec3926f037c85
Requested by: @pyramation

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.
@pyramation pyramation self-assigned this Aug 1, 2026
@devin-ai-integration

Copy link
Copy Markdown
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

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