Skip to content

Reject self-tracked objects; add reusable update-disable mechanism - #41

Open
jnasbyupgrade wants to merge 3 commits into
Postgres-Extensions:masterfrom
jnasbyupgrade:wip/issue-40
Open

jnasbyupgrade wants to merge 3 commits into
Postgres-Extensions:masterfrom
jnasbyupgrade:wip/issue-40

Conversation

@jnasbyupgrade

Copy link
Copy Markdown
Contributor

Two related fixes needed for #38's OID-repair design, but useful independent of it: object__getsert() (via _object_v__for_update()) now refuses to track any object that is a member of the object_reference extension itself, since letting the tracking system observe its own extension-member objects turns the extension's own DDL -- including its own update scripts restructuring itself -- into things the tracking/repair machinery would react to.

The update script's session_replication_role trick is replaced by a real, reusable mechanism. zzz_object_reference__fix_identity and zzz_object_reference_capture now self-recognize DDL from any extension's own install/update script via pg_event_trigger_ddl_commands()'s in_extension column and skip it; zzz__object_reference_drop can't self-recognize that way (pg_event_trigger_dropped_objects() has no equivalent column), so _object_reference.internal_update__begin()/__end() explicitly disable and re-enable it -- saving and restoring its actual prior enabled state rather than assuming 'origin' -- for this and future update scripts to call.

Details

  • New _object_reference._is_own_object(classid, objid) checks extension membership via pg_depend (deptype = 'e', refobjid = object_reference's own extension oid); wired into _object_v__for_update() right after the existing temp-object rejection.
  • internal_update__begin(event_trigger_names name[] DEFAULT '{zzz__object_reference_drop}') / internal_update__end() disable/re-enable the given event triggers, recording each one's prior evtenabled value in a temp table so end() restores it exactly. ALTER EVENT TRIGGER is ordinary transactional DDL, so a rolled-back update script undoes any DISABLE automatically -- no separate cleanup-on-error logic needed.
  • sql/object_reference--0.1.0--stable.sql creates all three new functions early (0.1.0 has none of them) and calls internal_update__begin()/__end() around its structural section instead of the old session_replication_role save/restore. It also now creates _object_reference.exec(), a permanent helper 0.1.0 never got that object__dependency__add()/object_group__dependency__add() (both unchanged since 0.1.0) already depended on, closing a pre-existing gap in the update path.
  • New test/sql/internal_update.sql covers the begin/end round-trip, restoring a non-default prior state, and the nested-call/no-matching-call guards. test/sql/base.sql gains two rejection tests (tracking _object_reference.object itself, and one of the event trigger functions).

Verified with both make test and make test-update (0.1.0 → stable).

Closes #40.

… objects

object__getsert() (via _object_v__for_update()) now refuses to track any
object that is itself a member of the object_reference extension, closing a
bootstrapping hazard where the extension's own update-time restructuring
could trip its own rename-detection/repair machinery.

Replace the update script's session_replication_role trick with a real,
reusable mechanism: zzz_object_reference__fix_identity and
zzz_object_reference_capture self-recognize (and skip) DDL from any
extension's own install/update script via in_extension;
zzz__object_reference_drop can't self-recognize that way, so
internal_update__begin()/__end() explicitly disable/re-enable it (saving and
restoring its actual prior state) for future update scripts to reuse.

Closes Postgres-Extensions#40.
@coderabbitai

coderabbitai Bot commented Aug 17, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 493f9b19-e221-4fb4-97a5-8fa202486d85

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown

Claude finished @jnasbyupgrade's task in 2m 54s —— View job


Claude's Review

Todo list

  • Gather context (diff, CLAUDE.md, changed files)
  • Review _is_own_object / _object_v__for_update() change
  • Review internal_update__begin/__end() mechanism
  • Review update script (--0.1.0--stable.sql) changes
  • Review test coverage
  • Post final review

Overall this is a clean, well-explained PR. The internal_update__begin/__end() mechanism is a solid improvement over the old session_replication_role trick (transactional rollback safety via ALTER EVENT TRIGGER, actual-prior-state restoration instead of assuming origin, and explicit nested-call/no-call guards backed by tests). Test coverage for the new mechanism (test/sql/internal_update.sql) is thorough — round-trip, non-default prior state, nesting guard, missing-begin guard, unknown-trigger-name guard.

One design point worth a second look:

Scope of the in_extension self-recognition guard may be broader than intended

sql/object_reference.sql:1448 (_etg_capture, AND NOT in_extension) and sql/object_reference.sql:1474 (_etg_fix_identity, early RETURN when in_extension) skip DDL for any extension's install/update script, not just object_reference's own — pg_event_trigger_ddl_commands().in_extension is true whenever the reported command runs as part of any CREATE EXTENSION/ALTER EXTENSION UPDATE script, with no extname/self-check filter.

The PR description confirms this is deliberate ("self-recognize DDL from any extension's own install/update script ... and skip it"), and it's a reasonable read of the goal — but it's a materially different (and broader) scope than the table-level guard added in the same PR, _is_own_object() (sql/object_reference.sql:165-181, used at sql/object_reference.sql:911), which only refuses tracking of objects that are members of object_reference itself.

Net effect: while an object_reference capture group is active, CREATE EXTENSION some_other_extension (or updating it) will now silently stop having its objects captured — a behavior change for a core feature (object_reference.capture__get_current()/object_group__dependency__add()), not just a self-protection fix. test/sql/base.sql:92-95 creates test_factory via CREATE EXTENSION, but only asserts lives_ok, not capture behavior, so this broader scope isn't exercised by the new tests either way.

Worth confirming this breadth is intentional (vs. narrowing the guard to just object_reference's own script, mirroring _is_own_object()'s scope) and, if intentional, calling it out explicitly in a comment/CHANGELOG note since it's easy to read the current comments ("skip DDL issued by any extension's own install/update script (ours included)") as self-protection when it's actually a general opt-out for every extension.

Minor

  • sql/object_reference.sql:913 / sql/object_reference--0.1.0--stable.sql:398: the DETAIL message uses r_identity.identity, computed by the pre-existing temp-object check a few lines above rather than being recomputed for this new guard — correct, just noting it relies on that ordering (guard is placed right after the temp-object check, so r_identity is already populated as intended).

No blocking issues found; CI is green (CI run succeeded). The scope question above is a design confirmation, not something that necessarily needs to block merge.

Comment thread sql/object_reference--0.1.0--stable.sql Outdated
BEGIN
BEGIN
CREATE TEMP TABLE __object_reference__internal_update(
evtname name PRIMARY KEY

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Abbreviations suck. They should never be used without explicit approval. Add that rule to ../ai/. s/evt/event_/

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bleh, just realized where you got the names from. Use either CTAS or CREATE TABLE LIKE to make it clear we're just mirroring pg_event_trigger.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — the temp table is now built via CREATE TEMP TABLE ... AS SELECT evtname, evtenabled FROM pg_catalog.pg_event_trigger WHERE false (plus a separate ADD PRIMARY KEY, since CTAS doesn't carry constraints), so the mirroring is visible in the code itself rather than a coincidental name match. Also proposed the general rule in Postgres-Extensions/ai#25.

Comment thread sql/object_reference--0.1.0--stable.sql Outdated
);

