Skip to content

Commit 914ddba

Browse files
committed
test(engine): harden rule-cache tests and guard _VERSION_COLUMNS exhaustiveness
- Add test_reload_publishes_a_fresh_cache_dict_atomically: asserts a reload swaps in a new cache dict instead of mutating the old one in place — the invariant behind the unpinned torn-read fix. - Rewrite test_pinned_block_holds_snapshot_across_concurrent_cache_reload to use a real second thread, proving _pin is thread-local (a plain global now fails it), with try/finally cache restoration so the simulated reload can't leak. - test_memo_is_bounded now checks the cap after every insert, catching a mid-loop leak toward 2*cap that the old end-of-loop snapshot missed. - Add EnabledRuleFingerprintColumnsTest: fails if any concrete model column is neither fingerprinted nor explicitly excluded, so a future match-affecting field can't silently break fingerprint-based cache invalidation.
1 parent e781c55 commit 914ddba

1 file changed

Lines changed: 129 additions & 21 deletions

File tree

netbox_interface_name_rules/tests/test_rules.py

Lines changed: 129 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -485,10 +485,25 @@ def test_memo_is_bounded(self):
485485
ModuleType.objects.create(manufacturer=mfr, model=f"MEMO-{i}", part_number=f"MEMO-{i}")
486486
for i in range(5)
487487
]
488-
for mt in module_types: # five distinct contexts → five distinct memo keys
488+
for i, mt in enumerate(module_types, start=1): # five distinct contexts → five distinct memo keys
489489
find_matching_rule(mt, None, None)
490-
491-
self.assertLessEqual(len(engine._RULE_CACHE["memo"]), engine._MEMO_MAX)
490+
# Check the bound after EVERY insert, not just once at the end. The memo size must never
491+
# exceed the cap at any point. A regression that let it leak toward 2*cap before clearing
492+
# would slip past an end-of-loop `<=` snapshot (which only sees the post-clear size) but
493+
# trips here on the very insert that crosses the cap.
494+
self.assertLessEqual(
495+
len(engine._RULE_CACHE["memo"]),
496+
engine._MEMO_MAX,
497+
f"memo exceeded the cap mid-insertion after {i} contexts",
498+
)
499+
500+
# And the eviction actually fired: the final size is below the number of distinct contexts,
501+
# so the test isn't vacuously green on a memo that simply never reached the cap.
502+
self.assertLess(
503+
len(engine._RULE_CACHE["memo"]),
504+
len(module_types),
505+
"memo never evicted — the cap was never exercised",
506+
)
492507
finally:
493508
engine._MEMO_MAX = original_max
494509

@@ -533,33 +548,85 @@ def test_pinned_block_uses_set_loaded_at_entry_then_resumes(self):
533548
self.assertEqual(find_matching_rule(self.module_type, None, self.device_type), specific)
534549

