Coverage for extras/cache.py: 72%
55 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
1"""
2Invalidation helpers for the pre-rendered config-context cache on Device and VirtualMachine.
4Every signal handler in extras/signals.py that needs to invalidate cached config-context data
5funnels through this module, so the synchronous NULL-out and background job enqueue are
6expressed in exactly one place.
7"""
8from django.apps import apps
9from django.db import router, transaction
10from django.db.models import F, Q
12from dcim.models import Device
13from extras.jobs import RenderConfigContextJob
14from extras.models.tags import TaggedItem
15from utilities.querysets import chunked_update
16from virtualization.models import VirtualMachine
19def invalidate_config_context_for_objects(model_label, pks, using=None):
20 """
21 Synchronously NULL the `_config_context_data` cache on the given objects (bumping the
22 generation counter so an in-flight render can't overwrite the invalidation), then enqueue a
23 background job to repopulate them once the surrounding transaction commits.
25 Args:
26 model_label: 'dcim.device' or 'virtualization.virtualmachine'.
27 pks: Any iterable of object PKs (queryset, list, set, generator). An empty iterable is a no-op.
28 using: The database alias to pin the invalidation to. Callers pass the alias supplied by the
29 signal which triggered the invalidation, so that the UPDATE lands in the same database
30 (and the same transaction) as the change which necessitated it. None defers to the
31 router, as an unpinned query would.
32 """
33 pks = list(pks)
34 if not pks:
35 return
37 Model = apps.get_model(model_label)
38 # Resolve the alias once, so that the UPDATE below and the on_commit() callback which follows
39 # it are bound to the same database. Left as None the two would diverge: an unpinned queryset
40 # consults the router, but transaction.on_commit() does not -- it attaches to 'default' -- so
41 # on a deployment whose router writes elsewhere the callback would be registered against a
42 # connection other than the one being written.
43 #
44 # This also decides which connection's commit the enqueue waits on, so it is not strictly an
45 # improvement on the previous 'default' binding for every caller: one which passes no alias
46 # while holding a transaction opened on 'default' (rather than on the router's write alias)
47 # leaves no atomic block open on the resolved alias, and on_commit() then runs the callback
48 # immediately rather than deferring it. Every caller in NetBox supplies `using`, and the
49 # generic views open their atomic block on router.db_for_write(model), so the two agree there.
50 using = using or router.db_for_write(Model)
51 updated = chunked_update(
52 Model.objects.using(using).filter(pk__in=pks),
53 _config_context_data=None,
54 _config_context_generation=F('_config_context_generation') + 1,
55 )
56 if not updated: 56 ↛ 57line 56 didn't jump to line 57 because the condition on line 56 was never true
57 return
59 # Defer enqueue until after the current transaction commits, so the background worker doesn't
60 # try to read uncommitted state. transaction.on_commit() is a no-op outside a transaction,
61 # in which case the callback runs immediately.
62 #
63 # We deliberately enqueue a *parameterless* sweep (model_label=None, pks=None) that re-renders
64 # every object whose cache is currently NULL, rather than a job scoped to these specific PKs.
65 # JobRunner.enqueue_once() coalesces against any already-pending job of the same class (object_id
66 # is NULL for all of these, so get_jobs(None) matches them all); a job carrying a specific PKs
67 # list would therefore be silently dropped whenever another invalidation already had one enqueued,
68 # leaving those objects NULLed but never re-rendered. A global NULL-sweep makes coalescing correct:
69 # whichever sweep runs next picks up *all* outstanding NULL caches across both models. (Reads
70 # remain correct in the interim because get_config_context() renders on demand when the cache is
71 # NULL; the sweep only restores the pre-rendered fast path.)
72 transaction.on_commit(
73 lambda: RenderConfigContextJob.enqueue_once(instance=None),
74 using=using,
75 )
78def invalidate_config_context_for_configcontext(configcontext, using=None):
79 """
80 Invalidate caches for all objects currently in scope for the given ConfigContext. `using` is
81 the database alias to pin every query to (see invalidate_config_context_for_objects()).
82 """
83 for queryset in configcontext.get_affected_objects(using=using):
84 invalidate_config_context_for_objects(
85 queryset.model._meta.label_lower,
86 queryset.values_list('pk', flat=True),
87 using=using,
88 )
91def invalidate_for_scope_delta(scope_field, scope_pks, using=None):
92 """
93 Invalidate the cache of every Device/VirtualMachine that is matchable via the given scope
94 items, regardless of which ConfigContext those items belong to. Used when items are removed
95 from a ConfigContext scope (so we don't know the new affected set under that scope, only the
96 items that used to extend it).
98 `scope_field` is the ConfigContext M2M attribute name ('sites', 'regions', 'tags', ...).
99 `scope_pks` is the iterable of PKs of scope items that were removed/cleared.
100 `using` is the database alias to pin every query to (see invalidate_config_context_for_objects()).
101 """
102 scope_pks = list(scope_pks or ())
103 if not scope_pks: 103 ↛ 104line 103 didn't jump to line 104 because the condition on line 103 was never true
104 return
106 device_q = None
107 vm_q = None
109 # Nested (ltree) scopes: any device/VM whose corresponding attribute resolves into the subtree
110 # of any of the changed items (descendant-or-equal).
111 nested_attrs = {
112 'regions': ('dcim', 'Region', 'site__region__path'),
113 'site_groups': ('dcim', 'SiteGroup', 'site__group__path'),
114 'roles': ('dcim', 'DeviceRole', 'role__path'),
115 'platforms': ('dcim', 'Platform', 'platform__path'),
116 'locations': ('dcim', 'Location', 'location__path'), # Devices only
117 }
118 direct_attrs = {
119 'sites': 'site__in',
120 'cluster_types': 'cluster__type__in',
121 'cluster_groups': 'cluster__group__in',
122 'clusters': 'cluster__in',
123 'tenant_groups': 'tenant__group__in',
124 'tenants': 'tenant__in',
125 'device_types': 'device_type__in', # Devices only
126 }
128 if scope_field in nested_attrs:
129 app, model_name, object_path = nested_attrs[scope_field]
130 Model = apps.get_model(app, model_name)
131 subtree_q = Q()
132 for path in Model.objects.using(using).filter(pk__in=scope_pks).values_list('path', flat=True):
133 subtree_q |= Q(**{f'{object_path}__descendant_or_equal': path})
134 if not subtree_q: 134 ↛ 135line 134 didn't jump to line 135 because the condition on line 134 was never true
135 return
136 device_q = subtree_q
137 if scope_field != 'locations': 137 ↛ 160line 137 didn't jump to line 160 because the condition on line 137 was always true
138 vm_q = subtree_q
139 elif scope_field in direct_attrs: 139 ↛ 144line 139 didn't jump to line 144 because the condition on line 139 was always true
140 attr_path = direct_attrs[scope_field]
141 device_q = Q(**{attr_path: scope_pks})
142 if scope_field != 'device_types': 142 ↛ 160line 142 didn't jump to line 160 because the condition on line 142 was always true
143 vm_q = Q(**{attr_path: scope_pks})
144 elif scope_field == 'tags':
145 device_tagged = TaggedItem.objects.using(using).filter(
146 tag_id__in=scope_pks,
147 content_type__app_label='dcim',
148 content_type__model='device',
149 ).values_list('object_id', flat=True)
150 vm_tagged = TaggedItem.objects.using(using).filter(
151 tag_id__in=scope_pks,
152 content_type__app_label='virtualization',
153 content_type__model='virtualmachine',
154 ).values_list('object_id', flat=True)
155 device_q = Q(pk__in=device_tagged)
156 vm_q = Q(pk__in=vm_tagged)
157 else:
158 return
160 if device_q is not None: 160 ↛ 166line 160 didn't jump to line 166 because the condition on line 160 was always true
161 invalidate_config_context_for_objects(
162 'dcim.device',
163 Device.objects.using(using).filter(device_q).values_list('pk', flat=True),
164 using=using,
165 )
166 if vm_q is not None: 166 ↛ exitline 166 didn't return from function 'invalidate_for_scope_delta' because the condition on line 166 was always true
167 invalidate_config_context_for_objects(
168 'virtualization.virtualmachine',
169 VirtualMachine.objects.using(using).filter(vm_q).values_list('pk', flat=True),
170 using=using,
171 )