Skip to content

Commit 1dd0a09

Browse files
authored
fix(engine): close unpinned rule-cache torn-read + review follow-ups to #49 (#50)
* fix(engine): publish the rule cache via atomic rebind to close the unpinned torn-read The unpinned path returned _RULE_CACHE["exact"], ["regex"], ["memo"] as three separate dict reads while a reload mutated those keys in place, so a concurrent reload on another worker thread could pair one rule-set version's rules with a different version's memo and then memoize a stale-version result into the live memo (persisting until the next rule edit). _get_enabled_rules() now reads the module global once into a local, and a reload publishes the new set by rebinding _RULE_CACHE to a fresh dict in a single atomic assignment. A reader sees the whole old set or the whole new one — never exact from V1 paired with memo from V2. The pin-prime path captures from the same consistent local, so it can no longer snapshot a torn pair either. * fix(config): raise min_version to 4.3.0 to match the documented floor PR #49 raised the stated compatibility floor to NetBox >= 4.3.0 in README.md and docs/installation.md but left the enforced PluginConfig.min_version at 4.2.0, so a 4.2.x install would still load despite the docs declaring it unsupported. Align the gate with the docs and pin them together with a test. * 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. * fix(engine): make the rule-cache memo read atomic and isolate the pinned memo CodeRabbit (Major, #50): find_matching_rule's `if sig in memo: return memo[sig]` is a non-atomic compound read — another thread sharing the per-version memo can `memo.clear()` at the cap between the membership test and the subscript, raising a sporadic KeyError under threaded workers (the switch can land between the two bytecodes even under the GIL). The pinned path made it worse by aliasing the shared memo into thread-local state. - Read via a single atomic `memo.get(sig, _MEMO_MISS)` (sentinel distinct from a memoized None), so the separate membership test the race needs no longer exists — fixes both the unpinned and pinned paths. - Copy the memo into the pin (`_pin.memo = dict(cache["memo"])`) and return it, so a pinned batch neither shares nor races the global memo and a concurrent clear can't evict its warmed entries mid-loop. Both tests red-checked: test_find_matching_rule_survives_concurrent_memo_clear reproduces the KeyError deterministically with a dict that clears on its membership test; test_pinned_block_uses_a_private_memo_copy asserts the pin holds a private copy.
1 parent 9e3aecb commit 1dd0a09

4 files changed

Lines changed: 237 additions & 29 deletions

File tree

netbox_interface_name_rules/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ class InterfaceNameRulesConfig(PluginConfig):
1313
description = "Automatic interface renaming when modules are installed into bays."
1414
version = __version__
1515
base_url = "interface-name-rules"
16-
min_version = "4.2.0"
16+
min_version = "4.3.0"
1717
required_settings = []
1818
default_settings = {}
1919
author = "Marcin Zieba"

netbox_interface_name_rules/engine.py

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@
2727
# per call and the set reloaded only when it changes, so it self-invalidates on any
2828
# create/delete/edit — including raw bulk ``.update()`` of any matching field and SET_NULL
2929
# cascades that bypass auto_now, and across test transactions — with no signal wiring.
30+
# A reload publishes the new set by rebinding this name to a fresh dict in one atomic assignment
31+
# (see _get_enabled_rules), so a concurrent reader on another worker thread sees either the whole
32+
# old set or the whole new one — never exact/regex/memo torn across two versions.
3033
_RULE_CACHE = {"version": None, "exact": (), "regex": (), "memo": {}}
3134

3235
# Per-version cap on the find_matching_rule memo. A long-lived worker that sees many distinct
@@ -35,6 +38,13 @@
3538
# of contexts in any single module-sync render, so it never churns mid-render.
3639
_MEMO_MAX = 4096
3740

41+
# Sentinel for the memo read. A single ``memo.get(sig, _MEMO_MISS)`` is one atomic dict lookup, so a
42+
# concurrent ``memo.clear()`` at the cap can't wedge between a membership test and the subscript the
43+
# way ``if sig in memo: return memo[sig]`` could — that compound read can raise a sporadic KeyError
44+
# under threaded workers sharing one per-version memo. A real "no rule matched" is memoized as None,
45+
# so the miss sentinel must be a distinct object, not None.
46+
_MEMO_MISS = object()
47+
3848
# Per-thread pin depth (see pinned_rule_cache). While > 0 on the current thread, _get_enabled_rules
3949
# trusts the loaded set without re-reading the fingerprint, so an internal batch that wraps its loop
4050
# turns N per-call fingerprint queries into one. Thread-local so concurrent requests don't affect
@@ -198,6 +208,8 @@ def _get_enabled_rules():
198208
lookup in the block) the fingerprint query is skipped and the snapshot captured at prime time is
199209
returned — never the live ``_RULE_CACHE``, which another thread may reload mid-block.
200210
"""
211+
global _RULE_CACHE
212+
201213
pinned = getattr(_pin, "depth", 0) > 0
202214
if pinned and getattr(_pin, "primed", False):
203215
# Serve the snapshot captured when this block primed, not the shared cache: a concurrent
@@ -207,27 +219,36 @@ def _get_enabled_rules():
207219

208220
from .models import InterfaceNameRule
209221

222+
# Read the module global exactly once. A reload below publishes the new set by rebinding
223+
# _RULE_CACHE to a brand-new dict (a single atomic name assignment) rather than mutating this
224+
# one in place, so this local is a consistent snapshot: exact/regex/memo can never be torn
225+
# across two rule-set versions even if another thread reloads between the reads at the end.
226+
cache = _RULE_CACHE
210227
version = _enabled_rules_version()
211-
if _RULE_CACHE["version"] != version:
228+
if cache["version"] != version:
212229
rules = list(InterfaceNameRule.objects.filter(enabled=True).order_by("module_type__model", "pk"))
213-
_RULE_CACHE["exact"] = tuple(r for r in rules if not r.module_type_is_regex)
230+
exact = tuple(r for r in rules if not r.module_type_is_regex)
214231
regex_rules = sorted(
215232
(r for r in rules if r.module_type_is_regex),
216233
key=lambda r: (-len(r.module_type_pattern or ""), r.pk),
217234
)
218-
_RULE_CACHE["regex"] = tuple((_compile_pattern(r.module_type_pattern), r) for r in regex_rules)
219-
_RULE_CACHE["memo"] = {}
220-
_RULE_CACHE["version"] = version
235+
regex = tuple((_compile_pattern(r.module_type_pattern), r) for r in regex_rules)
236+
# Publish the whole new version atomically: a reader either sees the old dict or this one,
237+
# never a mix of the two. Last writer wins; a concurrent reload to the same version just
238+
# rebuilds redundantly, never corrupts.
239+
cache = {"version": version, "exact": exact, "regex": regex, "memo": {}}
240+
_RULE_CACHE = cache
221241
if pinned:
222242
# Pin this thread to the freshly-resolved set for the rest of the block. The tuples are
223-
# immutable and the memo dict is now this thread's own reference, so a later global reload
224-
# leaves our snapshot — and the entries we memoize into it — untouched.
225-
_pin.exact = _RULE_CACHE["exact"]
226-
_pin.regex = _RULE_CACHE["regex"]
227-
_pin.memo = _RULE_CACHE["memo"]
243+
# immutable and the memo is COPIED into thread-local state — not aliased — so the pinned batch
244+
# neither shares nor races the global memo: another thread clearing the shared memo at the cap
245+
# can't evict our warmed entries (or wedge a KeyError) mid-loop, and entries we add stay private.
246+
_pin.exact = cache["exact"]
247+
_pin.regex = cache["regex"]
248+
_pin.memo = dict(cache["memo"])
228249
_pin.primed = True
229250
return _pin.exact, _pin.regex, _pin.memo
230-
return _RULE_CACHE["exact"], _RULE_CACHE["regex"], _RULE_CACHE["memo"]
251+
return cache["exact"], cache["regex"], cache["memo"]
231252

232253

233254
def _get_parent_module_type(module_bay):
@@ -675,8 +696,11 @@ def find_matching_rule(module_type, parent_module_type, device_type, platform=No
675696
# The regex tier matches against module_type.model (a live string), so the memo must key on
676697
# it too — otherwise a ModuleType.model rename (same pk) would return a stale regex result.
677698
sig = (module_type.pk, module_type.model, *_scope_ids(parent_module_type, device_type, platform))
678-
if sig in memo:
679-
return memo[sig]
699+
# One atomic lookup, not `if sig in memo: return memo[sig]`: another thread sharing this per-version
700+
# memo can clear it at the cap between a membership test and the subscript, raising KeyError.
701+
cached = memo.get(sig, _MEMO_MISS)
702+
if cached is not _MEMO_MISS:
703+
return cached
680704

681705
candidates = _build_candidates(parent_module_type, device_type, platform)
682706
result = _find_exact_match(module_type, candidates, exact_rules) or _find_regex_match(

netbox_interface_name_rules/tests/test_misc.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -801,3 +801,22 @@ def test_to_csv_no_dots_in_headers(self):
801801
"""csv_headers must not contain dots (regression guard for KeyError 'Ch' bug)."""
802802
for header in InterfaceNameRule.csv_headers:
803803
self.assertNotIn(".", header, f"csv_headers entry '{header}' contains a dot — would break import")
804+
805+
806+
# ---------------------------------------------------------------------------
807+
# __init__.py — PluginConfig version gate (must match the documented floor)
808+
# ---------------------------------------------------------------------------
809+
810+
811+
class PluginConfigVersionTest(TestCase):
812+
"""The enforced min_version gate must match the compatibility floor stated in the docs."""
813+
814+
def test_min_version_matches_documented_floor(self):
815+
"""min_version is the gate NetBox uses to refuse loading the plugin, so it must equal the
816+
floor advertised in README.md / docs/installation.md (NetBox >= 4.3.0). A drift here would
817+
let an unsupported NetBox load the plugin (or reject a supported one) with no other guard —
818+
the inconsistency a reviewer flagged when the docs were raised to 4.3.0 but the gate stayed 4.2.0.
819+
"""
820+
from netbox_interface_name_rules import InterfaceNameRulesConfig
821+
822+
self.assertEqual(InterfaceNameRulesConfig.min_version, "4.3.0")

0 commit comments

Comments
 (0)