This repository was archived by the owner on Feb 24, 2024. It is now read-only.
Repository navigation
This repository was archived by the owner on Feb 24, 2024. It is now read-only.
current_setting can lead to bad performance when used on RLS #609
Copy link
Copy link
Open
Labels
referencesTechnical reference.Technical reference.
Description
Edit: solution below.
Having:
-- $ postgrest-with-postgresql-15 psql -- on nix-shell
create table chat_messages(
id text PRIMARY KEY
, content text
, workspace uuid
);
create or replace function app_metadata()
returns jsonb as $$
select ((current_setting('request.jwt.claims', true)::jsonb)->'app_metadata');
$$ language sql stable;
alter table chat_messages enable row level security;
drop policy if exists sel on chat_messages;
create policy sel on chat_messages for select
using(
workspace = (app_metadata() ->> 'workspace')::uuid
);
grant select on chat_messages to postgrest_test_anonymous;
insert into chat_messages(id, content, workspace)
select
'id-' || x,
'content-' || x,
case when x % 2 = 0
then 'b080c0f7-8c22-429c-9d1a-ade3e43fb109'::uuid
else '2156a8d9-d06f-40cd-adf8-411ae8c0bc76'::uuid
end
from generate_series(1, 3000000) x;Doing a count is fast without RLS:
explain analyze SELECT COUNT(*) FROM chat_messages;
Planning Time: 0.105 ms
Execution Time: 161.549 msWhen RLS is enabled, the count is awfully slow:
begin;
set local role to postgrest_test_anonymous;
select set_config('request.jwt.claims', '{"aal":"aal1","amr":[{"method":"password","timestamp":1670987059}],"app_metadata":{"provider":"email","team":"T01TQ1XH8RW","workspace":"2156a8d9-d06f-40cd-adf8-411ae8c0bc76","workspace_int":195,"workspace_small_text":"195"},"aud":"authenticated","email":"user@email.com","exp":1671591861,"phone":"","role":"authenticated","session_id":"9033fca4-07e0-42ff-9e2f-0b0aa738e3d5","sub":"ef79d643-550e-4e65-a963-ad6f50e67dfe","user_metadata":{}}', true);
SELECT COUNT(*) FROM chat_messages;
count
---------
1500000
explain analyze SELECT COUNT(*) FROM chat_messages;
QUERY PLAN
----------------------------------------------------------------------------------------------------------------------------------------------------
Aggregate (cost=90821.44..90821.45 rows=1 width=8) (actual time=18597.007..18597.008 rows=1 loops=1)
-> Seq Scan on chat_messages (cost=0.00..90795.25 rows=10476 width=0) (actual time=18597.002..18597.002 rows=0 loops=1)
Filter: (workspace = ((((current_setting('request.jwt.claims'::text, true))::jsonb -> 'app_metadata'::text) ->> 'workspace'::text))::uuid)
Rows Removed by Filter: 3000000
Planning Time: 0.783 ms
Execution Time: 18597.062 msThis happens because:
- jsonb casting(
::jsonb) is not LEAKPROOF , this means that the casting will be done for each row on the query, PostgreSQL doesn't cache this static value. - The
jsonvalue inside therequest.jwt.claimsis somewhat lengthy. We have no control over this as the JWT can come from an external system. A shorterjsonclaims will lead to less time:
begin;
set local role to postgrest_test_anonymous;
select set_config('request.jwt.claims', '{"app_metadata":{"workspace":"2156a8d9-d06f-40cd-adf8-411ae8c0bc76"}}', true);
explain analyze SELECT COUNT(*) FROM chat_messages;
QUERY PLAN
----------------------------------------------------------------------------------------------------------------------------------------------------
Aggregate (cost=117937.00..117937.01 rows=1 width=8) (actual time=3449.217..3449.218 rows=1 loops=1)
-> Seq Scan on chat_messages (cost=0.00..117937.00 rows=1 width=0) (actual time=3449.213..3449.213 rows=0 loops=1)
Filter: (workspace = ((((current_setting('request.jwt.claims'::text, true))::jsonb -> 'app_metadata'::text) ->> 'workspace'::text))::uuid)
Rows Removed by Filter: 3000000
Planning Time: 0.259 ms
Execution Time: 3449.257 msThe count is a contrived example(for which there are workarounds) but more real queries will lead to the same problem.
References:
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
referencesTechnical reference.Technical reference.
Activity
dwagin commentedon Dec 20, 2022
wolfgangwalther commentedon Dec 20, 2022
This is certainly not because of LEAKPROOF or RLS. You can see it easily, by manually executing the constructed query:
This is equally slow - even without RLS.
LEAKPROOFis about pushing conditions through RLS, i.e. when you do a querySELECT ... FROM rls_enabled_table WHERE some_func_used_here(col). In this case some_func_used_here must be definedLEAKPROOFto make it run on the base query directly - and not on the result after applying RLS.But in this case, your function is inside the RLS policy. If you're leaking data there, that's all your own responsibility.
Yes, this is the right solution.
steve-chavez commentedon Dec 20, 2022
We encourage using
current_settingon RLS on the docs , that has been there for quite a while. All the HTTP context GUCs could be used inside RLS, and we can't control the wrapper functions users put over them.Thanks dwagin, yeah that works. But the thing is, we didn't have this issue with the legacy GUCs that were available before v9.0.
So in the above example, by doing this:
The query is now x14 faster, without adding an index. So the JSON GUC parsing is indeed a problem.
steve-chavez commentedon Dec 20, 2022
My proposal would be to have an allowlist config that brings back the legacy GUCs for certain keys to avoid the JSON parsing problem. Could be something like
This seems related to PostgREST/postgrest#1941, maybe the config proposed there could be merged with this one.
wolfgangwalther commentedon Dec 20, 2022
I still think it's only a problem when you parse it 3 million times like in your example on a seq scan. But that's not the GUCs fault.
You can easily do this in a pre-request function. Just set your custom GUC to the extracted value from the JSON.
I'd rather wait for schema variables to finally come to PG - because those will solve this nicely.
steve-chavez commentedon Dec 20, 2022
Yeah, that would work for avoiding the JSON guc but it will also come with a perf decrease because we don't have pipeline mode. Last time I checked adding a
pre-requestwas like 15% reduction in throughput.True. Have you reviewed that feature by any chance? I wonder if it will land on pg16 and if indeed we can use it as we intend to.
steve-chavez commentedon Dec 20, 2022
That's a downside I guess, it will be almost a year for pg16.
As I mentioned above the example is contrived. But there are some real world queries with big tables where filtering on some millions of rows is legitimate. I do think the JSON GUC is a problem here since the perf problem doesn't happen on the legacy gucs.
dwagin commentedon Dec 20, 2022
In my environment only x3 (PG 13).
steve-chavez commentedon Dec 20, 2022
Hm, I've tried on pg13(with
nix-shellandpostgrest-with-postgresql-13 psql) and I get a x9 perf increase.dwagin commentedon Dec 20, 2022
And speed up when
PARALLEL RESTRICTEDsteve-chavez commentedon Feb 2, 2023
Circling back on this. How about this config:
GUCs can be removed and shortcuts can be added:
For every sub level on
request.jwt.claims.sub-level/request.headers.sub-levelwe'll get the value of thesub-leveljson key.This will solve this issue and PostgREST/postgrest#1941.
I think it will be useful even if we can do it with
pre-requestlater.steve-chavez commentedon Apr 22, 2023
Just found out a plain SQL solution to the above issue, we can force computing the
current_settingonce with a MATERIALIZED CTE. So we have to change the above policy to:Now the policy is still correct and the COUNT is fast:
This should be added to the docs
tonyxiao commentedon Apr 22, 2023
This is epic! Also works wonders for things like postgres realtime too!
steve-chavez commentedon Apr 25, 2023
Note the above policy with the materialized CTE is not always a win. If the column is indexed, the above policy results in a noticeable slowdown(almost x6 slower) decrease:
While the original policy without the materialized CTE from above:
So maybe we can just boil down to "always use an index when comparing against
current_setting()values, if not possible make sure to cache them in a materialized CTE".[-]JSON GUCs lead to bad performance when used on RLS[/-][+]`current_setting` can lead to bad performance when used on RLS[/+]wolfgangwalther commentedon Apr 26, 2023
There is a better way to force pg to execute the
app_metadatafunction only once for the query: Make it use anInitPlan.Instead of this:
You'll need to wrap the right side in a scalar subquery:
Since the subquery does not depend on the outer query, it will result in an InitPlan. This will run it exactly once, and then put the result into the main query which can use an index or not, it doesn't matter - it will be fast in both cases.
First policy explained:
Second policy explained:
Second policy + index:
tonyxiao commentedon Apr 26, 2023
interesting, so should be the same performance as materialized CTE but a bit less verbose to write?
wolfgangwalther commentedon Apr 26, 2023
No, it has better performance than a materialized CTE.
GaryAustin1 commentedon Apr 26, 2023
I did a quick test in https://github.com/orgs/supabase/discussions/9311#discussioncomment-5732511 and get the same result with either method on a quick test, I'll do more. It makes sense they would be the same as they set a constant value to compare rows against and get run once.
BUT I believe this method is the one scattered thru out my original table https://github.com/orgs/supabase/discussions/9311#discussioncomment-3815869 which did things like:

GaryAustin1 commentedon Apr 26, 2023
I did just run a 2nd test of the original auth.uid() = user_id 25K rows, and get
43, 13, 3 for original CTE, init with non_indexed user_id
.07, 13, .13 for orginal, CTE, init with indexed user_id
So the initplan way is faster in this case.
If you have an indexed column it seems to take advantage of it, but still slower than just the indexed column test.
wolfgangwalther commentedon Apr 26, 2023
The difference you see between initplan and without for an indexed query is negligible. In theory, it's a very tiny overhead of running an additional initplan node in the query. But doing a few more runs gives me the following results:
Bare query using index, but no initplan:
Scalar subquery using index and initplan:
There's a lot more noise here than any noticeable difference between the two.
GaryAustin1 commentedon Apr 26, 2023
Added some more tests a realistic two part RLS policy here with code: https://github.com/orgs/supabase/discussions/9311#discussioncomment-5734798
is_admin() OR auth.uid() = user_id100K selects, no difference between indexed and not. Offset is adding
limit 10 offset 90000but only for admin as user gets 1 row.wrapped is initplan
Edit... Adding
where user_id = 'UUID'to the query takes the time down to... 0.2msec from 10 and 53 for a user query.