From 4da9fc8a61553d84bf826ec3e7a79d335141add9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 13:37:47 +0000 Subject: [PATCH 1/4] fix: stop scheduler jobs in uninstall scripts pg_cron / pg_timetable jobs are catalog rows, not dependent objects: dropping the pgque schema or extension leaves pgque_ticker, pgque_retry_events, pgque_maint and pgque_rotate_step2 behind, failing every 1-30 s forever. sql/pgque-tle-uninstall.sql now calls pgque.stop() before drop extension, and both uninstall scripts perform the drop in the same do block so a real stop() failure aborts the uninstall. The previous catch-all handler in sql/pgque_uninstall.sql is narrowed to undefined_function / invalid_schema_name (the sqlstates raised when pgque is not installed), keeping the scripts idempotent without swallowing real errors such as cron.unschedule permission failures. Addresses findings C4 and C6 of #283. https://claude.ai/code/session_01KAaEGkQZmey1D1xCsVGmqv --- sql/pgque-tle-uninstall.sql | 24 +++++++-- sql/pgque_uninstall.sql | 22 +++++--- tests/run_all.sql | 3 ++ tests/test_uninstall_guard.sql | 94 ++++++++++++++++++++++++++++++++++ 4 files changed, 133 insertions(+), 10 deletions(-) create mode 100644 tests/test_uninstall_guard.sql diff --git a/sql/pgque-tle-uninstall.sql b/sql/pgque-tle-uninstall.sql index a555d2f1..327a7966 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_uninstall.sql b/sql/pgque_uninstall.sql index 063495de..ced3226c 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 7ed6a1bf..140699fa 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_uninstall_guard.sql b/tests/test_uninstall_guard.sql new file mode 100644 index 00000000..9ccefd88 --- /dev/null +++ b/tests/test_uninstall_guard.sql @@ -0,0 +1,94 @@ +-- 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. +-- +-- 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 $$; + +-- 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 $$; + +-- 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; +drop table pg_temp.tle_stop_called; From 838f53c0b30b3c662e936396e5a00e5daf99115c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 13:38:04 +0000 Subject: [PATCH 2/4] fix: refuse uninstall() for extension installs When pgque is installed as an extension (pg_tle path), pgque.uninstall() ran drop schema pgque cascade, which fails with a confusing dependency error: extension pgque requires it. Detect pg_extension membership first and raise a clear exception pointing to drop extension pgque cascade and the pg_tle uninstall script. Regenerates sql/pgque.sql and sql/pgque-tle.sql; covered by a new assertion in tests/test_tle_install.sql (runs in the pg_tle CI job). Addresses finding C9 of #283. https://claude.ai/code/session_01KAaEGkQZmey1D1xCsVGmqv --- sql/pgque-additions/lifecycle.sql | 8 ++++++++ sql/pgque-tle.sql | 8 ++++++++ sql/pgque.sql | 8 ++++++++ tests/test_tle_install.sql | 23 +++++++++++++++++++++++ 4 files changed, 47 insertions(+) diff --git a/sql/pgque-additions/lifecycle.sql b/sql/pgque-additions/lifecycle.sql index de8ba674..423b09b2 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.sql b/sql/pgque-tle.sql index 2601a24f..a2076b26 100644 --- a/sql/pgque-tle.sql +++ b/sql/pgque-tle.sql @@ -4641,6 +4641,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 e3ac01a7..e4dedcc9 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/tests/test_tle_install.sql b/tests/test_tle_install.sql index 155758ac..caa41814 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; From 1cae84f343ebe27d04a9dc7a44f8da8ad7199b41 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 13:38:22 +0000 Subject: [PATCH 3/4] docs: mark pgque-tle.sql header as psql-only The generated header claimed the file works in GUI tools, JDBC and libpq-direct callers, but the script uses \set ON_ERROR_STOP and \echo psql meta-commands, so any non-psql client fails on the first one. State that it is a psql script and point non-psql callers at pgtle.install_extension() with the sql/pgque.sql body instead. Regenerates sql/pgque-tle.sql. Addresses finding C7 of #283. https://claude.ai/code/session_01KAaEGkQZmey1D1xCsVGmqv --- build/transform.sh | 6 ++++-- sql/pgque-tle.sql | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/build/transform.sh b/build/transform.sh index 01b38003..134949f0 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-tle.sql b/sql/pgque-tle.sql index a2076b26..8bc49639 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: From 313a87c888ed9773fcffd2d0555d3c5c3bf53a96 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 13:45:50 +0000 Subject: [PATCH 4/4] fix: skip TLE uninstall tests on extension install In the pg_tle CI job, pgque is installed as an extension. Running sql/pgque-tle-uninstall.sql from test_uninstall_guard.sql there (correctly, after the C4/C9 fixes) drops the extension -- and the whole pgque schema with it -- mid-suite, so the "plain install must survive" assertion failed. Gate the sub-tests that execute the TLE uninstall script behind a psql \if on pgque not being an extension member, emitting the suite's usual SKIP notice; the extension path of the script is covered by tests/test_tle_install.sql. https://claude.ai/code/session_01KAaEGkQZmey1D1xCsVGmqv --- tests/test_uninstall_guard.sql | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/tests/test_uninstall_guard.sql b/tests/test_uninstall_guard.sql index 9ccefd88..69c43e1b 100644 --- a/tests/test_uninstall_guard.sql +++ b/tests/test_uninstall_guard.sql @@ -8,7 +8,8 @@ -- -- 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. +-- 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. @@ -36,6 +37,21 @@ begin 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); @@ -78,6 +94,10 @@ begin 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 @@ -91,4 +111,3 @@ begin end $$; drop table pg_temp.saved_stop; -drop table pg_temp.tle_stop_called;