فهرست منبع

Fixes #23130: Ensure ltree cascade triggers can be restored from a pg_dump (#23137)

Jason Novinger 10 ساعت پیش
والد
کامیت
9d96894f4e

+ 54 - 0
docs/administration/management-commands.md

@@ -44,6 +44,60 @@ By default, only those objects whose cache is empty are rendered, so the command
 python3 netbox/manage.py rebuild_config_context_cache [--force]
 ```
 
+## rebuild_ltree_paths
+
+Recompute the `path` and `sort_path` columns of the hierarchical models (regions, site groups, locations, device roles, platforms, tenant groups, contact groups, wireless LAN groups, module bays, inventory items, and inventory item templates) from their parent relationships. These columns are maintained by PostgreSQL triggers, so this is needed only where a write bypassed them: a bulk `COPY`, a direct `UPDATE`, or a database restored from a NetBox v4.7.0 dump (see [#23130](https://github.com/netbox-community/netbox/issues/23130)).
+
+The command has two modes. Both operate on every hierarchical model by default, or on those named as `app_label.ModelName`.
+
+### Reporting
+
+`--check` compares each object's stored `path` and `sort_path` against its parent's and reports which models disagree. It modifies nothing and takes no locks, so it can be run on a live system or against a replica.
+
+```
+python3 netbox/manage.py rebuild_ltree_paths --check
+```
+
+```no-highlight
+dcim.location: 5 path, 5 sort_path row(s) out of date
+dcim.region: 2 sort_path row(s) out of date
+...
+
+Needs rebuilding: dcim.location dcim.region
+```
+
+The counts answer whether a model needs rebuilding, not how many of its objects are wrong. Where an object has moved, the objects beneath it still agree with their own parent and are not counted, though they are equally stale. Rebuild the whole model rather than acting on the number.
+
+A model can also be damaged in a way `--check` does not report: an object which no root reaches by following `parent_id` is compared against a parent that is itself unreachable, so it may agree and be counted clean. The rebuild detects that case and refuses (see below).
+
+### Rebuilding
+
+With no `--check`, each named model is rebuilt: every row's `path` and `sort_path` are recomputed from the hierarchy.
+
+```
+python3 netbox/manage.py rebuild_ltree_paths [app_label.ModelName ...]
+```
+
+```no-highlight
+dcim.region: rebuilding... done
+Finished.
+```
+
+A rebuild derives each object's path by walking down from the roots, so it can only repair an object which some root reaches. Where a model contains an object no root reaches — one in a cycle, one parented to itself, or one whose parent no longer exists — the command reports how many and stops without modifying that model, because a rebuild would silently skip exactly those objects:
+
+```no-highlight
+CommandError: dcim.region: 5 row(s) cannot be reached from a root by following
+parent_id, so a rebuild would skip them: 1, 2, 3, 4, 5. Correct the parent
+relationships, then re-run.
+```
+
+One of the listed objects is in a cycle, parented to itself, or pointing at an object which no longer exists; the rest are descended from it and are otherwise intact. Correcting the relationship is left to the operator, as only they can say what the hierarchy was meant to be. Each model is checked and rebuilt in its own transaction, so a refusal leaves that model untouched, and models already rebuilt stay rebuilt.
+
+!!! warning
+    A rebuild rewrites every row of each named model in a single statement, locking those rows until it commits. On a large table this blocks concurrent writes for minutes, so run it during a maintenance window. Use `--check` first to limit the rebuild to the models which need it.
+
+    A rebuild also assumes nothing else is changing the hierarchy while it runs. An object reparented after the command has checked the model, but before it rewrites it, is not accounted for, and the check which refuses unreachable objects cannot see it either. This is another reason to run the command with writes paused rather than against a live system.
+
 ## rebuild_prefixes
 
 Rebuild the IPAM prefix hierarchy, recalculating the depth and child counts for all prefixes.

+ 8 - 1
docs/administration/replicating-netbox.md

@@ -34,9 +34,16 @@ When restoring a database from a file, it's recommended to delete any existing d
 ```no-highlight
 psql -c 'drop database netbox'
 psql -c 'create database netbox'
