diff --git a/.claude/skills/new-lint/SKILL.md b/.claude/skills/new-lint/SKILL.md index 5d05c5b..23d695b 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 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 | @@ -118,6 +118,8 @@ Insert the new lint **before** `-d contrib_regression`, maintaining numeric orde ## Step 4 — Create `docs/XXXX_.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 diff --git a/bin/check_lints.py b/bin/check_lints.py index b563206..18ccf01 100644 --- a/bin/check_lints.py +++ b/bin/check_lints.py @@ -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]: @@ -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)" @@ -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: 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..a04d05d --- /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 `ip____` (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(''); +``` + +**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..87d81e4 --- /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, + -- 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; 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..a81ba18 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, + -- 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) \ 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..2573460 --- /dev/null +++ b/test/expected/0030_unused_replication_slot.out @@ -0,0 +1,160 @@ +-- 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 = ''; +-- Converge to a clean starting state instead of trusting the tail cleanup below. A prior aborted run of this same file (a failed assertion, a killed test process) can otherwise leave a stray splinter_test_* slot or a non-default GUC that turns an unrelated later file (queries_are_unionable runs immediately after this one) into a confusing failure. +do $$ +declare + stray record; +begin + for stray in select slot_name from pg_catalog.pg_replication_slots where slot_name like 'splinter_test_%' loop + perform pg_catalog.pg_drop_replication_slot(stray.slot_name); + end loop; +end $$; +alter system reset max_wal_size; +alter system reset max_slot_wal_keep_size; +select pg_catalog.pg_reload_conf(); + pg_reload_conf +---------------- + t +(1 row) + +-- 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', and 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. 10 iterations assumes the default 16MB wal_segment_size; this count and the other two copies of this loop below must move together if that assumption ever changes. +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. Deliberately reuses max_slot_wal_keep_size='1MB' still set by the physical fixture above; do not reorder this block away from it, and reset the GUC before it if a fixture is ever inserted between them. +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..c21ad6e --- /dev/null +++ b/test/sql/0030_unused_replication_slot.sql @@ -0,0 +1,72 @@ +-- 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 = ''; + +-- Converge to a clean starting state instead of trusting the tail cleanup below. A prior aborted run of this same file (a failed assertion, a killed test process) can otherwise leave a stray splinter_test_* slot or a non-default GUC that turns an unrelated later file (queries_are_unionable runs immediately after this one) into a confusing failure. +do $$ +declare + stray record; +begin + for stray in select slot_name from pg_catalog.pg_replication_slots where slot_name like 'splinter_test_%' loop + perform pg_catalog.pg_drop_replication_slot(stray.slot_name); + end loop; +end $$; +alter system reset max_wal_size; +alter system reset max_slot_wal_keep_size; +select pg_catalog.pg_reload_conf(); + +-- 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', and 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. 10 iterations assumes the default 16MB wal_segment_size; this count and the other two copies of this loop below must move together if that assumption ever changes. +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. Deliberately reuses max_slot_wal_keep_size='1MB' still set by the physical fixture above; do not reorder this block away from it, and reset the GUC before it if a fixture is ever inserted between them. +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;