From 54df63eb11792d16a93ce22d701640e0862ff811 Mon Sep 17 00:00:00 2001 From: Douglas J Hunley Date: Thu, 23 Jul 2026 10:44:20 -0400 Subject: [PATCH 01/13] feat: add unused_replication_slot lint for stale replication slots Read replica provisioning now creates a replication slot on the primary. If the consumer of that slot goes away for good, the slot retains WAL forever and silently eats disk. Add a Performance Advisor lint that flags any replication slot that is inactive and whose wal_status has moved past max_slot_wal_keep_size ('unreserved', WARN) or is already invalidated ('lost', ERROR). A slot that's merely inactive but still 'reserved' or 'extended' does not fire, so a brief replica restart doesn't generate noise. test/sql/0030_unused_replication_slot.sql can't use the usual begin/savepoint/rollback pattern: replication slots are non-transactional (they survive a rollback) and ALTER SYSTEM is rejected inside a transaction block outright, so cleanup is explicit instead. --- bin/installcheck | 2 +- docs/0030_unused_replication_slot.md | 53 +++++++++++ lints/0030_unused_replication_slot.sql | 32 +++++++ splinter.sql | 34 ++++++- .../expected/0030_unused_replication_slot.out | 91 +++++++++++++++++++ test/expected/queries_are_unionable.out | 4 +- test/sql/0030_unused_replication_slot.sql | 41 +++++++++ test/sql/queries_are_unionable.sql | 4 +- 8 files changed, 257 insertions(+), 4 deletions(-) create mode 100644 docs/0030_unused_replication_slot.md create mode 100644 lints/0030_unused_replication_slot.sql create mode 100644 test/expected/0030_unused_replication_slot.out create mode 100644 test/sql/0030_unused_replication_slot.sql diff --git a/bin/installcheck b/bin/installcheck index b54f6335..659aa2fb 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_unused_replication_slot.md b/docs/0030_unused_replication_slot.md new file mode 100644 index 00000000..76c04d15 --- /dev/null +++ b/docs/0030_unused_replication_slot.md @@ -0,0 +1,53 @@ +**Level:** WARN|ERROR + +**Summary:** Detects replication slots that are inactive and retaining WAL beyond `max_slot_wal_keep_size`. + +**Ramification:** A replication slot with no active consumer keeps every WAL segment since its `restart_lsn` on disk indefinitely. Left unattended, this can fill the primary's disk and cause an outage. + +--- + +### Rationale + +Postgres will never recycle WAL a replication slot still needs, even if nothing is reading from that slot anymore. This is normal and required for the slot to still be useful to a consumer that reconnects — but if the consumer (a read replica, a logical replication client, a CDC tool) is gone for good, the slot just accumulates WAL forever. + +Postgres itself tracks how close a slot is to actually causing harm via `pg_replication_slots.wal_status`: + +- `reserved` — normal, claimed WAL files are within `max_wal_size`. +- `extended` — `max_wal_size` is exceeded but the files are still retained (by the slot or by `wal_keep_size`). This is benign and can happen on perfectly healthy, currently-active slots (e.g. during a burst of write traffic) — it does not by itself indicate a problem. +- `unreserved` — the slot no longer retains its required WAL and some of it is due to be removed at the next checkpoint. This is what actually happens once retained WAL exceeds `max_slot_wal_keep_size`, and it's still recoverable (can return to `reserved`/`extended` if the consumer catches up before the next checkpoint). +- `lost` — the slot has been invalidated; the WAL it needed is already gone, and the slot can no longer be used to resume replication. + +This lint fires on `unreserved` or `lost`, not merely on `active = false` — a slot that's briefly inactive (e.g. its replica restarting) but still `reserved` (or even `extended`) is not yet a problem. + +### How to Resolve + +**Option 1: Drop the slot if its consumer is gone for good** + +```sql +select pg_drop_replication_slot(''); +``` + +**Option 2: If a consumer is expected to reconnect, investigate the disconnect** + +Check why the replica/consumer isn't connecting (network issue, instance down, credentials) and monitor disk usage on the primary in the meantime. Once it reconnects and catches up, `wal_status` returns to `reserved` on its own. + +### Example + +Given a physical replication slot whose replica was deleted weeks ago: + +```sql +select slot_name, active, wal_status from pg_replication_slots; +-- slot_name | active | wal_status +-- ---------------------+--------+------------ +-- replica_abandoned | f | unreserved +``` + +Fix: + +```sql +select pg_drop_replication_slot('replica_abandoned'); +``` + +### False Positives + +A slot that is `active = false` but still `wal_status = 'reserved'` or `'extended'` will not fire — this covers a replica restarting, being briefly taken offline for maintenance, or a currently-healthy slot that's simply using more than `max_wal_size` right now, without generating noise. If this lint fires, the slot has already exceeded `max_slot_wal_keep_size` (or been invalidated entirely). diff --git a/lints/0030_unused_replication_slot.sql b/lints/0030_unused_replication_slot.sql new file mode 100644 index 00000000..9852b090 --- /dev/null +++ b/lints/0030_unused_replication_slot.sql @@ -0,0 +1,32 @@ +create view lint."0030_unused_replication_slot" as + +select + 'unused_replication_slot' as name, + 'Unused Replication Slot' as title, + case prs.wal_status + when 'lost' then 'ERROR' + else 'WARN' + end as level, + 'EXTERNAL' as facing, + array['PERFORMANCE'] as categories, + 'Detects replication slots that are inactive and at risk of exceeding max_slot_wal_keep_size, or already invalidated, risking disk bloat on the primary.' as description, + format( + 'Replication slot `%s` is inactive and its WAL retention status is `%s`', + prs.slot_name, + prs.wal_status + ) as detail, + 'https://supabase.com/docs/guides/database/database-linter?lint=0030_unused_replication_slot' as remediation, + jsonb_build_object( + 'name', prs.slot_name, + 'type', 'replication_slot', + 'slot_type', prs.slot_type, + 'wal_status', prs.wal_status + ) as metadata, + format('unused_replication_slot_%s', prs.slot_name) as cache_key +from + pg_catalog.pg_replication_slots prs +where + prs.active = false + and prs.wal_status in ('unreserved', 'lost') +order by + prs.slot_name; diff --git a/splinter.sql b/splinter.sql index 9cd66c95..6c0ede1a 100644 --- a/splinter.sql +++ b/splinter.sql @@ -1838,4 +1838,36 @@ from order by schema_name, function_name, - function_args) \ No newline at end of file + function_args) +union all +( +select + 'unused_replication_slot' as name, + 'Unused Replication Slot' as title, + case prs.wal_status + when 'lost' then 'ERROR' + else 'WARN' + end as level, + 'EXTERNAL' as facing, + array['PERFORMANCE'] as categories, + 'Detects replication slots that are inactive and at risk of exceeding max_slot_wal_keep_size, or already invalidated, risking disk bloat on the primary.' as description, + format( + 'Replication slot `%s` is inactive and its WAL retention status is `%s`', + prs.slot_name, + prs.wal_status + ) as detail, + 'https://supabase.com/docs/guides/database/database-linter?lint=0030_unused_replication_slot' as remediation, + jsonb_build_object( + 'name', prs.slot_name, + 'type', 'replication_slot', + 'slot_type', prs.slot_type, + 'wal_status', prs.wal_status + ) as metadata, + format('unused_replication_slot_%s', prs.slot_name) as cache_key +from + pg_catalog.pg_replication_slots prs +where + prs.active = false + and prs.wal_status in ('unreserved', 'lost') +order by + prs.slot_name) \ No newline at end of file diff --git a/test/expected/0030_unused_replication_slot.out b/test/expected/0030_unused_replication_slot.out new file mode 100644 index 00000000..8683187e --- /dev/null +++ b/test/expected/0030_unused_replication_slot.out @@ -0,0 +1,91 @@ +-- Unlike other lint tests, this file cannot use begin/savepoint/rollback: replication +-- slots are non-transactional (they survive a rollback), and ALTER SYSTEM is outright +-- rejected inside a transaction block. Every statement here autocommits individually; +-- cleanup (dropping slots, resetting max_slot_wal_keep_size) is explicit instead. +set search_path = ''; +-- BASELINE: 0 issues, no slots exist +select * from lint."0030_unused_replication_slot"; + name | title | level | facing | categories | description | detail | remediation | metadata | cache_key +------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- +(0 rows) + +-- NEGATIVE EXAMPLE: a freshly created slot (with its LSN reserved, as a real replica +-- connection would) is wal_status='reserved' while inactive -- e.g. a replica that hasn't +-- connected yet, or restarted seconds ago -- and must NOT fire. +select pg_catalog.pg_create_physical_replication_slot('splinter_test_negative_slot', true); + pg_create_physical_replication_slot +----------------------------------------- + (splinter_test_negative_slot,0/14F92A8) +(1 row) + +select name, detail, cache_key from lint."0030_unused_replication_slot"; -- expect 0 rows + name | detail | cache_key +------+--------+----------- +(0 rows) + +select pg_catalog.pg_drop_replication_slot('splinter_test_negative_slot'); + pg_drop_replication_slot +-------------------------- + +(1 row) + +-- POSITIVE EXAMPLE: shrink max_slot_wal_keep_size and generate enough WAL past it so the +-- slot's retained WAL exceeds the limit. +select pg_catalog.pg_create_physical_replication_slot('splinter_test_positive_slot', true); + pg_create_physical_replication_slot +----------------------------------------- + (splinter_test_positive_slot,0/14F92A8) +(1 row) + +alter system set max_slot_wal_keep_size = '1MB'; +select pg_reload_conf(); + pg_reload_conf +---------------- + t +(1 row) + +create table public.splinter_test_wal_filler (id serial, data text); +insert into public.splinter_test_wal_filler (data) select repeat('x', 8000) from generate_series(1, 5000); +select pg_catalog.pg_switch_wal(); + pg_switch_wal +--------------- + 0/1B79CF0 +(1 row) + +-- before any checkpoint runs, the slot has exceeded max_slot_wal_keep_size but hasn't been +-- invalidated yet -- wal_status is 'unreserved', level WARN +select name, detail, cache_key from lint."0030_unused_replication_slot"; -- expect 1 row, wal_status unreserved + name | detail | cache_key +-------------------------+---------------------------------------------------------------------------------------------------------+----------------------------------------------------- + unused_replication_slot | Replication slot `splinter_test_positive_slot` is inactive and its WAL retention status is `unreserved` | unused_replication_slot_splinter_test_positive_slot +(1 row) + +-- a checkpoint is what actually invalidates an 'unreserved' slot once its required WAL is +-- gone -- wal_status becomes 'lost', level ERROR +checkpoint; +select name, detail, cache_key from lint."0030_unused_replication_slot"; -- expect 1 row, wal_status lost + name | detail | cache_key +-------------------------+---------------------------------------------------------------------------------------------------+----------------------------------------------------- + unused_replication_slot | Replication slot `splinter_test_positive_slot` is inactive and its WAL retention status is `lost` | unused_replication_slot_splinter_test_positive_slot +(1 row) + +-- RESOLUTION: drop the slot +select pg_catalog.pg_drop_replication_slot('splinter_test_positive_slot'); + pg_drop_replication_slot +-------------------------- + +(1 row) + +select * from lint."0030_unused_replication_slot"; -- expect 0 rows + name | title | level | facing | categories | description | detail | remediation | metadata | cache_key +------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- +(0 rows) + +drop table public.splinter_test_wal_filler; +alter system reset max_slot_wal_keep_size; +select pg_reload_conf(); + pg_reload_conf +---------------- + t +(1 row) + diff --git a/test/expected/queries_are_unionable.out b/test/expected/queries_are_unionable.out index 3b1005d4..563d8ace 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_unused_replication_slot"; name | title | level | facing | categories | description | detail | remediation | metadata | cache_key ------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- (0 rows) diff --git a/test/sql/0030_unused_replication_slot.sql b/test/sql/0030_unused_replication_slot.sql new file mode 100644 index 00000000..02367a12 --- /dev/null +++ b/test/sql/0030_unused_replication_slot.sql @@ -0,0 +1,41 @@ +-- Unlike other lint tests, this file cannot use begin/savepoint/rollback: replication +-- slots are non-transactional (they survive a rollback), and ALTER SYSTEM is outright +-- rejected inside a transaction block. Every statement here autocommits individually; +-- cleanup (dropping slots, resetting max_slot_wal_keep_size) is explicit instead. +set search_path = ''; + +-- BASELINE: 0 issues, no slots exist +select * from lint."0030_unused_replication_slot"; + +-- NEGATIVE EXAMPLE: a freshly created slot (with its LSN reserved, as a real replica +-- connection would) is wal_status='reserved' while inactive -- e.g. a replica that hasn't +-- connected yet, or restarted seconds ago -- and must NOT fire. +select pg_catalog.pg_create_physical_replication_slot('splinter_test_negative_slot', true); +select name, detail, cache_key from lint."0030_unused_replication_slot"; -- expect 0 rows +select pg_catalog.pg_drop_replication_slot('splinter_test_negative_slot'); + +-- POSITIVE EXAMPLE: shrink max_slot_wal_keep_size and generate enough WAL past it so the +-- slot's retained WAL exceeds the limit. +select pg_catalog.pg_create_physical_replication_slot('splinter_test_positive_slot', true); +alter system set max_slot_wal_keep_size = '1MB'; +select pg_reload_conf(); +create table public.splinter_test_wal_filler (id serial, data text); +insert into public.splinter_test_wal_filler (data) select repeat('x', 8000) from generate_series(1, 5000); +select pg_catalog.pg_switch_wal(); + +-- before any checkpoint runs, the slot has exceeded max_slot_wal_keep_size but hasn't been +-- invalidated yet -- wal_status is 'unreserved', level WARN +select name, detail, cache_key from lint."0030_unused_replication_slot"; -- expect 1 row, wal_status unreserved + +-- a checkpoint is what actually invalidates an 'unreserved' slot once its required WAL is +-- gone -- wal_status becomes 'lost', level ERROR +checkpoint; +select name, detail, cache_key from lint."0030_unused_replication_slot"; -- expect 1 row, wal_status lost + +-- RESOLUTION: drop the slot +select pg_catalog.pg_drop_replication_slot('splinter_test_positive_slot'); +select * from lint."0030_unused_replication_slot"; -- expect 0 rows + +drop table public.splinter_test_wal_filler; +alter system reset max_slot_wal_keep_size; +select pg_reload_conf(); diff --git a/test/sql/queries_are_unionable.sql b/test/sql/queries_are_unionable.sql index fe43d3da..70393d42 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_unused_replication_slot"; rollback; From bee99ea869051e34c8dae19b8cba3b94031345f7 Mon Sep 17 00:00:00 2001 From: Douglas J Hunley Date: Thu, 23 Jul 2026 11:07:23 -0400 Subject: [PATCH 02/13] fix: stop asserting raw WAL LSNs in the replication slot lint test test/expected/0030_unused_replication_slot.out baked in literal LSN values returned by pg_create_physical_replication_slot and pg_switch_wal. Since pg_regress runs every test file against one shared instance, those LSNs depend on cumulative WAL from every earlier test and shift whenever an earlier test's WAL volume changes, breaking this test with an unrelated diff. Wrap the calls in `do $$ begin perform ...; end $$;` so only the side effect (creating the slot / switching WAL) happens and the LSN never reaches the test output. --- .../expected/0030_unused_replication_slot.out | 21 +++---------------- test/sql/0030_unused_replication_slot.sql | 6 +++--- 2 files changed, 6 insertions(+), 21 deletions(-) diff --git a/test/expected/0030_unused_replication_slot.out b/test/expected/0030_unused_replication_slot.out index 8683187e..7cbfafde 100644 --- a/test/expected/0030_unused_replication_slot.out +++ b/test/expected/0030_unused_replication_slot.out @@ -12,12 +12,7 @@ select * from lint."0030_unused_replication_slot"; -- NEGATIVE EXAMPLE: a freshly created slot (with its LSN reserved, as a real replica -- connection would) is wal_status='reserved' while inactive -- e.g. a replica that hasn't -- connected yet, or restarted seconds ago -- and must NOT fire. -select pg_catalog.pg_create_physical_replication_slot('splinter_test_negative_slot', true); - pg_create_physical_replication_slot ------------------------------------------ - (splinter_test_negative_slot,0/14F92A8) -(1 row) - +do $$ begin perform pg_catalog.pg_create_physical_replication_slot('splinter_test_negative_slot', true); end $$; select name, detail, cache_key from lint."0030_unused_replication_slot"; -- expect 0 rows name | detail | cache_key ------+--------+----------- @@ -31,12 +26,7 @@ select pg_catalog.pg_drop_replication_slot('splinter_test_negative_slot'); -- POSITIVE EXAMPLE: shrink max_slot_wal_keep_size and generate enough WAL past it so the -- slot's retained WAL exceeds the limit. -select pg_catalog.pg_create_physical_replication_slot('splinter_test_positive_slot', true); - pg_create_physical_replication_slot ------------------------------------------ - (splinter_test_positive_slot,0/14F92A8) -(1 row) - +do $$ begin perform pg_catalog.pg_create_physical_replication_slot('splinter_test_positive_slot', true); end $$; alter system set max_slot_wal_keep_size = '1MB'; select pg_reload_conf(); pg_reload_conf @@ -46,12 +36,7 @@ select pg_reload_conf(); create table public.splinter_test_wal_filler (id serial, data text); insert into public.splinter_test_wal_filler (data) select repeat('x', 8000) from generate_series(1, 5000); -select pg_catalog.pg_switch_wal(); - pg_switch_wal ---------------- - 0/1B79CF0 -(1 row) - +do $$ begin perform pg_catalog.pg_switch_wal(); end $$; -- before any checkpoint runs, the slot has exceeded max_slot_wal_keep_size but hasn't been -- invalidated yet -- wal_status is 'unreserved', level WARN select name, detail, cache_key from lint."0030_unused_replication_slot"; -- expect 1 row, wal_status unreserved diff --git a/test/sql/0030_unused_replication_slot.sql b/test/sql/0030_unused_replication_slot.sql index 02367a12..e6de4fa8 100644 --- a/test/sql/0030_unused_replication_slot.sql +++ b/test/sql/0030_unused_replication_slot.sql @@ -10,18 +10,18 @@ select * from lint."0030_unused_replication_slot"; -- NEGATIVE EXAMPLE: a freshly created slot (with its LSN reserved, as a real replica -- connection would) is wal_status='reserved' while inactive -- e.g. a replica that hasn't -- connected yet, or restarted seconds ago -- and must NOT fire. -select pg_catalog.pg_create_physical_replication_slot('splinter_test_negative_slot', true); +do $$ begin perform pg_catalog.pg_create_physical_replication_slot('splinter_test_negative_slot', true); end $$; select name, detail, cache_key from lint."0030_unused_replication_slot"; -- expect 0 rows select pg_catalog.pg_drop_replication_slot('splinter_test_negative_slot'); -- POSITIVE EXAMPLE: shrink max_slot_wal_keep_size and generate enough WAL past it so the -- slot's retained WAL exceeds the limit. -select pg_catalog.pg_create_physical_replication_slot('splinter_test_positive_slot', true); +do $$ begin perform pg_catalog.pg_create_physical_replication_slot('splinter_test_positive_slot', true); end $$; alter system set max_slot_wal_keep_size = '1MB'; select pg_reload_conf(); create table public.splinter_test_wal_filler (id serial, data text); insert into public.splinter_test_wal_filler (data) select repeat('x', 8000) from generate_series(1, 5000); -select pg_catalog.pg_switch_wal(); +do $$ begin perform pg_catalog.pg_switch_wal(); end $$; -- before any checkpoint runs, the slot has exceeded max_slot_wal_keep_size but hasn't been -- invalidated yet -- wal_status is 'unreserved', level WARN From 17a727bcf00b3fdc5927aa50c7f6b10b2a17adfe Mon Sep 17 00:00:00 2001 From: Douglas J Hunley Date: Thu, 23 Jul 2026 11:10:18 -0400 Subject: [PATCH 03/13] test: add logical replication slot coverage for unused_replication_slot lint The lint's WHERE clause has no slot_type filter, so it's meant to cover both physical and logical slots, but the test only ever exercised physical slots. Add a negative-case fixture for a freshly created logical slot (still wal_status='reserved' while inactive, must not fire), same guarantee as the existing physical case. Requires wal_level=logical, which the test harness's initdb/pg_ctl start didn't set (community Postgres default is 'replica'), so add it to bin/installcheck's server start options. --- bin/installcheck | 2 +- test/expected/0030_unused_replication_slot.out | 13 +++++++++++++ test/sql/0030_unused_replication_slot.sql | 5 +++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/bin/installcheck b/bin/installcheck index 659aa2fb..18e73ef7 100755 --- a/bin/installcheck +++ b/bin/installcheck @@ -30,7 +30,7 @@ rm -rf "$TMPDIR" # Initialize: setting PGUSER as the owner initdb --no-locale --encoding=UTF8 --nosync -U "$PGUSER" # Start the server -pg_ctl start -o "-F -c listen_addresses=\"\" -c log_min_messages=WARNING -k $PGDATA" +pg_ctl start -o "-F -c listen_addresses=\"\" -c log_min_messages=WARNING -c wal_level=logical -k $PGDATA" # Create the test db createdb contrib_regression diff --git a/test/expected/0030_unused_replication_slot.out b/test/expected/0030_unused_replication_slot.out index 7cbfafde..3862f0c1 100644 --- a/test/expected/0030_unused_replication_slot.out +++ b/test/expected/0030_unused_replication_slot.out @@ -24,6 +24,19 @@ select pg_catalog.pg_drop_replication_slot('splinter_test_negative_slot'); (1 row) +-- NEGATIVE EXAMPLE (logical slot): the same reserved/inactive guarantee applies to logical slots, not just physical +do $$ begin perform pg_catalog.pg_create_logical_replication_slot('splinter_test_negative_logical_slot', 'test_decoding'); end $$; +select name, detail, cache_key from lint."0030_unused_replication_slot"; -- expect 0 rows + name | detail | cache_key +------+--------+----------- +(0 rows) + +select pg_catalog.pg_drop_replication_slot('splinter_test_negative_logical_slot'); + pg_drop_replication_slot +-------------------------- + +(1 row) + -- POSITIVE EXAMPLE: shrink max_slot_wal_keep_size and generate enough WAL past it so the -- slot's retained WAL exceeds the limit. do $$ begin perform pg_catalog.pg_create_physical_replication_slot('splinter_test_positive_slot', true); end $$; diff --git a/test/sql/0030_unused_replication_slot.sql b/test/sql/0030_unused_replication_slot.sql index e6de4fa8..3ff3e82f 100644 --- a/test/sql/0030_unused_replication_slot.sql +++ b/test/sql/0030_unused_replication_slot.sql @@ -14,6 +14,11 @@ do $$ begin perform pg_catalog.pg_create_physical_replication_slot('splinter_tes select name, detail, cache_key from lint."0030_unused_replication_slot"; -- expect 0 rows select pg_catalog.pg_drop_replication_slot('splinter_test_negative_slot'); +-- NEGATIVE EXAMPLE (logical slot): the same reserved/inactive guarantee applies to logical slots, not just physical +do $$ begin perform pg_catalog.pg_create_logical_replication_slot('splinter_test_negative_logical_slot', 'test_decoding'); end $$; +select name, detail, cache_key from lint."0030_unused_replication_slot"; -- expect 0 rows +select pg_catalog.pg_drop_replication_slot('splinter_test_negative_logical_slot'); + -- POSITIVE EXAMPLE: shrink max_slot_wal_keep_size and generate enough WAL past it so the -- slot's retained WAL exceeds the limit. do $$ begin perform pg_catalog.pg_create_physical_replication_slot('splinter_test_positive_slot', true); end $$; From 2449dcff9cc8646dee5f944ec076dea7fdb1bd09 Mon Sep 17 00:00:00 2001 From: Douglas J Hunley Date: Thu, 23 Jul 2026 11:53:53 -0400 Subject: [PATCH 04/13] test: add wal_status='extended' negative-case coverage for unused_replication_slot lint docs/0030_unused_replication_slot.md explicitly documents 'extended' as a benign, must-not-fire state, but only 'reserved' was tested. Add a fixture that shrinks max_wal_size (with max_slot_wal_keep_size left at its disabled default, so the slot can only ever reach 'extended', never 'unreserved'/ 'lost') and drives WAL past it, confirming the lint stays silent. Per src/backend/access/transam/xlog.c's GetWALAvailability, reserved vs extended is pure segment-count arithmetic against max_wal_size, independent of data volume or checkpoints -- so the fixture advances WAL via repeated pg_switch_wal() calls instead of writing real rows. pg_switch_wal() is a no-op once already sitting at a segment boundary, so each loop iteration emits one trivial WAL record first (pg_logical_emit_message) to move off the boundary before switching again. --- .../expected/0030_unused_replication_slot.out | 35 +++++++++++++++++++ test/sql/0030_unused_replication_slot.sql | 12 +++++++ 2 files changed, 47 insertions(+) diff --git a/test/expected/0030_unused_replication_slot.out b/test/expected/0030_unused_replication_slot.out index 3862f0c1..5c3e7671 100644 --- a/test/expected/0030_unused_replication_slot.out +++ b/test/expected/0030_unused_replication_slot.out @@ -37,6 +37,41 @@ select pg_catalog.pg_drop_replication_slot('splinter_test_negative_logical_slot' (1 row) +-- NEGATIVE EXAMPLE (extended): max_wal_size exceeded but max_slot_wal_keep_size left at its default (disabled), so the slot is 'extended', not 'unreserved' -- must NOT fire +do $$ begin perform pg_catalog.pg_create_physical_replication_slot('splinter_test_extended_slot', true); end $$; +alter system set max_wal_size = '2MB'; +select pg_reload_conf(); + pg_reload_conf +---------------- + t +(1 row) + +-- pg_switch_wal() is a no-op once already sitting at a segment boundary, so each loop iteration writes one trivial WAL record first to move off the boundary before switching again. +do $$ begin for i in 1..10 loop perform pg_catalog.pg_logical_emit_message(false, 'splinter_test', 'advance'); perform pg_catalog.pg_switch_wal(); end loop; end $$; +select slot_name, wal_status from pg_catalog.pg_replication_slots where slot_name = 'splinter_test_extended_slot'; -- confirm wal_status is actually 'extended' + slot_name | wal_status +-----------------------------+------------ + splinter_test_extended_slot | extended +(1 row) + +select name, detail, cache_key from lint."0030_unused_replication_slot"; -- expect 0 rows + name | detail | cache_key +------+--------+----------- +(0 rows) + +select pg_catalog.pg_drop_replication_slot('splinter_test_extended_slot'); + pg_drop_replication_slot +-------------------------- + +(1 row) + +alter system reset max_wal_size; +select pg_reload_conf(); + pg_reload_conf +---------------- + t +(1 row) + -- POSITIVE EXAMPLE: shrink max_slot_wal_keep_size and generate enough WAL past it so the -- slot's retained WAL exceeds the limit. do $$ begin perform pg_catalog.pg_create_physical_replication_slot('splinter_test_positive_slot', true); end $$; diff --git a/test/sql/0030_unused_replication_slot.sql b/test/sql/0030_unused_replication_slot.sql index 3ff3e82f..d226b998 100644 --- a/test/sql/0030_unused_replication_slot.sql +++ b/test/sql/0030_unused_replication_slot.sql @@ -19,6 +19,18 @@ do $$ begin perform pg_catalog.pg_create_logical_replication_slot('splinter_test select name, detail, cache_key from lint."0030_unused_replication_slot"; -- expect 0 rows select pg_catalog.pg_drop_replication_slot('splinter_test_negative_logical_slot'); +-- NEGATIVE EXAMPLE (extended): max_wal_size exceeded but max_slot_wal_keep_size left at its default (disabled), so the slot is 'extended', not 'unreserved' -- must NOT fire +do $$ begin perform pg_catalog.pg_create_physical_replication_slot('splinter_test_extended_slot', true); end $$; +alter system set max_wal_size = '2MB'; +select pg_reload_conf(); +-- pg_switch_wal() is a no-op once already sitting at a segment boundary, so each loop iteration writes one trivial WAL record first to move off the boundary before switching again. +do $$ begin for i in 1..10 loop perform pg_catalog.pg_logical_emit_message(false, 'splinter_test', 'advance'); perform pg_catalog.pg_switch_wal(); end loop; end $$; +select slot_name, wal_status from pg_catalog.pg_replication_slots where slot_name = 'splinter_test_extended_slot'; -- confirm wal_status is actually 'extended' +select name, detail, cache_key from lint."0030_unused_replication_slot"; -- expect 0 rows +select pg_catalog.pg_drop_replication_slot('splinter_test_extended_slot'); +alter system reset max_wal_size; +select pg_reload_conf(); + -- POSITIVE EXAMPLE: shrink max_slot_wal_keep_size and generate enough WAL past it so the -- slot's retained WAL exceeds the limit. do $$ begin perform pg_catalog.pg_create_physical_replication_slot('splinter_test_positive_slot', true); end $$; From e5588ab0dadb263ff8ca9b9a9f0eae220231967e Mon Sep 17 00:00:00 2001 From: Douglas J Hunley Date: Thu, 23 Jul 2026 12:12:50 -0400 Subject: [PATCH 05/13] fix: add entity key to unused_replication_slot metadata for Advisor UI display Studio's getLintEntityString (apps/studio/components/interfaces/Linter/Linter.utils.tsx) only builds a display string when metadata.entity is set, or both metadata.schema and metadata.name are set. This lint had name but no schema (replication slots aren't schema-scoped) and no entity, so every finding rendered as 'N/A' in the Advisor UI despite the slot name being known. Setting metadata.entity to the slot name fixes this without any Studio-side change, since the entity check short-circuits before the schema check. --- lints/0030_unused_replication_slot.sql | 1 + 1 file changed, 1 insertion(+) diff --git a/lints/0030_unused_replication_slot.sql b/lints/0030_unused_replication_slot.sql index 9852b090..be6d4ec6 100644 --- a/lints/0030_unused_replication_slot.sql +++ b/lints/0030_unused_replication_slot.sql @@ -18,6 +18,7 @@ select 'https://supabase.com/docs/guides/database/database-linter?lint=0030_unused_replication_slot' as remediation, jsonb_build_object( 'name', prs.slot_name, + 'entity', prs.slot_name, 'type', 'replication_slot', 'slot_type', prs.slot_type, 'wal_status', prs.wal_status From 9401b42ab8b740a7838b04f3c2ccb3906ff49196 Mon Sep 17 00:00:00 2001 From: Douglas J Hunley Date: Thu, 23 Jul 2026 12:14:58 -0400 Subject: [PATCH 06/13] fix: add plugin key to unused_replication_slot metadata for logical slots pg_replication_slots.plugin names the decoding plugin (pgoutput, wal2json, test_decoding, etc.) a logical slot uses -- null for physical slots, but the one field that identifies which CDC tool/consumer owned an abandoned logical slot, which the doc's remediation advice ("investigate the disconnect") assumes a user can see. --- lints/0030_unused_replication_slot.sql | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lints/0030_unused_replication_slot.sql b/lints/0030_unused_replication_slot.sql index be6d4ec6..5c1b6289 100644 --- a/lints/0030_unused_replication_slot.sql +++ b/lints/0030_unused_replication_slot.sql @@ -21,7 +21,8 @@ select 'entity', prs.slot_name, 'type', 'replication_slot', 'slot_type', prs.slot_type, - 'wal_status', prs.wal_status + 'wal_status', prs.wal_status, + 'plugin', prs.plugin ) as metadata, format('unused_replication_slot_%s', prs.slot_name) as cache_key from From 4768d613aa493d012eb8b4eccd7496f776b3890e Mon Sep 17 00:00:00 2001 From: Douglas J Hunley Date: Thu, 23 Jul 2026 12:18:49 -0400 Subject: [PATCH 07/13] refactor: drop unnecessary 40MB filler table from the unreserved/lost fixture pg_switch_wal() forces a full segment advance regardless of preceding data volume, and max_slot_wal_keep_size (1MB) is far below one WAL segment, so a single trivial WAL record plus a switch already exceeds the retention limit. The 5000-row insert added test runtime/IO for no determinism benefit; replaced with the same lightweight emit-then-switch pattern already used for the extended-state fixture. --- test/expected/0030_unused_replication_slot.out | 5 +---- test/sql/0030_unused_replication_slot.sql | 5 +---- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/test/expected/0030_unused_replication_slot.out b/test/expected/0030_unused_replication_slot.out index 5c3e7671..90731972 100644 --- a/test/expected/0030_unused_replication_slot.out +++ b/test/expected/0030_unused_replication_slot.out @@ -82,9 +82,7 @@ select pg_reload_conf(); t (1 row) -create table public.splinter_test_wal_filler (id serial, data text); -insert into public.splinter_test_wal_filler (data) select repeat('x', 8000) from generate_series(1, 5000); -do $$ begin perform pg_catalog.pg_switch_wal(); end $$; +do $$ begin perform pg_catalog.pg_logical_emit_message(false, 'splinter_test', 'advance'); perform pg_catalog.pg_switch_wal(); end $$; -- before any checkpoint runs, the slot has exceeded max_slot_wal_keep_size but hasn't been -- invalidated yet -- wal_status is 'unreserved', level WARN select name, detail, cache_key from lint."0030_unused_replication_slot"; -- expect 1 row, wal_status unreserved @@ -114,7 +112,6 @@ select * from lint."0030_unused_replication_slot"; -- expect 0 rows ------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- (0 rows) -drop table public.splinter_test_wal_filler; alter system reset max_slot_wal_keep_size; select pg_reload_conf(); pg_reload_conf diff --git a/test/sql/0030_unused_replication_slot.sql b/test/sql/0030_unused_replication_slot.sql index d226b998..528297bc 100644 --- a/test/sql/0030_unused_replication_slot.sql +++ b/test/sql/0030_unused_replication_slot.sql @@ -36,9 +36,7 @@ select pg_reload_conf(); do $$ begin perform pg_catalog.pg_create_physical_replication_slot('splinter_test_positive_slot', true); end $$; alter system set max_slot_wal_keep_size = '1MB'; select pg_reload_conf(); -create table public.splinter_test_wal_filler (id serial, data text); -insert into public.splinter_test_wal_filler (data) select repeat('x', 8000) from generate_series(1, 5000); -do $$ begin perform pg_catalog.pg_switch_wal(); end $$; +do $$ begin perform pg_catalog.pg_logical_emit_message(false, 'splinter_test', 'advance'); perform pg_catalog.pg_switch_wal(); end $$; -- before any checkpoint runs, the slot has exceeded max_slot_wal_keep_size but hasn't been -- invalidated yet -- wal_status is 'unreserved', level WARN @@ -53,6 +51,5 @@ select name, detail, cache_key from lint."0030_unused_replication_slot"; -- exp select pg_catalog.pg_drop_replication_slot('splinter_test_positive_slot'); select * from lint."0030_unused_replication_slot"; -- expect 0 rows -drop table public.splinter_test_wal_filler; alter system reset max_slot_wal_keep_size; select pg_reload_conf(); From 8052944498916ff6dd28beda84f7658631497684 Mon Sep 17 00:00:00 2001 From: Douglas J Hunley Date: Thu, 23 Jul 2026 12:21:12 -0400 Subject: [PATCH 08/13] style: condense stacked comment blocks to single lines in the slot lint test ~/.claude/CLAUDE.md: a comment must be exactly one line, never a run of several. Five explanations were spread across 2-4 consecutive -- lines; collapsed each into one line, no content change. --- test/expected/0030_unused_replication_slot.out | 18 +++++------------- test/sql/0030_unused_replication_slot.sql | 18 +++++------------- 2 files changed, 10 insertions(+), 26 deletions(-) diff --git a/test/expected/0030_unused_replication_slot.out b/test/expected/0030_unused_replication_slot.out index 90731972..6e33c216 100644 --- a/test/expected/0030_unused_replication_slot.out +++ b/test/expected/0030_unused_replication_slot.out @@ -1,7 +1,4 @@ --- Unlike other lint tests, this file cannot use begin/savepoint/rollback: replication --- slots are non-transactional (they survive a rollback), and ALTER SYSTEM is outright --- rejected inside a transaction block. Every statement here autocommits individually; --- cleanup (dropping slots, resetting max_slot_wal_keep_size) is explicit instead. +-- Unlike other lint tests, this file cannot use begin/savepoint/rollback: replication slots are non-transactional (they survive a rollback) and ALTER SYSTEM is rejected inside a transaction block, so every statement here autocommits individually and cleanup is explicit instead. set search_path = ''; -- BASELINE: 0 issues, no slots exist select * from lint."0030_unused_replication_slot"; @@ -9,9 +6,7 @@ select * from lint."0030_unused_replication_slot"; ------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- (0 rows) --- NEGATIVE EXAMPLE: a freshly created slot (with its LSN reserved, as a real replica --- connection would) is wal_status='reserved' while inactive -- e.g. a replica that hasn't --- connected yet, or restarted seconds ago -- and must NOT fire. +-- NEGATIVE EXAMPLE: a freshly created slot (LSN reserved, as a real replica connection would) is wal_status='reserved' while inactive -- e.g. a replica that just restarted -- and must NOT fire. do $$ begin perform pg_catalog.pg_create_physical_replication_slot('splinter_test_negative_slot', true); end $$; select name, detail, cache_key from lint."0030_unused_replication_slot"; -- expect 0 rows name | detail | cache_key @@ -72,8 +67,7 @@ select pg_reload_conf(); t (1 row) --- POSITIVE EXAMPLE: shrink max_slot_wal_keep_size and generate enough WAL past it so the --- slot's retained WAL exceeds the limit. +-- POSITIVE EXAMPLE: shrink max_slot_wal_keep_size and generate enough WAL past it so the slot's retained WAL exceeds the limit. do $$ begin perform pg_catalog.pg_create_physical_replication_slot('splinter_test_positive_slot', true); end $$; alter system set max_slot_wal_keep_size = '1MB'; select pg_reload_conf(); @@ -83,16 +77,14 @@ select pg_reload_conf(); (1 row) do $$ begin perform pg_catalog.pg_logical_emit_message(false, 'splinter_test', 'advance'); perform pg_catalog.pg_switch_wal(); end $$; --- before any checkpoint runs, the slot has exceeded max_slot_wal_keep_size but hasn't been --- invalidated yet -- wal_status is 'unreserved', level WARN +-- before any checkpoint runs, the slot has exceeded max_slot_wal_keep_size but hasn't been invalidated yet -- wal_status is 'unreserved', level WARN select name, detail, cache_key from lint."0030_unused_replication_slot"; -- expect 1 row, wal_status unreserved name | detail | cache_key -------------------------+---------------------------------------------------------------------------------------------------------+----------------------------------------------------- unused_replication_slot | Replication slot `splinter_test_positive_slot` is inactive and its WAL retention status is `unreserved` | unused_replication_slot_splinter_test_positive_slot (1 row) --- a checkpoint is what actually invalidates an 'unreserved' slot once its required WAL is --- gone -- wal_status becomes 'lost', level ERROR +-- a checkpoint is what actually invalidates an 'unreserved' slot once its required WAL is gone -- wal_status becomes 'lost', level ERROR checkpoint; select name, detail, cache_key from lint."0030_unused_replication_slot"; -- expect 1 row, wal_status lost name | detail | cache_key diff --git a/test/sql/0030_unused_replication_slot.sql b/test/sql/0030_unused_replication_slot.sql index 528297bc..9cbbaf0b 100644 --- a/test/sql/0030_unused_replication_slot.sql +++ b/test/sql/0030_unused_replication_slot.sql @@ -1,15 +1,10 @@ --- Unlike other lint tests, this file cannot use begin/savepoint/rollback: replication --- slots are non-transactional (they survive a rollback), and ALTER SYSTEM is outright --- rejected inside a transaction block. Every statement here autocommits individually; --- cleanup (dropping slots, resetting max_slot_wal_keep_size) is explicit instead. +-- Unlike other lint tests, this file cannot use begin/savepoint/rollback: replication slots are non-transactional (they survive a rollback) and ALTER SYSTEM is rejected inside a transaction block, so every statement here autocommits individually and cleanup is explicit instead. set search_path = ''; -- BASELINE: 0 issues, no slots exist select * from lint."0030_unused_replication_slot"; --- NEGATIVE EXAMPLE: a freshly created slot (with its LSN reserved, as a real replica --- connection would) is wal_status='reserved' while inactive -- e.g. a replica that hasn't --- connected yet, or restarted seconds ago -- and must NOT fire. +-- NEGATIVE EXAMPLE: a freshly created slot (LSN reserved, as a real replica connection would) is wal_status='reserved' while inactive -- e.g. a replica that just restarted -- and must NOT fire. do $$ begin perform pg_catalog.pg_create_physical_replication_slot('splinter_test_negative_slot', true); end $$; select name, detail, cache_key from lint."0030_unused_replication_slot"; -- expect 0 rows select pg_catalog.pg_drop_replication_slot('splinter_test_negative_slot'); @@ -31,19 +26,16 @@ select pg_catalog.pg_drop_replication_slot('splinter_test_extended_slot'); alter system reset max_wal_size; select pg_reload_conf(); --- POSITIVE EXAMPLE: shrink max_slot_wal_keep_size and generate enough WAL past it so the --- slot's retained WAL exceeds the limit. +-- POSITIVE EXAMPLE: shrink max_slot_wal_keep_size and generate enough WAL past it so the slot's retained WAL exceeds the limit. do $$ begin perform pg_catalog.pg_create_physical_replication_slot('splinter_test_positive_slot', true); end $$; alter system set max_slot_wal_keep_size = '1MB'; select pg_reload_conf(); do $$ begin perform pg_catalog.pg_logical_emit_message(false, 'splinter_test', 'advance'); perform pg_catalog.pg_switch_wal(); end $$; --- before any checkpoint runs, the slot has exceeded max_slot_wal_keep_size but hasn't been --- invalidated yet -- wal_status is 'unreserved', level WARN +-- before any checkpoint runs, the slot has exceeded max_slot_wal_keep_size but hasn't been invalidated yet -- wal_status is 'unreserved', level WARN select name, detail, cache_key from lint."0030_unused_replication_slot"; -- expect 1 row, wal_status unreserved --- a checkpoint is what actually invalidates an 'unreserved' slot once its required WAL is --- gone -- wal_status becomes 'lost', level ERROR +-- a checkpoint is what actually invalidates an 'unreserved' slot once its required WAL is gone -- wal_status becomes 'lost', level ERROR checkpoint; select name, detail, cache_key from lint."0030_unused_replication_slot"; -- expect 1 row, wal_status lost From 17913330e8a29f381876237b88c940aaab8b3c43 Mon Sep 17 00:00:00 2001 From: Douglas J Hunley Date: Thu, 23 Jul 2026 12:23:26 -0400 Subject: [PATCH 09/13] style: schema-qualify pg_reload_conf() calls in the slot lint test ~/.claude/CLAUDE.md SQL section: always schema-qualify every function reference, never rely on search_path resolving an unqualified name. Every other system function in this file (pg_create_physical_replication_slot, pg_drop_replication_slot, pg_switch_wal) was already pg_catalog.-qualified; pg_reload_conf() was the one exception. --- test/expected/0030_unused_replication_slot.out | 8 ++++---- test/sql/0030_unused_replication_slot.sql | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/test/expected/0030_unused_replication_slot.out b/test/expected/0030_unused_replication_slot.out index 6e33c216..f81f256b 100644 --- a/test/expected/0030_unused_replication_slot.out +++ b/test/expected/0030_unused_replication_slot.out @@ -35,7 +35,7 @@ select pg_catalog.pg_drop_replication_slot('splinter_test_negative_logical_slot' -- NEGATIVE EXAMPLE (extended): max_wal_size exceeded but max_slot_wal_keep_size left at its default (disabled), so the slot is 'extended', not 'unreserved' -- must NOT fire do $$ begin perform pg_catalog.pg_create_physical_replication_slot('splinter_test_extended_slot', true); end $$; alter system set max_wal_size = '2MB'; -select pg_reload_conf(); +select pg_catalog.pg_reload_conf(); pg_reload_conf ---------------- t @@ -61,7 +61,7 @@ select pg_catalog.pg_drop_replication_slot('splinter_test_extended_slot'); (1 row) alter system reset max_wal_size; -select pg_reload_conf(); +select pg_catalog.pg_reload_conf(); pg_reload_conf ---------------- t @@ -70,7 +70,7 @@ select pg_reload_conf(); -- POSITIVE EXAMPLE: shrink max_slot_wal_keep_size and generate enough WAL past it so the slot's retained WAL exceeds the limit. do $$ begin perform pg_catalog.pg_create_physical_replication_slot('splinter_test_positive_slot', true); end $$; alter system set max_slot_wal_keep_size = '1MB'; -select pg_reload_conf(); +select pg_catalog.pg_reload_conf(); pg_reload_conf ---------------- t @@ -105,7 +105,7 @@ select * from lint."0030_unused_replication_slot"; -- expect 0 rows (0 rows) alter system reset max_slot_wal_keep_size; -select pg_reload_conf(); +select pg_catalog.pg_reload_conf(); pg_reload_conf ---------------- t diff --git a/test/sql/0030_unused_replication_slot.sql b/test/sql/0030_unused_replication_slot.sql index 9cbbaf0b..1a0e7841 100644 --- a/test/sql/0030_unused_replication_slot.sql +++ b/test/sql/0030_unused_replication_slot.sql @@ -17,19 +17,19 @@ select pg_catalog.pg_drop_replication_slot('splinter_test_negative_logical_slot' -- NEGATIVE EXAMPLE (extended): max_wal_size exceeded but max_slot_wal_keep_size left at its default (disabled), so the slot is 'extended', not 'unreserved' -- must NOT fire do $$ begin perform pg_catalog.pg_create_physical_replication_slot('splinter_test_extended_slot', true); end $$; alter system set max_wal_size = '2MB'; -select pg_reload_conf(); +select pg_catalog.pg_reload_conf(); -- pg_switch_wal() is a no-op once already sitting at a segment boundary, so each loop iteration writes one trivial WAL record first to move off the boundary before switching again. do $$ begin for i in 1..10 loop perform pg_catalog.pg_logical_emit_message(false, 'splinter_test', 'advance'); perform pg_catalog.pg_switch_wal(); end loop; end $$; select slot_name, wal_status from pg_catalog.pg_replication_slots where slot_name = 'splinter_test_extended_slot'; -- confirm wal_status is actually 'extended' select name, detail, cache_key from lint."0030_unused_replication_slot"; -- expect 0 rows select pg_catalog.pg_drop_replication_slot('splinter_test_extended_slot'); alter system reset max_wal_size; -select pg_reload_conf(); +select pg_catalog.pg_reload_conf(); -- POSITIVE EXAMPLE: shrink max_slot_wal_keep_size and generate enough WAL past it so the slot's retained WAL exceeds the limit. do $$ begin perform pg_catalog.pg_create_physical_replication_slot('splinter_test_positive_slot', true); end $$; alter system set max_slot_wal_keep_size = '1MB'; -select pg_reload_conf(); +select pg_catalog.pg_reload_conf(); do $$ begin perform pg_catalog.pg_logical_emit_message(false, 'splinter_test', 'advance'); perform pg_catalog.pg_switch_wal(); end $$; -- before any checkpoint runs, the slot has exceeded max_slot_wal_keep_size but hasn't been invalidated yet -- wal_status is 'unreserved', level WARN @@ -44,4 +44,4 @@ select pg_catalog.pg_drop_replication_slot('splinter_test_positive_slot'); select * from lint."0030_unused_replication_slot"; -- expect 0 rows alter system reset max_slot_wal_keep_size; -select pg_reload_conf(); +select pg_catalog.pg_reload_conf(); From 14b61175261adaf8e39c794354a61206f39afecc Mon Sep 17 00:00:00 2001 From: Douglas J Hunley Date: Thu, 23 Jul 2026 12:26:46 -0400 Subject: [PATCH 10/13] docs: note the level CASE / WHERE wal_status coupling in unused_replication_slot level's CASE and the WHERE clause's wal_status allowlist are two independently-maintained expressions. Not wrong today (WHERE only ever admits 'unreserved'/'lost', and 'unreserved' correctly falls to WARN), but widening WHERE to admit another wal_status value without updating the CASE would silently mislabel it WARN. One-line comment for future maintainers, no logic change. --- lints/0030_unused_replication_slot.sql | 1 + 1 file changed, 1 insertion(+) diff --git a/lints/0030_unused_replication_slot.sql b/lints/0030_unused_replication_slot.sql index 5c1b6289..c3d92f53 100644 --- a/lints/0030_unused_replication_slot.sql +++ b/lints/0030_unused_replication_slot.sql @@ -3,6 +3,7 @@ create view lint."0030_unused_replication_slot" as select 'unused_replication_slot' as name, 'Unused Replication Slot' as title, + -- else only ever matches 'unreserved' today, since the WHERE clause admits only 'unreserved'/'lost' -- keep this in sync if that list changes case prs.wal_status when 'lost' then 'ERROR' else 'WARN' From 927e54ebb5232563e7495d9d3ee07d985bd0569e Mon Sep 17 00:00:00 2001 From: Douglas J Hunley Date: Thu, 23 Jul 2026 12:27:17 -0400 Subject: [PATCH 11/13] chore: regenerate splinter.sql after unused_replication_slot review fixes bin/compile.py, reflecting the entity/plugin metadata keys and coupling comment added to lints/0030_unused_replication_slot.sql during review. --- splinter.sql | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/splinter.sql b/splinter.sql index 6c0ede1a..0b21146b 100644 --- a/splinter.sql +++ b/splinter.sql @@ -1844,6 +1844,7 @@ union all select 'unused_replication_slot' as name, 'Unused Replication Slot' as title, + -- else only ever matches 'unreserved' today, since the WHERE clause admits only 'unreserved'/'lost' -- keep this in sync if that list changes case prs.wal_status when 'lost' then 'ERROR' else 'WARN' @@ -1859,9 +1860,11 @@ select 'https://supabase.com/docs/guides/database/database-linter?lint=0030_unused_replication_slot' as remediation, jsonb_build_object( 'name', prs.slot_name, + 'entity', prs.slot_name, 'type', 'replication_slot', 'slot_type', prs.slot_type, - 'wal_status', prs.wal_status + 'wal_status', prs.wal_status, + 'plugin', prs.plugin ) as metadata, format('unused_replication_slot_%s', prs.slot_name) as cache_key from From ef50dbe9b32ec1e60c5a15e6755ddde1db68ce00 Mon Sep 17 00:00:00 2001 From: Douglas J Hunley Date: Thu, 23 Jul 2026 12:33:23 -0400 Subject: [PATCH 12/13] docs(splinter): replace WHAT-comment with WHY and add missing why-comments The RESOLUTION comment on dropping a stale slot restated the action instead of explaining why dropping is necessary. Also adds why-comments to the WARN/ERROR case and the wal_status WHERE filter in the new 0030_unused_replication_slot lint, which previously had no rationale for why only 'lost' escalates to ERROR and why 'reserved'/'extended' are excluded. --- lints/0030_unused_replication_slot.sql | 3 ++- splinter.sql | 3 ++- test/expected/0030_unused_replication_slot.out | 2 +- test/sql/0030_unused_replication_slot.sql | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/lints/0030_unused_replication_slot.sql b/lints/0030_unused_replication_slot.sql index c3d92f53..efa0593c 100644 --- a/lints/0030_unused_replication_slot.sql +++ b/lints/0030_unused_replication_slot.sql @@ -3,7 +3,7 @@ create view lint."0030_unused_replication_slot" as select 'unused_replication_slot' as name, 'Unused Replication Slot' as title, - -- else only ever matches 'unreserved' today, since the WHERE clause admits only 'unreserved'/'lost' -- keep this in sync if that list changes + -- 'lost' means the WAL is already gone and unrecoverable (ERROR) -- the only other status the WHERE clause admits is 'unreserved', which can still self-heal before invalidation, so it's just a WARN case prs.wal_status when 'lost' then 'ERROR' else 'WARN' @@ -30,6 +30,7 @@ from pg_catalog.pg_replication_slots prs where prs.active = false + -- 'reserved'/'extended' are still within retention limits (or a replica reconnecting) -- only 'unreserved' (limit already exceeded) and 'lost' (already invalidated) are worth flagging and prs.wal_status in ('unreserved', 'lost') order by prs.slot_name; diff --git a/splinter.sql b/splinter.sql index 0b21146b..a614379c 100644 --- a/splinter.sql +++ b/splinter.sql @@ -1844,7 +1844,7 @@ union all select 'unused_replication_slot' as name, 'Unused Replication Slot' as title, - -- else only ever matches 'unreserved' today, since the WHERE clause admits only 'unreserved'/'lost' -- keep this in sync if that list changes + -- 'lost' means the WAL is already gone and unrecoverable (ERROR) -- the only other status the WHERE clause admits is 'unreserved', which can still self-heal before invalidation, so it's just a WARN case prs.wal_status when 'lost' then 'ERROR' else 'WARN' @@ -1871,6 +1871,7 @@ from pg_catalog.pg_replication_slots prs where prs.active = false + -- 'reserved'/'extended' are still within retention limits (or a replica reconnecting) -- only 'unreserved' (limit already exceeded) and 'lost' (already invalidated) are worth flagging and prs.wal_status in ('unreserved', 'lost') order by prs.slot_name) \ No newline at end of file diff --git a/test/expected/0030_unused_replication_slot.out b/test/expected/0030_unused_replication_slot.out index f81f256b..fc295cfa 100644 --- a/test/expected/0030_unused_replication_slot.out +++ b/test/expected/0030_unused_replication_slot.out @@ -92,7 +92,7 @@ select name, detail, cache_key from lint."0030_unused_replication_slot"; -- exp unused_replication_slot | Replication slot `splinter_test_positive_slot` is inactive and its WAL retention status is `lost` | unused_replication_slot_splinter_test_positive_slot (1 row) --- RESOLUTION: drop the slot +-- dropping is the only way to stop WAL accumulation once the consumer is confirmed gone for good select pg_catalog.pg_drop_replication_slot('splinter_test_positive_slot'); pg_drop_replication_slot -------------------------- diff --git a/test/sql/0030_unused_replication_slot.sql b/test/sql/0030_unused_replication_slot.sql index 1a0e7841..72518265 100644 --- a/test/sql/0030_unused_replication_slot.sql +++ b/test/sql/0030_unused_replication_slot.sql @@ -39,7 +39,7 @@ select name, detail, cache_key from lint."0030_unused_replication_slot"; -- exp checkpoint; select name, detail, cache_key from lint."0030_unused_replication_slot"; -- expect 1 row, wal_status lost --- RESOLUTION: drop the slot +-- dropping is the only way to stop WAL accumulation once the consumer is confirmed gone for good select pg_catalog.pg_drop_replication_slot('splinter_test_positive_slot'); select * from lint."0030_unused_replication_slot"; -- expect 0 rows From 58791e294d366becb7240975d1b609adf367a8cb Mon Sep 17 00:00:00 2001 From: Douglas J Hunley Date: Sun, 26 Jul 2026 17:29:02 -0400 Subject: [PATCH 13/13] docs(unused_replication_slot): split two run-on lint comments Both comments chained the wal_status classification and its rationale into one long clause. Split into two short sentences on the same line, and regenerated splinter.sql from the updated lint source. --- lints/0030_unused_replication_slot.sql | 4 ++-- splinter.sql | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lints/0030_unused_replication_slot.sql b/lints/0030_unused_replication_slot.sql index efa0593c..35b2d21c 100644 --- a/lints/0030_unused_replication_slot.sql +++ b/lints/0030_unused_replication_slot.sql @@ -3,7 +3,7 @@ create view lint."0030_unused_replication_slot" as select 'unused_replication_slot' as name, 'Unused Replication Slot' as title, - -- 'lost' means the WAL is already gone and unrecoverable (ERROR) -- the only other status the WHERE clause admits is 'unreserved', which can still self-heal before invalidation, so it's just a WARN + -- 'lost' means the WAL is already gone and unrecoverable (ERROR). The only other status the WHERE clause admits, 'unreserved', can still self-heal before invalidation, so it's just a WARN. case prs.wal_status when 'lost' then 'ERROR' else 'WARN' @@ -30,7 +30,7 @@ from pg_catalog.pg_replication_slots prs where prs.active = false - -- 'reserved'/'extended' are still within retention limits (or a replica reconnecting) -- only 'unreserved' (limit already exceeded) and 'lost' (already invalidated) are worth flagging + -- 'reserved'/'extended' are still within retention limits, or a replica is reconnecting. Only 'unreserved' (limit already exceeded) and 'lost' (already invalidated) are worth flagging. and prs.wal_status in ('unreserved', 'lost') order by prs.slot_name; diff --git a/splinter.sql b/splinter.sql index a614379c..9b9cce50 100644 --- a/splinter.sql +++ b/splinter.sql @@ -1844,7 +1844,7 @@ union all select 'unused_replication_slot' as name, 'Unused Replication Slot' as title, - -- 'lost' means the WAL is already gone and unrecoverable (ERROR) -- the only other status the WHERE clause admits is 'unreserved', which can still self-heal before invalidation, so it's just a WARN + -- 'lost' means the WAL is already gone and unrecoverable (ERROR). The only other status the WHERE clause admits, 'unreserved', can still self-heal before invalidation, so it's just a WARN. case prs.wal_status when 'lost' then 'ERROR' else 'WARN' @@ -1871,7 +1871,7 @@ from pg_catalog.pg_replication_slots prs where prs.active = false - -- 'reserved'/'extended' are still within retention limits (or a replica reconnecting) -- only 'unreserved' (limit already exceeded) and 'lost' (already invalidated) are worth flagging + -- 'reserved'/'extended' are still within retention limits, or a replica is reconnecting. Only 'unreserved' (limit already exceeded) and 'lost' (already invalidated) are worth flagging. and prs.wal_status in ('unreserved', 'lost') order by prs.slot_name) \ No newline at end of file