Jeremy Stretch 2 days ago
parent
commit
f12e28d641

+ 8 - 0
docs/features/context-data.md

@@ -90,3 +90,11 @@ Devices and virtual machines may also have a local context data defined. This lo
 A [config context profile](../models/extras/configcontextprofile.md) provides an organizational grouping for related config contexts and may optionally enforce a [JSON schema](https://json-schema.org/) describing the shape of their data. When a profile is assigned to a config context, NetBox validates the context's data against the profile's schema on save and rejects any context that fails validation. This makes it possible to constrain which keys may appear in a context, require certain keys to be present, or limit values to a defined enumeration — guarding against typos and drift as contexts proliferate.
 
 A profile's schema may be authored directly in NetBox or populated from an external [data source](../models/core/datasource.md), enabling teams to maintain schemas alongside the code or configurations that consume them.
+
+## Pre-rendered Caching
+
+NetBox pre-renders each device's and virtual machine's merged context data and stores it on the object itself, so most reads can return the result without recomputing the full set of applicable contexts. The cache is maintained automatically by a [background job](./background-jobs.md) that runs whenever an upstream change is detected: a config context being created, modified, or deleted; a device/VM's scope-relevant attribute changing (site, role, tenant, tags, cluster, etc.); or a related object (Site, Cluster, Tenant, MPTT parent reassignments, etc.) being re-routed in a way that changes which contexts apply.
+
+When such a change occurs, NetBox immediately marks the affected caches as invalid and enqueues a non-blocking job to repopulate them. During the brief window between invalidation and re-render, requests for the affected object's config context fall back to the original on-demand rendering path, so the data returned is always correct — never stale — but may be slightly slower for that window. Once the background job completes, reads are served from the cache.
+
+No configuration is required to use this caching layer; it is always active.

+ 16 - 0
netbox/dcim/migrations/0236_device__config_context_data.py

@@ -0,0 +1,16 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0235_cabletermination_circuit_site_cache'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='device',
+            name='_config_context_data',
+            field=models.JSONField(blank=True, editable=False, null=True),
+        ),
+    ]

+ 8 - 0
netbox/dcim/models/devices.py

@@ -732,6 +732,14 @@ class Device(
         to_field='device'
     )
 