535550
def test_pinned_block_holds_snapshot_across_concurrent_cache_reload(self):
536-
"""A pinned batch keeps its rule-set snapshot even if another thread reloads the shared cache.
551+
"""A pinned batch keeps its rule-set snapshot even when a real second thread reloads the cache.
537552
538553
The pin must capture exact/regex/memo at prime time; if it re-read the live module-level
539554
_RULE_CACHE, a concurrent request reloading it mid-block would silently switch this batch to a
540-
different rule set — renaming one device's modules with mixed rule versions.
555+
different rule set — renaming one device's modules with mixed rule versions. This exercises the
556+
guarantee across an actual thread: the worker must NOT inherit this thread's pin (``_pin`` is
557+
``threading.local``), and its reload — published the way ``_get_enabled_rules`` does, by
558+
rebinding ``_RULE_CACHE`` to a fresh dict — must leave our primed snapshot untouched. A plain
559+
global ``_pin`` would leak the pin into the worker and pass the old same-thread test, but fail here.
541560
"""
561+
import threading
562+
542563
from netbox_interface_name_rules import engine
543564
from netbox_interface_name_rules.engine import pinned_rule_cache
544565

545566
universal = InterfaceNameRule.objects.create(module_type=self.module_type, name_template="a{bay_position}")
546567

547-
with pinned_rule_cache():
548-
self.assertEqual(find_matching_rule(self.module_type, None, self.device_type), universal) # primes
549-
550-
# Simulate a concurrent request on another thread reloading _RULE_CACHE to a different
551-
# version mid-block — exactly what _get_enabled_rules() does on a version change.
552-
engine._RULE_CACHE["version"] = "concurrent-reload-other-version"
553-
engine._RULE_CACHE["exact"] = ()
554-
engine._RULE_CACHE["regex"] = ()
555-
engine._RULE_CACHE["memo"] = {}
556-
557-
# Still inside the pin: must serve the snapshot captured at entry, not the now-empty global.
558-
self.assertEqual(
559-
find_matching_rule(self.module_type, None, self.device_type),
560-
universal,
561-
"pinned block switched rule sets after a concurrent _RULE_CACHE reload",
562-
)
568+
worker_pin_depth = []
569+
570+
def concurrent_reload():
571+
# A genuine second thread. _pin is thread-local, so this worker must see depth 0 — it never
572+
# inherits the main thread's active pin. It then publishes a different rule-set version the
573+
# way _get_enabled_rules() does: one atomic rebind of the module global. (No DB access — a
574+
# separate thread has its own connection and cannot see this TestCase's uncommitted rows.)
575+
worker_pin_depth.append(getattr(engine._pin, "depth", 0))
576+
engine._RULE_CACHE = {"version": "concurrent-reload-other-version", "exact": (), "regex": (), "memo": {}}
577+
578+
try:
579+
with pinned_rule_cache():
580+
self.assertEqual(find_matching_rule(self.module_type, None, self.device_type), universal) # primes
581+
582+
worker = threading.Thread(target=concurrent_reload)
583+
worker.start()
584+
worker.join()
585+
586+
# The worker did not inherit our pin — proves _pin is per-thread, not a shared global.
587+
self.assertEqual(worker_pin_depth, [0], "pin leaked across threads — _pin is not thread-local")
588+
# ...and its reload really did replace the shared cache.
589+
self.assertEqual(engine._RULE_CACHE["version"], "concurrent-reload-other-version")
590+
591+
# Still inside the pin: must serve the snapshot captured at entry, not the worker's reload.
592+
self.assertEqual(
593+
find_matching_rule(self.module_type, None, self.device_type),
594+
universal,
595+
"pinned block switched rule sets after a concurrent _RULE_CACHE reload",
596+
)
597+
finally:
598+
# This test deliberately rebinds the module global from another thread; restore a clean
599+
# sentinel so the simulated reload can't leak into sibling tests even if setUp() is weakened.
600+
engine._RULE_CACHE = {"version": None, "exact": (), "regex": (), "memo": {}}
601+
602+
def test_reload_publishes_a_fresh_cache_dict_atomically(self):
603+
"""A version change rebinds _RULE_CACHE to a new dict instead of mutating the old one in place.
604+
605+
This is what makes the unpinned three-key read a consistent snapshot: a reader that grabbed the
606+
cache before a concurrent reload keeps one whole rule-set version, never exact from V1 paired
607+
with memo from V2. We assert the published dict is a *new object* and that a reference captured
608+
before the reload is left untouched — the in-place mutation the previous code did would fail both.
609+
"""
610+
from netbox_interface_name_rules import engine
611+
612+
InterfaceNameRule.objects.create(module_type=self.module_type, name_template="a{bay_position}")
613+
find_matching_rule(self.module_type, None, self.device_type) # prime version 1
614+
615+
snap = engine._RULE_CACHE
616+
snap_version = snap["version"]
617+
snap_exact = snap["exact"]
618+
619+
# Change the rule set so the next lookup must reload to a new version.
620+
InterfaceNameRule.objects.create(
621+
module_type=self.module_type, device_type=self.device_type, name_template="b{bay_position}"
622+
)
623+
find_matching_rule(self.module_type, None, self.device_type) # reload to version 2
624+
625+
self.assertIsNot(
626+
engine._RULE_CACHE, snap, "reload mutated the cache dict in place instead of publishing a new one"
627+
)
628+
self.assertEqual(snap["version"], snap_version, "a reload mutated a previously-published cache dict")
629+
self.assertEqual(snap["exact"], snap_exact, "the captured exact snapshot changed under a concurrent reload")
563630

564631
def test_fingerprint_resists_separator_injection_in_text_fields(self):
565632
r"""Separators embedded in a text field must not let one rule set forge another's fingerprint.
@@ -599,3 +666,44 @@ def test_fingerprint_resists_separator_injection_in_text_fields(self):
599666
fp_forged_one_rule,
600667
"distinct rule sets collided — a text-field separator forged a row boundary in the fingerprint",
601668
)
669+
670+
671+
class EnabledRuleFingerprintColumnsTest(TestCase):
672+
"""_VERSION_COLUMNS must stay exhaustive over the model's match-affecting columns."""
673+
674+
def test_version_columns_account_for_every_concrete_column(self):
675+
"""Every concrete InterfaceNameRule column must be fingerprinted or deliberately excluded.
676+
677+
The enabled-rule fingerprint (engine._enabled_rules_version) hashes exactly
678+
engine._VERSION_COLUMNS. If a future field that affects matching is added to the model but not
679+
to that hand-maintained tuple, edits to it won't change the fingerprint and find_matching_rule
680+
will keep serving a stale cached rule. This guard fails the moment a new concrete column appears
681+
that hasn't been consciously classified — forcing the author to either add it to _VERSION_COLUMNS
682+
(so the cache invalidates on its edits) or list it below with a reason it can't affect a match.
683+
"""
684+
from netbox_interface_name_rules import engine
685+
from netbox_interface_name_rules.models import InterfaceNameRule
686+
687+
# Columns intentionally NOT in the fingerprint, each with the reason it cannot change a match:
688+
excluded = {
689+
# the fingerprint's own filter predicate (filter(enabled=True)) — toggling it adds/removes
690+
# the row from the aggregate, so the hash already changes; it must not also be a column
691+
"enabled",
692+
"description", # operator notes; never consulted by the engine
693+
"created", # audit timestamp
694+
"last_updated", # audit timestamp
695+
"custom_field_data", # NetBox custom fields; not part of rule matching
696+
}
697+
concrete_columns = {
698+
f.column
699+
for f in InterfaceNameRule._meta.get_fields()
700+
if getattr(f, "concrete", False) and not f.many_to_many and f.column
701+
}
702+
classified = set(engine._VERSION_COLUMNS) | excluded
703+
self.assertEqual(
704+
concrete_columns,
705+
classified,
706+
"InterfaceNameRule has concrete columns that are neither fingerprinted nor excluded. "
707+
"Classify each: add match-affecting columns to engine._VERSION_COLUMNS so the rule cache "
708+
"invalidates when they change, or add audit/notes columns to this test's `excluded` set.",
709+
)

0 commit comments

Comments
 (0)