diff --git a/.claude/skills/new-lint/SKILL.md b/.claude/skills/new-lint/SKILL.md index 5d05c5b..6f94a37 100644 --- a/.claude/skills/new-lint/SKILL.md +++ b/.claude/skills/new-lint/SKILL.md @@ -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 when severity genuinely varies per row (see `lints/0030_unused_replication_slot.sql`); if you do this, the doc's `**Level:**` line (Step 4) must state which value maps to which level instead of using the bare placeholder | | `facing` | text | `'EXTERNAL'` or `'INTERNAL'` | | `categories` | text[] | e.g. `array['SECURITY']` or `array['PERFORMANCE']` | | `description` | text | What the lint checks and why it matters | @@ -119,7 +119,7 @@ Insert the new lint **before** `-d contrib_regression`, maintaining numeric orde ## Step 4 — Create `docs/XXXX_.md` ```markdown -**Level:** WARN|ERROR|INFO +**Level:** WARN|ERROR|INFO — pick the one your lint always emits. If level varies per row (a `case` expression in Step 2), replace this line with an explicit statement of which value maps to which level, e.g. `**Level:** WARN (\`unreserved\`) or ERROR (\`lost\`)`. **Summary:** One-line summary of what is detected. diff --git a/bin/check_lints.py b/bin/check_lints.py index b563206..682fa45 100644 --- a/bin/check_lints.py +++ b/bin/check_lints.py @@ -38,6 +38,8 @@ } STEM_RE = re.compile(r"^(\d{4})_[a-z0-9_]+$") +DYNAMIC_LEVEL_RE = re.compile(r"case\b.*?\bend\s+as\s+level\b", re.IGNORECASE | re.DOTALL) +LEVEL_PLACEHOLDER = "**Level:** WARN|ERROR|INFO" def find_by_number(directory: Path, number: str, suffix: str) -> list[Path]: @@ -96,6 +98,16 @@ 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") + elif DYNAMIC_LEVEL_RE.search(lint_path.read_text()): + # level varies per row (a `case` expression) -- the doc must say which + # value maps to which level instead of leaving the bare placeholder. + if LEVEL_PLACEHOLDER in docs[0].read_text(): + errors.append( + f"{docs[0]}: level varies per row in {lint_path.name} (case " + f"expression) but the Level line is still the unfilled " + f"'{LEVEL_PLACEHOLDER}' placeholder; state which value maps " + f"to which level" + ) # 5. tested if stem in NO_REGRESS_TEST: diff --git a/bin/installcheck b/bin/installcheck index b54f633..18e73ef 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 @@ -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 0000000..e941b18 --- /dev/null +++ b/docs/0030_unused_replication_slot.md @@ -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 after a read replica's IP or `supabase_realtime_replication_slot*` 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(''); +``` + +**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 0000000..2d1507d --- /dev/null +++ b/lints/0030_unused_replication_slot.sql @@ -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; diff --git a/mkdocs.yaml b/mkdocs.yaml index 1f5d05d..3e32107 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -37,6 +37,7 @@ nav: - Signed-In Users Can See Object in GraphQL Schema: '0027_pg_graphql_authenticated_table_exposed.md' - Public Can Execute SECURITY DEFINER Function: '0028_anon_security_definer_function_executable.md' - Signed-In Users Can Execute SECURITY DEFINER Function: '0029_authenticated_security_definer_function_executable.md' + - Unused Replication Slot: '0030_unused_replication_slot.md' theme: name: 'material' diff --git a/splinter.sql b/splinter.sql index 9cd66c9..1e2c232 100644 --- a/splinter.sql +++ b/splinter.sql @@ -1838,4 +1838,41 @@ 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, + -- '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) \ 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 0000000..f4b4b51 --- /dev/null +++ b/test/expected/0030_unused_replication_slot.out @@ -0,0 +1,143 @@ +-- 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 (physical): 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) + +-- same margin technique as the extended case above -- a single switch can land short of the 1MB threshold depending on how much WAL earlier statements in this script already emitted. +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 $$; +-- 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, level, detail, cache_key from lint."0030_unused_replication_slot"; -- expect 1 row, wal_status unreserved + name | level | detail | cache_key +-------------------------+-------+-----------------------------------------------------------------------------------------------+----------------------------------------------------- + unused_replication_slot | WARN | 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, level, detail, cache_key from lint."0030_unused_replication_slot"; -- expect 1 row, wal_status lost + name | level | detail | cache_key +-------------------------+-------+-----------------------------------------------------------------------------------------+----------------------------------------------------- + unused_replication_slot | ERROR | 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) + +-- POSITIVE EXAMPLE (logical): same escalation as the physical case, but also proves the entity/plugin metadata Studio needs for logical slots is actually populated. +do $$ begin perform pg_catalog.pg_create_logical_replication_slot('splinter_test_positive_logical_slot', 'test_decoding'); end $$; +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 name, level, metadata ->> 'entity' as entity, metadata ->> 'plugin' as plugin, cache_key from lint."0030_unused_replication_slot"; -- expect 1 row, wal_status unreserved + name | level | entity | plugin | cache_key +-------------------------+-------+-------------------------------------+---------------+------------------------------------------------------------- + unused_replication_slot | WARN | splinter_test_positive_logical_slot | test_decoding | unused_replication_slot_splinter_test_positive_logical_slot +(1 row) + +checkpoint; +select name, level, metadata ->> 'entity' as entity, metadata ->> 'plugin' as plugin, cache_key from lint."0030_unused_replication_slot"; -- expect 1 row, wal_status lost + name | level | entity | plugin | cache_key +-------------------------+-------+-------------------------------------+---------------+------------------------------------------------------------- + unused_replication_slot | ERROR | splinter_test_positive_logical_slot | test_decoding | unused_replication_slot_splinter_test_positive_logical_slot +(1 row) + +select pg_catalog.pg_drop_replication_slot('splinter_test_positive_logical_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) + diff --git a/test/expected/queries_are_unionable.out b/test/expected/queries_are_unionable.out index 3b1005d..563d8ac 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 0000000..c3b0d1e --- /dev/null +++ b/test/sql/0030_unused_replication_slot.sql @@ -0,0 +1,59 @@ +-- 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 (physical): 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(); +-- same margin technique as the extended case above -- a single switch can land short of the 1MB threshold depending on how much WAL earlier statements in this script already emitted. +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 $$; + +-- 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, level, 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, level, 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 + +-- POSITIVE EXAMPLE (logical): same escalation as the physical case, but also proves the entity/plugin metadata Studio needs for logical slots is actually populated. +do $$ begin perform pg_catalog.pg_create_logical_replication_slot('splinter_test_positive_logical_slot', 'test_decoding'); end $$; +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 name, level, metadata ->> 'entity' as entity, metadata ->> 'plugin' as plugin, cache_key from lint."0030_unused_replication_slot"; -- expect 1 row, wal_status unreserved +checkpoint; +select name, level, metadata ->> 'entity' as entity, metadata ->> 'plugin' as plugin, cache_key from lint."0030_unused_replication_slot"; -- expect 1 row, wal_status lost +select pg_catalog.pg_drop_replication_slot('splinter_test_positive_logical_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(); diff --git a/test/sql/queries_are_unionable.sql b/test/sql/queries_are_unionable.sql index fe43d3d..70393d4 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;