diff --git a/build/transform.sh b/build/transform.sh index 01b3800..134949f 100755 --- a/build/transform.sh +++ b/build/transform.sh @@ -1038,8 +1038,10 @@ cat > "${PGTLE_FILE}" << HEADER -- After loading this script, the extension is registered with pg_tle and can -- be created or dropped via the standard create / drop extension commands. -- --- This file is self-contained: it works in psql, GUI tools (DBeaver, etc.), --- JDBC, libpq-direct callers — anywhere that accepts SQL. +-- This file is a psql script: it uses backslash meta-commands (\set +-- ON_ERROR_STOP, \echo), so run it with psql. From other clients (GUI +-- tools, JDBC, direct libpq), call pgtle.install_extension() directly, +-- passing the contents of sql/pgque.sql as the extension body. -- -- Prerequisites: -- 1. pg_tle is installed in this database: diff --git a/sql/pgque-additions/lifecycle.sql b/sql/pgque-additions/lifecycle.sql index de8ba67..423b09b 100644 --- a/sql/pgque-additions/lifecycle.sql +++ b/sql/pgque-additions/lifecycle.sql @@ -421,6 +421,14 @@ $$ language plpgsql security definer set search_path = pgque, pg_catalog; create or replace function pgque.uninstall() returns void as $$ begin + -- Extension installs (pg_tle) must go through the extension machinery: + -- dropping the schema out from under the extension would fail with a + -- confusing dependency error ("extension pgque requires it"). + if exists (select 1 from pg_catalog.pg_extension where extname = 'pgque') then + raise exception 'pgque is installed as an extension; run: ' + 'drop extension pgque cascade; (for pg_tle installs, use ' + 'sql/pgque-tle-uninstall.sql to also unregister from pg_tle)'; + end if; -- Stop pg_cron jobs before dropping the schema. if exists (select 1 from pg_extension where extname = 'pg_cron') then perform pgque.stop(); diff --git a/sql/pgque-tle-uninstall.sql b/sql/pgque-tle-uninstall.sql index a555d2f..327a796 100644 --- a/sql/pgque-tle-uninstall.sql +++ b/sql/pgque-tle-uninstall.sql @@ -1,9 +1,10 @@ -- pgque-tle-uninstall.sql -- Remove PgQue from pg_tle. -- Copyright 2026 Nikolay Samokhvalov. Apache-2.0 license. -- --- Drops the pgque extension from this database (if installed) and unregisters --- pgque from pg_tle's catalog. Roles are NOT dropped because they may still --- be referenced by other databases on the cluster. +-- Stops scheduler jobs, drops the pgque extension from this database (if +-- installed), and unregisters pgque from pg_tle's catalog. Roles are NOT +-- dropped because they may still be referenced by other databases on the +-- cluster. -- -- Idempotent: safe to re-run. -- @@ -12,7 +13,22 @@ \set ON_ERROR_STOP on -drop extension if exists pgque cascade; +-- Stop scheduler jobs (pg_cron / pg_timetable) before dropping the +-- extension: scheduler jobs are catalog rows, not dependent objects, so +-- drop extension alone would leave them behind, failing forever afterwards. +-- A real stop() failure therefore aborts the uninstall; only "pgque is not +-- installed" errors are tolerated, keeping the script idempotent. +do $$ +begin + begin + perform pgque.stop(); + exception + when undefined_function or invalid_schema_name then + -- pgque is not installed (or has no stop()); nothing to stop. + null; + end; + drop extension if exists pgque cascade; +end $$; do $$ begin diff --git a/sql/pgque-tle.sql b/sql/pgque-tle.sql index 2601a24..8bc4963 100644 --- a/sql/pgque-tle.sql +++ b/sql/pgque-tle.sql @@ -8,8 +8,10 @@ -- After loading this script, the extension is registered with pg_tle and can -- be created or dropped via the standard create / drop extension commands. -- --- This file is self-contained: it works in psql, GUI tools (DBeaver, etc.), --- JDBC, libpq-direct callers — anywhere that accepts SQL. +-- This file is a psql script: it uses backslash meta-commands (\set +-- ON_ERROR_STOP, \echo), so run it with psql. From other clients (GUI +-- tools, JDBC, direct libpq), call pgtle.install_extension() directly, +-- passing the contents of sql/pgque.sql as the extension body. -- -- Prerequisites: -- 1. pg_tle is installed in this database: @@ -4641,6 +4643,14 @@ $$ language plpgsql security definer set search_path = pgque, pg_catalog; create or replace function pgque.uninstall() returns void as $$ begin + -- Extension installs (pg_tle) must go through the extension machinery: + -- dropping the schema out from under the extension would fail with a + -- confusing dependency error ("extension pgque requires it"). + if exists (select 1 from pg_catalog.pg_extension where extname = 'pgque') then + raise exception 'pgque is installed as an extension; run: ' + 'drop extension pgque cascade; (for pg_tle installs, use ' + 'sql/pgque-tle-uninstall.sql to also unregister from pg_tle)'; + end if; -- Stop pg_cron jobs before dropping the schema. if exists (select 1 from pg_extension where extname = 'pg_cron') then perform pgque.stop(); diff --git a/sql/pgque.sql b/sql/pgque.sql index e3ac01a..e4dedcc 100644 --- a/sql/pgque.sql +++ b/sql/pgque.sql @@ -4553,6 +4553,14 @@ $$ language plpgsql security definer set search_path = pgque, pg_catalog; create or replace function pgque.uninstall() returns void as $$ begin + -- Extension installs (pg_tle) must go through the extension machinery: + -- dropping the schema out from under the extension would fail with a + -- confusing dependency error ("extension pgque requires it"). + if exists (select 1 from pg_catalog.pg_extension where extname = 'pgque') then + raise exception 'pgque is installed as an extension; run: ' + 'drop extension pgque cascade; (for pg_tle installs, use ' + 'sql/pgque-tle-uninstall.sql to also unregister from pg_tle)'; + end if; -- Stop pg_cron jobs before dropping the schema. if exists (select 1 from pg_extension where extname = 'pg_cron') then perform pgque.stop(); diff --git a/sql/pgque_uninstall.sql b/sql/pgque_uninstall.sql index 063495d..ced3226 100644 --- a/sql/pgque_uninstall.sql +++ b/sql/pgque_uninstall.sql @@ -1,13 +1,23 @@ -- pgque_uninstall.sql -- Remove pgque from database -- Copyright 2026 Nikolay Samokhvalov. Apache-2.0 license. +-- +-- Stops scheduler jobs (pg_cron / pg_timetable) before dropping the schema: +-- scheduler jobs are catalog rows, not dependent objects, so dropping the +-- schema alone would leave them behind, failing forever afterwards. A real +-- stop() failure therefore aborts the uninstall; only "pgque is not +-- installed" errors are tolerated, keeping the script idempotent. -do $$ begin - perform pgque.stop(); -exception when others then - null; +do $$ +begin + begin + perform pgque.stop(); + exception + when undefined_function or invalid_schema_name then + -- pgque is not installed (or has no stop()); nothing to stop. + null; + end; + drop schema if exists pgque cascade; end $$; -drop schema if exists pgque cascade; - -- Roles are database-global and may be shared across databases. -- Do not drop them automatically here. diff --git a/tests/run_all.sql b/tests/run_all.sql index 7ed6a1b..140699f 100644 --- a/tests/run_all.sql +++ b/tests/run_all.sql @@ -133,5 +133,8 @@ \echo 'Running: test_legacy_next_batch_role_guard' \i tests/test_legacy_next_batch_role_guard.sql +\echo 'Running: test_uninstall_guard' +\i tests/test_uninstall_guard.sql + \echo '' \echo '=== ALL TESTS PASSED ===' diff --git a/tests/test_tle_install.sql b/tests/test_tle_install.sql index 155758a..caa4181 100644 --- a/tests/test_tle_install.sql +++ b/tests/test_tle_install.sql @@ -102,6 +102,29 @@ end $$; -- regression and acceptance suites running against the pg_tle install path -- in CI; nothing extra to assert here. +-- pgque.uninstall() must refuse extension installs with a clear pointer to +-- drop extension, instead of failing on the schema drop with a confusing +-- dependency error ("extension pgque requires it"). +do $$ +begin + begin + perform pgque.uninstall(); + raise exception 'sentinel: pgque.uninstall() did not raise'; + exception + when raise_exception then + if sqlerrm like 'sentinel:%' then + raise; + end if; + assert sqlerrm like '%drop extension pgque cascade%', + format('uninstall() error must point to drop extension, got: %s', sqlerrm); + end; + assert exists (select 1 from pg_catalog.pg_namespace where nspname = 'pgque'), + 'pgque schema must survive the refused uninstall()'; + assert exists (select 1 from pg_catalog.pg_extension where extname = 'pgque'), + 'pgque extension must survive the refused uninstall()'; + raise notice 'PASS: uninstall() refuses extension install, points to drop extension'; +end $$; + -- drop extension cascade removes the schema and the extension membership. drop extension pgque cascade; diff --git a/tests/test_uninstall_guard.sql b/tests/test_uninstall_guard.sql new file mode 100644 index 0000000..69c43e1 --- /dev/null +++ b/tests/test_uninstall_guard.sql @@ -0,0 +1,113 @@ +-- test_uninstall_guard.sql -- Uninstall scripts must stop scheduler jobs first +-- Copyright 2026 Nikolay Samokhvalov. Apache-2.0 license. +-- +-- Scheduler jobs (pg_cron, pg_timetable) are catalog rows, not dependent +-- objects: dropping the pgque schema or extension does not remove them, so +-- both uninstall scripts must call pgque.stop() before dropping, and a real +-- stop() failure must abort the uninstall instead of being swallowed. +-- +-- These tests swap pgque.stop() for instrumented fakes; the real definition +-- is saved first and restored at the end, so the rest of the suite is +-- unaffected. Runs without pg_cron / pg_tle. The TLE uninstall sub-tests +-- only run against a plain (non-extension) install; see the gate below. +-- +-- Run from the repo root: the \i commands below resolve relative to cwd. + +-- Save the real pgque.stop() so it can be restored at the end. +create table pg_temp.saved_stop as +select pg_catalog.pg_get_functiondef('pgque.stop()'::pg_catalog.regprocedure) as def; + +-- Test 1 (C6): a real pgque.stop() failure must abort sql/pgque_uninstall.sql +-- before the schema drop (no silent "when others" swallowing). +create or replace function pgque.stop() +returns void as $$ +begin + raise exception 'simulated stop() failure (test_uninstall_guard)'; +end; +$$ language plpgsql; + +\set ON_ERROR_STOP off +\i sql/pgque_uninstall.sql +\set ON_ERROR_STOP on + +do $$ +begin + assert exists (select 1 from pg_catalog.pg_namespace where nspname = 'pgque'), + 'pgque schema must survive when pgque.stop() raises during uninstall'; + raise notice 'PASS: pgque_uninstall.sql aborts before drop when stop() fails'; +end $$; + +-- Tests 2 and 3 execute sql/pgque-tle-uninstall.sql, which (correctly) +-- drops the pgque extension -- taking the whole schema with it -- and +-- unregisters pgque from pg_tle. Against an extension install (the pg_tle +-- CI job) that would destroy the install mid-suite, so the script must not +-- run at all there: skip both sub-tests. The extension path of the script +-- is covered by tests/test_tle_install.sql. +select exists (select 1 from pg_catalog.pg_extension where extname = 'pgque') as pgque_is_extension +\gset + +\if :pgque_is_extension + +\echo 'SKIP: pgque is installed as an extension; TLE uninstall sub-tests need a plain install' + +\else + +-- Test 2 (C4): sql/pgque-tle-uninstall.sql must call pgque.stop() before +-- drop extension, so pg_cron / pg_timetable jobs do not outlive the schema. +create table pg_temp.tle_stop_called (called bool); + +create or replace function pgque.stop() +returns void as $$ +begin + insert into pg_temp.tle_stop_called values (true); +end; +$$ language plpgsql; + +\i sql/pgque-tle-uninstall.sql + +do $$ +begin + assert exists (select 1 from pg_temp.tle_stop_called), + 'pgque-tle-uninstall.sql must call pgque.stop() before drop extension'; + assert exists (select 1 from pg_catalog.pg_namespace where nspname = 'pgque'), + 'plain (non-extension) install must survive the TLE uninstall script'; + raise notice 'PASS: pgque-tle-uninstall.sql calls stop() before drop extension'; +end $$; + +-- Test 3 (C4/C6): a real stop() failure must abort the TLE uninstall script +-- before drop extension as well. +create or replace function pgque.stop() +returns void as $$ +begin + raise exception 'simulated stop() failure (test_uninstall_guard)'; +end; +$$ language plpgsql; + +\set ON_ERROR_STOP off +\i sql/pgque-tle-uninstall.sql +\set ON_ERROR_STOP on + +do $$ +begin + assert exists (select 1 from pg_catalog.pg_namespace where nspname = 'pgque'), + 'pgque schema must survive when stop() raises during TLE uninstall'; + raise notice 'PASS: pgque-tle-uninstall.sql aborts before drop when stop() fails'; +end $$; + +drop table pg_temp.tle_stop_called; + +\endif + +-- Restore the real pgque.stop() and verify the restoration. +do $$ +declare + v_def text; +begin + select def into v_def from pg_temp.saved_stop; + execute v_def; + assert pg_catalog.pg_get_functiondef('pgque.stop()'::pg_catalog.regprocedure) = v_def, + 'pgque.stop() must be restored to its original definition'; + raise notice 'PASS: original pgque.stop() restored'; +end $$; + +drop table pg_temp.saved_stop;