Skip to content

feat: add unused_replication_slot lint for stale replication slots - #173

Open
hunleyd wants to merge 29 commits into
mainfrom
hunleyd/add-unused-replication-slot-lint
Open

feat: add unused_replication_slot lint for stale replication slots#173
hunleyd wants to merge 29 commits into
mainfrom
hunleyd/add-unused-replication-slot-lint

Conversation

@hunleyd

@hunleyd hunleyd commented Jul 23, 2026

Copy link
Copy Markdown

Summary

Read replica provisioning creates a replication slot on the primary, and if the consumer of that slot goes away for good, the slot retains WAL indefinitely and silently eats disk with no user-facing warning. This PR adds a new Performance Advisor lint, 0030_unused_replication_slot, that flags any inactive replication slot whose retained WAL has moved past max_slot_wal_keep_size.


Details
  • Adds a new Performance Advisor lint, 0030_unused_replication_slot, that flags any replication slot (physical or logical, regardless of what created it) that is inactive and whose wal_status has moved past max_wal_size:
    • wal_status = 'unreserved'WARN: retained WAL now exceeds max_slot_wal_keep_size, recoverable if the consumer catches up before the next checkpoint.
    • wal_status = 'lost'ERROR: slot already invalidated, unusable.
  • A slot that's merely active = false but still reserved or extended does not fire. reserved covers a replica restarting; extended covers a currently-healthy slot that's simply using more than max_wal_size right now (both benign per Postgres's own wal_status semantics).
  • Metadata includes entity/plugin so Supabase Studio's Advisor UI can display the slot name (instead of "N/A") and, for logical slots, which decoding plugin/consumer owned it.
  • This lint depends on max_slot_wal_keep_size being finite. Supabase's managed Postgres always sets one; a self-hosted instance left at Postgres's own default (-1, never invalidate for size) can accumulate WAL on an abandoned slot without this lint ever firing. This is documented in docs/0030_unused_replication_slot.md.
  • The remediation doc's "drop the slot" option now calls out that a platform-managed slot (a read replica's ip_x_x_x_x-pattern slot, or Realtime's supabase_realtime_replication_slot*) should never be dropped directly. Dropping it breaks replication/realtime delivery instead of fixing anything.
  • level is the first per-row (case-expression) severity in this repo instead of a fixed literal. This is formalized as an accepted pattern in .claude/skills/new-lint/SKILL.md, with a bin/check_lints.py check that the doc's **Level:** line states the mapping explicitly instead of the unfilled placeholder.

Testing
  • Docker pg_regress suite green (All 29 tests passed), including test/sql/0030_unused_replication_slot.sql covering: baseline (no slots), a physical reserved slot (negative), a logical reserved slot (negative), an extended slot with max_slot_wal_keep_size disabled (negative), the unreservedlost transition on both a physical and a logical slot (positive, level and entity/plugin metadata asserted on the logical case), plus the updated test/sql/queries_are_unionable.sql.
  • The physical positive fixture drives WAL past the threshold with a 10-iteration pg_switch_wal() loop (matching the extended fixture's own technique) instead of a single call, so it can't land short of the threshold depending on how much WAL earlier statements in the script already emitted.
  • splinter.sql regenerated via bin/compile.py.
  • Note: test/sql/0030_unused_replication_slot.sql can't use the standard begin;/savepoint/rollback; pattern other lint tests use, because replication slots and ALTER SYSTEM are both non-transactional (slots survive a rollback; ALTER SYSTEM is rejected outright inside a transaction block). Cleanup (dropping slots, resetting GUCs) is explicit instead; see comments in the test file. The test harness's initdb/pg_ctl start also now sets wal_level=logical (bin/installcheck) so the logical-slot fixtures can run.

Misc

Changelog: supabase/changelog#150 documents the platform PR that creates the physical replication slots this lint detects. Its internal notes already have a TODO to add a reference to this PR into its public text once this PR ships.

Studio doesn't yet know how to render this lint (no lintInfoMap entry), tracked as INDATA-1326, with companion PRs supabase/platform#37386 and supabase/supabase-nimbus#38 already open. Neither blocks this PR merging.

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.
@hunleyd hunleyd self-assigned this Jul 23, 2026
hunleyd added 18 commits July 23, 2026 11:07
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.
…ot 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.
…lication_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.
…I 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.
…lots

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.
… 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.
…nt 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.
~/.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.
…cation_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.
…ixes

bin/compile.py, reflecting the entity/plugin metadata keys and coupling
comment added to lints/0030_unused_replication_slot.sql during review.
…ments

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.
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.
…plication-slot-lint

* origin/main:
  ci: fail the build when the test suite fails
  feat: release splinter.json to S3
  chore: format
  fix: only release on feat/fix/breaking-change commits; defer S3 upload
  feat: generate splinter.json manifest and publish via release workflow
bin/check_lints.py (pulled in from main) requires every docs/*.md page
to have a nav entry; the new lint's page was missing one, which failed
CI's pre-commit hook.
The extended-slot fixture forces max_wal_size=2MB plus 10 WAL switches,
which triggers an async checkpoint. If that checkpoint lands after the
positive fixture's max_slot_wal_keep_size=1MB takes effect instead of
before, it invalidates the slot immediately and the 'unreserved'
assertion flakes straight to 'lost'.
Without the rationale in the file, the identical 'name'/'entity' values
in metadata read as dead duplication to a future reader; the entity key
exists only so Studio's getLintEntityString short-circuits to the slot
name instead of rendering N/A (slots have no schema).
'lost' isn't always caused by exceeding max_slot_wal_keep_size -- Postgres
16+ can also invalidate a slot for rows_removed or wal_level_insufficient,
neither of which is a WAL-retention problem. The lint's detail text and doc
both asserted the retention cause unconditionally; reword to describe the
observed wal_status rather than presuming its cause.
… own doc

'at risk of exceeding' implies the limit hasn't been hit yet, but the lint
only fires once retained WAL has already exceeded max_slot_wal_keep_size
(or the slot is already invalidated) -- the doc's own Summary and False
Positives sections already say this correctly; the description column
contradicted them.
@hunleyd
hunleyd marked this pull request as ready for review August 8, 2026 04:36
@hunleyd
hunleyd marked this pull request as draft August 9, 2026 03:47
The positive fixture drove WAL past max_slot_wal_keep_size with a single
pg_switch_wal() call, unlike the extended-case fixture's 10-iteration loop —
a single switch only advances the LSN by however much is left in the current
WAL segment, so it could land short of the 1MB threshold depending on WAL
already emitted earlier in the script, turning an unrelated future change
into a spurious failure here. Use the same 10-iteration loop as the extended
case for a comfortable margin, and add a level column to the positive
assertions so the WARN/ERROR escalation is actually verified instead of
inferred from the comments.

Also add a logical-slot positive case: the only prior logical-slot fixture
was negative, so `metadata.plugin` (the field this lint added specifically
for logical slots) was never observed with a non-null value anywhere.
…naged slots

The `**Level:** WARN|ERROR` header reused the skill template's pipe-separated
placeholder syntax (meaning "pick one"), but this lint is the first to
genuinely emit both depending on wal_status — on the rendered docs site that
reads as an unfilled template. State explicitly which wal_status maps to
which level instead.

Read replica provisioning and Realtime both create their own replication
slots (named after the replica's IP, or supabase_realtime_replication_slot*),
and this lint has no way to tell those apart from a user-created slot. The
remediation doc led with an unqualified "drop the slot" — for a
platform-managed slot that doesn't fix anything and can break replication or
realtime delivery outright. Add the carve-out.
Supabase's own managed Postgres always sets this GUC, but splinter also
runs against self-hosted instances. Left at Postgres's own default (-1,
never invalidate for size), an abandoned slot stays 'extended' forever and
this lint never fires -- the exact "silently eats disk" scenario the lint
exists to catch. State the dependency so a self-hosted user knows to set it.
…hecklist

0030 is the first lint whose level varies per row (a case expression,
WARN/ERROR depending on wal_status) instead of a fixed literal like every
other lint. Document it as an accepted pattern in SKILL.md's column table
and doc template, and add a check_lints.py assertion so a future dynamic-
level lint can't ship with the doc's Level line still showing the unfilled
WARN|ERROR|INFO placeholder.
CI's pre-commit black hook flagged the DYNAMIC_LEVEL_RE regex assignment as
unformatted -- black wraps it across three lines at this line length.
The regex-based DYNAMIC_LEVEL_RE check only fired for a lint with a case
expression matching one exact spelling (end as level, unquoted) and, even
then, only caught the placeholder for that one lint -- a static-level lint
left unfilled passed clean, and the regex missed realistic variants like a
quoted identifier or a cast. Drop the SQL-shape dependency entirely and
check unconditionally: no doc may contain the unfilled placeholder, for any
lint. Strictly stronger, no parsing.

Also stopped pasting instruction prose into Step 4's copy-paste fence --
an author copying it verbatim was shipping a sentence of meta-guidance
into their own doc page. The guidance now sits outside the fence; the
fence itself stays exactly what should land in the file.
supabase_realtime_replication_slot* misses Realtime's own second slot
family (supabase_realtime_messages_replication_slot_<version>_<hash>,
per platform's realtime-service stack) -- a user with an inactive slot
from that family would read the doc as permission to drop a
platform-managed slot. Widen to the supabase_realtime_* prefix platform's
own guards already use. Also spell out the ip_<x>_<x>_<x>_<x> shape
instead of leaving the reader to infer it from "named after the IP".
The test file only cleaned up at the tail, trusting a clean start -- a
prior aborted run (a failed assertion, a killed process) could leave a
stray splinter_test_* slot or a non-default GUC that turns the unrelated
file running right after it (queries_are_unionable) into a confusing
failure. Converge to a clean state at the top too: drop any stray slot,
reset both GUCs before the fixtures rely on them being at their defaults.

Documented two facts a future editor needs and wouldn't otherwise see:
the logical positive fixture depends on max_slot_wal_keep_size still
being set from the physical fixture above it, and the WAL-margin loop's
10-iteration count assumes the default 16MB wal_segment_size.

The level case expression relied on the WHERE clause (25 lines away) to
guarantee wal_status could only be 'lost' or 'unreserved', with a comment
explaining the coupling instead of the case expression enforcing it.
Made it exhaustive by listing both values explicitly instead of an else
branch, so a future widening of the WHERE clause surfaces as a null level
instead of silently mislabelling a new status as WARN.
docs[0] named the first matching doc even when a later one in the list
was the actual match -- irrelevant today (every lint has exactly one doc
page) but wrong in general. Iterate and report the doc that actually
contains the placeholder.

Also: the SKILL.md prose moved outside the Step 4 fence kept its
fence-era backslash escapes, which don't work inside a markdown code
span and rendered as broken/backslash-visible text once outside it. And
the case comment's semicolon got silently eaten by bin/compile.py's
line.replace(";", "") pass, producing a run-on sentence in the published
splinter.sql -- reworded without a semicolon and restored the note that
the case's exhaustiveness depends on the WHERE clause.
@hunleyd
hunleyd marked this pull request as ready for review August 22, 2026 22:52
Replace em-dash-as-separator style in the new lint's doc, SQL comments,
and test comments with plain punctuation (periods, colons, semicolons).
Regenerated splinter.sql and the pg_regress expected output to match.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant