Refuse to rebuild ltree paths a root cannot reach

populate_paths_sql() seeds its recursive CTE from parent_id IS NULL and walks
downward, so it rewrites only the rows some root reaches. Anything else it
leaves alone, and rebuild_ltree_paths reported success regardless: the same
shape of silent failure the command exists to repair.

Three states put a row out of reach, and only one of them is a cycle: a cycle,
a row parented to itself, and a parent_id referencing a row which no longer
exists. All three are reachable in a database affected by #23130, because the
triggers' own cycle guards compare against the stored path. Once the cascade
trigger is missing those paths drift from the parent_id graph, and a move the
guard would otherwise reject is accepted, closing a cycle.

The check counts unreachable rows before the rebuild and raises CommandError
naming the table, leaving the parent relationships for the operator to correct:
only they can say what the hierarchy was meant to be.

Each table is checked and rebuilt in one transaction. Sharing the transaction
closes the window in which a concurrent write could strand a row between the
check and the rebuild. Keeping it per-table rather than wrapping the whole run
means a refusal rolls back only that table, and one table's row locks are not
held while the remaining tables rebuild.
This commit is contained in:
Jason Novinger 2026-09-08 11:49:22 -05:00
parent eb57625a91
commit 31b3053003
5 changed files with 137 additions and 6 deletions

View File

@ -54,6 +54,8 @@ Pass one or more models as `app_label.ModelName` to limit the rebuild.
python3 netbox/manage.py rebuild_ltree_paths [app_label.ModelName ...]
```
The rebuild derives each object's path by walking down from the roots of the hierarchy, so it can only repair a row which some root reaches by following `parent_id`. Where a table contains a row that no root reaches — one belonging to a cycle, one parented to itself, or one whose parent no longer exists — the command reports the count and stops without modifying that table, because a rebuild would silently skip exactly those rows. Correct the parent relationships and run it again.
!!! warning
A rebuild rewrites every row of each named table 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. The detection queries in the [v4.7.1 release notes](../release-notes/version-4.7.md) take no locks, and can be used first to find which tables need it.

View File

@ -27,7 +27,7 @@
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.
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, applying `InstallLtreeTriggers` again from a new plugin migration reinstalls them: as of this release the operation drops each trigger before recreating it, so it is safe to re-run.

View File

@ -4,7 +4,7 @@ from django.db import connection, transaction
from netbox.models.ltree import LtreeModel
from netbox.plugins import PluginConfig
from utilities.mptt_to_ltree import populate_paths_sql
from utilities.mptt_to_ltree import count_unreachable_rows_sql, populate_paths_sql
class Command(BaseCommand):
@ -21,7 +21,8 @@ class Command(BaseCommand):
def get_models(self, names):
"""
Return the concrete core hierarchical models to operate on, ordered by table name.
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
@ -50,14 +51,45 @@ class Command(BaseCommand):
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 to keep it visible that this must run in the same
transaction as the rebuild it guards. Checking in a separate transaction would
leave a window in which a concurrent write could strand a row between the two.
"""
cursor.execute(count_unreachable_rows_sql(model._meta.db_table))
unreachable = cursor.fetchone()[0]
if unreachable:
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. A cycle, a row '
f'parented to itself, or a parent_id referencing a missing row will do this. '
f'Correct the parent relationships, then re-run.'
)
def handle(self, *args, **options):
# 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 self.get_models(options['model']):
# 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.
self.stdout.write(f'{model._meta.label_lower}: rebuilding... ', ending='')
self.stdout.flush()
with transaction.atomic(), connection.cursor() as cursor:
self.check_reachable(cursor, model)
# 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())
)

View File

@ -35,6 +35,7 @@ ancestor `name` values. Keep the two modules in sync if either changes.
__all__ = (
'assert_paths_populated_sql',
'count_unreachable_rows_sql',
'populate_paths_sql',
)
@ -117,6 +118,33 @@ UPDATE "{table}" SET path = t.path FROM t WHERE "{table}".id = t.id;
""" + _RESTORE_SEARCH_PATH
def count_unreachable_rows_sql(table):
"""
Return SQL counting the rows in `table` which no root can reach by following
`parent_id`.
`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 it returns non-zero: the parent relationships have
to be corrected before any path rebuild can produce a correct answer.
"""
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(*) 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

View File

@ -3,9 +3,11 @@ 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
@ -105,3 +107,70 @@ class RebuildLtreePathsTestCase(TestCase):
def test_rejects_a_model_which_is_not_hierarchical(self):
with self.assertRaises(CommandError):
call_command('rebuild_ltree_paths', 'dcim.site')
@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.
`session_replication_role` is used rather than `ALTER TABLE ... DISABLE TRIGGER`,
which cannot run while the enclosing transaction has pending trigger events from
the rows created in setUpTestData.
"""
with connection.cursor() as cursor:
cursor.execute("SET LOCAL session_replication_role = 'replica'")
cursor.execute(
'UPDATE dcim_region SET parent_id = %s WHERE id = %s', [parent_pk, pk]
)
cursor.execute("SET LOCAL session_replication_role = 'origin'")
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.
"""
self._set_parent_bypassing_triggers(self.parent.pk, self.child.pk)
with self.assertRaises(CommandError):
call_command('rebuild_ltree_paths', 'dcim.region')
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.
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')