/*
* New: internal update-time disable/enable mechanism, replacing the

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After this release these functions shouldn't normally be needed though, correct? If so then we should change the name to be more specific (event_trigger__disable()) and update the comment.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question — I'd guess it's plausible but not certain future updates need this again (whatever prompts a future need to drop/recreate _object_reference._object_v would hit the same issue), but agreed either way: the actual mechanism is a general disable-with-restore primitive for an event trigger, not something conceptually tied to "being mid-update" — the other two triggers never use it at all. Renamed to event_trigger__disable()/event_trigger__enable() and updated the comment to say so.

);

/*
* _etg_fix_identity/_etg_capture: gain a self-recognition guard so they skip

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs to also explain why this is needed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Expanded: without the guard, _etg_capture would try to call _object_reference._object_v__for_update() (the FUNCTION) for any CREATE-tagged command in this very script if a capture happened to be active during an extension update — including a moment where that function has been dropped and not yet recreated, which would fail outright; _etg_fix_identity would otherwise run its blanket identity-recompute pass on every one of this script's DDL statements for no reason, since nothing it touches is (or, after this update's self-tracking guard, ever legitimately can be) one of this extension's own tracked rows.

Comment thread sql/object_reference.sql
$args$
, 'boolean LANGUAGE sql STABLE'
, $body$
SELECT EXISTS(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pretty sure we need a special case here for the object_reference schema itself (ext depends on it instead of the other way 'round)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed — CREATE EXTENSION records the extension as depending on its declared schema (a plain 'n' DEPENDENCY_NORMAL row, extension -> schema; verified against ExecCreateExtension in extension.c), not the schema as an 'e' member of the extension the way everything else it creates is, so the original check never matched it. Added the special case plus a regression test (object__getsert('schema', 'object_reference') is now rejected too).

jnasbyupgrade and others added 2 commits September 16, 2026 17:00
…hanism

_is_own_object() missed the extension's own declared schema (object_reference):
CREATE EXTENSION records the extension as depending on that schema (a plain
'n' dependency, extension -> schema), not the schema as an 'e' member of the
extension the way every other object it creates is, so the original
pg_depend check never matched it. Added the missing special case, plus a
regression test proving the schema itself is now rejected too.

Renamed internal_update__begin()/__end() to event_trigger__disable()/
event_trigger__enable(): the mechanism is a general disable-with-restore
primitive, not something conceptually tied to "being mid-update" (only
zzz__object_reference_drop, of the three event triggers, ever needs it).
The backing temp table is now built via CTAS off pg_catalog.pg_event_trigger
itself so the connection to that catalog's own evtname/evtenabled columns
is visible in the code, not just a hand-typed column list that happened to
reuse its names. Also expands the _etg_fix_identity/_etg_capture update-script
comment to explain why recreating them is needed, not just what changed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ALTER EVENT TRIGGER is visible database-wide the instant it runs, unlike
the session_replication_role trick it replaces -- so the previous test,
which called these against the real zzz_* triggers, raced with every other
test file running concurrently in the same pg_regress batch: whichever one
happened to run a DROP on a tracked object while zzz__object_reference_drop
was mid-disable never got its tracking row cleaned up. Reproduced locally by
running the suite's test files concurrently against a fresh database
outside pg_regress's own scheduling, matching the exact CI failure.

Rewired the test around two dummy event triggers it creates and owns
itself, so exercising the mechanism never touches shared state any other
test file depends on; the default target (zzz__object_reference_drop) is
confirmed via pg_get_functiondef() instead of actually being invoked.
Documented the database-wide-visibility tradeoff in both event_trigger.sql
locations so a reader (or someone running ALTER EXTENSION UPDATE) knows not
to expect session-local isolation from it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 16, 2026

Copy link
Copy Markdown

Claude finished @jnasbyupgrade's task in 12m 14s —— View job


I'll analyze this and get back to you.

, 'Restore event triggers disabled by event_trigger__disable() to their exact prior state.'
);

SELECT _object_reference.event_trigger__disable();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-recognition guard is installed too late in this script.

event_trigger__disable() here only disables zzz__object_reference_drop. The comment above it claims the other two event triggers (zzz_object_reference__fix_identity / zzz_object_reference_capture) "self-recognize and skip our own script's DDL instead" via their new in_extension guard — but that guard isn't actually installed until _etg_fix_identity and _etg_capture are recreated later in this same script (around the _object_reference._etg_fix_identity/_etg_capture create_function calls, well after the structural DROP VIEW/CREATE VIEW/CREATE FUNCTION section for _object_v/_object_v__for_update/_object_oid__add).

Between this line and that recreation, the old (0.1.0) unguarded bodies of those two triggers are still live. Previously session_replication_role = replica silenced all three triggers for this whole window; now only one is silenced.

Failure scenario: a session calls object_reference.capture__start() and then runs ALTER EXTENSION object_reference UPDATE in the same transaction (the very scenario the deleted comments anticipated). The CREATE VIEW _object_reference._object_v statement a few lines down fires the old _etg_capture, which calls _object_reference._object_v__for_update(...) (the FUNCTION) — but that function was just DROPped and hasn't been recreated yet, so the update aborts with "function does not exist". No test in test/sql/event_trigger_disable.sql or test/sql/base.sql exercises an update-in-progress capture, so this isn't caught by CI.

Comment thread sql/object_reference.sql
* install/update script (ours included) rather than trying to
* capture it.
*/
AND NOT in_extension

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in_extension excludes DDL from any extension, not just object_reference's own.

