Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
54df63e
feat: add unused_replication_slot lint for stale replication slots
hunleyd Jul 23, 2026
bee99ea
fix: stop asserting raw WAL LSNs in the replication slot lint test
hunleyd Jul 23, 2026
17a727b
test: add logical replication slot coverage for unused_replication_sl…
hunleyd Jul 23, 2026
2449dcf
test: add wal_status='extended' negative-case coverage for unused_rep…
hunleyd Jul 23, 2026
e5588ab
fix: add entity key to unused_replication_slot metadata for Advisor U…
hunleyd Jul 23, 2026
9401b42
fix: add plugin key to unused_replication_slot metadata for logical s…
hunleyd Jul 23, 2026
4768d61
refactor: drop unnecessary 40MB filler table from the unreserved/lost…
hunleyd Jul 23, 2026
8052944
style: condense stacked comment blocks to single lines in the slot li…
hunleyd Jul 23, 2026
1791333
style: schema-qualify pg_reload_conf() calls in the slot lint test
hunleyd Jul 23, 2026
14b6117
docs: note the level CASE / WHERE wal_status coupling in unused_repli…
hunleyd Jul 23, 2026
927e54e
chore: regenerate splinter.sql after unused_replication_slot review f…
hunleyd Jul 23, 2026
ef50dbe
docs(splinter): replace WHAT-comment with WHY and add missing why-com…
hunleyd Jul 23, 2026
58791e2
docs(unused_replication_slot): split two run-on lint comments
hunleyd Jul 26, 2026
1acb78f
Merge remote-tracking branch 'origin/main' into hunleyd/add-unused-re…
hunleyd Aug 8, 2026
0508812
fix(docs): register 0030_unused_replication_slot in mkdocs nav
hunleyd Aug 8, 2026
35a0b0b
fix(test): drain pending checkpoint before the positive fixture in 0030
hunleyd Aug 8, 2026
e47cc7f
docs(unused_replication_slot): explain why entity duplicates name
hunleyd Aug 8, 2026
ffda39b
fix(unused_replication_slot): stop overclaiming why a slot went lost
hunleyd Aug 8, 2026
6cf63dc
fix(unused_replication_slot): correct description tense vs the lint's…
hunleyd Aug 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions bin/installcheck
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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}
Expand Down
53 changes: 53 additions & 0 deletions docs/0030_unused_replication_slot.md
Original file line number Diff line number Diff line change
@@ -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 (usually because its required WAL is already gone, though Postgres can invalidate a slot for other reasons too) and it 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('<slot_name>');
```

**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).
37 changes: 37 additions & 0 deletions lints/0030_unused_replication_slot.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
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, 'unreserved', can still self-heal before invalidation, so it's just a WARN.
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 retaining WAL beyond 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_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,
-- duplicates 'name': Studio's getLintEntityString needs schema+name or entity to render anything, and slots have no schema, so entity is set to short-circuit straight to the slot name.
'entity', prs.slot_name,
'type', 'replication_slot',
'slot_type', prs.slot_type,
'wal_status', prs.wal_status,
'plugin', prs.plugin
) as metadata,
format('unused_replication_slot_%s', prs.slot_name) as cache_key
from
pg_catalog.pg_replication_slots prs
where
prs.active = false
-- '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;
1 change: 1 addition & 0 deletions mkdocs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
- Unused Replication Slot: '0030_unused_replication_slot.md'

theme:
name: 'material'
Expand Down
39 changes: 38 additions & 1 deletion splinter.sql
Original file line number Diff line number Diff line change
Expand Up @@ -1838,4 +1838,41 @@ from
order by
schema_name,
function_name,
function_args)
function_args)
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, 'unreserved', can still self-heal before invalidation, so it's just a WARN.
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 retaining WAL beyond 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_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,
-- duplicates 'name': Studio's getLintEntityString needs schema+name or entity to render anything, and slots have no schema, so entity is set to short-circuit straight to the slot name.
'entity', prs.slot_name,
'type', 'replication_slot',
'slot_type', prs.slot_type,
'wal_status', prs.wal_status,
'plugin', prs.plugin
) as metadata,
format('unused_replication_slot_%s', prs.slot_name) as cache_key
from
pg_catalog.pg_replication_slots prs
where
prs.active = false
-- '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)
115 changes: 115 additions & 0 deletions test/expected/0030_unused_replication_slot.out
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
-- 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";
name | title | level | facing | categories | description | detail | remediation | metadata | cache_key
------+-------+-------+--------+------------+-------------+--------+-------------+----------+-----------
(0 rows)

-- 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
------+--------+-----------
(0 rows)

select pg_catalog.pg_drop_replication_slot('splinter_test_negative_slot');
pg_drop_replication_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)

-- 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_catalog.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_catalog.pg_reload_conf();
pg_reload_conf
----------------
t
(1 row)

-- drain any checkpoint still pending from the WAL burst above -- otherwise it can land mid-way through the positive fixture below and invalidate the slot before the 'unreserved' assertion runs.
checkpoint;
-- 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_catalog.pg_reload_conf();
pg_reload_conf
----------------
t
(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
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_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_status is `lost` | unused_replication_slot_splinter_test_positive_slot
(1 row)

-- 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
--------------------------

(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)

alter system reset max_slot_wal_keep_size;
select pg_catalog.pg_reload_conf();
pg_reload_conf
----------------
t
(1 row)

4 changes: 3 additions & 1 deletion test/expected/queries_are_unionable.out
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
49 changes: 49 additions & 0 deletions test/sql/0030_unused_replication_slot.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
-- 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 (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');

-- 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');

-- 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_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_catalog.pg_reload_conf();
-- drain any checkpoint still pending from the WAL burst above -- otherwise it can land mid-way through the positive fixture below and invalidate the slot before the 'unreserved' assertion runs.
checkpoint;

-- 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_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
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

-- 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

alter system reset max_slot_wal_keep_size;
select pg_catalog.pg_reload_conf();
4 changes: 3 additions & 1 deletion test/sql/queries_are_unionable.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Loading