Coverage for extras/cache.py: 72%

55 statements  

« 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. 

3 

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 

11 

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 

17 

18 

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. 

24 

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 

36 

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 

58 

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 ) 

76 

77 

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 ) 

89 

90 

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). 

97 

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 

105 

106 device_q = None 

107 vm_q = None 

108 

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 } 

127 

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 

159 

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 )