Coverage for utilities/management/commands/rebuild_ltree_paths.py: 0%
68 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
1from django.apps import apps
2from django.core.management.base import BaseCommand, CommandError
3from django.db import connection, transaction
5from netbox.models.ltree import LtreeModel
6from netbox.plugins import PluginConfig
7from utilities.mptt_to_ltree import (
8 count_stale_rows_sql,
9 populate_paths_sql,
10 unreachable_rows_sql,
11)
14class Command(BaseCommand):
15 help = (
16 "Recompute the trigger-maintained path (and sort_path) columns of hierarchical models "
17 "from their parent relationships"
18 )
20 # How many offending ids a refusal names. Enough to start from, short enough to read.
21 REPORTED_IDS = 10
23 def add_arguments(self, parser):
24 parser.add_argument(
25 'model', nargs='*',
26 help="Limit the rebuild to these models, as app_label.ModelName (default: all)",
27 )
28 parser.add_argument(
29 '--check', action='store_true',
30 help="Report which models need rebuilding, without modifying anything",
31 )
33 def get_models(self, names):
34 """
35 Return the concrete core hierarchical models to operate on: those named, in the
36 order given, or every one of them ordered by table name.
38 Plugin models are excluded, including when named explicitly: the SQL which rebuilds
39 `sort_path` reads the name column by name, while `InstallLtreeTriggers` lets a plugin
40 maintain it from any column, so rebuilding one is not something this command can do
41 correctly. A plugin in that position needs its own repair path.
42 """
43 def concrete_subclasses(base):
44 for subclass in base.__subclasses__():
45 if subclass._meta.abstract:
46 yield from concrete_subclasses(subclass)
47 elif not isinstance(apps.get_app_config(subclass._meta.app_label), PluginConfig):
48 yield subclass
50 candidates = {
51 model._meta.label_lower: model for model in concrete_subclasses(LtreeModel)
52 }
54 if not names:
55 return sorted(candidates.values(), key=lambda model: model._meta.db_table)
57 models = []
58 for name in names:
59 model = candidates.get(name.lower())
60 if model is None:
61 raise CommandError(f"{name} is not a core hierarchical (ltree-backed) model")
62 models.append(model)
63 return models
65 def check_reachable(self, cursor, model):
66 """
67 Raise unless every row is reachable from a root by following `parent_id`.
69 The rebuild walks down from `parent_id IS NULL`, so a row no root can reach is one
70 it silently leaves alone. Reporting success in that case would be the same failure
71 this command exists to repair: an operation which appears to have worked while the
72 data is still wrong. Refuse instead, and leave correcting the parent relationships
73 to the operator, since only they can say what the intended hierarchy was.
75 Takes the caller's cursor so a refusal rolls back with the transaction the rebuild
76 would have run in. That does not make the pair atomic with respect to other
77 writers: under READ COMMITTED every statement takes a fresh snapshot, so a
78 reparent committed between the check and the rebuild is still missed. Pause writes
79 for the duration, as the documentation says to.
80 """
81 cursor.execute(unreachable_rows_sql(model._meta.db_table, self.REPORTED_IDS))
82 unreachable, ids = cursor.fetchone()
84 if unreachable:
85 listed = ', '.join(str(pk) for pk in ids)
86 if unreachable > len(ids):
87 listed += ', ...'
88 raise CommandError(
89 f'{model._meta.label_lower}: {unreachable} row(s) cannot be reached from a '
90 f'root by following parent_id, so a rebuild would skip them: {listed}. '
91 f'Correct the parent relationships, then re-run.'
92 )
94 def report_stale(self, model):
95 """
96 Report whether a model's stored paths disagree with its parent relationships.
98 Read-only, and takes no locks, so it can be run outside a maintenance window or
99 against a replica. It answers which models need rebuilding, not how many rows are
100 damaged: see `count_stale_rows_sql()` for why the counts understate a deep tree.
101 """
102 with connection.cursor() as cursor:
103 cursor.execute(
104 count_stale_rows_sql(model._meta.db_table, sort_path=model._has_sort_path())
105 )
106 stale_paths, stale_sort_paths = cursor.fetchone()
108 if not (stale_paths or stale_sort_paths):
109 self.stdout.write(f'{model._meta.label_lower}: OK')
110 return False
112 damage = []
113 if stale_paths:
114 damage.append(f'{stale_paths} path')
115 if stale_sort_paths:
116 damage.append(f'{stale_sort_paths} sort_path')
117 self.stdout.write(self.style.WARNING(
118 f"{model._meta.label_lower}: {', '.join(damage)} row(s) out of date"
119 ))
120 return True
122 def handle(self, *args, **options):
123 models = self.get_models(options['model'])
125 if options['check']:
126 stale = [model for model in models if self.report_stale(model)]
127 if stale:
128 names = ' '.join(model._meta.label_lower for model in stale)
129 self.stdout.write(f'\nNeeds rebuilding: {names}')
130 else:
131 self.stdout.write(self.style.SUCCESS('Nothing to rebuild.'))
132 return
134 # Each table is checked and rebuilt in its own transaction. Tables already done
135 # stay done if a later one fails or is refused: rebuilding one table cannot leave
136 # another inconsistent, and holding every table's row locks until the last one
137 # finished would turn several short blocking windows into one long one.
138 for model in models:
139 with transaction.atomic(), connection.cursor() as cursor:
140 # Announce the rebuild only once the check has passed, so a refusal does
141 # not print "rebuilding..." for a table left untouched.
142 self.check_reachable(cursor, model)
143 self.stdout.write(f'{model._meta.label_lower}: rebuilding... ', ending='')
144 self.stdout.flush()
145 # populate_paths_sql() is the same SQL which backfilled these columns
146 # during the ltree migrations. It relies on SET LOCAL, so it must run
147 # inside a transaction, and the UPDATE it emits locks every row in the
148 # table until it commits.
149 cursor.execute(
150 populate_paths_sql(model._meta.db_table, sort_path=model._has_sort_path())
151 )
152 self.stdout.write(self.style.SUCCESS('done'))
154 self.stdout.write(self.style.SUCCESS('Finished.'))