From 7fe98d7e753b3fe2e7db582ade641ac0170eb1d0 Mon Sep 17 00:00:00 2001 From: Andrej Luptak Date: Thu, 7 May 2026 11:50:04 +0200 Subject: [PATCH 1/3] perf: use local cache with ttl for evaluator use local cache with ttl or warm cache time instead of lazy db cache load on each evaluation. Lets say a part of RHINENG-24321 --- common/config.py | 5 ++++ conf/evaluator.env | 1 + evaluator/logic.py | 75 +++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 74 insertions(+), 7 deletions(-) diff --git a/common/config.py b/common/config.py index b0c1af2dd..631f87dd5 100644 --- a/common/config.py +++ b/common/config.py @@ -162,6 +162,11 @@ def __init__(self): self.kessel_auth_oidc_issuer = os.getenv("KESSEL_AUTH_OIDC_ISSUER", "https://sso.redhat.com/auth/realms/redhat-external") self.kessel_insecure = strtobool(os.getenv("KESSEL_INSECURE", "TRUE")) + # Evaluator + self.insights_load_rule_cache_ttl_sec = int( + os.getenv("INSIGHTS_LOAD_RULE_CACHE_TTL_SEC", "0") + ) # 0 -> local cache disabled -> always use DB cache + # pylint: disable=too-many-instance-attributes class Config(BaseConfig, metaclass=Singleton): diff --git a/conf/evaluator.env b/conf/evaluator.env index 48fb7bb25..fc116b3c3 100644 --- a/conf/evaluator.env +++ b/conf/evaluator.env @@ -5,3 +5,4 @@ DB_MAX_POOL_SIZE=30 MAX_LOADED_EVALUATOR_MSGS=20 USE_VMAAS_GO=true INVENTORY_VIEWS_TOPIC=platform.inventory.host-apps +INSIGHTS_LOAD_RULE_CACHE_TTL_SEC=360 diff --git a/evaluator/logic.py b/evaluator/logic.py index 11bd3b171..174ebe5ac 100644 --- a/evaluator/logic.py +++ b/evaluator/logic.py @@ -4,6 +4,8 @@ import json from datetime import datetime +from datetime import timedelta +from datetime import timezone from typing import Dict from typing import List from typing import Optional @@ -55,6 +57,8 @@ def __init__(self, db_pool: AsyncConnectionPool): self.module_cache: Dict[str, ModuleCache] = {} self.vulnerable_package_cache: Dict[(int, int, Optional[int]), VulnerablePackageCache] = {} self.skipped_rules = ["CVE_2017_5715_cpu_virt|VIRT_CVE_2017_5715_CPU_3_ONLYKERNEL", "CVE_2017_5715_cpu_virt"] + self.rule_cache: Dict[str, RuleCache] = {} + self.rule_cache_expires_at: Optional[datetime] = None async def init(self): """Async constructor""" @@ -64,6 +68,7 @@ async def init(self): self.cpe_cache = await self._load_cpe_cache() self.module_cache = await self._load_module_cache() self.vulnerable_package_cache = await self._load_vulnerable_package_cache() + await self._get_rule_cache() # Rules cache warmup async def _load_cve_impact_cache(self) -> Dict[str, CveImpactCache]: """Load cve impact cache from DB""" @@ -86,7 +91,7 @@ async def _load_cve_cache(self) -> Dict[str, CveCache]: return cache async def _load_rule_cache(self, conn: AsyncConnection) -> Dict[str, RuleCache]: - """Load rule cache from DB""" + """Load fresh rule cache from DB. You can also load cache snapshot (see _get_rule_cache)""" cache = {} async with conn.cursor(row_factory=dict_row) as cur: await cur.execute("""SELECT id, name, playbook_count FROM insights_rule""") @@ -94,6 +99,45 @@ async def _load_rule_cache(self, conn: AsyncConnection) -> Dict[str, RuleCache]: cache[rule["name"]] = RuleCache(rule["id"], rule["playbook_count"]) return cache + def _set_rule_cache_expiry(self) -> None: + """Set rule cache expiry; only used when TTL > 0 (see _rule_cache_expired)""" + ttl = CFG.insights_load_rule_cache_ttl_sec + if ttl > 0: + self.rule_cache_expires_at = datetime.now(timezone.utc) + timedelta(seconds=ttl) + else: + self.rule_cache_expires_at = None + + def _rule_cache_expired(self) -> bool: + """TTL <= 0 disables caching (always reload on get from DB)""" + if CFG.insights_load_rule_cache_ttl_sec <= 0: + return True + if not self.rule_cache or self.rule_cache_expires_at is None: + return True + return datetime.now(timezone.utc) >= self.rule_cache_expires_at + + async def _load_rule_cache_for_name(self, conn: AsyncConnection, name: str) -> Optional[RuleCache]: + """Load one insight rule row by name for lazy cache misses during advisor merge""" + async with conn.cursor(row_factory=dict_row) as cur: + await cur.execute( + """SELECT id, playbook_count FROM insights_rule WHERE name = %s""", + (name,), + ) + rule = await cur.fetchone() + if not rule: + return None + return RuleCache(rule["id"], rule["playbook_count"]) + + async def _get_rule_cache(self, conn: Optional[AsyncConnection] = None) -> Dict[str, RuleCache]: + """Return a snapshot of the rule cache, refreshing from DB when cache TTL expired. You can also load full cache from DB (see _load_rule_cache)""" + if self._rule_cache_expired(): + if conn is None: + async with self.db_pool.connection() as conn: + self.rule_cache = await self._load_rule_cache(conn) + else: + self.rule_cache = await self._load_rule_cache(conn) + self._set_rule_cache_expiry() + return dict(self.rule_cache) + async def _load_package_name_cache(self) -> Dict[str, PackageNameCache]: """Load package name cache from DB""" cache = {} @@ -578,8 +622,26 @@ async def _evaluate_advisor_res( rule_cache: Dict[str, RuleCache], system_platform: SystemPlatform, unpatched_cves: Set[str], + conn: AsyncConnection, ) -> Dict[str, SystemVulnerabilitiesRow]: """Merge results from vmaas package evaluation with advisor rule evaluation""" + + missing_rules_not_found = set() + + async def resolve_rule_cache(rule_name: str) -> Optional[RuleCache]: + cached = rule_cache.get(rule_name) + if cached: + return cached + if rule_name in missing_rules_not_found: + return None + fetched = await self._load_rule_cache_for_name(conn, rule_name) + if fetched: + self.rule_cache[rule_name] = fetched + rule_cache[rule_name] = fetched + return fetched + missing_rules_not_found.add(rule_name) + return None + for cve, hit_details in rule_results["rule_hits"].items(): if cve in unpatched_cves: # skip cves that are unpatched to avoid duplicates @@ -596,7 +658,7 @@ async def _evaluate_advisor_res( if not isinstance(hit_details["details"], dict): hit_details["details"] = json.loads(hit_details["details"]) - rule_db = rule_cache.get(rule) + rule_db = await resolve_rule_cache(rule) if not rule_db: continue @@ -645,7 +707,7 @@ async def _evaluate_advisor_res( continue # system was marked vulnerable from vmaas but not from by rules -> abnv - rule_db = rule_cache.get(rule) + rule_db = await resolve_rule_cache(rule) if not rule_db: continue @@ -676,11 +738,10 @@ async def evaluate_vulnerabilities(self, system_platform: SystemPlatform, conn: if system_platform.rule_results: # set of unpatched cves from vmaas for exclusion from advisor evaluation unpatched_cves_set = set(x.cve for x in unpatched_cves) - # unfortunately, rule cache can change meanwhile evaluator is running, - # so it cannot be static and needs to be loaded on each evaluation + # Rule metadata is cached in memory (warm in init), TTL controls refresh while evaluator runs with RULES_EVAL_TIME.time(): - rule_cache = await self._load_rule_cache(conn) + rule_cache = await self._get_rule_cache(conn) sys_vuln_rows = await self._evaluate_advisor_res( - system_platform.rule_results, sys_vuln_rows, rule_cache, system_platform, unpatched_cves_set + system_platform.rule_results, sys_vuln_rows, rule_cache, system_platform, unpatched_cves_set, conn ) return sys_vuln_rows From 65d0bd4162265dcd289cf260a98897b0c71194b7 Mon Sep 17 00:00:00 2001 From: Andrej Luptak Date: Thu, 7 May 2026 12:15:12 +0200 Subject: [PATCH 2/3] tests: evaluator local cache ttl --- .../common_tests/test_evaluator_rule_cache.py | 245 ++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 tests/common_tests/test_evaluator_rule_cache.py diff --git a/tests/common_tests/test_evaluator_rule_cache.py b/tests/common_tests/test_evaluator_rule_cache.py new file mode 100644 index 000000000..1b97291f5 --- /dev/null +++ b/tests/common_tests/test_evaluator_rule_cache.py @@ -0,0 +1,245 @@ +# pylint: disable=missing-docstring,redefined-outer-name +"""Evaluator insights_rule cache and advisor merge behavior""" + +import json +from contextlib import asynccontextmanager +from datetime import datetime +from datetime import timedelta +from datetime import timezone +from types import MethodType + +import pytest + +pytestmark = pytest.mark.asyncio(loop_scope="function") + + +class FakeAsyncPool: + + @asynccontextmanager + async def connection(self): + yield object() # fake conn + + +async def test_get_rule_cache_second_call_skips_full_load_while_hot(monkeypatch): + """With TTL > 0, a warm cache should not trigger another full rule load""" + import evaluator.logic as logic_module + from evaluator.common import RuleCache + from evaluator.logic import EvaluatorLogic + + monkeypatch.setattr(logic_module.CFG, "insights_load_rule_cache_ttl_sec", 600) + + logic = EvaluatorLogic(FakeAsyncPool()) + loads = [] + + async def fake_load_rule_cache(_self, _conn): + loads.append(1) # Each time full load is run, increment the counter + return {"RULE_A": RuleCache(42, 1)} + + logic._load_rule_cache = MethodType(fake_load_rule_cache, logic) + + # Load twice + snap1 = await logic._get_rule_cache() + snap2 = await logic._get_rule_cache() + + # How many times the fake full-load ran + # For live warm cache two loads result into a single db fetch + assert len(loads) == 1 + assert snap1 == snap2 == {"RULE_A": RuleCache(42, 1)} + + +async def test_get_rule_cache_always_reloads_when_ttl_disabled(monkeypatch): + """TTL <= 0 disables caching: every get performs a full load""" + import evaluator.logic as logic_module + from evaluator.common import RuleCache + from evaluator.logic import EvaluatorLogic + + monkeypatch.setattr(logic_module.CFG, "insights_load_rule_cache_ttl_sec", 0) + + logic = EvaluatorLogic(FakeAsyncPool()) + loads = [] + + async def fake_load_rule_cache(_self, _conn): + loads.append(1) + return {"RULE_B": RuleCache(7, 77)} + + logic._load_rule_cache = MethodType(fake_load_rule_cache, logic) + + # Load twice + await logic._get_rule_cache() + await logic._get_rule_cache() + + # How many times the fake full-load ran + # On disabled cache two loads result in two db fetch + assert len(loads) == 2 + + +async def test_get_rule_cache_reloads_after_ttl_expires(monkeypatch): + """TTL > 0: after rule_cache_expires_at is in the past, the next get runs a full load again""" + import evaluator.logic as logic_module + from evaluator.common import RuleCache + from evaluator.logic import EvaluatorLogic + + monkeypatch.setattr(logic_module.CFG, "insights_load_rule_cache_ttl_sec", 600) + + logic = EvaluatorLogic(FakeAsyncPool()) + loads = [] + + async def fake_load_rule_cache(_self, _conn): + loads.append(1) + return {"RULE_C": RuleCache(3, 0)} + + logic._load_rule_cache = MethodType(fake_load_rule_cache, logic) + + await logic._get_rule_cache() + assert len(loads) == 1 + + logic.rule_cache_expires_at = datetime.now(timezone.utc) - timedelta(seconds=1) + + await logic._get_rule_cache() + assert len(loads) == 2 + + +def _rule_only_hit_payload(): + """Advisor rule hit: rule-only CVE (no existing vmaas row), no per-CVE mitigation""" + cve = "CVE-2026-5" + details = {"cves": {}} + rule_id = "TEST_RULE|VARIANT" + return ( + cve, + { + "rule_hits": { + cve: { + "rule_id": rule_id, + "details": details, + } + }, + "rule_passes": {}, + }, + rule_id, + ) + + +async def test_evaluate_advisor_res_lazy_fetch_when_warm_cache_omits_rule(monkeypatch): + """Full load populated TTL-warm map but not the advisor rule id -> per-name DB fetch on merge""" + import evaluator.logic as logic_module + from common.peewee_model import VulnerabilityState + from evaluator.common import CveCache + from evaluator.common import RuleCache + from evaluator.common import SystemPlatform + from evaluator.logic import EvaluatorLogic + + # TTL is used to decide the refresh, we need a positive TTL so the cache is "warm" after load + monkeypatch.setattr(logic_module.CFG, "insights_load_rule_cache_ttl_sec", 600) + + logic = EvaluatorLogic(FakeAsyncPool()) + cve, rule_results, rule_id = _rule_only_hit_payload() # payload references rule_id from rule_hits + expected_rule = RuleCache(99, 2) # row we pretend DB returns for that name on lazy fetch + platform = SystemPlatform(1, "inv-1", 1, None, {}, None, True) + conn = object() + + # Advisor path calls _get_or_upsert_cve per CVE instead of (DB/vmaas) + async def fake_get_cve(_self, _cve: str) -> CveCache: + return CveCache(500, 1, False) + + logic._get_or_upsert_cve = MethodType(fake_get_cve, logic) + + # Count full-table loads + # to verify, not to DB cache load run again while TTL is warm (only per-name fetch should run) + full_load_calls = [] + + # Simulate a full insights_rule refresh that does NOT include the rule in the advisor payload + async def full_load_other_rules_only(_self, _conn): + full_load_calls.append(1) + return {"THE_OTHER_RULE|X": RuleCache(1, 0)} + + logic._load_rule_cache = MethodType(full_load_other_rules_only, logic) + await logic._get_rule_cache() # fills self.rule_cache + sets rule_cache_expires_at + + assert len(full_load_calls) == 1 + + assert rule_id not in logic.rule_cache # miss: payload rule not in full-load cache map + assert logic.rule_cache_expires_at is not None # cache is still "warm" for TTL (not expired) + + lazy_names = [] # record which names hit the per-rule SELECT path + + async def fake_load_rule_for_name(_self, _conn, name: str): + lazy_names.append(name) + if name == rule_id: + return expected_rule + return None + + logic._load_rule_cache_for_name = MethodType(fake_load_rule_for_name, logic) + + # Empty sys_vuln_rows: rule-only hit branch (no prior VMAAS row for this CVE) + out = await logic._evaluate_advisor_res(rule_results, {}, {}, platform, set(), conn) + + assert len(full_load_calls) == 1 # warm TTL: advisor merge must not trigger another full refresh + assert lazy_names == [rule_id] # one lazy fetch for the missing name + assert out[cve].state is VulnerabilityState.VULNERABLE_BY_RULE + assert out[cve].rule_id == expected_rule.id # merged row used lazy-loaded id/playbook_count + + +async def test_evaluate_advisor_res_lazy_rule_load_matches_preloaded_cache(monkeypatch): + """Same advisor merge outcome whether rule metadata was prefetched or loaded on demand""" + import evaluator.logic as logic_module + from common.peewee_model import VulnerabilityState + from evaluator.common import CveCache + from evaluator.common import RuleCache + from evaluator.common import SystemPlatform + from evaluator.logic import EvaluatorLogic + + monkeypatch.setattr(logic_module.CFG, "insights_load_rule_cache_ttl_sec", 600) + + logic = EvaluatorLogic(FakeAsyncPool()) + cve, rule_results, rule_id = _rule_only_hit_payload() + expected_rule = RuleCache(99, 2) # single source of truth for id + playbook_count in both paths + platform = SystemPlatform(1, "inv-1", 1, None, {}, None, True) + conn = object() + + async def fake_get_cve(_self, _cve: str) -> CveCache: + return CveCache(500, 1, False) + + logic._get_or_upsert_cve = MethodType(fake_get_cve, logic) + + lazy_names = [] # non-empty => per-name DB path ran; empty => snapshot already had the rule + + async def fake_load_rule_for_name(_self, _conn, name: str): + lazy_names.append(name) + if name == rule_id: + return expected_rule + return None + + logic._load_rule_cache_for_name = MethodType(fake_load_rule_for_name, logic) + + # Full refresh returns no rules: snapshot passed into merge is empty, so resolve_rule_cache must lazy-load + async def empty_full_load(_self, _conn): + return {} + + logic._load_rule_cache = MethodType(empty_full_load, logic) + await logic._get_rule_cache() + + out_lazy = await logic._evaluate_advisor_res(rule_results, {}, {}, platform, set(), conn) + + assert lazy_names == [rule_id] + row_lazy = out_lazy[cve] + assert row_lazy.state is VulnerabilityState.VULNERABLE_BY_RULE + assert row_lazy.rule_id == expected_rule.id + assert row_lazy.playbook_count == expected_rule.playbook_count + assert row_lazy.cve_id == 500 + assert json.loads(row_lazy.rule_hit_details) == rule_results["rule_hits"][cve]["details"] + + # Second run: pretend the rule row was already in the dict returned by _get_rule_cache (should mimic old behavior before cache change) + lazy_names.clear() + logic.rule_cache.clear() # drop instance cache so we only measure the explicit snapshot dict + snapshot = {rule_id: expected_rule} # same data as lazy path returned, but supplied up front + out_pre = await logic._evaluate_advisor_res(rule_results, {}, dict(snapshot), platform, set(), conn) + + assert lazy_names == [] # no per-name fetch: rule_id was already in the merge snapshot + row_pre = out_pre[cve] + # Row must match exactly: lazy vs preloaded must not change vulnerability semantics + assert row_pre.state == row_lazy.state + assert row_pre.rule_id == row_lazy.rule_id + assert row_pre.playbook_count == row_lazy.playbook_count + assert row_pre.cve_id == row_lazy.cve_id + assert row_pre.rule_hit_details == row_lazy.rule_hit_details + assert row_pre.remediation_type_id == row_lazy.remediation_type_id From 1655bb3bbaf49d753e3e4d65b0c556ac9d384f4a Mon Sep 17 00:00:00 2001 From: Andrej Luptak Date: Sun, 10 May 2026 20:27:46 +0200 Subject: [PATCH 3/3] ref: canoical rule cache, moving inner closure Return rule cache from get rule cache (no dict copy), inner closure to class fnc, test updated --- evaluator/logic.py | 48 ++++++++++--------- .../common_tests/test_evaluator_rule_cache.py | 15 +++--- 2 files changed, 33 insertions(+), 30 deletions(-) diff --git a/evaluator/logic.py b/evaluator/logic.py index 174ebe5ac..0b6d51cdc 100644 --- a/evaluator/logic.py +++ b/evaluator/logic.py @@ -128,7 +128,7 @@ async def _load_rule_cache_for_name(self, conn: AsyncConnection, name: str) -> O return RuleCache(rule["id"], rule["playbook_count"]) async def _get_rule_cache(self, conn: Optional[AsyncConnection] = None) -> Dict[str, RuleCache]: - """Return a snapshot of the rule cache, refreshing from DB when cache TTL expired. You can also load full cache from DB (see _load_rule_cache)""" + """Return the canoical rule cache dict, refreshing from DB when TTL expired (see _load_rule_cache)""" if self._rule_cache_expired(): if conn is None: async with self.db_pool.connection() as conn: @@ -136,7 +136,26 @@ async def _get_rule_cache(self, conn: Optional[AsyncConnection] = None) -> Dict[ else: self.rule_cache = await self._load_rule_cache(conn) self._set_rule_cache_expiry() - return dict(self.rule_cache) + return self.rule_cache + + async def _get_rule_from_cache( + self, + conn: AsyncConnection, + rule_name: str, + missing_rules_not_found: Set[str], + ) -> Optional[RuleCache]: + """Resolve rule metadata from self.rule_cache, lazy-loading by name on miss""" + cached = self.rule_cache.get(rule_name) + if cached: + return cached + if rule_name in missing_rules_not_found: + return None + fetched = await self._load_rule_cache_for_name(conn, rule_name) + if fetched: + self.rule_cache[rule_name] = fetched + return fetched + missing_rules_not_found.add(rule_name) + return None async def _load_package_name_cache(self) -> Dict[str, PackageNameCache]: """Load package name cache from DB""" @@ -619,28 +638,14 @@ async def _evaluate_advisor_res( self, rule_results: dict, sys_vuln_rows: Dict[str, SystemVulnerabilitiesRow], - rule_cache: Dict[str, RuleCache], system_platform: SystemPlatform, unpatched_cves: Set[str], conn: AsyncConnection, ) -> Dict[str, SystemVulnerabilitiesRow]: """Merge results from vmaas package evaluation with advisor rule evaluation""" - missing_rules_not_found = set() - - async def resolve_rule_cache(rule_name: str) -> Optional[RuleCache]: - cached = rule_cache.get(rule_name) - if cached: - return cached - if rule_name in missing_rules_not_found: - return None - fetched = await self._load_rule_cache_for_name(conn, rule_name) - if fetched: - self.rule_cache[rule_name] = fetched - rule_cache[rule_name] = fetched - return fetched - missing_rules_not_found.add(rule_name) - return None + await self._get_rule_cache(conn) + missing_rules_not_found: Set[str] = set() for cve, hit_details in rule_results["rule_hits"].items(): if cve in unpatched_cves: @@ -658,7 +663,7 @@ async def resolve_rule_cache(rule_name: str) -> Optional[RuleCache]: if not isinstance(hit_details["details"], dict): hit_details["details"] = json.loads(hit_details["details"]) - rule_db = await resolve_rule_cache(rule) + rule_db = await self._get_rule_from_cache(conn, rule, missing_rules_not_found) if not rule_db: continue @@ -707,7 +712,7 @@ async def resolve_rule_cache(rule_name: str) -> Optional[RuleCache]: continue # system was marked vulnerable from vmaas but not from by rules -> abnv - rule_db = await resolve_rule_cache(rule) + rule_db = await self._get_rule_from_cache(conn, rule, missing_rules_not_found) if not rule_db: continue @@ -740,8 +745,7 @@ async def evaluate_vulnerabilities(self, system_platform: SystemPlatform, conn: unpatched_cves_set = set(x.cve for x in unpatched_cves) # Rule metadata is cached in memory (warm in init), TTL controls refresh while evaluator runs with RULES_EVAL_TIME.time(): - rule_cache = await self._get_rule_cache(conn) sys_vuln_rows = await self._evaluate_advisor_res( - system_platform.rule_results, sys_vuln_rows, rule_cache, system_platform, unpatched_cves_set, conn + system_platform.rule_results, sys_vuln_rows, system_platform, unpatched_cves_set, conn ) return sys_vuln_rows diff --git a/tests/common_tests/test_evaluator_rule_cache.py b/tests/common_tests/test_evaluator_rule_cache.py index 1b97291f5..c81cc47fe 100644 --- a/tests/common_tests/test_evaluator_rule_cache.py +++ b/tests/common_tests/test_evaluator_rule_cache.py @@ -44,7 +44,8 @@ async def fake_load_rule_cache(_self, _conn): # How many times the fake full-load ran # For live warm cache two loads result into a single db fetch assert len(loads) == 1 - assert snap1 == snap2 == {"RULE_A": RuleCache(42, 1)} + assert snap1 is snap2 is logic.rule_cache + assert snap1 == {"RULE_A": RuleCache(42, 1)} async def test_get_rule_cache_always_reloads_when_ttl_disabled(monkeypatch): @@ -171,7 +172,7 @@ async def fake_load_rule_for_name(_self, _conn, name: str): logic._load_rule_cache_for_name = MethodType(fake_load_rule_for_name, logic) # Empty sys_vuln_rows: rule-only hit branch (no prior VMAAS row for this CVE) - out = await logic._evaluate_advisor_res(rule_results, {}, {}, platform, set(), conn) + out = await logic._evaluate_advisor_res(rule_results, {}, platform, set(), conn) assert len(full_load_calls) == 1 # warm TTL: advisor merge must not trigger another full refresh assert lazy_names == [rule_id] # one lazy fetch for the missing name @@ -218,7 +219,7 @@ async def empty_full_load(_self, _conn): logic._load_rule_cache = MethodType(empty_full_load, logic) await logic._get_rule_cache() - out_lazy = await logic._evaluate_advisor_res(rule_results, {}, {}, platform, set(), conn) + out_lazy = await logic._evaluate_advisor_res(rule_results, {}, platform, set(), conn) assert lazy_names == [rule_id] row_lazy = out_lazy[cve] @@ -228,13 +229,11 @@ async def empty_full_load(_self, _conn): assert row_lazy.cve_id == 500 assert json.loads(row_lazy.rule_hit_details) == rule_results["rule_hits"][cve]["details"] - # Second run: pretend the rule row was already in the dict returned by _get_rule_cache (should mimic old behavior before cache change) + # Second run: canoical cache holds rule_id from lazy path, canoical name as instance lazy_names.clear() - logic.rule_cache.clear() # drop instance cache so we only measure the explicit snapshot dict - snapshot = {rule_id: expected_rule} # same data as lazy path returned, but supplied up front - out_pre = await logic._evaluate_advisor_res(rule_results, {}, dict(snapshot), platform, set(), conn) + out_pre = await logic._evaluate_advisor_res(rule_results, {}, platform, set(), conn) - assert lazy_names == [] # no per-name fetch: rule_id was already in the merge snapshot + assert lazy_names == [] # cache hit on self.rule_cache row_pre = out_pre[cve] # Row must match exactly: lazy vs preloaded must not change vulnerability semantics assert row_pre.state == row_lazy.state