|
@@ -0,0 +1,709 @@
|
|
|
|
|
+from unittest import mock
|
|
|
|
|
+
|
|
|
|
|
+from django.db import connection
|
|
|
|
|
+from django.db.models import F
|
|
|
|
|
+from django.test import TestCase
|
|
|
|
|
+from django.test.utils import CaptureQueriesContext
|
|
|
|
|
+
|
|
|
|
|
+from core.models import Job
|
|
|
|
|
+from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Platform, Region, Site, SiteGroup
|
|
|
|
|
+from extras.cache import invalidate_config_context_for_objects
|
|
|
|
|
+from extras.jobs import RenderConfigContextJob
|
|
|
|
|
+from extras.models import ConfigContext, Tag
|
|
|
|
|
+from tenancy.models import Tenant, TenantGroup
|
|
|
|
|
+from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+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 ConfigContextUpstreamDeleteInvalidationTest(TestCase):
|
|
|
|
|
+ """
|
|
|
|
|
+ Deleting an upstream object referenced by a Device/VM via a SET_NULL FK nulls that FK with a
|
|
|
|
|
+ bulk UPDATE that emits no post_save signal. The pre_delete handlers must invalidate the
|
|
|
|
|
+ affected caches before the references are cleared.
|
|
|
|
|
+ """
|
|
|
|
|
+
|
|
|
|
|
+ @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')
|
|
|
|
|
+
|
|
|
|
|
+ def test_platform_delete_invalidates(self):
|
|
|
|
|
+ platform = Platform.objects.create(name='Platform', slug='platform')
|
|
|
|
|
+ site = Site.objects.create(name='Site', slug='site')
|
|
|
|
|
+ device = Device.objects.create(
|
|
|
|
|
+ name='Device', device_type=self.devicetype, role=self.role, site=site, platform=platform,
|
|
|
|
|
+ )
|
|
|
|
|
+ _set_cache(device, {'cached': True})
|
|
|
|
|
+
|
|
|
|
|
+ platform.delete()
|
|
|
|
|
+
|
|
|
|
|
+ self.assertIsNone(_get_cache(device))
|
|
|
|
|
+
|
|
|
|
|
+ def test_region_delete_invalidates(self):
|
|
|
|
|
+ region = Region.objects.create(name='Region', slug='region')
|
|
|
|
|
+ site = Site.objects.create(name='Site', slug='site', region=region)
|
|
|
|
|
+ device = Device.objects.create(
|
|
|
|
|
+ name='Device', device_type=self.devicetype, role=self.role, site=site,
|
|
|
|
|
+ )
|
|
|
|
|
+ _set_cache(device, {'cached': True})
|
|
|
|
|
+
|
|
|
|
|
+ region.delete()
|
|
|
|
|
+
|
|
|
|
|
+ self.assertIsNone(_get_cache(device))
|
|
|
|
|
+
|
|
|
|
|
+ def test_site_group_delete_invalidates(self):
|
|
|
|
|
+ group = SiteGroup.objects.create(name='Group', slug='group')
|
|
|
|
|
+ site = Site.objects.create(name='Site', slug='site', group=group)
|
|
|
|
|
+ device = Device.objects.create(
|
|
|
|
|
+ name='Device', device_type=self.devicetype, role=self.role, site=site,
|
|
|
|
|
+ )
|
|
|
|
|
+ _set_cache(device, {'cached': True})
|
|
|
|
|
+
|
|
|
|
|
+ group.delete()
|
|
|
|
|
+
|
|
|
|
|
+ self.assertIsNone(_get_cache(device))
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+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)
|
|
|
|
|
+
|
|
|
|
|
+ def test_job_skips_stale_write_on_concurrent_invalidation(self):
|
|
|
|
|
+ """
|
|
|
|
|
+ If an invalidation bumps the generation counter after the renderer captured it but before
|
|
|
|
|
+ the result is written, the compare-and-set write must be rejected so a stale value is never
|
|
|
|
|
+ persisted (the row stays NULL for the follow-up job to re-render).
|
|
|
|
|
+ """
|
|
|
|
|
+ Device.objects.filter(pk=self.device.pk).update(
|
|
|
|
|
+ _config_context_data=None, _config_context_generation=1
|
|
|
|
|
+ )
|
|
|
|
|
+ runner = self._make_runner()
|
|
|
|
|
+
|
|
|
|
|
+ def racing_render(device_self):
|
|
|
|
|
+ # Simulate a concurrent invalidation committing mid-render.
|
|
|
|
|
+ Device.objects.filter(pk=device_self.pk).update(
|
|
|
|
|
+ _config_context_generation=F('_config_context_generation') + 1
|
|
|
|
|
+ )
|
|
|
|
|
+ return {'stale': True}
|
|
|
|
|
+
|
|
|
|
|
+ with mock.patch.object(Device, 'render_config_context', autospec=True, side_effect=racing_render):
|
|
|
|
|
+ runner._render_for_model('dcim.device', pks=[self.device.pk])
|
|
|
|
|
+
|
|
|
|
|
+ self.device.refresh_from_db()
|
|
|
|
|
+ self.assertIsNone(self.device._config_context_data)
|
|
|
|
|
+ self.assertEqual(self.device._config_context_generation, 2)
|
|
|
|
|
+
|
|
|
|
|
+ # A subsequent render against the current generation succeeds.
|
|
|
|
|
+ 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_run_rescans_for_caches_nulled_mid_sweep(self):
|
|
|
|
|
+ """
|
|
|
|
|
+ An invalidation that commits while this job is already RUNNING coalesces into it (rather
|
|
|
|
|
+ than enqueuing a follow-up), so run() must re-scan after each pass. Otherwise a cache that
|
|
|
|
|
+ is NULLed after its row has been passed over would be stranded on the on-demand read path
|
|
|
|
|
+ with no job left to repopulate it.
|
|
|
|
|
+ """
|
|
|
|
|
+ device_b = Device.objects.create(
|
|
|
|
|
+ name='Device B', device_type=self.device.device_type, role=self.device.role,
|
|
|
|
|
+ site=self.device.site,
|
|
|
|
|
+ )
|
|
|
|
|
+ # Device A needs rendering; Device B starts populated, so the first scan skips it.
|
|
|
|
|
+ Device.objects.filter(pk=self.device.pk).update(_config_context_data=None)
|
|
|
|
|
+ Device.objects.filter(pk=device_b.pk).update(_config_context_data={'old': True})
|
|
|
|
|
+
|
|
|
|
|
+ runner = self._make_runner()
|
|
|
|
|
+ real_render = Device.render_config_context
|
|
|
|
|
+ nulled = []
|
|
|
|
|
+
|
|
|
|
|
+ def render_then_null_b(device_self):
|
|
|
|
|
+ # While rendering Device A (the first pass), simulate a concurrent invalidation NULLing
|
|
|
|
|
+ # Device B's cache after B has already been passed over.
|
|
|
|
|
+ if device_self.pk == self.device.pk and not nulled:
|
|
|
|
|
+ nulled.append(True)
|
|
|
|
|
+ Device.objects.filter(pk=device_b.pk).update(_config_context_data=None)
|
|
|
|
|
+ return real_render(device_self)
|
|
|
|
|
+
|
|
|
|
|
+ with mock.patch.object(Device, 'render_config_context', autospec=True, side_effect=render_then_null_b):
|
|
|
|
|
+ runner.run(model_label='dcim.device')
|
|
|
|
|
+
|
|
|
|
|
+ # Both caches must be populated: A on the first pass, B on the re-scan.
|
|
|
|
|
+ self.device.refresh_from_db()
|
|
|
|
|
+ device_b.refresh_from_db()
|
|
|
|
|
+ self.assertEqual(self.device._config_context_data, {'foo': 'bar'})
|
|
|
|
|
+ self.assertEqual(device_b._config_context_data, {'foo': 'bar'})
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class ConfigContextCacheJobEnqueueTest(TestCase):
|
|
|
|
|
+ """
|
|
|
|
|
+ Invalidation NULLs the cache synchronously and enqueues the render job on transaction commit.
|
|
|
|
|
+ """
|
|
|
|
|
+
|
|
|
|
|
+ @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_invalidation_enqueues_render_job_on_commit(self):
|
|
|
|
|
+ _set_cache(self.device, {'x': 1})
|
|
|
|
|
+ self.assertFalse(Job.objects.filter(name=RenderConfigContextJob.name).exists())
|
|
|
|
|
+
|
|
|
|
|
+ with self.captureOnCommitCallbacks(execute=True):
|
|
|
|
|
+ invalidate_config_context_for_objects('dcim.device', [self.device.pk])
|
|
|
|
|
+
|
|
|
|
|
+ self.assertTrue(Job.objects.filter(name=RenderConfigContextJob.name).exists())
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+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_for_objects_bumps_generation(self):
|
|
|
|
|
+ _set_cache(self.device, {'x': 1})
|
|
|
|
|
+ self.device.refresh_from_db()
|
|
|
|
|
+ before = self.device._config_context_generation
|
|
|
|
|
+ invalidate_config_context_for_objects('dcim.device', [self.device.pk])
|
|
|
|
|
+ self.device.refresh_from_db()
|
|
|
|
|
+ self.assertEqual(self.device._config_context_generation, before + 1)
|
|
|
|
|
+
|
|
|
|
|
+ def test_invalidate_with_empty_args_is_noop(self):
|
|
|
|
|
+ invalidate_config_context_for_objects('dcim.device', [])
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class VirtualMachineInvalidationTest(TestCase):
|
|
|
|
|
+ """
|
|
|
|
|
+ The invalidation signals must cover VirtualMachine, not just Device.
|
|
|
|
|
+ """
|
|
|
|
|
+
|
|
|
|
|
+ @classmethod
|
|
|
|
|
+ def setUpTestData(cls):
|
|
|
|
|
+ cls.site1 = Site.objects.create(name='Site 1', slug='site-1')
|
|
|
|
|
+ cls.site2 = Site.objects.create(name='Site 2', slug='site-2')
|
|
|
|
|
+ cls.role = DeviceRole.objects.create(name='Role', slug='role')
|
|
|
|
|
+ cls.role2 = DeviceRole.objects.create(name='Role 2', slug='role-2')
|
|
|
|
|
+ clustertype = ClusterType.objects.create(name='CT', slug='ct')
|
|
|
|
|
+ cls.cluster = Cluster.objects.create(name='Cluster', type=clustertype)
|
|
|
|
|
+ cls.vm_in_scope = VirtualMachine.objects.create(name='In scope', site=cls.site1, role=cls.role)
|
|
|
|
|
+ cls.vm_out_of_scope = VirtualMachine.objects.create(name='Out of scope', site=cls.site2, role=cls.role)
|
|
|
|
|
+
|
|
|
|
|
+ def setUp(self):
|
|
|
|
|
+ _set_cache(self.vm_in_scope, {'cached': True})
|
|
|
|
|
+ _set_cache(self.vm_out_of_scope, {'cached': True})
|
|
|
|
|
+
|
|
|
|
|
+ def test_configcontext_save_invalidates_in_scope_vm_only(self):
|
|
|
|
|
+ cc = ConfigContext.objects.create(name='CC', weight=100, data={'x': 1})
|
|
|
|
|
+ cc.sites.add(self.site1)
|
|
|
|
|
+ _set_cache(self.vm_in_scope, {'cached': True})
|
|
|
|
|
+ _set_cache(self.vm_out_of_scope, {'cached': True})
|
|
|
|
|
+
|
|
|
|
|
+ cc.data = {'x': 2}
|
|
|
|
|
+ cc.save()
|
|
|
|
|
+
|
|
|
|
|
+ self.assertIsNone(_get_cache(self.vm_in_scope))
|
|
|
|
|
+ self.assertEqual(_get_cache(self.vm_out_of_scope), {'cached': True})
|
|
|
|
|
+
|
|
|
|
|
+ def test_vm_role_change_invalidates(self):
|
|
|
|
|
+ self.vm_in_scope.refresh_from_db()
|
|
|
|
|
+ self.vm_in_scope.snapshot()
|
|
|
|
|
+ self.vm_in_scope.role = self.role2
|
|
|
|
|
+ self.vm_in_scope.save()
|
|
|
|
|
+ self.assertIsNone(_get_cache(self.vm_in_scope))
|
|
|
|
|
+
|
|
|
|
|
+ def test_vm_tag_add_invalidates(self):
|
|
|
|
|
+ tag = Tag.objects.create(name='Tag', slug='tag')
|
|
|
|
|
+ self.vm_in_scope.tags.add(tag)
|
|
|
|
|
+ self.assertIsNone(_get_cache(self.vm_in_scope))
|
|
|
|
|
+
|
|
|
|
|
+ def test_device_only_scope_does_not_invalidate_vm(self):
|
|
|
|
|
+ # A context scoped by device_type can never apply to a VM, so its changes must not touch
|
|
|
|
|
+ # VM caches.
|
|
|
|
|
+ manufacturer = Manufacturer.objects.create(name='Mfr', slug='mfr')
|
|
|
|
|
+ devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='DT', slug='dt')
|
|
|
|
|
+ cc = ConfigContext.objects.create(name='CC', weight=100, data={'x': 1})
|
|
|
|
|
+ cc.device_types.add(devicetype)
|
|
|
|
|
+ _set_cache(self.vm_in_scope, {'cached': True})
|
|
|
|
|
+
|
|
|
|
|
+ cc.data = {'x': 2}
|
|
|
|
|
+ cc.save()
|
|
|
|
|
+
|
|
|
|
|
+ self.assertEqual(_get_cache(self.vm_in_scope), {'cached': True})
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class ConfigContextUpstreamChangeInvalidationTest(TestCase):
|
|
|
|
|
+ """
|
|
|
|
|
+ Changes to intermediate/hierarchical objects (not the Device/VM itself) must invalidate the
|
|
|
|
|
+ affected caches: direct FK changes on an intermediate model, and ltree reparents.
|
|
|
|
|
+ """
|
|
|
|
|
+
|
|
|
|
|
+ @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')
|
|
|
|
|
+
|
|
|
|
|
+ def _make_device(self, site):
|
|
|
|
|
+ device = Device.objects.create(
|
|
|
|
|
+ name=f'Device {site.slug}', device_type=self.devicetype, role=self.role, site=site,
|
|
|
|
|
+ )
|
|
|
|
|
+ _set_cache(device, {'cached': True})
|
|
|
|
|
+ return device
|
|
|
|
|
+
|
|
|
|
|
+ def test_direct_upstream_site_region_change_invalidates(self):
|
|
|
|
|
+ region1 = Region.objects.create(name='R1', slug='r1')
|
|
|
|
|
+ region2 = Region.objects.create(name='R2', slug='r2')
|
|
|
|
|
+ site = Site.objects.create(name='Site', slug='site', region=region1)
|
|
|
|
|
+ device = self._make_device(site)
|
|
|
|
|
+
|
|
|
|
|
+ site.snapshot()
|
|
|
|
|
+ site.region = region2
|
|
|
|
|
+ site.save()
|
|
|
|
|
+
|
|
|
|
|
+ self.assertIsNone(_get_cache(device))
|
|
|
|
|
+
|
|
|
|
|
+ def test_region_reparent_invalidates(self):
|
|
|
|
|
+ region_a = Region.objects.create(name='A', slug='a')
|
|
|
|
|
+ region_b = Region.objects.create(name='B', slug='b')
|
|
|
|
|
+ site = Site.objects.create(name='Site', slug='site', region=region_b)
|
|
|
|
|
+ device = self._make_device(site)
|
|
|
|
|
+
|
|
|
|
|
+ region_b.snapshot()
|
|
|
|
|
+ region_b.parent = region_a
|
|
|
|
|
+ region_b.save()
|
|
|
|
|
+
|
|
|
|
|
+ self.assertIsNone(_get_cache(device))
|
|
|
|
|
+
|
|
|
|
|
+ def test_direct_upstream_cluster_type_change_invalidates_vm(self):
|
|
|
|
|
+ ct1 = ClusterType.objects.create(name='CT1', slug='ct1')
|
|
|
|
|
+ ct2 = ClusterType.objects.create(name='CT2', slug='ct2')
|
|
|
|
|
+ cluster = Cluster.objects.create(name='Cluster', type=ct1)
|
|
|
|
|
+ site = Site.objects.create(name='Site', slug='site')
|
|
|
|
|
+ vm = VirtualMachine.objects.create(name='VM', site=site, role=self.role, cluster=cluster)
|
|
|
|
|
+ _set_cache(vm, {'cached': True})
|
|
|
|
|
+
|
|
|
|
|
+ cluster.snapshot()
|
|
|
|
|
+ cluster.type = ct2
|
|
|
|
|
+ cluster.save()
|
|
|
|
|
+
|
|
|
|
|
+ self.assertIsNone(_get_cache(vm))
|
|
|
|
|
+
|
|
|
|
|
+ def test_direct_upstream_cluster_group_change_invalidates_vm(self):
|
|
|
|
|
+ ct = ClusterType.objects.create(name='CT', slug='ct')
|
|
|
|
|
+ cg1 = ClusterGroup.objects.create(name='CG1', slug='cg1')
|
|
|
|
|
+ cg2 = ClusterGroup.objects.create(name='CG2', slug='cg2')
|
|
|
|
|
+ cluster = Cluster.objects.create(name='Cluster', type=ct, group=cg1)
|
|
|
|
|
+ site = Site.objects.create(name='Site', slug='site')
|
|
|
|
|
+ vm = VirtualMachine.objects.create(name='VM', site=site, role=self.role, cluster=cluster)
|
|
|
|
|
+ _set_cache(vm, {'cached': True})
|
|
|
|
|
+
|
|
|
|
|
+ cluster.snapshot()
|
|
|
|
|
+ cluster.group = cg2
|
|
|
|
|
+ cluster.save()
|
|
|
|
|
+
|
|
|
|
|
+ self.assertIsNone(_get_cache(vm))
|
|
|
|
|
+
|
|
|
|
|
+ def test_direct_upstream_tenant_group_change_invalidates_device(self):
|
|
|
|
|
+ tg1 = TenantGroup.objects.create(name='TG1', slug='tg1')
|
|
|
|
|
+ tg2 = TenantGroup.objects.create(name='TG2', slug='tg2')
|
|
|
|
|
+ tenant = Tenant.objects.create(name='Tenant', slug='tenant', group=tg1)
|
|
|
|
|
+ site = Site.objects.create(name='Site', slug='site')
|
|
|
|
|
+ device = Device.objects.create(
|
|
|
|
|
+ name='Device', device_type=self.devicetype, role=self.role, site=site, tenant=tenant,
|
|
|
|
|
+ )
|
|
|
|
|
+ _set_cache(device, {'cached': True})
|
|
|
|
|
+
|
|
|
|
|
+ tenant.snapshot()
|
|
|
|
|
+ tenant.group = tg2
|
|
|
|
|
+ tenant.save()
|
|
|
|
|
+
|
|
|
|
|
+ self.assertIsNone(_get_cache(device))
|
|
|
|
|
+
|
|
|
|
|
+ def test_direct_upstream_tenant_group_change_invalidates_vm(self):
|
|
|
|
|
+ tg1 = TenantGroup.objects.create(name='TG1', slug='tg1')
|
|
|
|
|
+ tg2 = TenantGroup.objects.create(name='TG2', slug='tg2')
|
|
|
|
|
+ tenant = Tenant.objects.create(name='Tenant', slug='tenant', group=tg1)
|
|
|
|
|
+ site = Site.objects.create(name='Site', slug='site')
|
|
|
|
|
+ vm = VirtualMachine.objects.create(name='VM', site=site, role=self.role, tenant=tenant)
|
|
|
|
|
+ _set_cache(vm, {'cached': True})
|
|
|
|
|
+
|
|
|
|
|
+ tenant.snapshot()
|
|
|
|
|
+ tenant.group = tg2
|
|
|
|
|
+ tenant.save()
|
|
|
|
|
+
|
|
|
|
|
+ self.assertIsNone(_get_cache(vm))
|
|
|
|
|
+
|
|
|
|
|
+ def test_m2m_post_clear_invalidates(self):
|
|
|
|
|
+ site = Site.objects.create(name='Site', slug='site')
|
|
|
|
|
+ device = self._make_device(site)
|
|
|
|
|
+ cc = ConfigContext.objects.create(name='CC', weight=100, data={'x': 1})
|
|
|
|
|
+ cc.sites.add(site)
|
|
|
|
|
+ _set_cache(device, {'cached': True})
|
|
|
|
|
+
|
|
|
|
|
+ cc.sites.clear()
|
|
|
|
|
+
|
|
|
|
|
+ self.assertIsNone(_get_cache(device))
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class ConfigContextScopeParityTest(TestCase):
|
|
|
|
|
+ """
|
|
|
|
|
+ The inverse matcher (ConfigContext.get_affected_objects()) must agree exactly with the forward
|
|
|
|
|
+ matcher (ConfigContextQuerySet.get_for_object()) across every scope dimension, including
|
|
|
|
|
+ hierarchy expansion and device-only dimensions.
|
|
|
|
|
+ """
|
|
|
|
|
+
|
|
|
|
|
+ @classmethod
|
|
|
|
|
+ def setUpTestData(cls):
|
|
|
|
|
+ # Hierarchies
|
|
|
|
|
+ cls.region_parent = Region.objects.create(name='Region Parent', slug='region-parent')
|
|
|
|
|
+ cls.region_child = Region.objects.create(name='Region Child', slug='region-child', parent=cls.region_parent)
|
|
|
|
|
+ cls.sg_parent = SiteGroup.objects.create(name='SG Parent', slug='sg-parent')
|
|
|
|
|
+ cls.sg_child = SiteGroup.objects.create(name='SG Child', slug='sg-child', parent=cls.sg_parent)
|
|
|
|
|
+ cls.role_parent = DeviceRole.objects.create(name='Role Parent', slug='role-parent')
|
|
|
|
|
+ cls.role_child = DeviceRole.objects.create(name='Role Child', slug='role-child', parent=cls.role_parent)
|
|
|
|
|
+ cls.plat_parent = Platform.objects.create(name='Plat Parent', slug='plat-parent')
|
|
|
|
|
+ cls.plat_child = Platform.objects.create(name='Plat Child', slug='plat-child', parent=cls.plat_parent)
|
|
|
|
|
+ cls.tg = TenantGroup.objects.create(name='TG', slug='tg')
|
|
|
|
|
+ cls.tenant = Tenant.objects.create(name='Tenant', slug='tenant', group=cls.tg)
|
|
|
|
|
+
|
|
|
|
|
+ cls.site_a = Site.objects.create(
|
|
|
|
|
+ name='Site A', slug='site-a', region=cls.region_child, group=cls.sg_child
|
|
|
|
|
+ )
|
|
|
|
|
+ cls.site_b = Site.objects.create(name='Site B', slug='site-b')
|
|
|
|
|
+ cls.loc_parent = Location.objects.create(name='Loc Parent', slug='loc-parent', site=cls.site_a)
|
|
|
|
|
+ cls.loc_child = Location.objects.create(
|
|
|
|
|
+ name='Loc Child', slug='loc-child', site=cls.site_a, parent=cls.loc_parent
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ manufacturer = Manufacturer.objects.create(name='Mfr', slug='mfr')
|
|
|
|
|
+ cls.devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='DT', slug='dt')
|
|
|
|
|
+ clustertype = ClusterType.objects.create(name='CT', slug='ct')
|
|
|
|
|
+ clustergroup = ClusterGroup.objects.create(name='CG', slug='cg')
|
|
|
|
|
+ cls.cluster = Cluster.objects.create(name='Cluster', type=clustertype, group=clustergroup)
|
|
|
|
|
+ cls.tag = Tag.objects.create(name='Tag', slug='tag')
|
|
|
|
|
+
|
|
|
|
|
+ cls.d_full = Device.objects.create(
|
|
|
|
|
+ name='d-full', device_type=cls.devicetype, role=cls.role_child, site=cls.site_a,
|
|
|
|
|
+ location=cls.loc_child, platform=cls.plat_child, tenant=cls.tenant, cluster=cls.cluster,
|
|
|
|
|
+ )
|
|
|
|
|
+ cls.d_full.tags.add(cls.tag)
|
|
|
|
|
+ cls.d_min = Device.objects.create(
|
|
|
|
|
+ name='d-min', device_type=cls.devicetype, role=cls.role_parent, site=cls.site_b,
|
|
|
|
|
+ )
|
|
|
|
|
+ cls.vm_full = VirtualMachine.objects.create(
|
|
|
|
|
+ name='vm-full', site=cls.site_a, role=cls.role_child, platform=cls.plat_child,
|
|
|
|
|
+ tenant=cls.tenant, cluster=cls.cluster,
|
|
|
|
|
+ )
|
|
|
|
|
+ cls.vm_full.tags.add(cls.tag)
|
|
|
|
|
+ cls.vm_min = VirtualMachine.objects.create(name='vm-min', site=cls.site_b, role=cls.role_parent)
|
|
|
|
|
+
|
|
|
|
|
+ def _assert_parity(self, cc):
|
|
|
|
|
+ device_qs, vm_qs = cc.get_affected_objects()
|
|
|
|
|
+ inverse_devices = set(device_qs.values_list('pk', flat=True))
|
|
|
|
|
+ inverse_vms = set(vm_qs.values_list('pk', flat=True))
|
|
|
|
|
+
|
|
|
|
|
+ forward_devices = {
|
|
|
|
|
+ d.pk for d in Device.objects.all()
|
|
|
|
|
+ if ConfigContext.objects.get_for_object(d).filter(pk=cc.pk).exists()
|
|
|
|
|
+ }
|
|
|
|
|
+ forward_vms = {
|
|
|
|
|
+ v.pk for v in VirtualMachine.objects.all()
|
|
|
|
|
+ if ConfigContext.objects.get_for_object(v).filter(pk=cc.pk).exists()
|
|
|
|
|
+ }
|
|
|
|
|
+ self.assertEqual(inverse_devices, forward_devices, f"device mismatch for {cc.name}")
|
|
|
|
|
+ self.assertEqual(inverse_vms, forward_vms, f"VM mismatch for {cc.name}")
|
|
|
|
|
+
|
|
|
|
|
+ def test_scope_parity_across_dimensions(self):
|
|
|
|
|
+ scopes = {
|
|
|
|
|
+ 'regions': [self.region_parent], # hierarchy: matches descendant region_child
|
|
|
|
|
+ 'site_groups': [self.sg_parent], # hierarchy
|
|
|
|
|
+ 'sites': [self.site_a],
|
|
|
|
|
+ 'locations': [self.loc_parent], # device-only, hierarchy
|
|
|
|
|
+ 'device_types': [self.devicetype], # device-only
|
|
|
|
|
+ 'roles': [self.role_parent], # hierarchy
|
|
|
|
|
+ 'platforms': [self.plat_parent], # hierarchy
|
|
|
|
|
+ 'cluster_types': [self.cluster.type],
|
|
|
|
|
+ 'cluster_groups': [self.cluster.group],
|
|
|
|
|
+ 'clusters': [self.cluster],
|
|
|
|
|
+ 'tenant_groups': [self.tg], # direct (immediate group only)
|
|
|
|
|
+ 'tenants': [self.tenant],
|
|
|
|
|
+ 'tags': [self.tag],
|
|
|
|
|
+ }
|
|
|
|
|
+ for i, (field, items) in enumerate(scopes.items()):
|
|
|
|
|
+ cc = ConfigContext.objects.create(name=f'cc-{field}', weight=100 + i, data={field: True})
|
|
|
|
|
+ getattr(cc, field).set(items)
|
|
|
|
|
+ self._assert_parity(cc)
|
|
|
|
|
+
|
|
|
|
|
+ # A context with no scope matches every object.
|
|
|
|
|
+ cc_all = ConfigContext.objects.create(name='cc-all', weight=999, data={'all': True})
|
|
|
|
|
+ self._assert_parity(cc_all)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class ConfigContextCacheQueryCountTest(TestCase):
|
|
|
|
|
+ """
|
|
|
|
|
+ Proves the optimization: when caches are warm, get_config_context() issues no per-object
|
|
|
|
|
+ queries, whereas the cold fallback path scales with the number of objects.
|
|
|
|
|
+ """
|
|
|
|
|
+
|
|
|
|
|
+ @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')
|
|
|
|
|
+ for i in range(4):
|
|
|
|
|
+ Device.objects.create(name=f'Device {i}', device_type=devicetype, role=role, site=site)
|
|
|
|
|
+ ConfigContext.objects.create(name='A', weight=100, data={'a': 1})
|
|
|
|
|
+ ConfigContext.objects.create(name='B', weight=200, data={'b': 2})
|
|
|
|
|
+
|
|
|
|
|
+ def test_warm_cache_avoids_per_object_queries(self):
|
|
|
|
|
+ # Cold: caches are NULL, so each object renders on demand.
|
|
|
|
|
+ Device.objects.update(_config_context_data=None)
|
|
|
|
|
+ cold_devices = list(Device.objects.all())
|
|
|
|
|
+ with CaptureQueriesContext(connection) as cold:
|
|
|
|
|
+ for device in cold_devices:
|
|
|
|
|
+ device.get_config_context()
|
|
|
|
|
+
|
|
|
|
|
+ # Warm the caches.
|
|
|
|
|
+ for device in Device.objects.all():
|
|
|
|
|
+ Device.objects.filter(pk=device.pk).update(_config_context_data=device.render_config_context())
|
|
|
|
|
+
|
|
|
|
|
+ warm_devices = list(Device.objects.all())
|
|
|
|
|
+ with CaptureQueriesContext(connection) as warm:
|
|
|
|
|
+ for device in warm_devices:
|
|
|
|
|
+ device.get_config_context()
|
|
|
|
|
+
|
|
|
|
|
+ self.assertEqual(len(warm.captured_queries), 0)
|
|
|
|
|
+ self.assertGreaterEqual(len(cold.captured_queries), len(cold_devices))
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class ConditionalConfigContextAnnotationTest(TestCase):
|
|
|
|
|
+ """
|
|
|
|
|
+ annotate_config_context_data(only_invalidated=True) is the list/detail read-path optimization:
|
|
|
|
|
+ it must compute the aggregation only for rows whose cache is NULL and leave it NULL for warm
|
|
|
|
|
+ rows, while a single query still serves a mix of warm and cold objects correctly via
|
|
|
|
|
+ get_config_context().
|
|
|
|
|
+ """
|
|
|
|
|
+
|
|
|
|
|
+ @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.warm = Device.objects.create(name='Warm', device_type=devicetype, role=role, site=site)
|
|
|
|
|
+ cls.cold = Device.objects.create(name='Cold', device_type=devicetype, role=role, site=site)
|
|
|
|
|
+ ConfigContext.objects.create(name='A', weight=100, data={'a': 1})
|
|
|
|
|
+ ConfigContext.objects.create(name='B', weight=200, data={'b': 2})
|
|
|
|
|
+
|
|
|
|
|
+ def setUp(self):
|
|
|
|
|
+ # Warm one device with a sentinel cache, leave the other invalidated.
|
|
|
|
|
+ _set_cache(self.warm, {'sentinel': True})
|
|
|
|
|
+ Device.objects.filter(pk=self.cold.pk).update(_config_context_data=None)
|
|
|
|
|
+
|
|
|
|
|
+ def test_annotation_skips_warm_rows(self):
|
|
|
|
|
+ rows = {
|
|
|
|
|
+ d.pk: d
|
|
|
|
|
+ for d in Device.objects.annotate_config_context_data(only_invalidated=True)
|
|
|
|
|
+ }
|
|
|
|
|
+ # Warm row: the aggregation must not have run; the annotated value is NULL.
|
|
|
|
|
+ self.assertIsNone(rows[self.warm.pk].config_context_data)
|
|
|
|
|
+ # Cold row: the aggregation ran and produced the ordered list of context data.
|
|
|
|
|
+ self.assertEqual(rows[self.cold.pk].config_context_data, [{'a': 1}, {'b': 2}])
|
|
|
|
|
+
|
|
|
|
|
+ def test_single_query_serves_mixed_warm_and_cold(self):
|
|
|
|
|
+ qs = Device.objects.annotate_config_context_data(only_invalidated=True)
|
|
|
|
|
+ with CaptureQueriesContext(connection) as ctx:
|
|
|
|
|
+ rows = {d.pk: d.get_config_context() for d in qs}
|
|
|
|
|
+ # The warm row returns its cached sentinel; the cold row is rendered from the annotation.
|
|
|
|
|
+ self.assertEqual(rows[self.warm.pk], {'sentinel': True})
|
|
|
|
|
+ self.assertEqual(rows[self.cold.pk], {'a': 1, 'b': 2})
|
|
|
|
|
+ # A single query backs the whole page — no per-object fallback to get_for_object().
|
|
|
|
|
+ self.assertEqual(len(ctx.captured_queries), 1)
|