Search code examples
postgresqlindexingquery-optimization

Postgres only uses part of index and slow filter instead of whole index when using RLS


I've got the following table:

CREATE TYPE "weekday" AS ENUM ('mo', 'tu', 'we', 'th', 'fr', 'sa', 'su');

CREATE TABLE index_test
(
    school_id          uuid    NOT NULL,
    record_number      bigint  NOT NULL,
    weekday            weekday NOT NULL,
    room_record_number int     NULL,
    from_time          time    NOT NULL,
    to_time            time    NOT NULL,
    from_date          date    NOT NULL,
    to_date            date    NULL,
    CONSTRAINT "index_test_pkey" PRIMARY KEY (school_id, record_number)
);
CREATE INDEX "temp_multiple_idx" ON index_test (school_id, weekday, room_record_number, from_time, to_time);
ALTER TABLE index_test ENABLE ROW LEVEL SECURITY;

The RLS policy of the table is the following:

CREATE POLICY "separate_schools" ON "index_test"
    USING (school_id = nullif(current_setting('mm_cloud.rls_restricted_by_school_id', true), '')::uuid);

There also exists a user called index_test for whom RLS applies:

CREATE USER index_test ENCRYPTED PASSWORD 'index_test';
GRANT SELECT ON TABLE index_test to index_test;

I vacuumed the table:

VACUUM (ANALYSE , VERBOSE) index_test;

Then i run the following query using the index_test user:

SET mm_cloud.rls_restricted_by_school_id = '5a950d02-00f1-4bd8-ba27-9ac6c2c5d193';
EXPLAIN (ANALYZE, COSTS, VERBOSE, BUFFERS)
SELECT *
FROM index_test
WHERE weekday = 'we'
  AND room_record_number = 3
  AND from_time = '15:15'
  AND to_time = '16:00';

This results in the following query plan:

+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|QUERY PLAN                                                                                                                                                                                                                                                                                                  |
+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|Index Scan using temp_multiple_idx on public.index_test  (cost=0.43..2020.22 rows=1 width=56) (actual time=0.785..0.786 rows=0 loops=1)                                                                                                                                                                     |
|  Output: school_id, record_number, weekday, room_record_number, from_time, to_time, from_date, to_date                                                                                                                                                                                                     |
|  Index Cond: ((index_test.school_id = (NULLIF(current_setting('mm_cloud.rls_restricted_by_school_id'::text, true), ''::text))::uuid) AND (index_test.room_record_number = 3) AND (index_test.from_time = '15:15:00'::time without time zone) AND (index_test.to_time = '16:00:00'::time without time zone))|
|  Filter: (index_test.weekday = 'we'::weekday)                                                                                                                                                                                                                                                              |
|  Buffers: shared hit=200                                                                                                                                                                                                                                                                                   |
|Planning Time: 0.144 ms                                                                                                                                                                                                                                                                                     |
|Execution Time: 0.806 ms                                                                                                                                                                                                                                                                                    |
+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+

Notice that only a part of the index is used.

Then i run a similar query having the RLS rule inlined, using the table owner user which does not have RLS applied:

EXPLAIN (ANALYZE, COSTS, VERBOSE, BUFFERS)
SELECT *
FROM index_test
WHERE school_id = '5a950d02-00f1-4bd8-ba27-9ac6c2c5d193'
  AND weekday = 'we'
  AND room_record_number = 3
  AND from_time = '15:15'
  AND to_time = '16:00';

This results in a much better query planin using the full index:

+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|QUERY PLAN                                                                                                                                                                                                                                                                                          |
+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|Index Scan using temp_multiple_idx on public.index_test  (cost=0.42..8.45 rows=1 width=56) (actual time=0.073..0.074 rows=0 loops=1)                                                                                                                                                                |
|  Output: school_id, record_number, weekday, room_record_number, from_time, to_time, from_date, to_date                                                                                                                                                                                             |
|  Index Cond: ((index_test.school_id = '5a950d02-00f1-4bd8-ba27-9ac6c2c5d193'::uuid) AND (index_test.weekday = 'we'::weekday) AND (index_test.room_record_number = 3) AND (index_test.from_time = '15:15:00'::time without time zone) AND (index_test.to_time = '16:00:00'::time without time zone))|
|  Buffers: shared hit=3                                                                                                                                                                                                                                                                             |
|Planning Time: 0.260 ms                                                                                                                                                                                                                                                                             |
|Execution Time: 0.128 ms                                                                                                                                                                                                                                                                            |
+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
  • Tested on Postgres 15.2 and 14.7.
  • The table has 119277 rows.

Solution

  • The problem is that the equality operator for enum types is not leakproof:

    SELECT f.proleakproof
    FROM pg_proc AS f
       JOIN pg_operator AS o ON o.oprcode = f.oid
    WHERE o.oprname = '='
      AND o.oprleft = 'anyenum'::regtype
      AND o.oprright = 'anyenum'::regtype;
    
     proleakproof 
    ══════════════
     f
    (1 row)
    

    So the optimizer has to make sure that the condition from the row level security policy is evaluated before that WHERE condition. If both were evaluated with the same index scan, the operator might have to process data that don't qualify for row level security. Using a filter makes sure that the correct order is maintained.

    Don't ask me why that operator is not leakproof. It should be in my opinion, because under the hood, an enum is nothing but a real, and the equality operator for real is leakproof. Perhaps you should bring it up with the pgsql-hackers mailing list (once the current commitfest is over).