-psql netbox < netbox.sql
+psql -v ON_ERROR_STOP=1 netbox < netbox.sql
 ```
 
+!!! warning "Always restore with ON_ERROR_STOP"
+    By default, `psql` continues after an error and still exits with status 0. A restore which failed partway through, leaving out an index, a function, or a trigger, therefore reports success and yields a database which looks healthy but is incomplete. Passing `-v ON_ERROR_STOP=1` makes `psql` abort on the first error and exit non-zero, so check the exit status before putting the restored database into service.
+
+    This changes the behavior of the restore: a dump which previously appeared to restore successfully will now abort on its first error, including errors unrelated to NetBox's own schema (a role which already exists, an extension owned by another user, and so on). That is the intended outcome, but expect a restore which used to "succeed" to start reporting failures which were there all along.
+
+    For a dump in one of `pg_dump`'s non-plain formats, restore it with `pg_restore --exit-on-error` instead.
+
 Keep in mind that PostgreSQL user accounts and permissions are not included with the dump: You will need to create those manually if you want to fully replicate the original database (see the [installation docs](../installation/1-postgresql.md)). When setting up a development instance of NetBox, it's strongly recommended to use different credentials anyway.
 
 ### Export the Database Schema

+ 39 - 0
docs/release-notes/version-4.7.md

@@ -1,5 +1,44 @@
 # NetBox v4.7
 
+## v4.7.1 (FUTURE)
+
+!!! warning "Databases Restored From a v4.7.0 Dump"
+    The triggers which cascade a hierarchical object's path to its descendants could not be recreated when restoring a `pg_dump` of a v4.7.0 database, because `pg_dump` resets the `search_path` and the triggers' `WHEN` clause depended on it. As `psql` does not stop on error by default, such a restore reported success while leaving the database without those triggers, so renaming or moving a region, site group, location, device role, platform, tenant group, contact group, wireless LAN group, module bay, or inventory item did not update its descendants.
+
+    Upgrading reinstalls the triggers, so all subsequent changes are cascaded correctly. It does **not** repair values which have already gone stale. After upgrading, `rebuild_ltree_paths --check` reports which models are affected without modifying anything or taking any locks:
+
+    ```no-highlight
+    python netbox/manage.py rebuild_ltree_paths --check
+    ```
+
+    To check before upgrading, the same test can be run as SQL. Substitute each hierarchical table in turn: `dcim_region`, `dcim_sitegroup`, `dcim_location`, `dcim_devicerole`, `dcim_platform`, `dcim_modulebay`, `dcim_inventoryitem`, `dcim_inventoryitemtemplate`, `tenancy_tenantgroup`, `tenancy_contactgroup`, and `wireless_wirelesslangroup`.
+
+    ```no-highlight
+    SELECT count(*) FROM dcim_region c JOIN dcim_region p ON c.parent_id = p.id
+    WHERE c.path <> p.path || lpad(c.id::text, 19, '0')::ltree;
+    ```
+
+    Treat any non-zero result as "this table needs rebuilding" rather than as a count of the damage: a node whose ancestor moved is reported, but its own descendants are consistent with it and so are not, even though they are equally stale.
+
+    The nine tables which order their children by name additionally maintain a `sort_path`, which can go stale on a rename even when `path` is correct. Every table in the list above except `dcim_inventoryitem` and `dcim_inventoryitemtemplate` carries one, and is checked with:
+
+    ```no-highlight
+    SELECT count(*) FROM dcim_region c JOIN dcim_region p ON c.parent_id = p.id
+    WHERE c.sort_path <> p.sort_path || chr(9) || c.name;
+    ```
+
+    Stale `sort_path` values affect only the order in which objects are listed. A stale `path`, by contrast, misplaces an object within the hierarchy, so it can be omitted from its ancestor's list of descendants. Repair an affected table with the [`rebuild_ltree_paths`](../administration/management-commands.md#rebuild_ltree_paths) management command, naming the models the queries above flagged:
+
+    ```no-highlight
+    python netbox/manage.py rebuild_ltree_paths dcim.region
+    ```
+
+    A rebuild rewrites every row of the named tables, locking those rows until it commits, so run it during a maintenance window. Should it report that a table contains rows unreachable from any root, the parent relationships themselves need correcting first: a rebuild walks down from the roots and would skip those rows.
+
+    Plugins which maintain their own `ltree` models via the `InstallLtreeTriggers` migration operation are affected in the same way, and their tables are not touched by the migrations above. Where such a database was restored from a dump, the plugin's cascade triggers are missing entirely; where it was upgraded in place, they carry the old definition and will be lost by its next dump. Either way, a new plugin migration applying `ReinstallLtreeTriggers` (passing the same `name_column` as the original) installs the corrected definitions. Use that operation rather than `InstallLtreeTriggers`: both drop each trigger before recreating it, so either works going forwards, but reversing the corrective migration should not undo the original installation. `InstallLtreeTriggers` reverses by dropping both triggers and their functions, which would leave the table with no path maintenance while the migration that first installed them remains applied. `ReinstallLtreeTriggers` reverses to a no-op instead.
+
+---
+
 ## v4.7.0 (2026-09-02)
 
 !!! warning "PostgreSQL 15 or Later Required"

+ 53 - 0
netbox/dcim/migrations/0251_fix_ltree_cascade_triggers.py

@@ -0,0 +1,53 @@
+"""Reinstall the ltree cascade triggers with a restore-safe WHEN clause.
+
+The cascade triggers installed by 0242_ltree_paths compared two ltree values with
+`IS DISTINCT FROM`, which resolves the `ltree = ltree` operator through search_path at
+CREATE TRIGGER time. pg_dump emits `set_config('search_path', '', false)`, so restoring a
+v4.7.0 dump could not create these triggers — and because psql does not stop on error by
+default, the restore reported success with the triggers silently missing. See #23130.
+
+Reinstalling covers both affected databases: one restored from such a dump (the triggers
+are absent) and one upgraded in place (they exist with the old definition, which would
+fail its own next restore). InstallLtreeTriggers drops before creating, so this applies
+cleanly in either state.
+
+This reinstalls triggers only, so it takes ACCESS EXCLUSIVE on each table for the DDL
+itself and performs no table scan. Note that this is a stronger lock than the ROW
+EXCLUSIVE held by 0242's backfill, and it blocks readers as well as writers: it is brief,
+but on a busy table it queues behind any long-running query and holds everything behind
+it for that query's duration.
+
+It does not repair path/sort_path values which went stale while the triggers were
+missing; see the v4.7.1 release notes for detection and repair.
+
+Reversing this migration is a no-op. Reversing 0242_ltree_paths in turn drops these
+triggers rather than recreating them, which is that migration's business; what matters
+here is that undoing a corrective reinstall has no target state of its own, since the
+definition it replaced is the broken one.
+"""
+from django.db import migrations
+
+from utilities.ltree import ReinstallLtreeTriggers
+
+# The tables carrying a sort_path column, maintained from `name`.
+SORT_TABLES = (
+    'dcim_region',
+    'dcim_sitegroup',
+    'dcim_location',
+    'dcim_devicerole',
+    'dcim_platform',
+    'dcim_modulebay',
+)
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0250_cooling_infrastructure'),
+    ]
+
+    operations = [
+        *[ReinstallLtreeTriggers(t, name_column='name') for t in SORT_TABLES],
+        ReinstallLtreeTriggers('dcim_inventoryitem'),
+        ReinstallLtreeTriggers('dcim_inventoryitemtemplate'),
+    ]

+ 35 - 0
netbox/tenancy/migrations/0027_fix_ltree_cascade_triggers.py

@@ -0,0 +1,35 @@
+"""Reinstall the ltree cascade triggers with a restore-safe WHEN clause.
+
+The cascade triggers installed by 0025_ltree_paths compared two ltree values with
+`IS DISTINCT FROM`, which resolves the `ltree = ltree` operator through search_path at
+CREATE TRIGGER time. pg_dump emits `set_config('search_path', '', false)`, so restoring a
+v4.7.0 dump could not create these triggers — and because psql does not stop on error by
+default, the restore reported success with the triggers silently missing. See #23130.
+
+Reinstalling covers both affected databases: one restored from such a dump (the triggers
+are absent) and one upgraded in place (they exist with the old definition, which would
+fail its own next restore). InstallLtreeTriggers drops before creating, so this applies
+cleanly in either state.
+
+This does not repair path/sort_path values which went stale while the triggers were
+missing; see the v4.7.1 release notes for detection and repair.
+
+Reversing this migration is a no-op: the triggers it replaces belong to 0025_ltree_paths,
+which recreates them (from the corrected template) when reversed in turn.
+"""
+from django.db import migrations
+
+from utilities.ltree import ReinstallLtreeTriggers
+
+TABLES = ('tenancy_tenantgroup', 'tenancy_contactgroup')
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('tenancy', '0026_consolidate_unique_constraints'),
+    ]
+
+    operations = [
+        *[ReinstallLtreeTriggers(t, name_column='name') for t in TABLES],
+    ]

+ 129 - 20
netbox/utilities/ltree.py

@@ -9,11 +9,43 @@ not on the model definitions.
 
 The paths maintained by these triggers are never computed or mutated from Python;
 the model layer only reads `path`/`sort_path` back from the database.
+
+Trigger DDL and search_path
+---------------------------
+Everything this module emits is replayed verbatim by `pg_restore`, which runs with
+`search_path` set to the empty string and schema-qualifies every name it can (a
+CVE-2018-1058 hardening). Unqualified names in the SQL below therefore have to
+resolve without help from the path, and the two halves of a trigger differ in when
+that resolution happens:
+
+* A trigger's WHEN clause is resolved at CREATE TRIGGER time. `IS DISTINCT FROM`
+  (like `=`, `<`, ...) is grammar which expands to the operand type's operator, and
+  there is no syntax to schema-qualify it. An extension type whose operators live
+  outside `pg_catalog` — `ltree` installs into `public` — makes that CREATE TRIGGER
+  unrestorable, and because `psql` does not stop on error by default the restore
+  appears to succeed with the trigger silently missing (#23130).
+* A trigger FUNCTION's body survives only because pg_dump emits
+  `SET check_function_bodies = false`, which suppresses the validation that would
+  otherwise reject the unqualified `ltree` declarations below at CREATE FUNCTION
+  time. Under the default `check_function_bodies = on` they fail with
+  `type "ltree" does not exist`. So the bodies are not inherently path-independent;
+  they are exempted by the restore's own configuration. Anything replaying this DDL
+  outside a pg_dump context must either put the extension's schema on the path or
+  set that GUC itself.
+
+    Rule: keep WHEN-clause operand types inside `pg_catalog`. Cast an
+    extension-typed column with `::text` (ltree's text I/O is byte-canonical, so
+    `::text` equality is exactly ltree equality).
+
+`RestoreUnderRestrictedSearchPathTests` in `utilities/tests/test_ltree.py` enforces
+this by creating the generated DDL with the extension's schema off the search_path.
 """
 from django.db import migrations
 
 __all__ = (
     'InstallLtreeTriggers',
+    'ReinstallLtreeTriggers',
+    'ltree_trigger_sql',
 )
 
 
@@ -220,10 +252,35 @@ CREATE TRIGGER "{table}_ltree_compute_path"
 # because that statement does not touch parent_id or {name_col}, the AFTER
 # trigger does not re-fire on those descendant rows. This prevents the
 # quadratic re-cascade that would otherwise occur for any deep subtree.
+#
+# `path` is compared as text rather than as ltree. `IS DISTINCT FROM` is SQL
+# grammar, not an operator: it has no schema-qualification syntax, and it expands
+# to the operand type's `=` operator, which is resolved from search_path at
+# CREATE TRIGGER time. `ltree =` lives in whichever schema the extension was
+# installed into (normally `public`), so a CREATE TRIGGER replayed by pg_restore
+# — which runs with `search_path` set to the empty string and schema-qualifies
+# every name it can — cannot resolve it and fails with "operator does not exist:
+# public.ltree = public.ltree", silently dropping the cascade trigger from the
+# restored database (#23130). `text =` is in `pg_catalog`, which is always on the
+# effective path, so the cast makes the DDL search_path-independent.
+#
+# The comparison is equivalent: ltree's text I/O is byte-preserving (parse_ltree
+# and deparse_ltree copy label bytes with memcpy, and ltree_eq is a memcmp over
+# those same bytes), so two ltree values are equal iff their text renderings are
+# — see contrib/ltree/ltree_io.c and ltree_op.c. PostgreSQL publishes no explicit
+# guarantee of this; it is a property of the implementation, which cannot change
+# without breaking ltree's on-disk format and every existing ltree index.
+#
+# `sort_path` needs no cast: it is already a text column.
+#
+# See also the module docstring ("Trigger DDL and search_path") and
+# RestoreUnderRestrictedSearchPathTests in utilities/tests/test_ltree.py, which
+# enforces this by creating the generated DDL with the extension's schema off the
+# search_path.
 _AFTER_TRIGGER_PATH_ONLY = '''
 CREATE TRIGGER "{table}_ltree_cascade_path"
     AFTER UPDATE OF parent_id ON "{table}"
-    FOR EACH ROW WHEN (OLD.path IS DISTINCT FROM NEW.path)
+    FOR EACH ROW WHEN (OLD.path::text IS DISTINCT FROM NEW.path::text)
     EXECUTE FUNCTION "{table}_ltree_cascade_path_fn"();
 '''
 
@@ -231,13 +288,57 @@ _AFTER_TRIGGER_PATH_AND_SORT = '''
 CREATE TRIGGER "{table}_ltree_cascade_path"
     AFTER UPDATE OF parent_id, "{name_col}" ON "{table}"
     FOR EACH ROW WHEN (
-        OLD.path IS DISTINCT FROM NEW.path
+        OLD.path::text IS DISTINCT FROM NEW.path::text
         OR OLD.sort_path IS DISTINCT FROM NEW.sort_path
     )
     EXECUTE FUNCTION "{table}_ltree_cascade_path_fn"();
 '''
 
 
+def ltree_trigger_sql(table, name_column=None):
+    """
+    Return the DDL statements which install ltree path-maintenance triggers on `table`.
+
+    Two functions and two triggers, in dependency order. If `name_column` is given, the
+    table is expected to carry a `sort_path` column and gets the variants which maintain
+    it alongside `path`.
+
+    The triggers are dropped before being created, so re-running this SQL converges
+    instead of failing: a plain `CREATE TRIGGER` raises 42710 when the trigger already
+    exists, which a re-run, a partially-applied migration, or a later migration
+    reinstalling a corrected definition (#23130) would all hit. The functions already use
+    CREATE OR REPLACE. This mirrors `utilities.migration.InstallDenormalizationTrigger`.
+
+    `InstallLtreeTriggers` executes exactly this SQL, so tests can assert against the
+    statements migrations really run rather than a copy which can drift.
+    """
+    if name_column:
+        function_sql = (
+            _COMPUTE_PATH_AND_SORT_FN.format(table=table, name_col=name_column),
+            _CASCADE_PATH_AND_SORT_FN.format(table=table),
+        )
+        trigger_sql = (
+            _BEFORE_TRIGGER_PATH_AND_SORT.format(table=table, name_col=name_column),
+            _AFTER_TRIGGER_PATH_AND_SORT.format(table=table, name_col=name_column),
+        )
+    else:
+        function_sql = (
+            _COMPUTE_PATH_ONLY_FN.format(table=table),
+            _CASCADE_PATH_ONLY_FN.format(table=table),
+        )
+        trigger_sql = (
+            _BEFORE_TRIGGER_PATH_ONLY.format(table=table),
+            _AFTER_TRIGGER_PATH_ONLY.format(table=table),
+        )
+
+    return (
+        *function_sql,
+        f'DROP TRIGGER IF EXISTS "{table}_ltree_cascade_path" ON "{table}";',
+        f'DROP TRIGGER IF EXISTS "{table}_ltree_compute_path" ON "{table}";',
+        *trigger_sql,
+    )
+
+
 class InstallLtreeTriggers(migrations.operations.base.Operation):
     """
     Install per-table ltree path-maintenance triggers.
@@ -252,6 +353,10 @@ class InstallLtreeTriggers(migrations.operations.base.Operation):
     ancestor names. This implements MPTT's `order_insertion_by=(name,)`
     semantics: insert, reparent, and rename all honor the current value of
     `name_column`, with renames cascaded into descendants' sort_paths.
+
+    Applying this operation is idempotent (see `ltree_trigger_sql`), so it can be
+    re-run to reinstall a corrected trigger definition on a table which already has
+    one.
     """
     reversible = True
 
@@ -263,24 +368,8 @@ class InstallLtreeTriggers(migrations.operations.base.Operation):
         pass
 
     def database_forwards(self, app_label, schema_editor, from_state, to_state):
-        if self.name_column:
-            schema_editor.execute(_COMPUTE_PATH_AND_SORT_FN.format(
-                table=self.table_name, name_col=self.name_column,
-            ))
-            schema_editor.execute(_CASCADE_PATH_AND_SORT_FN.format(
-                table=self.table_name,
-            ))
-            schema_editor.execute(_BEFORE_TRIGGER_PATH_AND_SORT.format(
-                table=self.table_name, name_col=self.name_column,
-            ))
-            schema_editor.execute(_AFTER_TRIGGER_PATH_AND_SORT.format(
-                table=self.table_name, name_col=self.name_column,
-            ))
-        else:
-            schema_editor.execute(_COMPUTE_PATH_ONLY_FN.format(table=self.table_name))
-            schema_editor.execute(_CASCADE_PATH_ONLY_FN.format(table=self.table_name))
-            schema_editor.execute(_BEFORE_TRIGGER_PATH_ONLY.format(table=self.table_name))
-            schema_editor.execute(_AFTER_TRIGGER_PATH_ONLY.format(table=self.table_name))
+        for sql in ltree_trigger_sql(self.table_name, self.name_column):
+            schema_editor.execute(sql)
 
     def database_backwards(self, app_label, schema_editor, from_state, to_state):
         t = self.table_name
@@ -291,3 +380,23 @@ class InstallLtreeTriggers(migrations.operations.base.Operation):
 
     def describe(self):
         return f"Install ltree path triggers on {self.table_name}"
+
+
+class ReinstallLtreeTriggers(InstallLtreeTriggers):
+    """
+    Reinstall a table's ltree path-maintenance triggers, replacing an earlier definition.
+
+    Identical to `InstallLtreeTriggers` going forwards, but a no-op in reverse. The
+    parent operation's reverse drops both triggers and both functions, which is right
+    when reversing the migration that first installed them and wrong when reversing one
+    that merely corrected them: it would leave the table with no path maintenance at all
+    — a state no release ever shipped — and every subsequent INSERT failing on `path`'s
+    NOT NULL constraint. The triggers this replaces are recreated by reversing back to
+    the migration which installed them, so there is nothing for this operation to undo.
+    """
+
+    def database_backwards(self, app_label, schema_editor, from_state, to_state):
+        pass
+
+    def describe(self):
+        return f"Reinstall ltree path triggers on {self.table_name}"

+ 154 - 0
netbox/utilities/management/commands/rebuild_ltree_paths.py

@@ -0,0 +1,154 @@
+from django.apps import apps
+from django.core.management.base import BaseCommand, CommandError
+from django.db import connection, transaction
+
+from netbox.models.ltree import LtreeModel
+from netbox.plugins import PluginConfig
+from utilities.mptt_to_ltree import (
+    count_stale_rows_sql,
+    populate_paths_sql,
+    unreachable_rows_sql,
+)
+
+
+class Command(BaseCommand):
+    help = (
+        "Recompute the trigger-maintained path (and sort_path) columns of hierarchical models "
+        "from their parent relationships"
+    )
+
+    # How many offending ids a refusal names. Enough to start from, short enough to read.
+    REPORTED_IDS = 10
+
+    def add_arguments(self, parser):
+        parser.add_argument(
+            'model', nargs='*',
+            help="Limit the rebuild to these models, as app_label.ModelName (default: all)",
+        )
+        parser.add_argument(
+            '--check', action='store_true',
+            help="Report which models need rebuilding, without modifying anything",
+        )
+
+    def get_models(self, names):
+        """
+        Return the concrete core hierarchical models to operate on: those named, in the
+        order given, or every one of them ordered by table name.
+
+        Plugin models are excluded, including when named explicitly: the SQL which rebuilds
+        `sort_path` reads the name column by name, while `InstallLtreeTriggers` lets a plugin
+        maintain it from any column, so rebuilding one is not something this command can do
+        correctly. A plugin in that position needs its own repair path.
+        """
+        def concrete_subclasses(base):
+            for subclass in base.__subclasses__():
+                if subclass._meta.abstract:
+                    yield from concrete_subclasses(subclass)
+                elif not isinstance(apps.get_app_config(subclass._meta.app_label), PluginConfig):
+                    yield subclass
+
+        candidates = {
+            model._meta.label_lower: model for model in concrete_subclasses(LtreeModel)
+        }
+
+        if not names:
+            return sorted(candidates.values(), key=lambda model: model._meta.db_table)
+
+        models = []
+        for name in names:
+            model = candidates.get(name.lower())
+            if model is None:
+                raise CommandError(f"{name} is not a core hierarchical (ltree-backed) model")
+            models.append(model)
+        return models
+
+    def check_reachable(self, cursor, model):
+        """
+        Raise unless every row is reachable from a root by following `parent_id`.
+
+        The rebuild walks down from `parent_id IS NULL`, so a row no root can reach is one
+        it silently leaves alone. Reporting success in that case would be the same failure
+        this command exists to repair: an operation which appears to have worked while the
+        data is still wrong. Refuse instead, and leave correcting the parent relationships
+        to the operator, since only they can say what the intended hierarchy was.
+
+        Takes the caller's cursor so a refusal rolls back with the transaction the rebuild
+        would have run in. That does not make the pair atomic with respect to other
+        writers: under READ COMMITTED every statement takes a fresh snapshot, so a
+        reparent committed between the check and the rebuild is still missed. Pause writes
+        for the duration, as the documentation says to.
+        """
+        cursor.execute(unreachable_rows_sql(model._meta.db_table, self.REPORTED_IDS))
+        unreachable, ids = cursor.fetchone()
+
+        if unreachable:
+            listed = ', '.join(str(pk) for pk in ids)
+            if unreachable > len(ids):
+                listed += ', ...'
+            raise CommandError(
+                f'{model._meta.label_lower}: {unreachable} row(s) cannot be reached from a '
+                f'root by following parent_id, so a rebuild would skip them: {listed}. '
+                f'Correct the parent relationships, then re-run.'
+            )
+
+    def report_stale(self, model):
+        """
+        Report whether a model's stored paths disagree with its parent relationships.
+
+        Read-only, and takes no locks, so it can be run outside a maintenance window or
+        against a replica. It answers which models need rebuilding, not how many rows are
+        damaged: see `count_stale_rows_sql()` for why the counts understate a deep tree.
+        """
+        with connection.cursor() as cursor:
+            cursor.execute(
+                count_stale_rows_sql(model._meta.db_table, sort_path=model._has_sort_path())
+            )
+            stale_paths, stale_sort_paths = cursor.fetchone()
+
+        if not (stale_paths or stale_sort_paths):
+            self.stdout.write(f'{model._meta.label_lower}: OK')
+            return False
+
+        damage = []
+        if stale_paths:
+            damage.append(f'{stale_paths} path')
+        if stale_sort_paths:
+            damage.append(f'{stale_sort_paths} sort_path')
+        self.stdout.write(self.style.WARNING(
+            f"{model._meta.label_lower}: {', '.join(damage)} row(s) out of date"
+        ))
+        return True
+
+    def handle(self, *args, **options):
+        models = self.get_models(options['model'])
+
+        if options['check']:
+            stale = [model for model in models if self.report_stale(model)]
+            if stale:
+                names = ' '.join(model._meta.label_lower for model in stale)
+                self.stdout.write(f'\nNeeds rebuilding: {names}')
+            else:
+                self.stdout.write(self.style.SUCCESS('Nothing to rebuild.'))
+            return
+
+        # Each table is checked and rebuilt in its own transaction. Tables already done
+        # stay done if a later one fails or is refused: rebuilding one table cannot leave
+        # another inconsistent, and holding every table's row locks until the last one
+        # finished would turn several short blocking windows into one long one.
+        for model in models:
+            with transaction.atomic(), connection.cursor() as cursor:
+                # Announce the rebuild only once the check has passed, so a refusal does
+                # not print "rebuilding..." for a table left untouched.
+                self.check_reachable(cursor, model)
+                self.stdout.write(f'{model._meta.label_lower}: rebuilding... ', ending='')
+                self.stdout.flush()
+                # populate_paths_sql() is the same SQL which backfilled these columns
+                # during the ltree migrations. It relies on SET LOCAL, so it must run
+                # inside a transaction, and the UPDATE it emits locks every row in the
+                # table until it commits.
+                cursor.execute(
+                    populate_paths_sql(model._meta.db_table, sort_path=model._has_sort_path())
+                )
+            self.stdout.write(self.style.SUCCESS('done'))
+
+        self.stdout.write(self.style.SUCCESS('Finished.'))

+ 10 - 0
netbox/utilities/migration.py

@@ -67,6 +67,16 @@ class InstallDenormalizationTrigger(migrations.operations.base.Operation):
     newly created source row has no dependents yet) and it does not recurse: the dependent tables carry no
     triggers of their own.
 
+    !!! warning "Watched columns must be of a type whose `=` lives in `pg_catalog`"
+        The generated WHEN clause compares each watched column with `IS DISTINCT FROM`, which expands to
+        that column type's `=` operator, resolved from `search_path` at CREATE TRIGGER time and with no
+        syntax available to schema-qualify it. Every current caller watches integer FK columns, whose `=` is
+        a built-in in `pg_catalog` and therefore always resolvable. Do NOT pass a column of an extension
+        type (`ltree`, `hstore`, PostGIS `geometry`, ...): its operators live in the extension's schema, so
+        the resulting trigger would fail to restore from a `pg_dump`, which replays DDL with an empty
+        `search_path` — and because `psql` does not stop on error by default, the restore would appear to
+        succeed with the trigger silently missing. See `utilities/ltree.py` and #23130.
+
     Example: refresh a CircuitTermination's cached region/sitegroup when its Site's region or group changes::
 
         InstallDenormalizationTrigger(

+ 76 - 0
netbox/utilities/mptt_to_ltree.py

@@ -35,7 +35,9 @@ ancestor `name` values. Keep the two modules in sync if either changes.
 
 __all__ = (
     'assert_paths_populated_sql',
+    'count_stale_rows_sql',
     'populate_paths_sql',
+    'unreachable_rows_sql',
 )
 
 # Width to which each PK is zero-padded when used as an ltree label. Must match
@@ -117,6 +119,80 @@ UPDATE "{table}" SET path = t.path FROM t WHERE "{table}".id = t.id;
 """ + _RESTORE_SEARCH_PATH
 
 
+def count_stale_rows_sql(table, sort_path=False):
+    """
+    Return SQL counting the rows in `table` whose `path` disagrees with the hierarchy, and
+    (when `sort_path` is set) the rows whose `sort_path` does.
+
+    A reparent leaves `path` wrong, a rename leaves `sort_path` wrong, and while the
+    cascade trigger is missing either can happen without the other, so both are counted
+    separately. Roots are checked against what `populate_paths_sql()` would give them (a
+    path of their own padded id, and a sort_path of their own name) and every other row
+    against its parent: a root has no parent to compare with, but it can still be wrong.
+
+    This answers "does this table need rebuilding", not "how many rows are damaged". Where
+    an object has moved, the objects below it agree with their own parent and are not
+    counted, though they are equally stale. Treat any non-zero result as the whole table
+    needing a rebuild, and do not use it to decide which rows to touch.
+    """
+    root_path = (
+        f'SELECT id FROM "{table}"'
+        f" WHERE parent_id IS NULL"
+        f" AND path <> lpad(id::text, {_PATH_LABEL_WIDTH}, '0')::ltree"
+    )
+    child_path = (
+        f'SELECT c.id FROM "{table}" c JOIN "{table}" p ON c.parent_id = p.id'
+        f" WHERE c.path <> p.path || lpad(c.id::text, {_PATH_LABEL_WIDTH}, '0')::ltree"
+    )
+    if sort_path:
+        root_sort_path = (
+            f'SELECT id FROM "{table}" WHERE parent_id IS NULL AND sort_path <> name'
+        )
+        child_sort_path = (
+            f'SELECT c.id FROM "{table}" c JOIN "{table}" p ON c.parent_id = p.id'
+            f' WHERE c.sort_path <> p.sort_path || chr(9) || c.name'
+        )
+        stale_sort_path = f'SELECT count(*) FROM ({root_sort_path} UNION ALL {child_sort_path}) s'
+    else:
+        stale_sort_path = 'SELECT 0'
+
+    return f"""
+SELECT
+    (SELECT count(*) FROM ({root_path} UNION ALL {child_path}) p) AS stale_paths,
+    ({stale_sort_path}) AS stale_sort_paths;
+"""
+
+
+def unreachable_rows_sql(table, limit):
+    """
+    Return SQL reporting the rows in `table` which no root can reach by following
+    `parent_id`: how many there are, and the first `limit` of their ids.
+
+    `populate_paths_sql()` seeds from `parent_id IS NULL` and walks downward, so it
+    rewrites only the rows reachable that way. Anything else it leaves untouched, which
+    makes an unreachable row an unrepaired one. Three shapes cause it: a cycle, a row
+    whose `parent_id` is its own id, and a `parent_id` referencing a row which does not
+    exist.
+
+    Callers which repair a populated table (rather than backfilling a fresh column, where
+    `assert_paths_populated_sql()` catches the same condition via the NULLs left behind)
+    should run this first and refuse if the count is non-zero: the parent relationships
+    have to be corrected before any path rebuild can produce a correct answer. The ids are
+    returned so that refusal can name rows to start from, rather than leaving the operator
+    to search the table for them.
+    """
+    return f"""
+WITH RECURSIVE reachable(id) AS (
+    SELECT id FROM "{table}" WHERE parent_id IS NULL
+    UNION ALL
+    SELECT c.id FROM "{table}" c JOIN reachable r ON c.parent_id = r.id
+)
+SELECT count(*), (array_agg(t.id ORDER BY t.id))[:{limit}]
+FROM "{table}" t
+WHERE NOT EXISTS (SELECT 1 FROM reachable r WHERE r.id = t.id);
+"""
+
+
 def assert_paths_populated_sql(table):
     """
     Return SQL that raises if any row in `table` still has a NULL `path` after

+ 242 - 1
netbox/utilities/tests/test_ltree.py

@@ -1,11 +1,15 @@
 """Tests for the ltree-based hierarchical model infrastructure."""
+from django.apps import apps
 from django.contrib.contenttypes.models import ContentType
 from django.db import connection
-from django.test import TestCase
+from django.test import SimpleTestCase, TestCase, TransactionTestCase
 
 from core.models import ObjectChange
 from dcim.models import Region, Site
+from netbox.models.ltree import LtreeModel
+from netbox.plugins import PluginConfig
 from tenancy.models import Contact, ContactGroup
+from utilities.ltree import ReinstallLtreeTriggers, ltree_trigger_sql
 from utilities.mptt_to_ltree import populate_paths_sql
 
 
@@ -992,3 +996,240 @@ class RestrictedSearchPathBackfillTests(TestCase):
 
         self.assertEqual(len(plain_rows), 2)
         self.assertEqual([r[0] for r in plain_rows], [_path(1), _path(1, 2)])
+
+
+class RestoreUnderRestrictedSearchPathTests(TestCase):
+    """
+    The generated trigger DDL must create with the ltree extension's schema off the
+    search_path, because that is how pg_dump replays it: dumps begin with
+    `set_config('search_path', '', false)` and schema-qualify every name they can.
+
+    `IS DISTINCT FROM` cannot be schema-qualified — it expands to the operand type's
+    `=` operator, resolved at CREATE TRIGGER time — so comparing two ltree values in a
+    WHEN clause produced a cascade trigger which silently failed to restore, leaving
+    descendant paths to go stale on the next rename or reparent (#23130). Comparing
+    `path::text` resolves `pg_catalog.text =` instead, which is always available.
+
+    The trigger functions are created with the extension's schema on the path: they
+    legitimately declare `parent_path ltree`, and pg_dump schema-qualifies those
+    declarations, so only the CREATE TRIGGER statements are under test here.
+    """
+
+    def _install_with_extension_off_path(self, schema, table, name_column):
+        with connection.cursor() as cursor:
+            cursor.execute(f'CREATE SCHEMA {schema}')
+            columns = 'id bigint PRIMARY KEY, parent_id bigint, path ltree, name text'
+            if name_column:
+                columns += ', sort_path text'
+            cursor.execute(f'SET LOCAL search_path = {schema}, public')
+            cursor.execute(f'CREATE TABLE {schema}.{table} ({columns})')
+
+            statements = ltree_trigger_sql(table, name_column)
+            functions = [s for s in statements if 'CREATE OR REPLACE FUNCTION' in s]
+            triggers = [s for s in statements if 'CREATE TRIGGER' in s]
+            self.assertEqual(len(functions), 2)
+            self.assertEqual(len(triggers), 2)
+
+            # Pass an empty parameter list, as schema_editor.execute() does during a
+            # migration: the function bodies double their literal percent signs for
+            # .format(), and psycopg only collapses `%%` to `%` when parameters are
+            # given. Executing them without it fails to compile the plpgsql.
+            for statement in functions:
+                cursor.execute(statement, ())
+
+            # Drop the extension's schema, as a pg_dump restore does, and create only
+            # the triggers.
+            cursor.execute(f'SET LOCAL search_path = {schema}')
+            for statement in triggers:
+                cursor.execute(statement, ())
+
+            cursor.execute(
+                'SELECT tgname FROM pg_trigger t '
+                'JOIN pg_class c ON t.tgrelid = c.oid '
+                'JOIN pg_namespace n ON c.relnamespace = n.oid '
+                'WHERE n.nspname = %s AND NOT t.tgisinternal ORDER BY tgname',
+                [schema],
+            )
+            return [row[0] for row in cursor.fetchall()]
+
+    def test_path_and_sort_triggers_create_with_extension_off_search_path(self):
+        installed = self._install_with_extension_off_path('sp_sorted', 'sorted', 'name')
+        self.assertEqual(installed, ['sorted_ltree_cascade_path', 'sorted_ltree_compute_path'])
+
+    def test_path_only_triggers_create_with_extension_off_search_path(self):
+        installed = self._install_with_extension_off_path('sp_plain', 'plain', None)
+        self.assertEqual(installed, ['plain_ltree_cascade_path', 'plain_ltree_compute_path'])
+
+
+class CascadeTriggerDefinitionTests(TestCase):
+    """
+    Every core LtreeModel's cascade trigger must compare `path` as text.
+
+    This covers the templates as they are installed, catching a new hierarchical model
+    which ships without triggers at all. It cannot tell whether the corrective migrations
+    reached a given table: a test database is built by migrating forward, so the original
+    migrations install the current, already-corrected definitions. See
+    `CorrectiveMigrationTests` for the seeded states which do exercise that.
+
+    The expected tables are derived from the model layer and plugin models are excluded,
+    so installing a plugin with its own ltree model cannot fail this.
+    """
+
+    @staticmethod
+    def _core_ltree_tables():
+        def concrete_subclasses(base):
+            for subclass in base.__subclasses__():
+                if subclass._meta.abstract:
+                    yield from concrete_subclasses(subclass)
+                elif not isinstance(apps.get_app_config(subclass._meta.app_label), PluginConfig):
+                    yield subclass
+
+        return {model._meta.db_table for model in concrete_subclasses(LtreeModel)}
+
+    def test_core_cascade_triggers_compare_path_as_text(self):
+        expected = self._core_ltree_tables()
+        self.assertTrue(expected, 'no core LtreeModel subclasses found')
+
+        with connection.cursor() as cursor:
+            cursor.execute(
+                'SELECT c.relname, pg_get_triggerdef(t.oid) FROM pg_trigger t '
+                'JOIN pg_class c ON t.tgrelid = c.oid '
+                'WHERE NOT t.tgisinternal AND t.tgname = c.relname || %s',
+                ['_ltree_cascade_path'],
+            )
+            definitions = dict(cursor.fetchall())
+
+        self.assertSetEqual(
+            expected - set(definitions), set(),
+            msg='these core ltree tables have no cascade trigger installed',
+        )
+        # Assert on the cast rather than on PostgreSQL's exact rendering of the clause:
+        # the parenthesization pg_get_triggerdef() emits is an implementation detail.
+        for table in sorted(expected):
+            definition = definitions[table]
+            self.assertIn(
+                '::text IS DISTINCT FROM', definition,
+                msg=f'{table}: the cascade trigger compares ltree values directly, so it '
+                    f'will not survive a pg_dump restore (see #23130)',
+            )
+            self.assertNotRegex(
+                definition, r'old\.path\s+IS DISTINCT FROM\s+new\.path',
+                msg=f'{table}: the cascade trigger compares path without a cast to text',
+            )
+
+
+class LtreeTriggerSqlTests(SimpleTestCase):
+    """The generated cascade DDL must not compare ltree values directly (#23130)."""
+
+    def test_cascade_when_clause_casts_path_to_text(self):
+        for name_column in ('name', None):
+            with self.subTest(name_column=name_column):
+                sql = '\n'.join(ltree_trigger_sql('probe', name_column))
+                self.assertIn('OLD.path::text IS DISTINCT FROM NEW.path::text', sql)
+                self.assertNotIn('OLD.path IS DISTINCT FROM NEW.path', sql)
+
+    def test_triggers_are_dropped_before_creation(self):
+        sql = ltree_trigger_sql('probe', 'name')
+        for trigger in ('probe_ltree_cascade_path', 'probe_ltree_compute_path'):
+            drop = f'DROP TRIGGER IF EXISTS "{trigger}" ON "probe";'
+            self.assertIn(drop, sql)
+            create = next(s for s in sql if f'CREATE TRIGGER "{trigger}"' in s)
+            self.assertLess(sql.index(drop), sql.index(create))
+
+
+class CorrectiveMigrationTests(TransactionTestCase):
+    """
+    `ReinstallLtreeTriggers` must repair both states a v4.7.0 database can be in.
+
+    A test database is built by migrating forward, so `0242_ltree_paths` installs its
+    triggers from the current templates and every table already carries the corrected
+    definition before `0251_fix_ltree_cascade_triggers` runs. Nothing asserted about the
+    end state of that database says whether the corrective migration did anything. Seed
+    each state a real database can be in instead:
+
+    - the definition v4.7.0 shipped, which an upgraded-in-place database still carries
+    - no cascade trigger, which is what a database restored from a v4.7.0 dump has
+
+    then apply the operation those migrations are built from and assert the repair.
+
+    Scope: this covers the operation, not the migrations which call it. The tables each
+    corrective migration names are hand-maintained lists, and a table omitted from one
+    would not fail here. Catching that needs the pre-migration state a forward-migrated
+    test database does not have, i.e. replaying `0250 -> 0251` against a seeded fixture.
+
+    TransactionTestCase, because the seeded DDL has to be committed for the operation's
+    own transaction to see it.
+    """
+
+    TABLE = 'dcim_region'
+    TRIGGER = 'dcim_region_ltree_cascade_path'
+
+    def tearDown(self):
+        # Leave the trigger as the migrations would have it, for whatever runs next.
+        self.apply_corrective_operation()
+
+    def cascade_triggerdef(self):
+        with connection.cursor() as cursor:
+            cursor.execute(
+                'SELECT pg_get_triggerdef(oid) FROM pg_trigger '
+                'WHERE tgname = %s AND NOT tgisinternal',
+                [self.TRIGGER],
+            )
+            row = cursor.fetchone()
+        return row[0] if row else None
+
+    def drop_cascade_trigger(self):
+        """Leave the table as a database restored from a v4.7.0 dump: no cascade trigger."""
+        with connection.cursor() as cursor:
+            cursor.execute(f'DROP TRIGGER IF EXISTS "{self.TRIGGER}" ON "{self.TABLE}"')
+
+    def install_v470_cascade_trigger(self):
+        """
+        Install the definition v4.7.0 shipped: bare ltree comparisons, which a dump cannot
+        restore because the `ltree` operator is unresolvable under an empty search_path.
+        """
+        self.drop_cascade_trigger()
+        with connection.cursor() as cursor:
+            cursor.execute(
+                f'CREATE TRIGGER "{self.TRIGGER}" '
+                f'AFTER UPDATE OF parent_id, "name" ON "{self.TABLE}" '
+                f'FOR EACH ROW WHEN ('
+                f'  OLD.path IS DISTINCT FROM NEW.path'
+                f'  OR OLD.sort_path IS DISTINCT FROM NEW.sort_path'
+                f') EXECUTE FUNCTION "{self.TABLE}_ltree_cascade_path_fn"()'
+            )
+
+    def apply_corrective_operation(self):
+        with connection.schema_editor() as schema_editor:
+            ReinstallLtreeTriggers(self.TABLE, name_column='name').database_forwards(
+                'dcim', schema_editor, None, None,
+            )
+
+    def test_replaces_the_definition_shipped_in_v470(self):
+        self.install_v470_cascade_trigger()
+        self.assertNotIn('::text', self.cascade_triggerdef())
+
+        self.apply_corrective_operation()
+
+        self.assertIn('::text IS DISTINCT FROM', self.cascade_triggerdef())
+
+    def test_reinstalls_a_cascade_trigger_lost_in_a_restore(self):
+        self.drop_cascade_trigger()
+        self.assertIsNone(self.cascade_triggerdef())
+
+        self.apply_corrective_operation()
+
+        self.assertIn('::text IS DISTINCT FROM', self.cascade_triggerdef())
+
+    def test_the_repaired_trigger_cascades_a_rename(self):
+        """The reinstalled trigger has to work, not merely exist."""
+        self.drop_cascade_trigger()
+        self.apply_corrective_operation()
+
+        parent = Region.objects.create(name='Before', slug='before-cm')
+        child = Region.objects.create(name='Child', slug='child-cm', parent=parent)
+        parent.name = 'After'
+        parent.save()
+
+        child.refresh_from_db()
+        self.assertEqual(child.sort_path, f'After{chr(9)}Child')

+ 233 - 0
netbox/utilities/tests/test_management_commands.py

@@ -2,8 +2,12 @@ from io import StringIO
 from unittest.mock import MagicMock, patch
 
 from django.core.management import call_command
+from django.core.management.base import CommandError
+from django.db import connection
 from django.test import TestCase
 
+from dcim.models import Region
+from tenancy.models import TenantGroup
 from utilities.management.commands.calculate_cached_counts import Command
 
 
@@ -49,3 +53,232 @@ class CalculateCachedCountsTestCase(TestCase):
         ChildModel._meta.get_field.assert_called_once_with('parent')
         fk_field.related_query_name.assert_called_once_with()
         self.assertEqual(dict(models), {ParentModel: {'child_count': 'children'}})
+
+
+class RebuildLtreePathsTestCase(TestCase):
+    """
+    The command must repair path/sort_path values the triggers did not maintain.
+
+    Corruption is injected by writing the path columns directly: the triggers fire on
+    parent_id and the name column, so a raw UPDATE of path bypasses them, reproducing a
+    database whose cascade trigger went missing across a restore.
+    """
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.parent = Region.objects.create(name='Alpha', slug='alpha-rlp')
+        cls.child = Region.objects.create(name='Beta', slug='beta-rlp', parent=cls.parent)
+
+    @staticmethod
+    def _set_parent_bypassing_triggers(pk, parent_pk):
+        """
+        Repoint a row's parent_id without firing the ltree triggers.
+
+        The BEFORE trigger recomputes `path` and rejects a move which its own cycle guard
+        can see, so the ORM cannot produce these states directly. Suppressing the triggers
+        for the statement reproduces what #23130 leaves behind: a database whose parent_id
+        graph has drifted from the paths stored alongside it.
+
+        `ALTER TABLE ... DISABLE TRIGGER` needs only ownership of the table, which the
+        role running the tests has, where `session_replication_role` needs SUPERUSER or an
+        explicit grant. It does refuse while the transaction holds pending trigger events,
+        which the rows created in setUpTestData leave behind, so flush those first: the
+        events are the deferred foreign key checks, and firing them early is harmless.
+        `netbox/tests/test_search.py` does the same to reach its own schema states.
+        """
+        with connection.cursor() as cursor:
+            cursor.execute('SET CONSTRAINTS ALL IMMEDIATE')
+            cursor.execute('ALTER TABLE dcim_region DISABLE TRIGGER USER')
+            try:
+                cursor.execute(
+                    'UPDATE dcim_region SET parent_id = %s WHERE id = %s', [parent_pk, pk]
+                )
+            finally:
+                cursor.execute('ALTER TABLE dcim_region ENABLE TRIGGER USER')
+
+    def test_rebuilds_stale_path_and_sort_path(self):
+        Region.objects.filter(pk=self.child.pk).update(
+            path='9999999999999999999', sort_path='stale',
+        )
+
+        call_command('rebuild_ltree_paths', 'dcim.region', stdout=StringIO())
+
+        self.child.refresh_from_db()
+        self.assertEqual(
+            self.child.path,
+            f'{str(self.parent.pk).zfill(19)}.{str(self.child.pk).zfill(19)}',
+        )
+        self.assertEqual(self.child.sort_path, f'Alpha{chr(9)}Beta')
+
+    def test_rebuilds_a_stale_sort_path_alone(self):
+        # What a rename leaves behind: the renamed row's own sort_path is rewritten by the
+        # BEFORE trigger, its descendants' are not, and no path changes.
+        Region.objects.filter(pk=self.child.pk).update(sort_path='stale')
+
+        call_command('rebuild_ltree_paths', 'dcim.region', stdout=StringIO())
+
+        self.child.refresh_from_db()
+        self.assertEqual(self.child.sort_path, f'Alpha{chr(9)}Beta')
+
+    def test_rebuilds_every_core_hierarchical_model_by_default(self):
+        out = StringIO()
+
+        call_command('rebuild_ltree_paths', stdout=out)
+
+        output = out.getvalue()
+        for label in ('dcim.region', 'dcim.inventoryitem', 'dcim.inventoryitemtemplate',
+                      'tenancy.tenantgroup', 'wireless.wirelesslangroup'):
+            self.assertIn(label, output)
+        self.assertIn('Finished.', output)
+
+    def test_check_reports_a_model_needing_a_rebuild(self):
+        Region.objects.filter(pk=self.child.pk).update(sort_path='stale')
+        out = StringIO()
+
+        call_command('rebuild_ltree_paths', 'dcim.region', '--check', stdout=out)
+
+        output = out.getvalue()
+        self.assertIn('sort_path', output)
+        self.assertIn('Needs rebuilding: dcim.region', output)
+
+    def test_check_reports_a_healthy_model_as_ok(self):
+        out = StringIO()
+
+        call_command('rebuild_ltree_paths', 'dcim.region', '--check', stdout=out)
+
+        self.assertIn('dcim.region: OK', out.getvalue())
+        self.assertIn('Nothing to rebuild.', out.getvalue())
+
+    def test_check_modifies_nothing(self):
+        Region.objects.filter(pk=self.child.pk).update(sort_path='stale')
+
+        call_command('rebuild_ltree_paths', 'dcim.region', '--check', stdout=StringIO())
+
+        self.child.refresh_from_db()
+        self.assertEqual(self.child.sort_path, 'stale')
+
+    def test_check_reports_a_stale_path_where_sort_path_is_correct(self):
+        # A reparent leaves path wrong on its own, so the two counts are separate.
+        Region.objects.filter(pk=self.child.pk).update(path='9999999999999999999')
+        out = StringIO()
+
+        call_command('rebuild_ltree_paths', 'dcim.region', '--check', stdout=out)
+
+        self.assertIn('1 path', out.getvalue())
+
+    def test_check_reports_a_stale_root(self):
+        """
+        A root has no parent to be compared against, so a check which only joins children
+        to parents never examines it and reports a corrupt root as clean.
+        """
+        root = Region.objects.create(name='Solo', slug='solo-rlp')
+        Region.objects.filter(pk=root.pk).update(
+            path='9999999999999999999', sort_path='WRONG',
+        )
+        out = StringIO()
+
+        call_command('rebuild_ltree_paths', 'dcim.region', '--check', stdout=out)
+
+        output = out.getvalue()
+        self.assertIn('1 path, 1 sort_path', output)
+        self.assertNotIn('dcim.region: OK', output)
+
+    def test_rebuilds_a_stale_root(self):
+        root = Region.objects.create(name='Solo', slug='solo-rlp')
+        Region.objects.filter(pk=root.pk).update(
+            path='9999999999999999999', sort_path='WRONG',
+        )
+
+        call_command('rebuild_ltree_paths', 'dcim.region', stdout=StringIO())
+
+        root.refresh_from_db()
+        self.assertEqual(root.path, str(root.pk).zfill(19))
+        self.assertEqual(root.sort_path, 'Solo')
+
+    def test_check_reports_a_stale_root_whose_child_agrees_with_it(self):
+        """
+        The child of a corrupt root can be consistent with that root, so a parent-only
+        comparison sees nothing wrong anywhere in the subtree.
+        """
+        root = Region.objects.create(name='Solo', slug='solo-rlp')
+        child = Region.objects.create(name='Sub', slug='sub-rlp', parent=root)
+        Region.objects.filter(pk=root.pk).update(path='9999999999999999999')
+        Region.objects.filter(pk=child.pk).update(
+            path=f'9999999999999999999.{str(child.pk).zfill(19)}',
+        )
+        out = StringIO()
+
+        call_command('rebuild_ltree_paths', 'dcim.region', '--check', stdout=out)
+
+        self.assertIn('1 path', out.getvalue())
+
+    def test_rejects_a_model_which_is_not_hierarchical(self):
+        with self.assertRaises(CommandError):
+            call_command('rebuild_ltree_paths', 'dcim.site')
+
+    def test_refuses_a_table_containing_a_cycle(self):
+        """
+        A rebuild walks down from the roots, so rows in a cycle are never reached and keep
+        whatever paths they have. Refuse rather than report success, and name the rows to
+        start from: "correct the parent relationships" is not actionable without them.
+        """
+        self._set_parent_bypassing_triggers(self.parent.pk, self.child.pk)
+
+        with self.assertRaises(CommandError) as ctx:
+            call_command('rebuild_ltree_paths', 'dcim.region')
+
+        message = str(ctx.exception)
+        self.assertIn(str(self.parent.pk), message)
+        self.assertIn(str(self.child.pk), message)
+
+    def test_refuses_a_table_containing_a_self_parented_row(self):
+        self._set_parent_bypassing_triggers(self.child.pk, self.child.pk)
+
+        with self.assertRaises(CommandError):
+            call_command('rebuild_ltree_paths', 'dcim.region')
+
+    def test_refuses_a_table_whose_parent_id_references_a_missing_row(self):
+        """
+        Not a cycle, but equally unreachable, so a cycle-specific check would miss it.
+
+        Disabling the triggers leaves the foreign key enforced, so drop it for this row as
+        well. Such a row does occur in practice: `pg_restore --disable-triggers` and
+        logical replication both load rows without enforcing it.
+        """
+        with connection.cursor() as cursor:
+            cursor.execute('SET CONSTRAINTS ALL IMMEDIATE')
+            cursor.execute(
+                "SELECT conname FROM pg_constraint "
+                "WHERE conrelid = 'dcim_region'::regclass AND contype = 'f' "
+                "AND conkey = ARRAY[(SELECT attnum FROM pg_attribute "
+                "WHERE attrelid = 'dcim_region'::regclass AND attname = 'parent_id')]"
+            )
+            constraint = cursor.fetchone()[0]
+            cursor.execute(f'ALTER TABLE dcim_region DROP CONSTRAINT "{constraint}"')
+        self._set_parent_bypassing_triggers(self.child.pk, self.parent.pk + 10000)
+
+        with self.assertRaises(CommandError):
+            call_command('rebuild_ltree_paths', 'dcim.region')
+
+    def test_refusing_a_table_leaves_that_table_untouched(self):
+        """
+        A refusal rolls back the transaction it was raised in, so the refused table keeps
+        the paths it had. Tables already rebuilt stay rebuilt: each is its own transaction,
+        which is what keeps one table's row locks from being held while the rest run.
+        """
+        group = TenantGroup.objects.create(name='Unrelated', slug='unrelated-rlp')
+        TenantGroup.objects.filter(pk=group.pk).update(sort_path='stale')
+        Region.objects.filter(pk=self.child.pk).update(sort_path='also-stale')
+        # dcim.region is named second and is the table which fails the check.
+        self._set_parent_bypassing_triggers(self.parent.pk, self.child.pk)
+
+        with self.assertRaises(CommandError):
+            call_command('rebuild_ltree_paths', 'tenancy.tenantgroup', 'dcim.region')
+
+        # The refused table is untouched: no partial rebuild, nothing to undo by hand.
+        self.child.refresh_from_db()
+        self.assertEqual(self.child.sort_path, 'also-stale')
+
+        # The table which passed its own check was rebuilt and committed.
+        group.refresh_from_db()
+        self.assertEqual(group.sort_path, 'Unrelated')

+ 35 - 0
netbox/wireless/migrations/0024_fix_ltree_cascade_triggers.py

@@ -0,0 +1,35 @@
+"""Reinstall the ltree cascade trigger with a restore-safe WHEN clause.
+
+The cascade trigger installed by 0021_ltree_paths compared two ltree values with
+`IS DISTINCT FROM`, which resolves the `ltree = ltree` operator through search_path at
+CREATE TRIGGER time. pg_dump emits `set_config('search_path', '', false)`, so restoring a
+v4.7.0 dump could not create this trigger — and because psql does not stop on error by
+default, the restore reported success with the trigger silently missing. See #23130.
+
+Reinstalling covers both affected databases: one restored from such a dump (the trigger is
+absent) and one upgraded in place (the trigger exists with the old definition, which would
+fail its own next restore). InstallLtreeTriggers drops before creating, so this applies
+cleanly in either state.
+
+This does not repair path/sort_path values which went stale while the trigger was missing;
+see the v4.7.1 release notes for detection and repair.
+
+Reversing this migration is a no-op: the trigger it replaces belongs to 0021_ltree_paths,
+which recreates it (from the corrected template) when reversed in turn.
+"""
+from django.db import migrations
+
+from utilities.ltree import ReinstallLtreeTriggers
+
+TABLE = 'wireless_wirelesslangroup'
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('wireless', '0023_wirelesslangroup_drop_unique_constraint'),
+    ]
+
+    operations = [
+        ReinstallLtreeTriggers(TABLE, name_column='name'),
+    ]