+    # Pre-rendered config context cache. NULL means "invalidated; render on demand". Populated by
+    # extras.jobs.RenderConfigContextJob in the background.
+    _config_context_data = models.JSONField(
+        blank=True,
+        null=True,
+        editable=False,
+    )
+
     objects = ConfigContextModelQuerySet.as_manager()
 
     clone_fields = (

+ 130 - 0
netbox/extras/cache.py

@@ -0,0 +1,130 @@
+"""
+Invalidation helpers for the pre-rendered config-context cache on Device and VirtualMachine.
+
+Every signal handler in extras/signals.py that needs to invalidate cached config-context data
+funnels through this module, so the synchronous NULL-out and background job enqueue are
+expressed in exactly one place.
+"""
+from django.apps import apps
+from django.db import transaction
+from django.db.models import Q
+
+from dcim.models import Device
+from extras.jobs import RenderConfigContextJob
+from extras.models.tags import TaggedItem
+from virtualization.models import VirtualMachine
+
+
+def invalidate_config_context_for_objects(model_label, pks):
+    """
+    Synchronously NULL the `_config_context_data` cache on the given objects, then enqueue a
+    background job to repopulate them once the surrounding transaction commits.
+
+    Args:
+        model_label: 'dcim.device' or 'virtualization.virtualmachine'.
+        pks: Any iterable of object PKs (queryset, list, set, generator). An empty iterable is a no-op.
+    """
+    pks = list(pks)
+    if not pks:
+        return
+
+    Model = apps.get_model(model_label)
+    updated = Model.objects.filter(pk__in=pks).update(_config_context_data=None)
+    if not updated:
+        return
+
+    # Defer enqueue until after the current transaction commits, so the background worker doesn't
+    # try to read uncommitted state. transaction.on_commit() is a no-op outside a transaction,
+    # in which case the callback runs immediately.
+    transaction.on_commit(
+        lambda: RenderConfigContextJob.enqueue_once(instance=None, model_label=model_label, pks=pks)
+    )
+
+
+def invalidate_config_context_for_configcontext(configcontext):
+    """
+    Invalidate caches for all objects currently in scope for the given ConfigContext.
+    """
+    for queryset in configcontext.get_affected_objects():
+        invalidate_config_context_for_objects(
+            queryset.model._meta.label_lower,
+            queryset.values_list('pk', flat=True),
+        )
+
+
+def invalidate_for_scope_delta(scope_field, scope_pks):
+    """
+    Invalidate the cache of every Device/VirtualMachine that is matchable via the given scope
+    items, regardless of which ConfigContext those items belong to. Used when items are removed
+    from a ConfigContext scope (so we don't know the new affected set under that scope, only the
+    items that used to extend it).
+
+    `scope_field` is the ConfigContext M2M attribute name ('sites', 'regions', 'tags', ...).
+    `scope_pks` is the iterable of PKs of scope items that were removed/cleared.
+    """
+    scope_pks = list(scope_pks or ())
+    if not scope_pks:
+        return
+
+    device_q = None
+    vm_q = None
+
+    # MPTT-based scopes: any device/VM whose corresponding attribute is a descendant
+    # of any of the changed items.
+    mptt_attrs = {
+        'regions': ('dcim', 'Region', 'site__region__in'),
+        'site_groups': ('dcim', 'SiteGroup', 'site__group__in'),
+        'roles': ('dcim', 'DeviceRole', 'role__in'),
+        'platforms': ('dcim', 'Platform', 'platform__in'),
+        'locations': ('dcim', 'Location', 'location__in'),  # Devices only
+    }
+    direct_attrs = {
+        'sites': 'site__in',
+        'cluster_types': 'cluster__type__in',
+        'cluster_groups': 'cluster__group__in',
+        'clusters': 'cluster__in',
+        'tenant_groups': 'tenant__group__in',
+        'tenants': 'tenant__in',
+        'device_types': 'device_type__in',  # Devices only
+    }
+
+    if scope_field in mptt_attrs:
+        app, model_name, attr_path = mptt_attrs[scope_field]
+        Model = apps.get_model(app, model_name)
+        descendant_pks = list(
+            Model.objects.filter(pk__in=scope_pks)
+            .get_descendants(include_self=True)
+            .values_list('pk', flat=True)
+        )
+        device_q = Q(**{attr_path: descendant_pks})
+        if scope_field != 'locations':
+            vm_q = Q(**{attr_path: descendant_pks})
+    elif scope_field in direct_attrs:
+        attr_path = direct_attrs[scope_field]
+        device_q = Q(**{attr_path: scope_pks})
+        if scope_field != 'device_types':
+            vm_q = Q(**{attr_path: scope_pks})
+    elif scope_field == 'tags':
+        device_tagged = TaggedItem.objects.filter(
+            tag_id__in=scope_pks,
+            content_type__app_label='dcim',
+            content_type__model='device',
+        ).values_list('object_id', flat=True)
+        vm_tagged = TaggedItem.objects.filter(
+            tag_id__in=scope_pks,
+            content_type__app_label='virtualization',
+            content_type__model='virtualmachine',
+        ).values_list('object_id', flat=True)
+        device_q = Q(pk__in=device_tagged)
+        vm_q = Q(pk__in=vm_tagged)
+    else:
+        return
+
+    if device_q is not None:
+        invalidate_config_context_for_objects(
+            'dcim.device', Device.objects.filter(device_q).values_list('pk', flat=True)
+        )
+    if vm_q is not None:
+        invalidate_config_context_for_objects(
+            'virtualization.virtualmachine', VirtualMachine.objects.filter(vm_q).values_list('pk', flat=True)
+        )

+ 13 - 0
netbox/extras/constants.py

@@ -201,3 +201,16 @@ LOG_LEVEL_RANK = {
     LogLevelChoices.LOG_WARNING: 3,
     LogLevelChoices.LOG_FAILURE: 4,
 }
+
+# Config context cache: fields whose modification on an object requires re-rendering its config
+# context cache, keyed by model label.
+CC_FIELDS_BY_MODEL = {
+    'dcim.device': (
+        'site_id', 'location_id', 'device_type_id', 'role_id', 'tenant_id', 'platform_id',
+        'cluster_id', 'local_context_data',
+    ),
+    'virtualization.virtualmachine': (
+        'site_id', 'cluster_id', 'device_id', 'tenant_id', 'platform_id', 'role_id',
+        'local_context_data',
+    ),
+}

+ 51 - 0
netbox/extras/jobs.py

@@ -2,6 +2,7 @@ import logging
 import traceback
 from contextlib import ExitStack
 
+from django.apps import apps
 from django.db import DEFAULT_DB_ALIAS, router, transaction
 from django.utils.translation import gettext as _
 
@@ -15,6 +16,56 @@ from utilities.exceptions import AbortScript, AbortTransaction
 
 from .utils import is_report
 
+RENDER_CONFIG_CONTEXT_CHUNK_SIZE = 500
+
+
+class RenderConfigContextJob(JobRunner):
+    """
+    Recompute the pre-rendered `_config_context_data` cache for a set of Devices or
+    VirtualMachines. Triggered whenever an upstream change (ConfigContext, related object, or the
+    object itself) invalidates the cache.
+    """
+
+    class Meta:
+        name = 'Render config context'
+
+    def run(self, model_label=None, pks=None, **kwargs):
+        """
+        Args:
+            model_label: 'dcim.device' or 'virtualization.virtualmachine'. If None, both are processed.
+            pks: An iterable of object PKs to refresh. If None, refresh all objects whose cache is null.
+        """
+        if model_label is None:
+            for label in ('dcim.device', 'virtualization.virtualmachine'):
+                self._render_for_model(label, pks=None)
+            return
+        self._render_for_model(model_label, pks=pks)
+
+    def _render_for_model(self, model_label, pks):
+        Model = apps.get_model(model_label)
+        qs = Model.objects.filter(_config_context_data__isnull=True)
+        if pks is not None:
+            qs = qs.filter(pk__in=list(pks))
+
+        # Annotate so each instance's render() uses the same aggregated subquery the on-demand
+        # path would use, avoiding N additional queries.
+        qs = qs.annotate_config_context_data()
+
+        rendered = 0
+        batch = []
+        for obj in qs.iterator(chunk_size=RENDER_CONFIG_CONTEXT_CHUNK_SIZE):
+            obj._config_context_data = obj.render_config_context()
+            batch.append(obj)
+            if len(batch) >= RENDER_CONFIG_CONTEXT_CHUNK_SIZE:
+                Model.objects.bulk_update(batch, ['_config_context_data'])
+                rendered += len(batch)
+                batch = []
+        if batch:
+            Model.objects.bulk_update(batch, ['_config_context_data'])
+            rendered += len(batch)
+
+        self.logger.info(f"Rendered config context for {rendered} {model_label} object(s)")
+
 
 class ScriptJob(JobRunner):
     """

+ 126 - 1
netbox/extras/models/configs.py

@@ -4,6 +4,7 @@ import jsonschema
 from django.conf import settings
 from django.core.validators import ValidationError
 from django.db import models
+from django.db.models import Q
 from django.urls import reverse
 from django.utils.translation import gettext_lazy as _
 from jinja2.exceptions import TemplateError
@@ -215,6 +216,111 @@ class ConfigContext(SyncedDataMixin, CloningMixin, CustomLinksMixin, OwnerMixin,
         self.data = self.data_file.get_data()
     sync_data.alters_data = True
 
+    def get_affected_objects(self):
+        """
+        Return a (device_qs, vm_qs) tuple of all Devices and VirtualMachines that fall within this
+        ConfigContext's scope. This is the inverse of ConfigContextQuerySet.get_for_object().
+        Used to determine which pre-rendered context caches must be invalidated when this
+        ConfigContext changes.
+        """
+        from dcim.models import Device
+        from virtualization.models import VirtualMachine
+
+        device_q, vm_q = self._get_affected_object_filters()
+        return (
+            Device.objects.filter(device_q),
+            VirtualMachine.objects.filter(vm_q),
+        )
+
+    def _get_affected_object_filters(self):
+        """
+        Build the Q expressions matching Devices and VirtualMachines in this context's scope.
+        Returns (device_q, vm_q). Does NOT consider `is_active` — callers that need that should
+        check it separately. For invalidation purposes, we want the scope set regardless of
+        whether the context is currently active (toggling is_active also requires invalidation).
+        """
+        from extras.models.tags import TaggedItem
+
+        def _mptt_descendants(m2m):
+            # Return the PKs of all descendants (incl. self) of the items in this MPTT m2m,
+            # or None if the m2m is empty (meaning: no scope restriction).
+            scope_pks = list(m2m.values_list('pk', flat=True))
+            if not scope_pks:
+                return None
+            return list(
+                m2m.model.objects.filter(pk__in=scope_pks)
+                .get_descendants(include_self=True)
+                .values_list('pk', flat=True)
+            )
+
+        def _direct_pks(m2m):
+            pks = list(m2m.values_list('pk', flat=True))
+            return pks or None
+
+        # Shared filters (applicable to both Device and VirtualMachine)
+        shared = Q()
+
+        region_pks = _mptt_descendants(self.regions)
+        if region_pks is not None:
+            shared &= Q(site__region__in=region_pks)
+
+        site_group_pks = _mptt_descendants(self.site_groups)
+        if site_group_pks is not None:
+            shared &= Q(site__group__in=site_group_pks)
+
+        role_pks = _mptt_descendants(self.roles)
+        if role_pks is not None:
+            shared &= Q(role__in=role_pks)
+
+        platform_pks = _mptt_descendants(self.platforms)
+        if platform_pks is not None:
+            shared &= Q(platform__in=platform_pks)
+
+        for m2m, path in (
+            (self.sites, 'site'),
+            (self.cluster_types, 'cluster__type'),
+            (self.cluster_groups, 'cluster__group'),
+            (self.clusters, 'cluster'),
+            (self.tenant_groups, 'tenant__group'),
+            (self.tenants, 'tenant'),
+        ):
+            pks = _direct_pks(m2m)
+            if pks is not None:
+                shared &= Q(**{f'{path}__in': pks})
+
+        # Tag-scoped contexts: object must be tagged with at least one of the context's tags
+        tag_pks = _direct_pks(self.tags)
+
+        device_q = Q(shared)
+        vm_q = Q(shared)
+
+        # Device-only filters: location (MPTT) and device_type (direct)
+        location_pks = _mptt_descendants(self.locations)
+        if location_pks is not None:
+            device_q &= Q(location__in=location_pks)
+        device_type_pks = _direct_pks(self.device_types)
+        if device_type_pks is not None:
+            device_q &= Q(device_type__in=device_type_pks)
+        # For VMs, locations and device_types must be empty for the context to apply
+        if location_pks is not None or device_type_pks is not None:
+            vm_q &= Q(pk__in=())
+
+        if tag_pks is not None:
+            device_tagged = TaggedItem.objects.filter(
+                tag_id__in=tag_pks,
+                content_type__app_label='dcim',
+                content_type__model='device',
+            ).values_list('object_id', flat=True)
+            vm_tagged = TaggedItem.objects.filter(
+                tag_id__in=tag_pks,
+                content_type__app_label='virtualization',
+                content_type__model='virtualmachine',
+            ).values_list('object_id', flat=True)
+            device_q &= Q(pk__in=device_tagged)
+            vm_q &= Q(pk__in=vm_tagged)
+
+        return device_q, vm_q
+
 
 class ConfigContextModel(models.Model):
     """
@@ -233,9 +339,20 @@ class ConfigContextModel(models.Model):
         abstract = True
 
     def get_config_context(self):
+        """
+        Return the merged config context for this object. If a pre-rendered cache is present
+        (`_config_context_data`), return it directly. Otherwise, fall back to rendering on demand.
+        """
+        cached = getattr(self, '_config_context_data', None)
+        if cached is not None:
+            return cached
+        return self.render_config_context()
+
+    def render_config_context(self):
         """
         Compile all config data, overwriting lower-weight values with higher-weight values where a collision occurs.
-        Return the rendered configuration context for a device or VM.
+        Return the rendered configuration context for a device or VM. This bypasses the pre-rendered cache
+        (`_config_context_data`); use get_config_context() for the cached read path.
         """
         data = {}
 
@@ -264,6 +381,14 @@ class ConfigContextModel(models.Model):
                 {'local_context_data': _('JSON data must be in object form. Example:') + ' {"foo": 123}'}
             )
 
