diff --git a/bin/installcheck b/bin/installcheck index b54f633..659aa2f 100755 --- a/bin/installcheck +++ b/bin/installcheck @@ -52,7 +52,7 @@ else fi # Execute the test fixtures -psql -v ON_ERROR_STOP= -f test/fixtures.sql -f lints/0001*.sql -f lints/0002*.sql -f lints/0003*.sql -f lints/0004*.sql -f lints/0005*.sql -f lints/0006*.sql -f lints/0007*.sql -f lints/0008*.sql -f lints/0009*.sql -f lints/0010*.sql -f lints/0011*.sql -f lints/0013*.sql -f lints/0014*.sql -f lints/0015*.sql -f lints/0016*.sql -f lints/0017*.sql -f lints/0018*.sql -f lints/0019*.sql -f lints/0020*.sql -f lints/0021*.sql -f lints/0022*.sql -f lints/0023*.sql -f lints/0024*.sql -f lints/0025*.sql -f lints/0026*.sql -f lints/0027*.sql -f lints/0028*.sql -f lints/0029*.sql -d contrib_regression +psql -v ON_ERROR_STOP= -f test/fixtures.sql -f lints/0001*.sql -f lints/0002*.sql -f lints/0003*.sql -f lints/0004*.sql -f lints/0005*.sql -f lints/0006*.sql -f lints/0007*.sql -f lints/0008*.sql -f lints/0009*.sql -f lints/0010*.sql -f lints/0011*.sql -f lints/0013*.sql -f lints/0014*.sql -f lints/0015*.sql -f lints/0016*.sql -f lints/0017*.sql -f lints/0018*.sql -f lints/0019*.sql -f lints/0020*.sql -f lints/0021*.sql -f lints/0022*.sql -f lints/0023*.sql -f lints/0024*.sql -f lints/0025*.sql -f lints/0026*.sql -f lints/0027*.sql -f lints/0028*.sql -f lints/0029*.sql -f lints/0030*.sql -d contrib_regression # Run tests ${REGRESS} --use-existing --dbname=contrib_regression --inputdir=${TESTDIR} ${TESTS} diff --git a/docs/0030_invalid_index.md b/docs/0030_invalid_index.md new file mode 100644 index 0000000..297112a --- /dev/null +++ b/docs/0030_invalid_index.md @@ -0,0 +1,61 @@ + +**Level:** WARN + +**Summary:** Index is marked invalid and is never used by the query planner. + +**Ramification:** The index still consumes disk space and is maintained (slowing writes) on every insert/update, but provides zero query benefit and, if it was meant to back a unique constraint, that constraint is not being enforced. + +--- + +### Rationale + +Postgres marks an index `invalid` (`pg_index.indisvalid = false`) when it is left in a partially built state, most commonly: + +- `CREATE INDEX CONCURRENTLY` fails partway through (e.g. a conflicting row, a timeout, or the session was killed) +- `REINDEX CONCURRENTLY` fails partway through +- A crash occurred while `CREATE INDEX CONCURRENTLY` was running + +An invalid index is never used by the planner, but Postgres does not automatically drop it. It sits on disk, still gets updated on every write to the underlying table, and does nothing useful in return. + +### How to Resolve + +**Option 1: Reindex concurrently** + +```sql +reindex index concurrently public.idx_orders_customer_id; +``` + +This rebuilds and validates the index without blocking writes to the table. + +Indexes backing exclusion constraints cannot be reindexed concurrently, and a plain `drop index` is rejected because the constraint requires the index. Only in that case, reindex non-concurrently. This blocks writes to the table and may take significant time on large tables, so run it in a maintenance window: + +```sql +reindex index public.reservations_during_excl; +``` + +**Option 2: Investigate why it failed first** + +If the original `CREATE INDEX CONCURRENTLY` failed due to a constraint violation (common for unique indexes), fix the underlying data before recreating the index, otherwise the rebuild will fail the same way. + +### Example + +Given this problematic configuration: + +```sql +-- This fails partway through, e.g. due to a lock timeout or duplicate values +create unique index concurrently idx_orders_order_number +on public.orders (order_number); +``` + +The resulting index is left behind as invalid — still consuming space, still slowing down writes, enforcing nothing. + +Fix by rebuilding it: + +```sql +reindex index concurrently idx_orders_order_number; +``` + +### False Positives + +1. Partitioned parent indexes can be marked as invalid even after the children have been repaired, see the [Postgres mailing list for more information](https://www.postgresql.org/message-id/CAGnOmWqi1D9ycBgUeOGf6mOCd2Dcf%3D6sKhbf4sHLs5xAcKVCMQ%40mail.gmail.com) +2. In progress indexes - an index that is still being built can show as invalid. To address this the lint only considers indexes where `indisready` is true and excludes builds reported in `pg_stat_progress_create_index`, but there may be edge cases diff --git a/lints/0030_invalid_index.sql b/lints/0030_invalid_index.sql new file mode 100644 index 0000000..1776132 --- /dev/null +++ b/lints/0030_invalid_index.sql @@ -0,0 +1,77 @@ +create view lint."0030_invalid_index" as + +-- Detects indexes marked as invalid in pg_index.indisvalid, typically left behind +-- by a failed `CREATE INDEX CONCURRENTLY` or `REINDEX CONCURRENTLY`. Invalid +-- indexes are never used by the query planner but still consume disk space and +-- take a write penalty to maintain. +select + 'invalid_index' as name, + 'Invalid Index' as title, + 'WARN' as level, + 'EXTERNAL' as facing, + array['PERFORMANCE'] as categories, + 'Detects indexes marked as invalid, typically left behind by a failed `CREATE INDEX CONCURRENTLY` or `REINDEX CONCURRENTLY`. Invalid indexes are ignored by the planner but still incur maintenance overhead.' as description, + case + when con.oid is not null then format( + 'Index `%s` on table `%s.%s` is invalid and is not used by the query planner. It backs an exclusion constraint and cannot be reindexed concurrently. Rebuild it with `reindex index %s.%s;`. Note that a non-concurrent reindex blocks writes to the table and may take significant time on large tables.', + ic.relname, + nsp.nspname, + tc.relname, + pg_catalog.quote_ident(nsp.nspname), + pg_catalog.quote_ident(ic.relname) + ) + else format( + 'Index `%s` on table `%s.%s` is invalid and is not used by the query planner. Rebuild it with `reindex index concurrently %s.%s;`', + ic.relname, + nsp.nspname, + tc.relname, + pg_catalog.quote_ident(nsp.nspname), + pg_catalog.quote_ident(ic.relname) + ) + end as detail, + 'https://supabase.com/docs/guides/database/database-linter?lint=0030_invalid_index' as remediation, + jsonb_build_object( + 'schema', nsp.nspname, + 'name', tc.relname, + 'type', 'table', + 'index_name', ic.relname + ) as metadata, + format('invalid_index_%s_%s_%s', nsp.nspname, tc.relname, ic.relname) as cache_key +from + pg_catalog.pg_index pi + join pg_catalog.pg_class ic + on pi.indexrelid = ic.oid + join pg_catalog.pg_class tc + on pi.indrelid = tc.oid + join pg_catalog.pg_namespace nsp + on tc.relnamespace = nsp.oid + left join pg_catalog.pg_depend dep + on dep.objid = ic.oid + and dep.deptype = 'e' + and dep.classid = 'pg_catalog.pg_class'::regclass + left join pg_catalog.pg_constraint con + on con.conindid = ic.oid + and con.contype = 'x' +where + not pi.indisvalid + -- partitioned parent indexes ('I') are invalid by design until every + -- child index attaches + and ic.relkind = 'i' + and pi.indisready -- exclude indexes that are still being built (phase 1) + and not exists ( -- exclude indexes actively being built (phase 2+) + select 1 from pg_catalog.pg_stat_progress_create_index pci + where pci.index_relid = ic.oid + ) + and dep.objid is null -- exclude indexes owned by extensions + and nsp.nspname not in ( + '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', + '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', + 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', + 'pgsodium', 'pgsodium_masks', 'pgbouncer', 'pg_catalog', + 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', + 'supabase_migrations', 'tiger', 'topology', 'vault' + ) +order by + nsp.nspname, + tc.relname, + ic.relname; diff --git a/mkdocs.yaml b/mkdocs.yaml index 1f5d05d..9dc469f 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -37,6 +37,7 @@ nav: - Signed-In Users Can See Object in GraphQL Schema: '0027_pg_graphql_authenticated_table_exposed.md' - Public Can Execute SECURITY DEFINER Function: '0028_anon_security_definer_function_executable.md' - Signed-In Users Can Execute SECURITY DEFINER Function: '0029_authenticated_security_definer_function_executable.md' + - Invalid indexes exist in the database: '0030_invalid_index.md' theme: name: 'material' diff --git a/splinter.sql b/splinter.sql index 9cd66c9..9889fc7 100644 --- a/splinter.sql +++ b/splinter.sql @@ -1838,4 +1838,81 @@ from order by schema_name, function_name, - function_args) \ No newline at end of file + function_args) +union all +( +-- Detects indexes marked as invalid in pg_index.indisvalid, typically left behind +-- by a failed `CREATE INDEX CONCURRENTLY` or `REINDEX CONCURRENTLY`. Invalid +-- indexes are never used by the query planner but still consume disk space and +-- take a write penalty to maintain. +select + 'invalid_index' as name, + 'Invalid Index' as title, + 'WARN' as level, + 'EXTERNAL' as facing, + array['PERFORMANCE'] as categories, + 'Detects indexes marked as invalid, typically left behind by a failed `CREATE INDEX CONCURRENTLY` or `REINDEX CONCURRENTLY`. Invalid indexes are ignored by the planner but still incur maintenance overhead.' as description, + case + when con.oid is not null then format( + 'Index `%s` on table `%s.%s` is invalid and is not used by the query planner. It backs an exclusion constraint and cannot be reindexed concurrently. Rebuild it with `reindex index %s.%s`. Note that a non-concurrent reindex blocks writes to the table and may take significant time on large tables.', + ic.relname, + nsp.nspname, + tc.relname, + pg_catalog.quote_ident(nsp.nspname), + pg_catalog.quote_ident(ic.relname) + ) + else format( + 'Index `%s` on table `%s.%s` is invalid and is not used by the query planner. Rebuild it with `reindex index concurrently %s.%s`', + ic.relname, + nsp.nspname, + tc.relname, + pg_catalog.quote_ident(nsp.nspname), + pg_catalog.quote_ident(ic.relname) + ) + end as detail, + 'https://supabase.com/docs/guides/database/database-linter?lint=0030_invalid_index' as remediation, + jsonb_build_object( + 'schema', nsp.nspname, + 'name', tc.relname, + 'type', 'table', + 'index_name', ic.relname + ) as metadata, + format('invalid_index_%s_%s_%s', nsp.nspname, tc.relname, ic.relname) as cache_key +from + pg_catalog.pg_index pi + join pg_catalog.pg_class ic + on pi.indexrelid = ic.oid + join pg_catalog.pg_class tc + on pi.indrelid = tc.oid + join pg_catalog.pg_namespace nsp + on tc.relnamespace = nsp.oid + left join pg_catalog.pg_depend dep + on dep.objid = ic.oid + and dep.deptype = 'e' + and dep.classid = 'pg_catalog.pg_class'::regclass + left join pg_catalog.pg_constraint con + on con.conindid = ic.oid + and con.contype = 'x' +where + not pi.indisvalid + -- partitioned parent indexes ('I') are invalid by design until every + -- child index attaches + and ic.relkind = 'i' + and pi.indisready -- exclude indexes that are still being built (phase 1) + and not exists ( -- exclude indexes actively being built (phase 2+) + select 1 from pg_catalog.pg_stat_progress_create_index pci + where pci.index_relid = ic.oid + ) + and dep.objid is null -- exclude indexes owned by extensions + and nsp.nspname not in ( + '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', + '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', + 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', + 'pgsodium', 'pgsodium_masks', 'pgbouncer', 'pg_catalog', + 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', + 'supabase_migrations', 'tiger', 'topology', 'vault' + ) +order by + nsp.nspname, + tc.relname, + ic.relname) \ No newline at end of file diff --git a/test/expected/0030_invalid_index.out b/test/expected/0030_invalid_index.out new file mode 100644 index 0000000..24cfe46 --- /dev/null +++ b/test/expected/0030_invalid_index.out @@ -0,0 +1,118 @@ +begin; + set local search_path = ''; + -- BASELINE: 0 issues on empty schema + select * from lint."0030_invalid_index"; + name | title | level | facing | categories | description | detail | remediation | metadata | cache_key +------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- +(0 rows) + + savepoint a; + -- NEGATIVE EXAMPLE: a normal, valid index should NOT trigger + create table public.orders( + id int primary key, + customer_id int + ); + create index idx_orders_customer_id on public.orders (customer_id); + select * from lint."0030_invalid_index"; -- expect 0 rows + name | title | level | facing | categories | description | detail | remediation | metadata | cache_key +------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- +(0 rows) + + rollback to savepoint a; + -- NEGATIVE EXAMPLE: a partitioned parent index created with ON ONLY is + -- marked invalid by design until every child index is attached; it should + -- NOT trigger + create table public.events(id int, ts date) partition by range (ts); + create table public.events_2026 partition of public.events + for values from ('2026-01-01') to ('2027-01-01'); + create index idx_events_ts on only public.events (ts); + select * from lint."0030_invalid_index"; -- expect 0 rows + name | title | level | facing | categories | description | detail | remediation | metadata | cache_key +------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- +(0 rows) + + rollback to savepoint a; + -- POSITIVE EXAMPLE: an index left invalid (as happens when + -- CREATE INDEX CONCURRENTLY / REINDEX CONCURRENTLY fails partway through) + -- Simulated here via direct catalog update since CONCURRENTLY cannot + -- run inside a transaction block. + create table public.orders( + id int primary key, + order_number int + ); + create unique index idx_orders_order_number on public.orders (order_number); + update pg_catalog.pg_index + set indisvalid = false + where indexrelid = 'public.idx_orders_order_number'::regclass; + select name, detail, cache_key from lint."0030_invalid_index"; -- expect 1 row + name | detail | cache_key +---------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+----------------------------------------------------- + invalid_index | Index `idx_orders_order_number` on table `public.orders` is invalid and is not used by the query planner. Rebuild it with `reindex index concurrently public.idx_orders_order_number;` | invalid_index_public_orders_idx_orders_order_number +(1 row) + + -- RESOLUTION: reindex validates the index. The docs recommend + -- `reindex index concurrently` but CONCURRENTLY cannot run inside a + -- transaction block, so the test uses the non-concurrent form. + reindex index public.idx_orders_order_number; + select * from lint."0030_invalid_index"; -- expect 0 rows + name | title | level | facing | categories | description | detail | remediation | metadata | cache_key +------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- +(0 rows) + + rollback to savepoint a; + -- POSITIVE EXAMPLE (exception): an invalid index backing an exclusion + -- constraint cannot be reindexed concurrently, so the message recommends + -- a non-concurrent reindex instead. + create table public.reservations( + id int primary key, + during int4range, + constraint reservations_during_excl exclude using gist (during with &&) + ); + update pg_catalog.pg_index + set indisvalid = false + where indexrelid = 'public.reservations_during_excl'::regclass; + select name, detail, cache_key from lint."0030_invalid_index"; -- expect 1 row + name | detail | cache_key +---------------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+------------------------------------------------------------ + invalid_index | Index `reservations_during_excl` on table `public.reservations` is invalid and is not used by the query planner. It backs an exclusion constraint and cannot be reindexed concurrently. Rebuild it with `reindex index public.reservations_during_excl;`. Note that a non-concurrent reindex blocks writes to the table and may take significant time on large tables. | invalid_index_public_reservations_reservations_during_excl +(1 row) + + -- RESOLUTION: a non-concurrent reindex validates the index + reindex index public.reservations_during_excl; + select * from lint."0030_invalid_index"; -- expect 0 rows + name | title | level | facing | categories | description | detail | remediation | metadata | cache_key +------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- +(0 rows) + + rollback to savepoint a; + -- POSITIVE EXAMPLE: a LEAF partition's index left invalid IS flagged. Only the + -- partitioned PARENT index (relkind 'I') is excluded; a leaf partition's index + -- (relkind 'i') is an ordinary index and must still be linted. + create table public.events(id int, ts date) partition by range (ts); + create table public.events_2026 partition of public.events + for values from ('2026-01-01') to ('2027-01-01'); + create index idx_events_2026_ts on public.events_2026 (ts); + update pg_catalog.pg_index + set indisvalid = false + where indexrelid = 'public.idx_events_2026_ts'::regclass; + select name, detail, cache_key from lint."0030_invalid_index"; -- expect 1 row + name | detail | cache_key +---------------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+----------------------------------------------------- + invalid_index | Index `idx_events_2026_ts` on table `public.events_2026` is invalid and is not used by the query planner. Rebuild it with `reindex index concurrently public.idx_events_2026_ts;` | invalid_index_public_events_2026_idx_events_2026_ts +(1 row) + + rollback to savepoint a; + -- NEGATIVE EXAMPLE: an invalid index in an internal (excluded) schema is NOT + -- flagged -- the lint only reports customer-facing schemas. + create schema repack; + create table repack.t(id int); + create unique index idx_repack_t_id on repack.t (id); + update pg_catalog.pg_index + set indisvalid = false + where indexrelid = 'repack.idx_repack_t_id'::regclass; + select * from lint."0030_invalid_index"; -- expect 0 rows + name | title | level | facing | categories | description | detail | remediation | metadata | cache_key +------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- +(0 rows) + +rollback; diff --git a/test/expected/queries_are_unionable.out b/test/expected/queries_are_unionable.out index 3b1005d..fbfa37e 100644 --- a/test/expected/queries_are_unionable.out +++ b/test/expected/queries_are_unionable.out @@ -54,7 +54,9 @@ begin; union all select * from lint."0028_anon_security_definer_function_executable" union all - select * from lint."0029_authenticated_security_definer_function_executable"; + select * from lint."0029_authenticated_security_definer_function_executable" + union all + select * from lint."0030_invalid_index"; name | title | level | facing | categories | description | detail | remediation | metadata | cache_key ------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- (0 rows) diff --git a/test/sql/0030_invalid_index.sql b/test/sql/0030_invalid_index.sql new file mode 100644 index 0000000..57ceee8 --- /dev/null +++ b/test/sql/0030_invalid_index.sql @@ -0,0 +1,103 @@ +begin; + set local search_path = ''; + + -- BASELINE: 0 issues on empty schema + select * from lint."0030_invalid_index"; + + savepoint a; + + -- NEGATIVE EXAMPLE: a normal, valid index should NOT trigger + create table public.orders( + id int primary key, + customer_id int + ); + create index idx_orders_customer_id on public.orders (customer_id); + + select * from lint."0030_invalid_index"; -- expect 0 rows + + rollback to savepoint a; + + -- NEGATIVE EXAMPLE: a partitioned parent index created with ON ONLY is + -- marked invalid by design until every child index is attached; it should + -- NOT trigger + create table public.events(id int, ts date) partition by range (ts); + create table public.events_2026 partition of public.events + for values from ('2026-01-01') to ('2027-01-01'); + create index idx_events_ts on only public.events (ts); + + select * from lint."0030_invalid_index"; -- expect 0 rows + + rollback to savepoint a; + + -- POSITIVE EXAMPLE: an index left invalid (as happens when + -- CREATE INDEX CONCURRENTLY / REINDEX CONCURRENTLY fails partway through) + -- Simulated here via direct catalog update since CONCURRENTLY cannot + -- run inside a transaction block. + create table public.orders( + id int primary key, + order_number int + ); + create unique index idx_orders_order_number on public.orders (order_number); + update pg_catalog.pg_index + set indisvalid = false + where indexrelid = 'public.idx_orders_order_number'::regclass; + + select name, detail, cache_key from lint."0030_invalid_index"; -- expect 1 row + + -- RESOLUTION: reindex validates the index. The docs recommend + -- `reindex index concurrently` but CONCURRENTLY cannot run inside a + -- transaction block, so the test uses the non-concurrent form. + reindex index public.idx_orders_order_number; + + select * from lint."0030_invalid_index"; -- expect 0 rows + + rollback to savepoint a; + + -- POSITIVE EXAMPLE (exception): an invalid index backing an exclusion + -- constraint cannot be reindexed concurrently, so the message recommends + -- a non-concurrent reindex instead. + create table public.reservations( + id int primary key, + during int4range, + constraint reservations_during_excl exclude using gist (during with &&) + ); + update pg_catalog.pg_index + set indisvalid = false + where indexrelid = 'public.reservations_during_excl'::regclass; + + select name, detail, cache_key from lint."0030_invalid_index"; -- expect 1 row + + -- RESOLUTION: a non-concurrent reindex validates the index + reindex index public.reservations_during_excl; + + select * from lint."0030_invalid_index"; -- expect 0 rows + + rollback to savepoint a; + + -- POSITIVE EXAMPLE: a LEAF partition's index left invalid IS flagged. Only the + -- partitioned PARENT index (relkind 'I') is excluded; a leaf partition's index + -- (relkind 'i') is an ordinary index and must still be linted. + create table public.events(id int, ts date) partition by range (ts); + create table public.events_2026 partition of public.events + for values from ('2026-01-01') to ('2027-01-01'); + create index idx_events_2026_ts on public.events_2026 (ts); + update pg_catalog.pg_index + set indisvalid = false + where indexrelid = 'public.idx_events_2026_ts'::regclass; + + select name, detail, cache_key from lint."0030_invalid_index"; -- expect 1 row + + rollback to savepoint a; + + -- NEGATIVE EXAMPLE: an invalid index in an internal (excluded) schema is NOT + -- flagged -- the lint only reports customer-facing schemas. + create schema repack; + create table repack.t(id int); + create unique index idx_repack_t_id on repack.t (id); + update pg_catalog.pg_index + set indisvalid = false + where indexrelid = 'repack.idx_repack_t_id'::regclass; + + select * from lint."0030_invalid_index"; -- expect 0 rows + +rollback; diff --git a/test/sql/queries_are_unionable.sql b/test/sql/queries_are_unionable.sql index fe43d3d..53e08b2 100644 --- a/test/sql/queries_are_unionable.sql +++ b/test/sql/queries_are_unionable.sql @@ -56,6 +56,8 @@ begin; union all select * from lint."0028_anon_security_definer_function_executable" union all - select * from lint."0029_authenticated_security_definer_function_executable"; + select * from lint."0029_authenticated_security_definer_function_executable" + union all + select * from lint."0030_invalid_index"; rollback;