fix(cache): keep a mid-render domain map out of the finished one

26 roles resolve a domain in their metadata, so lookup('tls') and lookup('domains') run inside the applications render. Under the re-entry guard those calls receive the unrendered tree, whose service flags are still Jinja strings, and _as_bool reads "{{ 'svc-net-tor' in group_names }}" as false. The resulting map skipped onion injection for every app that does not enable tor literally, and it was cached, so it answered every later read in that process.

Before the applications carrier each worker rendered and poisoned its own cache the same way, which is why the shared Keycloak client registered 5 onion origins then and 65 now. The render state joins the cache key, so the render's own call sites still share one map while a later read builds the finished one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5 committed 2026-09-03 09:31:25 +02:00
1 parent b764b00ba3
commit fa3797540a
3 files changed
+40 -2

No files matched your search

+32
View File
@@ -18,6 +18,7 @@ from unittest.mock import patch
from utils.cache import _reset_cache_for_tests
from utils.cache import domains as cache_domains
from utils.cache.base import _RENDER_GUARD
from utils.roles.mapping import ROLE_FILE_META_SERVICES, ROLE_FILE_META_USERS
from . import PROJECT_ROOT
@@ -114,6 +115,37 @@ class TestCachingPerVariablesSignature(unittest.TestCase):
)
self.assertEqual(mocked.call_count, 2)
def test_a_map_built_during_the_render_never_becomes_the_finished_one(self):
"""Under the guard the applications view is still unrendered, so its
map must not answer a later read; it may still serve the render."""
with tempfile.TemporaryDirectory() as tmp:
roles = _seed_minimal_role(Path(tmp))
variables = {"DOMAIN_PRIMARY": "infinito.test"}
with patch(
"utils.cache.applications.get_merged_applications",
return_value={},
) as mocked:
_RENDER_GUARD.applications = True
try:
cache_domains.get_merged_domains(
variables=variables, roles_dir=roles, templar=None
)
cache_domains.get_merged_domains(
variables=variables, roles_dir=roles, templar=None
)
self.assertEqual(mocked.call_count, 1)
finally:
_RENDER_GUARD.applications = False
cache_domains.get_merged_domains(
variables=variables, roles_dir=roles, templar=None
)
self.assertEqual(mocked.call_count, 2)
self.assertEqual(len(cache_domains._MERGED_DOMAINS_CACHE), 2)
self.assertEqual(
sorted(key[-1] for key in cache_domains._MERGED_DOMAINS_CACHE),
[False, True],
)
class TestResetClearsDomainsCache(unittest.TestCase):
def test_reset_evicts_cached_entry(self):
+1 -1
View File
@@ -12,7 +12,7 @@ This package contains the in-process cache layers Infinito.Nexus uses to keep CL
| [applications.py](applications.py) | Per-app variants + defaults + `get_merged_applications`. **Strictly ansible-free at import time** so the GitHub Actions runner-host CLI path (`cli.administration.deploy.development.init` → `plan_dev_inventory_matrix` → `get_variants`) keeps working without ansible installed. |
| [carrier.py](carrier.py) | The play-scoped carrier of the rendered applications payload: the fact name `_INFINITO_APPLICATIONS_RENDERED`, `merged_applications_cache_key`, and the reader `get_merged_applications` consults before it renders. |
| [users/](users/) | User definitions, token store hydration, alias materialization, `get_user_defaults`, `get_merged_users` (package; `users/placeholders.py` substitutes `DOMAIN_PRIMARY` / `ORGANIZATION` / `SOFTWARE_NAME` ahead of the templar render pass). |
| [domains.py](domains.py) | Canonical-domains map derived from the merged applications view: `get_merged_domains`. |
| [domains.py](domains.py) | Canonical-domains map derived from the merged applications view: `get_merged_domains`. A read issued while the applications render is in progress (the re-entry guard is set) sees the unrendered view, so it is cached under its own key and never answers a later read. |
| [`__init__.py`](__init__.py) | Owns the package-level `_reset_cache_for_tests()` orchestrator that clears every cache plus the shared fingerprint memo in one call. |
## When To Use Which 🎯
+7 -1
View File
@@ -12,6 +12,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any
from .base import (
_RENDER_GUARD,
_cache_key,
_resolve_roles_dir,
_stable_variables_signature,
@@ -136,7 +137,11 @@ def get_merged_domains(
(canonical/aliases) and flow through the regular applications-merge
pipeline.
Cached keyed on (roles_dir, variables_signature).
Cached keyed on (roles_dir, variables_signature, render_state). A read
issued while the applications render is in progress sees the unrendered
tree, whose service flags are still Jinja strings; its map is cached under
its own key so the render's own call sites reuse it without it ever being
served as the finished map.
"""
from plugins.filter.canonical_domains_map import (
FilterModule as _CanonicalDomainsFilter,
@@ -150,6 +155,7 @@ def get_merged_domains(
cache_key = (
_cache_key(resolved_roles_dir),
_stable_variables_signature(variables),
bool(getattr(_RENDER_GUARD, "applications", False)),
)
cached = _MERGED_DOMAINS_CACHE.get(cache_key)
if cached is not None: