Skip to content

Commit 8a42751

Browse files
authored
fix: include active branch in find_existing_object cache keys (#163)
The Redis-backed find_existing_object cache keyed entries by (object_type, scalar_field_fingerprint) only. The active NetBox Branching schema was not part of the key, allowing cross-branch cache pollution when concurrent ingests target different branches. Read the active_branch contextvar from netbox_branching to namespace both the lookup cache key and the reverse-index key per branch. Non-branching deployments are unaffected (key format unchanged).
1 parent ff3786d commit 8a42751

3 files changed

Lines changed: 330 additions & 6 deletions

File tree

netbox_diode_plugin/api/matcher.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,32 @@ def _get_find_obj_cache_ttl() -> int:
3232
return get_plugin_config("netbox_diode_plugin", "find_obj_cache_ttl")
3333

3434

35+
def _get_active_branch_schema() -> str | None:
36+
try:
37+
from netbox_branching.contextvars import active_branch
38+
except ImportError:
39+
return None
40+
branch = active_branch.get()
41+
if branch is not None:
42+
return branch.schema_id
43+
return None
44+
45+
46+
def _find_obj_rev_key(object_type: str, object_id: int) -> str:
47+
branch_schema = _get_active_branch_schema()
48+
if branch_schema:
49+
return f"diode:fobj:rev:{branch_schema}:{object_type}:{object_id}"
50+
return f"diode:fobj:rev:{object_type}:{object_id}"
51+
52+
3553
def invalidate_find_obj_entry(object_type: str, object_id: int):
3654
"""
3755
Delete a cached find_existing_object result by PK.
3856
3957
Uses a reverse-index (PK → cache key) to find and delete the
4058
lookup cache entry. Call this after updating an existing object.
4159
"""
42-
rev_key = f"diode:fobj:rev:{object_type}:{object_id}"
60+
rev_key = _find_obj_rev_key(object_type, object_id)
4361
lookup_key = django_cache.get(rev_key)
4462
if lookup_key:
4563
django_cache.delete(lookup_key)
@@ -921,7 +939,11 @@ def _find_obj_cache_key(data: dict, object_type: str) -> str | None:
921939
if not items:
922940
return None
923941

924-
raw = f"{object_type}:{items}"
942+
branch_schema = _get_active_branch_schema()
943+
if branch_schema:
944+
raw = f"{branch_schema}:{object_type}:{items}"
945+
else:
946+
raw = f"{object_type}:{items}"
925947
key_hash = hashlib.sha256(raw.encode()).hexdigest()[:20]
926948
return f"diode:fobj:{key_hash}"
927949

@@ -955,8 +977,7 @@ def find_existing_object(data: dict, object_type: str): # noqa: C901
955977
# Object deleted since cached — clean up and fall through
956978
cache_hit = False
957979
django_cache.delete(cache_key)
958-
rev_key = f"diode:fobj:rev:{object_type}:{cached_id}"
959-
django_cache.delete(rev_key)
980+
django_cache.delete(_find_obj_rev_key(object_type, cached_id))
960981

961982
if not cache_hit:
962983
for matcher in get_model_matchers(model_class):
@@ -973,8 +994,7 @@ def find_existing_object(data: dict, object_type: str): # noqa: C901
973994

974995
if cache_key and result is not None:
975996
django_cache.set(cache_key, result.id, cache_ttl)
976-
rev_key = f"diode:fobj:rev:{object_type}:{result.id}"
977-
django_cache.set(rev_key, cache_key, cache_ttl)
997+
django_cache.set(_find_obj_rev_key(object_type, result.id), cache_key, cache_ttl)
978998

979999
if ctx:
9801000
ctx.record_timing("find_obj", (time.monotonic() - start) * 1000)

netbox_diode_plugin/tests/v4.4.x/tests/test_matcher_cache.py

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from netbox_diode_plugin.api.common import UnresolvedReference
1212
from netbox_diode_plugin.api.matcher import (
1313
_find_obj_cache_key,
14+
_find_obj_rev_key,
1415
find_existing_object,
1516
invalidate_find_obj_entry,
1617
)
@@ -292,3 +293,154 @@ def test_invalidate_does_not_affect_other_entries(self, _mock_ttl):
292293
# Main should be gone
293294
cache_key_main = _find_obj_cache_key(data_main, "dcim.manufacturer")
294295
self.assertIsNone(django_cache.get(cache_key_main))
296+
297+
298+
BRANCH_SCHEMA_MOCK = "netbox_diode_plugin.api.matcher._get_active_branch_schema"
299+
300+
301+
class BranchAwareCacheKeyTestCase(TestCase):
302+
"""Tests that cache keys are isolated per branch."""
303+
304+
def setUp(self):
305+
"""Clear cache before each test."""
306+
django_cache.clear()
307+
308+
def tearDown(self):
309+
"""Clear cache after each test."""
310+
django_cache.clear()
311+
312+
def test_different_branch_different_cache_key(self):
313+
"""Same data under different branches produces different cache keys."""
314+
data = {"name": "Cisco"}
315+
with mock.patch(BRANCH_SCHEMA_MOCK, return_value="branch_a"):
316+
key_a = _find_obj_cache_key(data, "dcim.manufacturer")
317+
with mock.patch(BRANCH_SCHEMA_MOCK, return_value="branch_b"):
318+
key_b = _find_obj_cache_key(data, "dcim.manufacturer")
319+
self.assertNotEqual(key_a, key_b)
320+
321+
def test_same_branch_same_cache_key(self):
322+
"""Same data under the same branch produces identical cache keys."""
323+
data = {"name": "Cisco"}
324+
with mock.patch(BRANCH_SCHEMA_MOCK, return_value="branch_a"):
325+
key1 = _find_obj_cache_key(data, "dcim.manufacturer")
326+
key2 = _find_obj_cache_key(data, "dcim.manufacturer")
327+
self.assertEqual(key1, key2)
328+
329+
def test_no_branch_unchanged_from_legacy_format(self):
330+
"""Without active branch, cache key matches the original format."""
331+
data = {"name": "Cisco"}
332+
with mock.patch(BRANCH_SCHEMA_MOCK, return_value=None):
333+
key = _find_obj_cache_key(data, "dcim.manufacturer")
334+
self.assertTrue(key.startswith("diode:fobj:"))
335+
336+
def test_branch_vs_no_branch_different_keys(self):
337+
"""A branched key differs from a non-branched key for the same data."""
338+
data = {"name": "Cisco"}
339+
with mock.patch(BRANCH_SCHEMA_MOCK, return_value=None):
340+
key_main = _find_obj_cache_key(data, "dcim.manufacturer")
341+
with mock.patch(BRANCH_SCHEMA_MOCK, return_value="branch_a"):
342+
key_branch = _find_obj_cache_key(data, "dcim.manufacturer")
343+
self.assertNotEqual(key_main, key_branch)
344+
345+
def test_different_branch_different_rev_key(self):
346+
"""Reverse-index keys are isolated per branch."""
347+
with mock.patch(BRANCH_SCHEMA_MOCK, return_value="branch_a"):
348+
rev_a = _find_obj_rev_key("dcim.manufacturer", 100)
349+
with mock.patch(BRANCH_SCHEMA_MOCK, return_value="branch_b"):
350+
rev_b = _find_obj_rev_key("dcim.manufacturer", 100)
351+
self.assertNotEqual(rev_a, rev_b)
352+
self.assertIn("branch_a", rev_a)
353+
self.assertIn("branch_b", rev_b)
354+
355+
def test_no_branch_rev_key_unchanged(self):
356+
"""Without active branch, rev key matches the original format."""
357+
with mock.patch(BRANCH_SCHEMA_MOCK, return_value=None):
358+
rev = _find_obj_rev_key("dcim.manufacturer", 100)
359+
self.assertEqual(rev, "diode:fobj:rev:dcim.manufacturer:100")
360+
361+
362+
class BranchAwareFindExistingObjectTestCase(TestCase):
363+
"""Tests that find_existing_object cache does not cross branches."""
364+
365+
def setUp(self):
366+
"""Set up test fixtures."""
367+
self.manufacturer = Manufacturer.objects.create(
368+
name="BranchTestMfr",
369+
slug="branch-test-mfr",
370+
)
371+
django_cache.clear()
372+
373+
def tearDown(self):
374+
"""Clean up cache after each test."""
375+
django_cache.clear()
376+
377+
@mock.patch(
378+
"netbox_diode_plugin.api.matcher._get_find_obj_cache_ttl",
379+
return_value=5,
380+
)
381+
def test_cache_does_not_cross_branches(self, _mock_ttl):
382+
"""Cache populated under branch A is a miss under branch B."""
383+
data = {"name": "BranchTestMfr", "_object_type": "dcim.manufacturer"}
384+
385+
with mock.patch(BRANCH_SCHEMA_MOCK, return_value="branch_a"):
386+
result_a = find_existing_object(data, "dcim.manufacturer")
387+
self.assertEqual(result_a.id, self.manufacturer.id)
388+
cache_key_a = _find_obj_cache_key(data, "dcim.manufacturer")
389+
self.assertIsNotNone(django_cache.get(cache_key_a))
390+
391+
with mock.patch(BRANCH_SCHEMA_MOCK, return_value="branch_b"):
392+
cache_key_b = _find_obj_cache_key(data, "dcim.manufacturer")
393+
self.assertIsNone(django_cache.get(cache_key_b))
394+
395+
@mock.patch(
396+
"netbox_diode_plugin.api.matcher._get_find_obj_cache_ttl",
397+
return_value=5,
398+
)
399+
def test_invalidate_does_not_cross_branches(self, _mock_ttl):
400+
"""Invalidation under branch B does not evict branch A's cache."""
401+
data = {"name": "BranchTestMfr", "_object_type": "dcim.manufacturer"}
402+
403+
with mock.patch(BRANCH_SCHEMA_MOCK, return_value="branch_a"):
404+
find_existing_object(data, "dcim.manufacturer")
405+
cache_key_a = _find_obj_cache_key(data, "dcim.manufacturer")
406+
self.assertIsNotNone(django_cache.get(cache_key_a))
407+
408+
with mock.patch(BRANCH_SCHEMA_MOCK, return_value="branch_b"):
409+
invalidate_find_obj_entry("dcim.manufacturer", self.manufacturer.id)
410+
411+
with mock.patch(BRANCH_SCHEMA_MOCK, return_value="branch_a"):
412+
self.assertIsNotNone(django_cache.get(cache_key_a))
413+
414+
@mock.patch(
415+
"netbox_diode_plugin.api.matcher._get_find_obj_cache_ttl",
416+
return_value=5,
417+
)
418+
def test_invalidate_within_same_branch(self, _mock_ttl):
419+
"""Invalidation under the same branch correctly evicts cache."""
420+
data = {"name": "BranchTestMfr", "_object_type": "dcim.manufacturer"}
421+
422+
with mock.patch(BRANCH_SCHEMA_MOCK, return_value="branch_a"):
423+
find_existing_object(data, "dcim.manufacturer")
424+
cache_key_a = _find_obj_cache_key(data, "dcim.manufacturer")
425+
self.assertIsNotNone(django_cache.get(cache_key_a))
426+
invalidate_find_obj_entry("dcim.manufacturer", self.manufacturer.id)
427+
self.assertIsNone(django_cache.get(cache_key_a))
428+
429+
@mock.patch(
430+
"netbox_diode_plugin.api.matcher._get_find_obj_cache_ttl",
431+
return_value=5,
432+
)
433+
def test_branch_and_main_caches_independent(self, _mock_ttl):
434+
"""Cache entries for main (no branch) and a branch are independent."""
435+
data = {"name": "BranchTestMfr", "_object_type": "dcim.manufacturer"}
436+
437+
with mock.patch(BRANCH_SCHEMA_MOCK, return_value=None):
438+
find_existing_object(data, "dcim.manufacturer")
439+
cache_key_main = _find_obj_cache_key(data, "dcim.manufacturer")
440+
441+
with mock.patch(BRANCH_SCHEMA_MOCK, return_value="branch_a"):
442+
cache_key_branch = _find_obj_cache_key(data, "dcim.manufacturer")
443+
self.assertIsNone(django_cache.get(cache_key_branch))
444+
445+
with mock.patch(BRANCH_SCHEMA_MOCK, return_value=None):
446+
self.assertIsNotNone(django_cache.get(cache_key_main))

netbox_diode_plugin/tests/v4.5.x/tests/test_matcher_cache.py

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from netbox_diode_plugin.api.common import UnresolvedReference
1212
from netbox_diode_plugin.api.matcher import (
1313
_find_obj_cache_key,
14+
_find_obj_rev_key,
1415
find_existing_object,
1516
invalidate_find_obj_entry,
1617
)
@@ -292,3 +293,154 @@ def test_invalidate_does_not_affect_other_entries(self, _mock_ttl):
292293
# Main should be gone
293294
cache_key_main = _find_obj_cache_key(data_main, "dcim.manufacturer")
294295
self.assertIsNone(django_cache.get(cache_key_main))
296+
297+
298+
BRANCH_SCHEMA_MOCK = "netbox_diode_plugin.api.matcher._get_active_branch_schema"
299+
300+
301+
class BranchAwareCacheKeyTestCase(TestCase):
302+
"""Tests that cache keys are isolated per branch."""
303+
304+
def setUp(self):
305+
"""Clear cache before each test."""
306+
django_cache.clear()
307+
308+
def tearDown(self):
309+
"""Clear cache after each test."""
310+
django_cache.clear()
311+
312+
def test_different_branch_different_cache_key(self):
313+
"""Same data under different branches produces different cache keys."""
314+
data = {"name": "Cisco"}
315+
with mock.patch(BRANCH_SCHEMA_MOCK, return_value="branch_a"):
316+
key_a = _find_obj_cache_key(data, "dcim.manufacturer")
317+
with mock.patch(BRANCH_SCHEMA_MOCK, return_value="branch_b"):
318+
key_b = _find_obj_cache_key(data, "dcim.manufacturer")
319+
self.assertNotEqual(key_a, key_b)
320+
321+
def test_same_branch_same_cache_key(self):
322+
"""Same data under the same branch produces identical cache keys."""
323+
data = {"name": "Cisco"}
324+
with mock.patch(BRANCH_SCHEMA_MOCK, return_value="branch_a"):
325+
key1 = _find_obj_cache_key(data, "dcim.manufacturer")
326+
key2 = _find_obj_cache_key(data, "dcim.manufacturer")
327+
self.assertEqual(key1, key2)
328+
329+
def test_no_branch_unchanged_from_legacy_format(self):
330+
"""Without active branch, cache key matches the original format."""
331+
data = {"name": "Cisco"}
332+
with mock.patch(BRANCH_SCHEMA_MOCK, return_value=None):
333+
key = _find_obj_cache_key(data, "dcim.manufacturer")
334+
self.assertTrue(key.startswith("diode:fobj:"))
335+
336+
def test_branch_vs_no_branch_different_keys(self):
337+
"""A branched key differs from a non-branched key for the same data."""
338+
data = {"name": "Cisco"}
339+
with mock.patch(BRANCH_SCHEMA_MOCK, return_value=None):
340+
key_main = _find_obj_cache_key(data, "dcim.manufacturer")
341+
with mock.patch(BRANCH_SCHEMA_MOCK, return_value="branch_a"):
342+
key_branch = _find_obj_cache_key(data, "dcim.manufacturer")
343+
self.assertNotEqual(key_main, key_branch)
344+
345+
def test_different_branch_different_rev_key(self):
346+
"""Reverse-index keys are isolated per branch."""
347+
with mock.patch(BRANCH_SCHEMA_MOCK, return_value="branch_a"):
348+
rev_a = _find_obj_rev_key("dcim.manufacturer", 100)
349+
with mock.patch(BRANCH_SCHEMA_MOCK, return_value="branch_b"):
350+
rev_b = _find_obj_rev_key("dcim.manufacturer", 100)
351+
self.assertNotEqual(rev_a, rev_b)
352+
self.assertIn("branch_a", rev_a)
353+
self.assertIn("branch_b", rev_b)
354+
355+
def test_no_branch_rev_key_unchanged(self):
356+
"""Without active branch, rev key matches the original format."""
357+
with mock.patch(BRANCH_SCHEMA_MOCK, return_value=None):
358+
rev = _find_obj_rev_key("dcim.manufacturer", 100)
359+
self.assertEqual(rev, "diode:fobj:rev:dcim.manufacturer:100")
360+
361+
362+
class BranchAwareFindExistingObjectTestCase(TestCase):
363+
"""Tests that find_existing_object cache does not cross branches."""
364+
365+
def setUp(self):
366+
"""Set up test fixtures."""
367+
self.manufacturer = Manufacturer.objects.create(
368+
name="BranchTestMfr",
369+
slug="branch-test-mfr",
370+
)
371+
django_cache.clear()
372+
373+
def tearDown(self):
374+
"""Clean up cache after each test."""
375+
django_cache.clear()
376+
377+
@mock.patch(
378+
"netbox_diode_plugin.api.matcher._get_find_obj_cache_ttl",
379+
return_value=5,
380+
)
381+
def test_cache_does_not_cross_branches(self, _mock_ttl):
382+
"""Cache populated under branch A is a miss under branch B."""
383+
data = {"name": "BranchTestMfr", "_object_type": "dcim.manufacturer"}
384+
385+
with mock.patch(BRANCH_SCHEMA_MOCK, return_value="branch_a"):
386+
result_a = find_existing_object(data, "dcim.manufacturer")
387+
self.assertEqual(result_a.id, self.manufacturer.id)
388+
cache_key_a = _find_obj_cache_key(data, "dcim.manufacturer")
389+
self.assertIsNotNone(django_cache.get(cache_key_a))
390+
391+
with mock.patch(BRANCH_SCHEMA_MOCK, return_value="branch_b"):
392+
cache_key_b = _find_obj_cache_key(data, "dcim.manufacturer")
393+
self.assertIsNone(django_cache.get(cache_key_b))
394+
395+
@mock.patch(
396+
"netbox_diode_plugin.api.matcher._get_find_obj_cache_ttl",
397+
return_value=5,
398+
)
399+
def test_invalidate_does_not_cross_branches(self, _mock_ttl):
400+
"""Invalidation under branch B does not evict branch A's cache."""
401+
data = {"name": "BranchTestMfr", "_object_type": "dcim.manufacturer"}
402+
403+
with mock.patch(BRANCH_SCHEMA_MOCK, return_value="branch_a"):
404+
find_existing_object(data, "dcim.manufacturer")
405+
cache_key_a = _find_obj_cache_key(data, "dcim.manufacturer")
406+
self.assertIsNotNone(django_cache.get(cache_key_a))
407+
408+
with mock.patch(BRANCH_SCHEMA_MOCK, return_value="branch_b"):
409+
invalidate_find_obj_entry("dcim.manufacturer", self.manufacturer.id)
410+
411+
with mock.patch(BRANCH_SCHEMA_MOCK, return_value="branch_a"):
412+
self.assertIsNotNone(django_cache.get(cache_key_a))
413+
414+
@mock.patch(
415+
"netbox_diode_plugin.api.matcher._get_find_obj_cache_ttl",
416+
return_value=5,
417+
)
418+
def test_invalidate_within_same_branch(self, _mock_ttl):
419+
"""Invalidation under the same branch correctly evicts cache."""
420+
data = {"name": "BranchTestMfr", "_object_type": "dcim.manufacturer"}
421+
422+
with mock.patch(BRANCH_SCHEMA_MOCK, return_value="branch_a"):
423+
find_existing_object(data, "dcim.manufacturer")
424+
cache_key_a = _find_obj_cache_key(data, "dcim.manufacturer")
425+
self.assertIsNotNone(django_cache.get(cache_key_a))
426+
invalidate_find_obj_entry("dcim.manufacturer", self.manufacturer.id)
427+
self.assertIsNone(django_cache.get(cache_key_a))
428+
429+
@mock.patch(
430+
"netbox_diode_plugin.api.matcher._get_find_obj_cache_ttl",
431+
return_value=5,
432+
)
433+
def test_branch_and_main_caches_independent(self, _mock_ttl):
434+
"""Cache entries for main (no branch) and a branch are independent."""
435+
data = {"name": "BranchTestMfr", "_object_type": "dcim.manufacturer"}
436+
437+
with mock.patch(BRANCH_SCHEMA_MOCK, return_value=None):
438+
find_existing_object(data, "dcim.manufacturer")
439+
cache_key_main = _find_obj_cache_key(data, "dcim.manufacturer")
440+
441+
with mock.patch(BRANCH_SCHEMA_MOCK, return_value="branch_a"):
442+
cache_key_branch = _find_obj_cache_key(data, "dcim.manufacturer")
443+
self.assertIsNone(django_cache.get(cache_key_branch))
444+
445+
with mock.patch(BRANCH_SCHEMA_MOCK, return_value=None):
446+
self.assertIsNotNone(django_cache.get(cache_key_main))

0 commit comments

Comments
 (0)