Repository navigation
Add event_trigger__disable()/__enable() for update scripts - #45
jnasbyupgrade wants to merge 22 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 (
|
…sentinel exists Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
|
Claude finished @jnasbyupgrade's task in 20s —— View job Code review: PR #45 (
|
|
Claude here. Summary of the automated-review items still open, with where each stands. Code change for item 1 is in 5d768a2. 1. #45 (comment), note 1: questioned in part.
That does not happen. The sentinel event trigger depends on its The part that is true: while that session was still alive, another session's 2. Same review, note 2: no action needed (the review says so). Note 3: answered earlier, unchanged. 3. Same review, note 4 (keep the unlocked fallback silent): acknowledged. Whether that fallback should stay silent, emit a NOTICE or raise an error is the maintainer's choice, asked in #45 (comment). 4. #45 (comment), note 1 (the temp-table name usable as a flag; repeated in #45 (comment) and #45 (comment)): no longer applies. The 5. #45 (comment), nit 3 (update-mode CI coverage): update-mode CI is the open #19; the PR description notes the gap. |
|
Claude finished @jnasbyupgrade's task in 18s —— View job Code review: PR #45 (
|
test/load.sql now raises when the test mode is update or existing and test/install/load.sql did not leave object_reference installed. Without that, every later test file ran CREATE EXTENSION IF NOT EXISTS, installed the current version fresh and passed, so make exited 0. test/dump/load_all.sql installs the extension itself and sets the mode to fresh before including test/load.sql. Reword the load.sql comments accordingly, and narrow the sentinel-check comment in disable() to a committed sentinel. The update script's bootstrap notes that, unlike disable(), it does not check for an existing sentinel.
|
Claude finished @jnasbyupgrade's task in 17s —— View job Code review: PR #45 (
|
…ad guard version-aware The 42501 fallback in the event trigger lock helper and in the update script's bootstrap now raises a WARNING naming the trigger, then reads evtenabled without the lock. A test runs the fallback as an unprivileged role and checks the WARNING in the expected output. test/load.sql now checks that update and existing modes left the extension at the version under test (test_update_to if set, else default_version), not only that it is installed. Comments in test/load.sql and test/install/load.sql describe this accurately.
|
Claude finished @jnasbyupgrade's task in 17s —— View job Code review: PR #45 (
|
Comment and message wording only; no behavior change. The load guard's error message is now accurate in update and existing modes alike.
|
Claude finished @jnasbyupgrade's task in 12s —— View job Code review: PR #45 (
|
| ; | ||
| EXCEPTION WHEN insufficient_privilege THEN | ||
| -- No catalog UPDATE rights: the row can't be locked. | ||
| RAISE WARNING 'could not lock the pg_event_trigger row of event trigger "%"; reading its state without a lock, so a concurrent ALTER EVENT TRIGGER of it is not blocked', event_trigger_name; |
There was a problem hiding this comment.
It'd be clearer to say something like races with ALTER EVEnT TRIGGER are possible
There was a problem hiding this comment.
Claude here: Fixed in 12c5b07: the warning now reads "...reading its state without a lock, so races with ALTER EVENT TRIGGER are possible" (also in the inline copy in the update script).
| ); | ||
|
|
||
| /* | ||
| * event_trigger__disable(names) disables the given event triggers, and |
There was a problem hiding this comment.
comment no longer matches structure of code
There was a problem hiding this comment.
Claude here: Restructured in 8f48f5f. _event_trigger__lock() has its own short comment directly above it. The overview, the sentinel explanation and the locking note now sit directly above event_trigger__disable(), and every "above"/"below" cross-reference points at the right place or is gone. If you meant a different mismatch, tell me which and I will fix it.
| v_name pg_catalog.pg_event_trigger.evtname%TYPE; | ||
| v_enabled pg_catalog.pg_event_trigger.evtenabled%TYPE; | ||
| BEGIN | ||
| -- Validate everything before any side effect. |
There was a problem hiding this comment.
No need for comment; code is obvious.
There was a problem hiding this comment.
Claude here: Fixed in 12c5b07: comment removed.
| SELECT n INTO v_name | ||
| FROM unnest(event_trigger_names) n | ||
| WHERE NOT EXISTS (SELECT 1 FROM pg_catalog.pg_event_trigger WHERE evtname = n) | ||
| ORDER BY n COLLATE "C" |
There was a problem hiding this comment.
Worth calling out the ORDER BY is to ensure our ordering is always consistent so we can't get deadlocks (at least with ourselves)
There was a problem hiding this comment.
Wait, we do NOT need an ORDER BY here! Remove it, and add a comment below where it actually matters.
There was a problem hiding this comment.
Claude here: Fixed in 12c5b07: ORDER BY removed from the existence check; the sorted-order comment now sits on the lock loop and says why it matters (same lock order for every caller, so callers can't deadlock on each other). This supersedes the earlier ORDER BY comment.
| INSERT INTO pg_temp.__object_reference__event_trigger_state(evtname, evtenabled) | ||
| VALUES (v_name, v_enabled); | ||
|
|
||
| PERFORM _object_reference.exec(format('ALTER EVENT TRIGGER %I DISABLE', v_name)); |
There was a problem hiding this comment.
Eh, given that this is where a race exists, do it before the insert
There was a problem hiding this comment.
Claude here: Fixed in 12c5b07: the ALTER EVENT TRIGGER ... DISABLE now runs before the INSERT of the recorded state.
| ; | ||
| END IF; | ||
|
|
||
| -- Same order disable() locked in |
There was a problem hiding this comment.
Shouldn't it be opposite order?
There was a problem hiding this comment.
Claude here: Keeping the order, and 8f48f5f rewords the comment to say why. The sorted COLLATE "C" order is the lock-acquisition order that prevents deadlock between concurrent callers; release order is irrelevant because every lock is released at commit. When enable() runs in its own transaction after a committed disable(), its ALTER EVENT TRIGGER statements take the row locks again, so it needs the same sorted order as disable(). Reversing it would bring back the deadlock risk against a concurrent caller. Tell me if you still want it reversed.
| ok 34 - names were processed in "C" collation order, not input order | ||
| ok 35 - enable() after unsorted disable() | ||
| ok 36 - all three restored | ||
| WARNING: could not lock the pg_event_trigger row of event trigger "event_trigger_disable_test__a"; reading its state without a lock, so a concurrent ALTER EVENT TRIGGER of it is not blocked |
There was a problem hiding this comment.
Need to exclude the WARNING, since we might or might not get it depending on the environment.
There was a problem hiding this comment.
Claude here: Fixed in 12c5b07: the test now sets client_min_messages = error around that call, so the WARNING no longer appears in the expected output.
| CREATE EXTENSION IF NOT EXISTS count_nulls; | ||
|
|
||
| /* | ||
| * Update scenarios. Each installs the old version inside its own |
There was a problem hiding this comment.
This is too much for a script that's supposedly just loading. Break it into a test_update.sql or similar. Also, it's completely unclear why we're doing this as part of test/install instead of test/build (which at least is intended to rollback). Off-hand it seems it'd be simpler to make it part of build?
There was a problem hiding this comment.
Claude here: Done in 8f48f5f. The three rolled-back scenarios and their helpers moved to test/build/update.sql (with test/build/expected/update.out); test/install/load.sql keeps only the committed update, and the state assertion both files use is in test/helpers/event_trigger_state.sql. On install vs build: there is no reason for install. Its output is self-comparing and never asserted, so a failure there could not fail make by itself. test-build runs before the main suite in a fresh database where the extension is not installed, its output is compared, and everything rolls back, which is what the scenarios need. The file prints nothing in fresh/existing mode and runs only when TEST_LOAD_SOURCE=update. Flipping one scenario assertion makes make test-update exit non-zero.
There was a problem hiding this comment.
Claude here: Correction to my reply above: test/build/update.sql has four rolled-back scenarios (the fourth checks the D/R/A state across the update), test/install/load.sql holds only the committed install/update step plus existing-mode presence and version checks, with no event-trigger state assertion, and test/helpers/event_trigger_state.sql is used only by update.sql.
| \i test/load.sql | ||
|
|
||
| /* | ||
| * This file runs in one transaction that is never committed (test/load.sql |
There was a problem hiding this comment.
Eh... need to be careful about depending on that, since someone running by hand via psql could accidentally commit. Not saying it's worth a bunch of extra crap to deal with that possibility, but we should at least word the comment as we're assuming that this won't commit (uh, and should probably \echo a warning at the end instead-of or after finish.sql).
There was a problem hiding this comment.
Claude here: Fixed in 12c5b07: the comment now says the file assumes the transaction is never committed, and an \echo after finish.sql warns a by-hand psql run to ROLLBACK.
…names comments, drop redundant ORDER BY, quiet test warning Reword the unlocked-read warning, move the sorted-order comment to the lock loop, drop the existence-check ORDER BY, disable before recording state, suppress the environment-dependent warning in the test, and warn on a manual run that the transaction is left open.
|
Claude finished @jnasbyupgrade's task in 15s —— View job Code review: PR #45 (
|
Caller sanity checks belong before any meaningful action, unless there is a good reason to wait. Prompted by review feedback on Postgres-Extensions/object_reference#45
Place the disable/enable overview, sentinel and locking notes directly above event_trigger__disable(), give _event_trigger__lock() its own short comment, and say why enable() restores in sorted order. The update script's bootstrap now runs ALTER EVENT TRIGGER before recording the state, like disable(). The rolled-back update scenarios move from test/install/load.sql to test/build/update.sql, whose output is compared, so a failed assertion fails make. load.sql keeps only the committed update; the shared state assertion lives in test/helpers/event_trigger_state.sql.
|
Claude finished @jnasbyupgrade's task in 14s —— 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 15s —— View job Code review: PR #45 (
|
|
Claude finished @jnasbyupgrade's task in 11s —— View job Code review: PR #45 (
|
| , 'a second disable() without enable() in between is rejected' | ||
| ); | ||
| SELECT lives_ok( | ||
| $$SELECT _object_reference.event_trigger__enable()$$ |
There was a problem hiding this comment.
Uh... or move this chunk to the end so we don't have to worry about __enable...
There was a problem hiding this comment.
Claude here: Done in e9aa9d6. I read "this chunk" as the nested-disable section: it and the stray-sentinel section both ended in a cleanup enable(), so they now form one closing sequence that leaves a disable() in effect (this file's transaction is never committed), and both cleanup calls are gone. Say if you meant a different chunk.
| , 'disable() rejects a NULL element' | ||
| ); | ||
|
|
||
| -- Argument errors are reported even while a previous call is in effect |
There was a problem hiding this comment.
Eh... I don't think any of this is worth it. There's nothing all that magical about what order we're checking starting conditions/inputs in, certainly nothing critical. Given that it certainly doesn't deserve dedicated testing.
There was a problem hiding this comment.
Claude here: Fixed in ddfe844: removed the tests that checked which argument error wins when several apply, along with the lead-in comment reference to them.
|
|
||
| /* | ||
| * Same sorted order as disable(): if run in a later transaction, these ALTERs | ||
| * re-take the row locks, and one order for every caller prevents deadlock. |
There was a problem hiding this comment.
I'm pretty sure best practice is to release locks in REVERSE order of how they were taken, no?
There was a problem hiding this comment.
Claude here: Reverse-order release is the rule for locks you release explicitly (mutexes, LWLocks). These row locks, taken by disable()'s FOR UPDATE and by the ALTERs in enable(), are held until the transaction ends and all drop together at commit or rollback, so there is no release order to choose. What matters is acquisition order: when enable() runs in a later transaction than disable(), it takes the same row locks again, and if it took them in reverse, a concurrent disable() (ascending) and enable() (descending) could each hold one lock and wait for the other, which is a deadlock. One ascending order for every caller rules that out, so I'm keeping the loop as is.
|
Claude finished @jnasbyupgrade's task in 13s —— 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 (
|
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: the same state table, sentinel,COLLATE "C"order and lock handling. Its finalenable()undoes it._etg_captureand_etg_fix_identitykeep their 0.1.0 bodies.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(35 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 and which error wins over a call still in effect,"C"-order processing, the lock helper for a role that can't row-lock the catalog (it warns, returnsevtenabledand takes no advisory lock), 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 runs fresh mode only.test/build/expected/build.outgains three blank lines, one per newcreate_functioncall.