pg_event_trigger_ddl_commands().in_extension is true whenever the command runs as part of any extension's install/update script — it can't distinguish object_reference's own script from, say, CREATE EXTENSION postgis. This filter silently changes what capture__start()/object groups can capture: previously, objects created by installing any extension while a capture was active would be captured (ordinary tables/functions with CREATE-tagged DDL); after this change they never will be, for any extension, forever — not just during object_reference's own updates.

Failure scenario: SELECT object_reference.capture__start(g); CREATE EXTENSION hstore; SELECT object_reference.capture__stop(); — hstore's tables/functions/operators are silently no longer added to group g, with no error and no test coverage of this case.

Comment thread sql/object_reference.sql
* script (ours included); pg_event_trigger_ddl_commands() marks this via
* in_extension, unlike pg_event_trigger_dropped_objects() (see _etg_drop).
*/
IF EXISTS(SELECT 1 FROM pg_catalog.pg_event_trigger_ddl_commands() WHERE in_extension) THEN

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same broad-in_extension issue as _etg_capture: this RETURNs (skipping the entire identity-repair scan across all tracked objects) if any row from pg_event_trigger_ddl_commands() for this event has in_extension = true — i.e. whenever the triggering DDL happened to run inside any extension's install/update script, not just object_reference's own.

