@@ -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