+    def serialize_object(self, exclude=None):
+        # Exclude the pre-rendered cache from change-log snapshots; it's a derived field and
+        # would otherwise produce noisy diffs.
+        exclude = list(exclude or [])
+        if '_config_context_data' not in exclude:
+            exclude.append('_config_context_data')
+        return super().serialize_object(exclude=exclude)
+
 
 #
 # Config templates

+ 4 - 0
netbox/extras/querysets.py

@@ -18,6 +18,10 @@ class ConfigContextQuerySet(RestrictedQuerySet):
         """
         Return all applicable ConfigContexts for a given object. Only active ConfigContexts will be included.
 
+        WARNING: This method's scope-matching logic is mirrored (inverted) by ConfigContext.get_affected_objects(),
+        which powers cache invalidation. Any change to the matching criteria here MUST be applied there as well, or
+        pre-rendered config context caches will go stale. See extras/models/configs.py.
+
         Args:
           aggregate_data: If True, use the JSONBAgg aggregate function to return only the list of JSON data objects
         """

+ 238 - 1
netbox/extras/signals.py

@@ -12,7 +12,13 @@ from netbox.signals import post_clean
 from utilities.data import get_config_value_ci
 from utilities.exceptions import AbortRequest
 
-from .models import CustomField, TaggedItem
+from .cache import (
+    invalidate_config_context_for_configcontext,
+    invalidate_config_context_for_objects,
+    invalidate_for_scope_delta,
+)
+from .constants import CC_FIELDS_BY_MODEL
+from .models import ConfigContext, CustomField, TaggedItem
 from .utils import run_validators
 
 #