Failure scenario: some unrelated extension's update script renames/moves an object that is unrelated to that extension but happens to already be tracked by object_reference (e.g. a table ALTERed as a side effect of another extension's migration) — the blanket early return means this event trigger invocation does no identity-repair work at all for that DDL batch, so a rename that should have updated _object_reference.object.object_names silently doesn't.

Comment thread sql/object_reference.sql
*/
OR (
_is_own_object.classid = 'pg_catalog.pg_namespace'::regclass
AND _is_own_object.objid = (SELECT extnamespace FROM pg_catalog.pg_extension WHERE extname = 'object_reference')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_is_own_object() handles the extension's declared schema as a special case (since CREATE EXTENSION links it via a plain dependency, not deptype = 'e'), but doesn't handle the extension's own pg_extension row itself (classid = 'pg_catalog.pg_extension'::regclass, objid = its own oid). That row isn't an 'e' member of itself, and isn't covered by the namespace special case either.

Failure scenario: if extension is a supported cat_tools.object_type for this system, object_reference.object__getsert('extension', 'object_reference') bypasses this guard entirely and successfully tracks the extension as an object of itself — contradicting the "refuse to track objects that are themselves members of this extension" intent this PR adds.

Comment thread sql/object_reference.sql
END IF;

-- Refuse to track objects that are themselves members of this extension
IF _object_reference._is_own_object(c_classid, objid) THEN

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Efficiency: _is_own_object() now runs on every call to _object_v__for_update() — i.e. on every object__getsert() for every tracked object, forever, not just during extension updates — re-resolving (SELECT oid FROM pg_catalog.pg_extension WHERE extname = 'object_reference') (and the analogous extnamespace lookup) by name on each invocation instead of once. Minor, but it's added catalog work on what's otherwise a fairly hot path; hoisting the extension oid lookup (e.g. into a WITH once per call, or caching it) would avoid repeating the same lookup twice within a single _is_own_object() call (once per branch of the OR).

Comment thread sql/object_reference.sql
DECLARE
v_name name;
BEGIN
BEGIN

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reuse/simplification: this reentrancy guard (CREATE TEMP TABLE + catch duplicate_table to detect "a previous disable() is still in effect") is a new bookkeeping idiom. The codebase already has an established convention for exactly this kind of "session temp-table state, create lazily" problem — capture__start()'s own pg_temp.__object_reference__ddl_capture table, created lazily in an EXCEPTION WHEN undefined_table handler rather than pre-emptively. Two different idioms now exist for the same "is there already session state from a paired call" check, making the pattern harder to recognize/reuse next time.

Comment thread sql/object_reference.sql
SELECT __object_reference.create_function(
'_object_reference.event_trigger__disable'
, $args$
event_trigger_names name[] DEFAULT '{zzz__object_reference_drop}'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Altitude: this is framed as a "general-purpose event-trigger disable/enable mechanism" (per the comment above), but the default event_trigger_names name[] DEFAULT '{zzz__object_reference_drop}' is its only actual caller anywhere in the codebase or tests — it's really a single hardcoded workaround for one trigger that can't self-recognize, wearing a general-purpose signature. That's fine as far as it goes, but nothing here has exercised or needs the "multiple/arbitrary trigger names" generality it advertises, so that generality is unproven and a future caller passing something other than the default is on its own.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Standard internal update-time disable mechanism, and refuse to self-track extension-owned objects

1 participant