Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 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
d3bdb97
test: harden 0030 WAL-margin loop and add logical-slot positive coverage
hunleyd Aug 22, 2026
d62b9c0
docs: clarify 0030's dual level and warn against dropping platform-ma…
hunleyd Aug 22, 2026
d05248d
docs: note this lint depends on max_slot_wal_keep_size being finite
hunleyd Aug 22, 2026
882cc38
docs: formalize per-row level as an allowed pattern in the new-lint c…
hunleyd Aug 22, 2026
be1cc9d
style: reformat check_lints.py per black
hunleyd Aug 22, 2026
3392964
fix: simplify level-placeholder check, keep new-lint template pasteable
hunleyd Aug 22, 2026
e88365e
fix(docs): widen and clarify the platform-managed-slot pattern in 0030
hunleyd Aug 22, 2026
03fc544
fix: harden 0030's test setup and remove an implicit invariant coupling
hunleyd Aug 22, 2026
79d19d5
fix: point the level-placeholder error at the actual offending doc
hunleyd Aug 22, 2026
4a4b421
docs: unslop PR body and comments for #173
hunleyd Aug 23, 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: 3 additions & 1 deletion .claude/skills/new-lint/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Create a SQL view in the `lint` schema. The view **must** return exactly these 1
|--------|------|-------|
| `name` | text | snake_case identifier, e.g. `'my_lint_name'` |
| `title` | text | Human-readable title |
| `level` | text | `'ERROR'`, `'WARN'`, or `'INFO'` |
| `level` | text | `'ERROR'`, `'WARN'`, or `'INFO'` — a `case` expression is fine if severity varies per row (see `lints/0030_unused_replication_slot.sql`) |
| `facing` | text | `'EXTERNAL'` or `'INTERNAL'` |
| `categories` | text[] | e.g. `array['SECURITY']` or `array['PERFORMANCE']` |
| `description` | text | What the lint checks and why it matters |
Expand Down Expand Up @@ -118,6 +118,8 @@ Insert the new lint **before** `-d contrib_regression`, maintaining numeric orde

## Step 4 — Create `docs/XXXX_<name>.md`

Pick the level your lint always emits. If level varies per row (a `case` expression in Step 2), replace the line with an explicit statement of which value maps to which level instead, e.g. "`**Level:** WARN (unreserved) or ERROR (lost)`" — never leave the bare placeholder below unfilled; `bin/check_lints.py` rejects it.

```markdown
**Level:** WARN|ERROR|INFO

Expand Down
13 changes: 12 additions & 1 deletion bin/check_lints.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
}

STEM_RE = re.compile(r"^(\d{4})_[a-z0-9_]+$")
LEVEL_PLACEHOLDER = "**Level:** WARN|ERROR|INFO"


def find_by_number(directory: Path, number: str, suffix: str) -> list[Path]:
Expand Down Expand Up @@ -71,8 +72,10 @@ def check() -> list[str]:
)
seen_numbers[number] = lint_path

sql = lint_path.read_text()

# 1. view name matches the file stem
if f'create view lint."{stem}"' not in lint_path.read_text().lower():
if f'create view lint."{stem}"' not in sql.lower():
errors.append(
f'{lint_path}: must declare `create view lint."{stem}"` '
f"(view name has to match the file name)"
Expand All @@ -96,6 +99,14 @@ def check() -> list[str]:
docs = find_by_number(DOCS_DIR, number, ".md")
if not docs:
errors.append(f"{lint_path}: missing docs page {DOCS_DIR}/{number}_*.md")
else:
for doc in docs:
if LEVEL_PLACEHOLDER in doc.read_text():
errors.append(
f"{doc}: Level line is still the unfilled "
f"'{LEVEL_PLACEHOLDER}' placeholder; state which level(s) "
f"this lint actually emits"
)

# 5. tested
if stem in NO_REGRESS_TEST:
Expand Down
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
57 changes: 57 additions & 0 deletions docs/0030_unused_replication_slot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
**Level:** WARN (`unreserved`) or ERROR (`lost`). See Rationale below.

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

This lint relies on `max_slot_wal_keep_size` being set to a finite value. Supabase's managed Postgres always sets one. On a self-hosted instance left at Postgres's own default (`max_slot_wal_keep_size = -1`, meaning "never invalidate for size"), an abandoned slot can stay `extended` and accumulate WAL indefinitely without ever reaching `unreserved`, so this lint will not catch it. Set `max_slot_wal_keep_size` to a finite value to get this protection.

### How to Resolve

**Option 1: Drop the slot if its consumer is gone for good**

Only do this for a slot you created yourself. A slot named `ip_<x>_<x>_<x>_<x>` (a read replica's IP) or prefixed `supabase_realtime_*` is owned by the platform (a read replica or Realtime), not by you. Dropping it does not fix anything and can break replication or realtime delivery outright. Remove the read replica from the dashboard, or contact support, instead of dropping a platform-managed slot directly.

```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,
-- exhaustive: the WHERE clause below admits no wal_status besides 'lost' and 'unreserved', so this case needs no else.
case prs.wal_status
when 'lost' then 'ERROR'
when 'unreserved' then '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,
-- exhaustive: the WHERE clause below admits no wal_status besides 'lost' and 'unreserved', so this case needs no else.
case prs.wal_status
when 'lost' then 'ERROR'
when 'unreserved' then '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)
Loading
Loading