@@ -90,6 +96,237 @@ def validate_assigned_tags(sender, instance, action, model, pk_set, **kwargs):
             raise AbortRequest(f"Tag {tag} cannot be assigned to {ct.model} objects.")
 
 
+#
+# Config context cache invalidation
+#
+
+@receiver(post_save, sender=ConfigContext)
+def invalidate_on_configcontext_save(sender, instance, **kwargs):
+    """
+    Whenever a ConfigContext's scalar fields change (e.g. `data`, `weight`, `is_active`),
+    invalidate the caches of all Devices/VMs currently in scope. M2M scope changes are handled
+    separately by invalidate_on_configcontext_m2m_change().
+    """
+    invalidate_config_context_for_configcontext(instance)
+
+
+@receiver(pre_delete, sender=ConfigContext)
+def invalidate_on_configcontext_delete(sender, instance, **kwargs):
+    """
+    Before a ConfigContext is deleted, invalidate the caches of all Devices/VMs currently in
+    scope. The scope is still readable here (pre_delete fires before the row and its M2M rows
+    are removed).
+    """
+    invalidate_config_context_for_configcontext(instance)
+
+
+def invalidate_on_configcontext_m2m_change(sender, instance, action, pk_set, scope_field, **kwargs):
+    """
+    Whenever a ConfigContext's scope M2M changes, invalidate the caches of all Devices/VMs that
+    were or now are in scope.
+
+    Strategy:
+    - For post_add: the current scope is broader than (or equal to) the previous scope. Devices
+      newly in scope are caught by invalidating the current affected set.
+    - For post_remove: the current scope is narrower. We must also invalidate devices that
+      matched only via the just-removed scope items.
+    - For post_clear: the scope is now empty (matches all). The current full affected set is the
+      broadest possible for this attribute; invalidating it suffices.
+    """
+    if action not in ('post_add', 'post_remove', 'post_clear'):
+        return
+
+    # Always invalidate based on the current (post-change) scope.
+    invalidate_config_context_for_configcontext(instance)
+
+    # For post_remove, also invalidate devices/VMs that matched via the removed scope items.
+    if action == 'post_remove' and pk_set:
+        invalidate_for_scope_delta(scope_field, pk_set)
+
+
+def _connect_configcontext_m2m_handlers():
+    """
+    Wire `invalidate_on_configcontext_m2m_change` to every ConfigContext scope M2M's through
+    model. The set of scope M2Ms is introspected from the model so new ones are picked up
+    automatically. The receiver is curried with `scope_field` to identify which attribute changed.
+    """
+    for m2m_field in ConfigContext._meta.many_to_many:
+        field_name = m2m_field.name
+        through = getattr(ConfigContext, field_name).through
+
+        def _handler(sender, instance, action, pk_set, _field=field_name, **kwargs):
+            invalidate_on_configcontext_m2m_change(
+                sender=sender,
+                instance=instance,
+                action=action,
+                pk_set=pk_set,
+                scope_field=_field,
+                **kwargs,
+            )
+
+        m2m_changed.connect(_handler, sender=through, weak=False)
+
+
+_connect_configcontext_m2m_handlers()
+
+
+def _changed_fields(instance, fields):
+    """
+    Return True if any of `fields` differs between the prechange snapshot and the current state.
+    If no snapshot exists (e.g. object loaded fresh from DB and saved without a snapshot), assume
+    we cannot tell what changed and conservatively return True. The cost is one extra background
+    re-render per non-instrumented save; the cost of returning False would be stale caches.
+    """
+    snapshot = getattr(instance, '_prechange_snapshot', None)
+    if not snapshot:
+        return True
+    for field in fields:
+        # Snapshot keys mirror Django's JSON serializer: FK ids are stored under the bare name
+        # (no `_id` suffix). Convert.
+        snap_key = field[:-3] if field.endswith('_id') else field
+        if snapshot.get(snap_key) != getattr(instance, field, None):
+            return True
+    return False
+
+
+def _make_object_save_handler(model_label):
+    fields = CC_FIELDS_BY_MODEL[model_label]
+
+    def _handler(sender, instance, created, **kwargs):
+        if created:
+            # No cache exists yet; the next read will render on demand. We could pre-render
+            # eagerly via a background job, but lazy rendering is sufficient.
+            return
+        if _changed_fields(instance, fields):
+            invalidate_config_context_for_objects(model_label, [instance.pk])
+
+    return _handler
+
+
+def _connect_object_save_handlers():
+    from django.apps import apps as django_apps
+
+    for model_label in CC_FIELDS_BY_MODEL:
+        Model = django_apps.get_model(model_label)
+        post_save.connect(_make_object_save_handler(model_label), sender=Model, weak=False)
+
+
+_connect_object_save_handlers()
+
+
+@receiver(m2m_changed, sender=TaggedItem)
+def invalidate_on_device_vm_tag_change(sender, instance, action, **kwargs):
+    """
+    When tags are added or removed on a Device/VM, invalidate that object's cache.
+    """
+    if action not in ('post_add', 'post_remove', 'post_clear'):
+        return
+    from dcim.models import Device
+    from virtualization.models import VirtualMachine
+
+    if isinstance(instance, Device):
+        invalidate_config_context_for_objects('dcim.device', [instance.pk])
+    elif isinstance(instance, VirtualMachine):
+        invalidate_config_context_for_objects('virtualization.virtualmachine', [instance.pk])
+
+
+# Upstream object changes that affect ConfigContext matching even when the Device/VM itself is
+# untouched. Two patterns are handled:
+#
+#   1. Direct FK changes (Site.region, Cluster.type, Tenant.group, ...): invalidate the caches of
+#      Devices/VMs that reference the changed object.
+#   2. MPTT reparents (Region.parent, SiteGroup.parent, ...): invalidate every Device/VM whose
+#      attribute resolves into the changed node's subtree, because the ancestor list used by the
+#      matching query has shifted.
+
+
+def _make_direct_upstream_handler(fields, device_lookup, vm_lookup):
+    def _handler(sender, instance, created, **kwargs):
+        if created or not _changed_fields(instance, fields):
+            return
+        from dcim.models import Device
+        from virtualization.models import VirtualMachine
+
+        if device_lookup:
+            invalidate_config_context_for_objects(
+                'dcim.device',
+                Device.objects.filter(**{device_lookup: instance.pk}).values_list('pk', flat=True),
+            )
+        if vm_lookup:
+            invalidate_config_context_for_objects(
+                'virtualization.virtualmachine',
+                VirtualMachine.objects.filter(**{vm_lookup: instance.pk}).values_list('pk', flat=True),
+            )
+
+    return _handler
+
+
+def _make_mptt_reparent_handler(device_attr, vm_attr):
+    def _handler(sender, instance, created, **kwargs):
+        if created or not _changed_fields(instance, ('parent_id',)):
+            return
+        from dcim.models import Device
+        from virtualization.models import VirtualMachine
+
+        subtree_pks = list(
+            type(instance).objects.filter(pk=instance.pk)
+            .get_descendants(include_self=True)
+            .values_list('pk', flat=True)
+        )
+
+        if device_attr:
+            invalidate_config_context_for_objects(
+                'dcim.device',
+                Device.objects.filter(**{device_attr: subtree_pks}).values_list('pk', flat=True),
+            )
+        if vm_attr:
+            invalidate_config_context_for_objects(
+                'virtualization.virtualmachine',
+                VirtualMachine.objects.filter(**{vm_attr: subtree_pks}).values_list('pk', flat=True),
+            )
+
+    return _handler
+
+
+def _connect_upstream_handlers():
+    from django.apps import apps as django_apps
+
+    # (app, model, fields_to_watch, device_lookup, vm_lookup)
+    direct_triggers = (
+        ('dcim', 'Site', ('region_id', 'group_id'), 'site_id', 'site_id'),
+        ('dcim', 'Location', ('site_id',), 'location_id', None),
+        ('virtualization', 'Cluster', ('type_id', 'group_id', 'site_id'), 'cluster_id', 'cluster_id'),
+        ('tenancy', 'Tenant', ('group_id',), 'tenant_id', 'tenant_id'),
+    )
+    for app, name, fields, device_lookup, vm_lookup in direct_triggers:
+        Model = django_apps.get_model(app, name)
+        post_save.connect(
+            _make_direct_upstream_handler(fields, device_lookup, vm_lookup),
+            sender=Model,
+            weak=False,
+        )
+
+    # (app, model, device_attr_path__in, vm_attr_path__in)
+    mptt_triggers = (
+        ('dcim', 'Region', 'site__region__in', 'site__region__in'),
+        ('dcim', 'SiteGroup', 'site__group__in', 'site__group__in'),
+        ('dcim', 'DeviceRole', 'role__in', 'role__in'),
+        ('dcim', 'Platform', 'platform__in', 'platform__in'),
+        ('tenancy', 'TenantGroup', 'tenant__group__in', 'tenant__group__in'),
+        ('dcim', 'Location', 'location__in', None),
+    )
+    for app, name, device_attr, vm_attr in mptt_triggers:
+        Model = django_apps.get_model(app, name)
+        post_save.connect(
+            _make_mptt_reparent_handler(device_attr, vm_attr),
+            sender=Model,
+            weak=False,
+        )
+
+
+_connect_upstream_handlers()
+
+
 #
 # Event rules
 #

