Repository navigation
Add event_trigger__disable()/__enable() for update scripts - #45
jnasbyupgrade wants to merge 36 commits into
Conversation
`object__getsert()` (via `_object_v__for_update()`) now raises feature_not_supported for any object belonging to the object_reference extension. Letting the tracking machinery observe its own members means the extension's own DDL -- including its update scripts restructuring the extension -- becomes something that machinery reacts to. `_object_reference._is_own_object()` checks `pg_depend` membership (`deptype = 'e'`), plus the two objects that are never 'e' members: the extension's declared schema and its own `pg_extension` row. `_etg_capture` also skips objects in the object_reference, _object_reference and __object_reference schemas: an object created by the extension's own install/update script isn't recorded as an 'e' member until the surrounding CREATE/ALTER EXTENSION finishes, so `_is_own_object()` can't see it yet when ddl_command_end fires. The 0.1.0 -> stable update script creates `_is_own_object()` and the updated `_etg_capture` inside its existing session_replication_role section. Part of #40
…le() `_object_reference.event_trigger__disable(name[])` disables the given event triggers and records each one's prior `evtenabled` value in a temp table; `event_trigger__enable()` restores exactly that state. ALTER EVENT TRIGGER is transactional, so a rolled-back caller undoes the DISABLE without separate cleanup. `_etg_capture` and `_etg_fix_identity` return early while that temp table exists in the current session, so they never need disabling. `_etg_drop` can't recognize the extension's own update script that way, so the 0.1.0 -> stable update script disables it with `event_trigger__disable()` around its structural section, replacing the `session_replication_role = replica` bracket. 0.1.0's `_etg_capture`/`_etg_fix_identity` have no such guard, so the update script disables both with raw ALTER EVENT TRIGGER for the bootstrap statements that run before their guarded replacements are installed, then restores each one's saved prior state. Closes #40
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the
|
|
Claude finished @jnasbyupgrade's task in 30s —— View job Code review: PR #45 (event_trigger__disable()/__enable())I read the diff against No blocking bugs found. The save/restore logic holds up:
Observations
|
In update mode, test/install/load.sql now starts `zzz_object_reference_capture`, `zzz_object_reference__fix_identity` and `zzz__object_reference_drop` in distinct non-default states (DISABLE, ENABLE REPLICA, ENABLE ALWAYS), runs the update, and raises unless each one is back in exactly that state. That covers both restore paths: the bootstrap block for the first two and `event_trigger__enable()` for the third. load.sql's own output is never compared against anything, so the update and the check share a transaction: a failed check also undoes the update, and the suite then fails against the old version. All three triggers are reset to ENABLE afterwards so the rest of the suite runs unchanged.
|
Claude finished @jnasbyupgrade's task in 24s —— View job Code review: PR #45 (
|
|
Written by Claude. Replies to items 2-5 of the automated review.
No change. The error is the intended behavior, and the state is not lost.
No change.
No change. The bootstrap temp table can't be registered. 0.1.0's
Fixed in 8429c9d. In update mode, |
|
Written by Claude. Re: #45 (comment)
No change. Passing NULL does not produce a generic error: PL/pgSQL raises
Items 2 and 3 of this review repeat items 2 and 3 of the earlier review, answered in #45 (comment). |
|
Claude finished @jnasbyupgrade's task in 14s —— View job Code review: PR #45 (
|
| SELECT evtenabled INTO v_enabled | ||
| FROM pg_catalog.pg_event_trigger | ||
| WHERE evtname = v_name | ||
| FOR UPDATE |
There was a problem hiding this comment.
This is not going to work on something like RDS that doesn't give true superuser access. I'd say we should use an advisory lock as a backup mechanism (ie, detect the error we'd get for not being able to modify the catalog).
Also, we should force event_trigger_names to be sorted (in a fixed local); otherwise there's a deadlock potential.
There was a problem hiding this comment.
Claude here. Done in 7a44f99.
Lock fallback: per-trigger locking moved into _object_reference._event_trigger__lock(name). It tries SELECT ... FOR UPDATE on pg_event_trigger and, on insufficient_privilege (SQLSTATE 42501) only, takes pg_advisory_xact_lock(<pg_event_trigger regclass oid>::int, <trigger oid>::int). I confirmed 42501 ("permission denied for table pg_event_trigger") by running that SELECT as a plain non-superuser role on PG12 and PG17. Oid-to-int casts wrap (4294967295::oid::int is -1) rather than error, so large oids are safe as keys. The advisory lock only serializes against other callers of this function. A test calls the helper under SET ROLE as a role that cannot row-lock and checks pg_locks.
Sorting: I read "force event_trigger_names to be sorted (in a fixed local)" as the function sorting the names itself under COLLATE "C", not rejecting unsorted input. disable() locks and disables in that order and enable() restores in the same order. A test passes unsorted names and checks they are processed in "C" order.
There was a problem hiding this comment.
Claude here. Changed in 266688a: _event_trigger__lock() no longer takes an advisory lock. An advisory lock only serializes other callers of this function, not a raw ALTER EVENT TRIGGER, so it bought nothing.
It still tries SELECT ... FOR UPDATE on the trigger's pg_event_trigger row. On insufficient_privilege (42501) only, it now reads evtenabled without a lock and carries on, with no NOTICE or WARNING. That's my reading of handling environments where the FOR UPDATE lock isn't possible: run unlocked rather than fail. Without catalog UPDATE rights the row can't be locked, so the remaining race needs a concurrent raw ALTER EVENT TRIGGER of the same trigger during an administrator's own update. The update script's hand-written bootstrap (0.1.0 has no disable()) handles the lock the same way.
Would you rather this path raise a NOTICE, or fail with an error, instead of staying silent?
There was a problem hiding this comment.
Lets raise a warning
There was a problem hiding this comment.
Claude here. Commit 5a5d2ef: the 42501 (insufficient_privilege) fallback now raises a WARNING naming the event trigger, in both _event_trigger__lock() and the update script's bootstrap, then reads the state without a lock. The test run as an unprivileged role covers it via the expected output.
In update mode, test/install/load.sql now runs three update scenarios before the committed update, each installing 0.1.0 in its own transaction and rolling back: 1. All three event triggers at ENABLE: the update leaves the capture group empty, restores every trigger, leaves no state table behind, and a table created afterwards is captured. 2. zzz__object_reference_drop already disabled: it stays disabled. 3. A table captured before the update and renamed after it shows its new name in the capture group. Each scenario asserts the trigger state it starts the update from. Also restore the trailing space after WHERE in the update script's copy of _etg_fix_identity, so its body matches sql/object_reference.sql, and correct load.sql's description of how a failed check fails the run.
|
Claude finished @jnasbyupgrade's task in 21s —— View job Code review: PR #45 (
|
…fects disable() now rejects a NULL/empty array, NULL elements, duplicates, and unknown names, and a call still in effect, before creating its state table or locking anything. enable() checks for a matching disable() up front. Names are processed in "C" collation order (and restored in the same order) so concurrent callers lock in a consistent order. Per-trigger locking moves to _event_trigger__lock(), which falls back to a transaction-scoped advisory lock when the role cannot row-lock pg_event_trigger (insufficient_privilege). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
|
Claude finished @jnasbyupgrade's task in 17s —— View job Code review: PR #45 (
|
…rage - Move _event_trigger__lock() ahead of the WARNING block so the block documents event_trigger__disable()/__enable() again; state the advisory fallback's actual scope (other callers of disable() only). - Use pg_event_trigger column %TYPE declarations in the functions this PR adds. - Drop the "nothing was changed" assertions (a rolled-back call can't fail them) and add error-precedence tests for an empty array and a NULL element. - Correct the load.sql comment: a failed check does not fail the make run on any PostgreSQL version.
|
Claude finished @jnasbyupgrade's task in 19s —— View job Code review: PR #45 (
|
event_trigger__disable() now creates a no-op ddl_command_end event trigger on a pg_temp function before disabling anything, and event_trigger__enable() drops it after restoring every state. While every sql_drop/table_rewrite/ddl_command_end trigger is disabled, Postgres collects the commands nested in an extension script onto the enclosing ALTER EXTENSION and replays them to its ddl_command_end, where pg_event_trigger_ddl_commands() fails on dropped objects before PG14 and hands capture the update's own objects on PG14+. The 0.1.0 -> stable update script disables all three event triggers at its start (by hand, since 0.1.0 has no disable()) and restores them with one enable() at the end. _etg_capture and _etg_fix_identity no longer recognize the update script themselves, so their 0.1.0 bodies are kept unchanged. _event_trigger__lock() no longer falls back to an advisory lock: on insufficient_privilege it reads evtenabled without a lock. disable() and enable() raise undefined_object, invalid_parameter_value or object_not_in_prerequisite_state as appropriate, and the tests check the SQLSTATE.
event_trigger__disable() raised the same 'does not exist' text for a trigger missing at validation and for one dropped between validation and the lock. The latter now says it was dropped concurrently (same ERRCODE). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
|
Claude finished @jnasbyupgrade's task in 22s —— View job Code review: PR #45 (
|
|
Claude here. Correction to my earlier reply (#45 (comment)): it is out of date on two points. (a) That reply quoted the review as saying:
(b) That reply said of Both were added while moving the caller sanity checks ahead of any side effect (#45 (comment)). |
|
Claude here. Resolution of the three automated review bodies (#45 (comment), #45 (comment), #45 (comment)).
|
|
Claude finished @jnasbyupgrade's task in 15s —— View job Code review: PR #45 (
|
…ger_disable.sql Both sections ended in a cleanup enable(); as one closing sequence that leaves a disable() in effect (the file's transaction is never committed) they need none.
|
Claude finished @jnasbyupgrade's task in 15s —— View job Code review: PR #45 (
|
…e plan comment The plan comment for the closing block listed two of its four assertions. It now names each one: the stray sentinel is rejected, no state table is created, disable() succeeds once the stray is dropped, then a nested disable() is rejected.
|
Claude finished @jnasbyupgrade's task in 12s —— View job Code review: PR #45 (
|
|
Claude finished @jnasbyupgrade's task in 13s —— View job Code review: PR #45 (
|
|
Claude finished @jnasbyupgrade's task in 14s —— View job Code review: PR #45 (
|
…le() A deferred constraint trigger on the session's event-trigger state table raises at commit while any state row remains. enable() empties the table and fires the pending check before dropping it, so a later disable() still works.
|
Claude finished @jnasbyupgrade's task in 19s —— View job Code review: PR #45 (
|
…ALWAYS event_trigger__enable() now drops the commit-check trigger's state only when the trigger exists: the update script's hand-written disable creates the state table without it, so enable() failed there. The replica-role test now sets session_replication_role before disable(), so ENABLE ALWAYS on the check trigger is what makes it fail. Document that an active capture group must be named in disable()'s arguments, and correct stale comments.
|
Claude finished @jnasbyupgrade's task in 16s —— View job Code review: PR #45 (
|
Test 36 now claims only that the check passes after enable(); the tolerance of a preceding SET CONSTRAINTS ALL IMMEDIATE moves to the description of the next disable(). A new test records that SET CONSTRAINTS ALL IMMEDIATE between disable() and enable() raises.
|
Claude finished @jnasbyupgrade's task in 20s —— View job Code review: PR #45 (
|
| SELECT is( | ||
| (SELECT count(*)::int FROM pg_catalog.pg_locks WHERE locktype = 'advisory' AND pid = pg_backend_pid()) | ||
| , 0 | ||
| , 'helper took no advisory lock' |
There was a problem hiding this comment.
Uhm, why is this here???
There was a problem hiding this comment.
Claude here. Removed in 8668c0a. It asserted that the helper takes no advisory lock, but the advisory lock was dropped in 266688a, so it only pinned the absence of removed behavior. (An earlier reply on this thread answered a different question, the pgTAP one.) If you wanted it kept for another reason, say so and it will be restored.
| * unlocked, with a WARNING naming the trigger, instead of an error. The DO | ||
| * block runs as that role, and first proves the role really cannot FOR UPDATE | ||
| * the catalog. The WARNING is suppressed because whether it is raised depends | ||
| * on the environment's catalog privileges. |
There was a problem hiding this comment.
If this is using a non-superuser role then it definitely wouldn't be able to FOR UPDATE on the catalog, regardless of the environment. What we can't assume is that the regular role running the test is a true superuser (ie, it won't be on RDS).
There was a problem hiding this comment.
Claude here. Fixed the comment (b37123e): it no longer says the WARNING depends on the environment's catalog privileges; the test role never has them, so the WARNING is always raised, and it is suppressed only to keep it out of the expected output.
There was a problem hiding this comment.
Claude here. I read your second sentence as explaining why the original comment's reasoning was wrong, not as a request to change the suite's assumption that the runner is a superuser (the shared test conventions set that). The WARNING stays suppressed, as requested in 4457948113, so the expected output stays free of it. If you would rather assert it or leave it in the expected output, say so.
There was a problem hiding this comment.
Claude here: Understood. No non-superuser mode will be exposed via make, and the existing role-based test (the helper called under SET LOCAL ROLE as a role that cannot row-lock the catalog) stays as the non-superuser sanity check. The WARNING suppression in that test also stays as it is, so no further change for this thread.
| FROM _object_reference.object_group__object m | ||
| JOIN _object_reference.object o USING (object_id) | ||
| WHERE m.object_group_id = (object_reference.object_group__get('event_trigger_disable_test_group')).object_group_id) | ||
| , '{public.event_trigger_disable_test__after_enable}'::text[] |
There was a problem hiding this comment.
Uh, this is really looking like it should be results_eq or bag_eq, with a VALUES().
There was a problem hiding this comment.
Claude here. Fixed (b37123e): the three array_agg/is() comparisons (processing order, restored states, captured objects) are now results_eq against a VALUES list.
| $$SELECT _object_reference.event_trigger__disable('{event_trigger_disable_test__a}')$$ | ||
| , 'disable() again after SET CONSTRAINTS ALL IMMEDIATE (the check trigger must start out deferred)' | ||
| ); | ||
| SELECT throws_ok( |
There was a problem hiding this comment.
SIGH if you did this BEFORE the passing case above you wouldn't need to re-run a disable. You should always be looking at ways to reduce needless state manipulation by leveraging existing state in the test.
There was a problem hiding this comment.
Make sure this is in ../ai/
There was a problem hiding this comment.
Claude here. Fixed (b37123e): the SET CONSTRAINTS ALL IMMEDIATE failure case now runs right after the replica check, while disable() is still in effect, ahead of the passing case. The extra disable()/enable() cycle is gone; the final disable() in the closing sequence still covers that a disable() after ALL IMMEDIATE does not fail early (the check trigger starts out deferred).
There was a problem hiding this comment.
Claude here. Recorded as a rule in test/CLAUDE.md in Postgres-Extensions/ai: Postgres-Extensions/ai#46. I read "this" as the "reduce needless state manipulation by leveraging existing state" lesson (the pgTap/results_eq point is already in that file). Say if a different rule was meant.
There was a problem hiding this comment.
Claude here. 3356fa9 collapses one more cycle: the origin-role commit-check assertions (check fails, and SET CONSTRAINTS ALL IMMEDIATE fails, between disable() and enable()) now run inside the existing unsorted-names disable()/enable() pair instead of their own. The session_replication_role = replica cycle stays separate: the check trigger's event is queued when disable() inserts, so a replica-role check needs its own disable(), and without an origin-role disable() a trigger enabled only for replica would go unnoticed. Mutations (no SET CONSTRAINTS ... DEFERRED in disable(); no ENABLE ALWAYS; ENABLE REPLICA; neutered check function) all still fail the suite. The all-three-restored check is now a bag_eq against a VALUES list.
…to reuse state Replace to_regprocedure/to_regclass checks with has_function/hasnt_function/ hasnt_table, array_agg comparisons with results_eq, and run the SET CONSTRAINTS ALL IMMEDIATE failure case before enable() instead of re-running disable().
|
Claude finished @jnasbyupgrade's task in 23s —— View job Code review: PR #45 (
|
_event_trigger__lock() no longer takes an advisory lock, so the test only pinned the absence of removed behavior.
The origin-role commit-check assertions now run inside the unsorted-names pair instead of their own, leaving one dedicated pair for the session_replication_role = replica case (the check trigger's event is queued when disable() inserts, so replica needs its own disable()). Compare the restored states with bag_eq against a VALUES list.
…escription throws_ok rolls the failed disable() back in a subtransaction, so a state table created before the sentinel check would vanish before hasnt_table looked; the assertion could not fail.
|
Claude finished @jnasbyupgrade's task in 18s —— View job Code review: PR #45 (
|
Add a CI step that runs the suite against a database installed at TEST_UPDATE_FROM and then ALTER EXTENSION UPDATEd. Describe test-update as a human shorthand for the TEST_LOAD_SOURCE form.
|
Claude finished @jnasbyupgrade's task in 16s —— View job Code review: PR #45 (
|
|
Claude finished @jnasbyupgrade's task in 14s —— View job Code review: PR #45 (
|
make test exits non-zero on a regression with the vendored pgxntool, so the update-path step needs no CI-specific form. Drop the comments claiming otherwise.
|
Claude finished @jnasbyupgrade's task in 25s —— View job Code review: PR #45 (
|
|
Claude finished @jnasbyupgrade's task in 13s —— View job Code review: PR #45 (
|
Questioned automated-review findings: #45 (comment), #45 (comment), #45 (comment), #45 (comment)
Replaces the 0.1.0 -> stable update script's
session_replication_role = replicabracket with a reusable internal pair,_object_reference.event_trigger__disable(name[])andevent_trigger__enable().disable()records each named event trigger's exact priorevtenabledand disables it;enable()restores that state rather than assumingorigin. The update script disables all three of the extension's event triggers for its whole body and restores them with oneenable()at the end; no trigger tries to recognize the extension's own script.Closes #40
Details
disable()first creates a no-opddl_command_endevent trigger,__object_reference__event_trigger_sentinel, on apg_tempfunction, andenable()drops both after restoring every state. While everysql_drop/table_rewrite/ddl_command_endevent trigger is disabled, Postgres collects the commands nested in an extension script onto the enclosingCREATE/ALTER EXTENSIONand replays them to itsddl_command_end. There,pg_event_trigger_ddl_commands()fails on objects dropped since on PG12/13 (cache lookup failed for namespace), and on PG14+ hands capture the update's own objects. One enabled event trigger prevents that. The function lives inpg_tempbecause capture skipspg_temp(0.1.0's capture body too), so creating it is never captured, inside or outside an extension script.disable(), so the update script starts by doing by hand whatdisable()does for all three triggers: a state table, the sentinel,COLLATE "C"order and lock handling. Unlikedisable(), it has no pre-check for an existing sentinel (a committed one fails with the plain "already exists" error) and its state table has no commit check (below), so the update path doesn't get that check;enable()skips the check cleanup when the trigger is absent. Its finalenable()undoes it._etg_captureand_etg_fix_identitykeep their 0.1.0 bodies.disable()also puts a deferred constraint trigger (__object_reference__event_trigger_state_check,ENABLE ALWAYS) on the temp state table. If the transaction commits withoutenable()having run, the commit fails with SQLSTATE 55000 instead of leaving event triggers disabled;enable()empties the table, fires the check, and drops the table.ALTER EVENT TRIGGERis transactional: other sessions don't see a disable until commit, and a rolled-back caller undoes it without any cleanup logic._object_reference._event_trigger__lock(name)row-locks the trigger'spg_event_triggerrowFOR UPDATEbefore its state is read, which blocks a concurrent rawALTER EVENT TRIGGERof that trigger until commit. A role without UPDATE on the catalog (SQLSTATE 42501) can't take that lock; the helper then raises a WARNING naming the trigger and readsevtenabledunlocked, in_event_trigger__lock()and in the update script's bootstrap alike. The tests exercise only the helper's fallback; the bootstrap's is untested because the update runs as superuser there, and its WARNING text matches the helper's. The remaining race needs a concurrent rawALTERof the same trigger during an administrator's own update. Names are locked and restored inCOLLATE "C"order, so concurrent callers can't deadlock on lock order.disable()checks its arguments before any side effect. A NULL or empty array, a NULL element or a duplicate name raisesinvalid_parameter_value; an unknown trigger raisesundefined_object; a previous call still in effect, or a sentinel event trigger left by another session, raisesobject_not_in_prerequisite_state.enable()without a matchingdisable()raisesobject_not_in_prerequisite_state.test/sql/event_trigger_disable.sql(40 assertions) covers prior-state preservation, the sentinel existing during a call and being gone (function included) afterenable(), an existing sentinel rejected before anything changes, each argument error with its SQLSTATE,"C"-order processing, the lock helper for a role that can't row-lock the catalog (when thepg_event_triggerrow lock isn't permitted, it falls back to readingevtenabledwithout the lock and emits a WARNING that the test silences), the commit check under the origin and replicasession_replication_role(fails beforeenable(), passes after), and disablingzzz_object_reference_captureitself stopping capture untilenable(). The file runs in a transaction that is never committed, so no other session sees its disables.make test--update):test/build/update.sqlruns four rolled-back scenarios (its output is compared, so a failed assertion fails the build step; it prints nothing in other modes);test/install/load.sqlthen does a plain committed update. Scenarios 1-3 run with a capture group active. (1) All three triggers at their default state; a table created after the update is captured. (2)zzz__object_reference_dropalready disabled, and still disabled after. (3) A table captured before the update is renamed after it, and the group shows the new name. (4) The triggers start asDISABLE,ENABLE REPLICAandENABLE ALWAYSand must be restored to exactly those states.load.sqlinstall or update failsmake test--update. pgxntool ignores installcheck's exit status and failsmake testonly whenregression.diffsexists, andload.sql's output is never compared (pg_regress 16+ printsnot okfor it, PG12's printsok). Insteadtest/load.sqlraises in every later test file when update or existing mode left the extension uninstalled or at a version other than the one under test (in update modetest_update_toif set, elsedefault_version; in existing modedefault_version), so those files fail and make exits non-zero.test/dump/load_all.sqlinstalls the extension itself and sets the mode tofreshfirst. CI runsmake verify-results(fresh mode) andmake test--update.make test--updateruns the update-path test (make test TEST_LOAD_SOURCE=update);test-updateis a synonym.test/install/load.sqlsetsclient_min_messages = WARNINGbefore installing the old version, so the install's NOTICEs don't appear in the test output.test/build/expected/build.outgains four blank lines, one per newcreate_functioncall.