+ 204 - 0
netbox/extras/tests/test_configcontext_cache.py

@@ -0,0 +1,204 @@
+from unittest import mock
+
+from django.test import TestCase
+
+from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
+from extras.cache import invalidate_config_context_for_objects
+from extras.jobs import RenderConfigContextJob
+from extras.models import ConfigContext, Tag
+
+
+def _set_cache(device, value):
+    """Manually set the cache field and refresh the in-memory instance so subsequent save()
+    calls don't overwrite the DB value with stale in-memory state."""
+    type(device).objects.filter(pk=device.pk).update(_config_context_data=value)
+    device.refresh_from_db()
+
+
+def _get_cache(device):
+    device.refresh_from_db()
+    return device._config_context_data
+
+
+class ConfigContextCacheReadPathTest(TestCase):
+    """
+    get_config_context() must return the cached `_config_context_data` blob when present, and
+    fall back to the on-demand render path when it is NULL.
+    """
+
+    @classmethod
+    def setUpTestData(cls):
+        manufacturer = Manufacturer.objects.create(name='Manufacturer', slug='mfr')
+        cls.devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='DT', slug='dt')
+        cls.role = DeviceRole.objects.create(name='Role', slug='role')
+        cls.site = Site.objects.create(name='Site', slug='site')
+        cls.device = Device.objects.create(
+            name='Device', device_type=cls.devicetype, role=cls.role, site=cls.site
+        )
+
+    def test_cached_value_is_returned(self):
+        cached = {'cached': True, 'value': 42}
+        _set_cache(self.device, cached)
+        device = Device.objects.get(pk=self.device.pk)
+        self.assertEqual(device.get_config_context(), cached)
+
+    def test_null_cache_falls_back_to_render(self):
+        ConfigContext.objects.create(name='CC', weight=100, data={'rendered': True})
+        device = Device.objects.get(pk=self.device.pk)
+        self.assertIsNone(device._config_context_data)
+        self.assertEqual(device.get_config_context(), {'rendered': True})
+
+    def test_render_matches_legacy_path(self):
+        ConfigContext.objects.create(name='A', weight=100, data={'a': 1})
+        ConfigContext.objects.create(name='B', weight=200, data={'a': 2, 'b': 3})
+
+        device = Device.objects.get(pk=self.device.pk)
+        on_demand = device.render_config_context()
+        _set_cache(device, on_demand)
+        self.assertEqual(device.get_config_context(), on_demand)
+
+
+class ConfigContextInvalidationTest(TestCase):
+
+    @classmethod
+    def setUpTestData(cls):
+        manufacturer = Manufacturer.objects.create(name='Mfr', slug='mfr')
+        cls.devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='DT', slug='dt')
+        cls.role = DeviceRole.objects.create(name='Role', slug='role')
+        cls.role2 = DeviceRole.objects.create(name='Role 2', slug='role-2')
+        cls.site1 = Site.objects.create(name='Site 1', slug='site-1')
+        cls.site2 = Site.objects.create(name='Site 2', slug='site-2')
+        cls.device_in_scope = Device.objects.create(
+            name='In scope', device_type=cls.devicetype, role=cls.role, site=cls.site1,
+        )
+        cls.device_out_of_scope = Device.objects.create(
+            name='Out of scope', device_type=cls.devicetype, role=cls.role, site=cls.site2,
+        )
+
+    def setUp(self):
+        # Pre-populate caches for both devices.
+        _set_cache(self.device_in_scope, {'cached': True})
+        _set_cache(self.device_out_of_scope, {'cached': True})
+
+    def test_configcontext_save_invalidates_in_scope_only(self):
+        cc = ConfigContext.objects.create(name='CC', weight=100, data={'x': 1})
+        cc.sites.add(self.site1)
+        # Re-populate caches (the create + m2m add above already triggered invalidations).
+        _set_cache(self.device_in_scope, {'cached': True})
+        _set_cache(self.device_out_of_scope, {'cached': True})
+
+        cc.data = {'x': 2}
+        cc.save()
+
+        self.assertIsNone(_get_cache(self.device_in_scope))
+        self.assertEqual(_get_cache(self.device_out_of_scope), {'cached': True})
+
+    def test_configcontext_delete_invalidates_in_scope(self):
+        cc = ConfigContext.objects.create(name='CC', weight=100, data={'x': 1})
+        cc.sites.add(self.site1)
+        _set_cache(self.device_in_scope, {'cached': True})
+        _set_cache(self.device_out_of_scope, {'cached': True})
+
+        cc.delete()
+
+        self.assertIsNone(_get_cache(self.device_in_scope))
+        self.assertEqual(_get_cache(self.device_out_of_scope), {'cached': True})
+
+    def test_m2m_post_add_invalidates_newly_in_scope(self):
+        cc = ConfigContext.objects.create(name='CC', weight=100, data={'x': 1})
+        _set_cache(self.device_in_scope, {'cached': True})
+
+        cc.sites.add(self.site1)
+
+        self.assertIsNone(_get_cache(self.device_in_scope))
+
+    def test_m2m_post_remove_invalidates_previously_in_scope(self):
+        cc = ConfigContext.objects.create(name='CC', weight=100, data={'x': 1})
+        cc.sites.add(self.site1)
+        _set_cache(self.device_in_scope, {'cached': True})
+
+        cc.sites.remove(self.site1)
+
+        self.assertIsNone(_get_cache(self.device_in_scope))
+
+    def test_device_role_change_invalidates(self):
+        self.device_in_scope.snapshot()
+        self.device_in_scope.role = self.role2
+        self.device_in_scope.save()
+
+        self.assertIsNone(_get_cache(self.device_in_scope))
+
+    def test_device_serial_change_does_not_invalidate(self):
+        # Refresh first so the in-memory instance has the cached value (avoids save() writing
+        # stale NULL back to the DB).
+        self.device_in_scope.refresh_from_db()
+        self.device_in_scope.snapshot()
+        self.device_in_scope.serial = 'ABC123'
+        self.device_in_scope.save()
+
+        self.assertEqual(_get_cache(self.device_in_scope), {'cached': True})
+
+    def test_device_tag_add_invalidates(self):
+        tag = Tag.objects.create(name='Tag', slug='tag')
+        self.device_in_scope.tags.add(tag)
+
+        self.assertIsNone(_get_cache(self.device_in_scope))
+
+
+class RenderConfigContextJobTest(TestCase):
+
+    @classmethod
+    def setUpTestData(cls):
+        manufacturer = Manufacturer.objects.create(name='Mfr', slug='mfr')
+        devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='DT', slug='dt')
+        role = DeviceRole.objects.create(name='Role', slug='role')
+        site = Site.objects.create(name='Site', slug='site')
+        cls.device = Device.objects.create(
+            name='Device', device_type=devicetype, role=role, site=site,
+        )
+        ConfigContext.objects.create(name='CC', weight=100, data={'foo': 'bar'})
+
+    def _make_runner(self):
+        runner = RenderConfigContextJob.__new__(RenderConfigContextJob)
+        runner.job = mock.Mock()
+        runner.logger = mock.Mock()
+        return runner
+
+    def test_job_populates_cache(self):
+        Device.objects.filter(pk=self.device.pk).update(_config_context_data=None)
+        self._make_runner()._render_for_model('dcim.device', pks=[self.device.pk])
+
+        self.device.refresh_from_db()
+        self.assertEqual(self.device._config_context_data, {'foo': 'bar'})
+
+    def test_job_idempotent_on_repopulated_cache(self):
+        runner = self._make_runner()
+        runner._render_for_model('dcim.device', pks=[self.device.pk])
+        self.device.refresh_from_db()
+        first = self.device._config_context_data
+
+        Device.objects.filter(pk=self.device.pk).update(_config_context_data=None)
+        runner._render_for_model('dcim.device', pks=[self.device.pk])
+        self.device.refresh_from_db()
+        self.assertEqual(self.device._config_context_data, first)
+
+
+class CacheHelperTest(TestCase):
+
+    @classmethod
+    def setUpTestData(cls):
+        manufacturer = Manufacturer.objects.create(name='Mfr', slug='mfr')
+        devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='DT', slug='dt')
+        role = DeviceRole.objects.create(name='Role', slug='role')
+        site = Site.objects.create(name='Site', slug='site')
+        cls.device = Device.objects.create(
+            name='Device', device_type=devicetype, role=role, site=site,
+        )
+
+    def test_invalidate_for_objects_nulls_cache(self):
+        _set_cache(self.device, {'x': 1})
+        invalidate_config_context_for_objects('dcim.device', [self.device.pk])
+        self.assertIsNone(_get_cache(self.device))
+
+    def test_invalidate_with_empty_args_is_noop(self):
+        invalidate_config_context_for_objects('dcim.device', [])

+ 16 - 0
netbox/virtualization/migrations/0057_virtualmachine__config_context_data.py

@@ -0,0 +1,16 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('virtualization', '0056_virtualmachine_render_config_permission'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='virtualmachine',
+            name='_config_context_data',
+            field=models.JSONField(blank=True, editable=False, null=True),
+        ),
+    ]

+ 8 - 0
netbox/virtualization/models/virtualmachines.py

@@ -239,6 +239,14 @@ class VirtualMachine(
         to_field='virtual_machine'
     )
 
+    # Pre-rendered config context cache. NULL means "invalidated; render on demand". Populated by
+    # extras.jobs.RenderConfigContextJob in the background.
+    _config_context_data = models.JSONField(
+        blank=True,
+        null=True,
+        editable=False,
+    )
+
     objects = ConfigContextModelQuerySet.as_manager()
 
     clone_fields = (