Skip to content

Commit ff376ad

Browse files
perf(templates): mtime-aware caches for cross_links + parsed templates (#5)
* fix(prefixes): add `gui` mapping for `guide` template kind The schema in attune-rag has carried `guide` in the type enum since v1, but `attune-help`'s prefix maps had no entry for it. A new `type: guide` template would have been silently dropped from cross-linking (`_find_template_file` would return None for any `gui-*` id; `_COMPOUND_PREFIXES` would not match a `gui-` template during retrieval ranking). Found while resolving an open question in the template-corpus-tidy spec — the spec asked which prefix `quickstart` should use, and the answer was already-correct (`qui`). The investigation surfaced the `guide` gap as a sibling problem. Changes: - `templates._PREFIX_MAP` gains `"gui": "guides"`. Map reordered alphabetically; behaviour unchanged. - `progression._COMPOUND_PREFIXES` gains `"gui-"`. Order tweaked so the base prefixes are alphabetical (compounds for ref/tas/con stay first per the longest-first contract documented in the comment above the list). - New `tests/test_template_prefixes.py` (4 tests) pins both maps against the expected enum and explicitly asserts `qui` and `gui`. Future schema-enum additions will fail this test until both maps are updated, surfacing the kind of silent drop this commit fixes. The schema enum lives in attune-rag and the prefix maps live here. The new test mirrors the enum as a literal set with a comment pointing at the schema; introducing a runtime dep on attune-rag would violate ADR-002 (attune-help ships with zero required deps beyond python-frontmatter). 250 attune-help tests pass; ruff clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * perf(templates): mtime-aware caches for cross_links + parsed templates Two related fixes from code-review 2026-05-07: 1. ``_load_cross_links`` cached cross_links.json by directory path alone — no mtime check. Long-running processes served stale data after a regen until callers remembered to invoke ``invalidate_cross_links_cache``. Switch the cache key to include ``st_mtime_ns`` so file rewrites invalidate themselves. 2. ``_parse_template_file`` had no cache at all. Every ``populate()`` call re-read and re-parsed the .md (frontmatter + section split). Add a module-level ``(path, mtime_ns) -> parsed_dict`` cache with a thread lock. Same self-invalidating mtime model as #1. Also expose ``invalidate_template_cache()`` for symmetry with the existing ``invalidate_cross_links_cache()`` — useful for tests and hard-reset scenarios; not needed for normal file edits. Tests: 8 new in test_cross_links_cache.py covering first-call load, mtime-stable cache hit, mtime-change reload, missing-file no-cache, manual invalidation. Tests use ``os.utime`` to bump mtime explicitly because macOS HFS+ stores 1s-resolution mtimes and naive rewrites in the same second can otherwise look unchanged. 258 passed. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent aadcdcd commit ff376ad

4 files changed

Lines changed: 350 additions & 22 deletions

File tree

src/attune_help/progression.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ def _record_topic(
7474

7575
# Ordered longest-first so compound prefixes match before
7676
# their shorter components (e.g. "ref-skill-" before "ref-").
77+
# Base prefixes here must stay in sync with `templates._PREFIX_MAP`.
7778
_COMPOUND_PREFIXES = [
7879
"ref-skill-",
7980
"ref-tool-",
@@ -83,14 +84,15 @@ def _record_topic(
8384
"tas-",
8485
"con-tool-",
8586
"con-",
87+
"com-",
8688
"err-",
87-
"war-",
88-
"tip-",
8989
"faq-",
90+
"gui-",
9091
"not-",
9192
"qui-",
93+
"tip-",
9294
"tro-",
93-
"com-",
95+
"war-",
9496
]
9597

9698

src/attune_help/templates.py

Lines changed: 80 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@
2020
_CROSS_LINKS_CACHE: dict[str, dict[str, Any]] = {}
2121
_CROSS_LINKS_LOCK = threading.Lock()
2222

23+
# Parsed-template cache: keyed by ``(str(filepath), mtime_ns)`` so an
24+
# edit-and-reload picks up the new file automatically. Without this,
25+
# every ``populate()`` call re-parsed the markdown + frontmatter, which
26+
# made template lookups O(file-size) per request in long-lived processes.
27+
_PARSED_TEMPLATE_CACHE: dict[tuple[str, int], dict[str, Any]] = {}
28+
_PARSED_TEMPLATE_LOCK = threading.Lock()
29+
2330

2431
def invalidate_cross_links_cache() -> None:
2532
"""Clear the cross-links cache so the next lookup re-reads disk.
@@ -31,6 +38,17 @@ def invalidate_cross_links_cache() -> None:
3138
_CROSS_LINKS_CACHE.clear()
3239

3340

41+
def invalidate_template_cache() -> None:
42+
"""Clear the parsed-template cache.
43+
44+
Normally not needed — the cache key includes mtime so file edits
45+
invalidate themselves. Useful for tests that mock filesystems or
46+
for processes that need a hard reset across all templates.
47+
"""
48+
with _PARSED_TEMPLATE_LOCK:
49+
_PARSED_TEMPLATE_CACHE.clear()
50+
51+
3452
@dataclass(frozen=True)
3553
class TemplateContext:
3654
"""Runtime parameters for template population."""
@@ -78,18 +96,24 @@ class PopulatedTemplate:
7896
# Template file resolution
7997
# ------------------------------------------------------------------
8098

99+
# Maps the 3-letter `{prefix}-{slug}` template-id prefix to the
100+
# directory the matching template lives in. Mirrors the enum in
101+
# `attune-rag` at `src/attune_rag/editor/template_schema.json`. Adding
102+
# a kind to that enum implies adding both an entry here AND the
103+
# corresponding subdirectory under `templates/`.
81104
_PREFIX_MAP = {
105+
"com": "comparisons",
106+
"con": "concepts",
82107
"err": "errors",
83-
"war": "warnings",
84-
"tip": "tips",
85-
"ref": "references",
86-
"tas": "tasks",
87108
"faq": "faqs",
109+
"gui": "guides",
88110
"not": "notes",
89111
"qui": "quickstarts",
90-
"con": "concepts",
112+
"ref": "references",
113+
"tas": "tasks",
114+
"tip": "tips",
91115
"tro": "troubleshooting",
92-
"com": "comparisons",
116+
"war": "warnings",
93117
}
94118

95119

@@ -132,12 +156,32 @@ def _find_template_file(
132156
def _parse_template_file(filepath: Path) -> dict[str, Any]:
133157
"""Parse a template file into structured data.
134158
159+
Cached by ``(filepath, mtime_ns)`` so repeated ``populate()`` calls
160+
on the same template don't re-read and re-parse the file. The mtime
161+
component means filesystem edits invalidate the cache automatically;
162+
no explicit ``invalidate_template_cache()`` call is needed in normal
163+
operation.
164+
135165
Args:
136166
filepath: Path to the template .md file.
137167
138168
Returns:
139169
Dict with frontmatter fields and parsed sections.
140170
"""
171+
try:
172+
mtime_ns = filepath.stat().st_mtime_ns
173+
except OSError:
174+
# Path no longer readable; fall through to the parser so the
175+
# caller sees the same exception they would have seen before.
176+
mtime_ns = -1
177+
178+
cache_key = (str(filepath), mtime_ns)
179+
if mtime_ns != -1:
180+
with _PARSED_TEMPLATE_LOCK:
181+
cached = _PARSED_TEMPLATE_CACHE.get(cache_key)
182+
if cached is not None:
183+
return cached
184+
141185
import frontmatter as fm
142186

143187
post = fm.load(str(filepath))
@@ -170,7 +214,7 @@ def _parse_template_file(filepath: Path) -> dict[str, Any]:
170214
else:
171215
tags = list(tags_raw)
172216

173-
return {
217+
parsed: dict[str, Any] = {
174218
"type": post.get("type", ""),
175219
"subtype": post.get("subtype", ""),
176220
"name": post.get("name", filepath.stem),
@@ -182,6 +226,10 @@ def _parse_template_file(filepath: Path) -> dict[str, Any]:
182226
"sections": sections,
183227
"body": post.content,
184228
}
229+
if mtime_ns != -1:
230+
with _PARSED_TEMPLATE_LOCK:
231+
_PARSED_TEMPLATE_CACHE[cache_key] = parsed
232+
return parsed
185233

186234

187235
# ------------------------------------------------------------------
@@ -190,7 +238,14 @@ def _parse_template_file(filepath: Path) -> dict[str, Any]:
190238

191239

192240
def _load_cross_links(generated_dir: Path) -> dict[str, Any]:
193-
"""Load cross-links index with caching.
241+
"""Load cross-links index with mtime-aware caching.
242+
243+
Cached entries store the file's mtime alongside the parsed data.
244+
When ``cross_links.json`` is rewritten (e.g. by a regen run in a
245+
long-lived process), its mtime changes and the next call reloads
246+
automatically. Without this, callers had to remember to invoke
247+
:func:`invalidate_cross_links_cache` after every regen, and
248+
forgetting it served stale results until process restart.
194249
195250
Args:
196251
generated_dir: Path to generated/ directory.
@@ -199,26 +254,32 @@ def _load_cross_links(generated_dir: Path) -> dict[str, Any]:
199254
Parsed cross_links.json, or empty dict.
200255
"""
201256
cache_key = str(generated_dir)
202-
with _CROSS_LINKS_LOCK:
203-
if cache_key in _CROSS_LINKS_CACHE:
204-
return _CROSS_LINKS_CACHE[cache_key]
257+
index_path = generated_dir / "cross_links.json"
258+
try:
259+
mtime_ns = index_path.stat().st_mtime_ns
260+
except FileNotFoundError:
261+
# File doesn't exist — return empty without caching so a later
262+
# regen that creates the file is picked up on the next call.
263+
return {}
264+
except OSError as e:
265+
logger.warning("Cannot stat cross_links.json: %s", e)
266+
return {}
205267

206-
index_path = generated_dir / "cross_links.json"
207-
if not index_path.exists():
208-
return {}
268+
with _CROSS_LINKS_LOCK:
269+
cached = _CROSS_LINKS_CACHE.get(cache_key)
270+
if cached is not None and cached.get("__mtime_ns") == mtime_ns:
271+
return cached["__data"]
209272

210273
try:
211-
data = json.loads(
212-
index_path.read_text(encoding="utf-8"),
213-
)
214-
_CROSS_LINKS_CACHE[cache_key] = data
215-
return data
274+
data = json.loads(index_path.read_text(encoding="utf-8"))
216275
except (json.JSONDecodeError, OSError) as e:
217276
logger.warning(
218277
"Failed to load cross_links.json: %s",
219278
e,
220279
)
221280
return {}
281+
_CROSS_LINKS_CACHE[cache_key] = {"__mtime_ns": mtime_ns, "__data": data}
282+
return data
222283

223284

224285
def _resolve_related(

tests/test_cross_links_cache.py

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
"""mtime-aware caching of cross_links.json.
2+
3+
Verifies:
4+
5+
- First call hits disk and caches.
6+
- Subsequent calls with unchanged mtime hit cache.
7+
- Rewriting the file (mtime change) triggers a reload.
8+
- Missing file returns empty without poisoning the cache.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import json
14+
import os
15+
from pathlib import Path
16+
17+
import pytest
18+
19+
from attune_help.templates import (
20+
_CROSS_LINKS_CACHE,
21+
_load_cross_links,
22+
invalidate_cross_links_cache,
23+
)
24+
25+
26+
@pytest.fixture(autouse=True)
27+
def _clear_cache() -> None:
28+
"""Ensure tests don't share cache state."""
29+
invalidate_cross_links_cache()
30+
yield
31+
invalidate_cross_links_cache()
32+
33+
34+
def _bump_mtime(path: Path) -> None:
35+
"""Force mtime forward by at least one nanosecond.
36+
37+
macOS HFS+ stores 1s-resolution mtimes, so naive rewrites in the
38+
same second don't change ``st_mtime_ns``. This explicitly sets a
39+
later mtime so the cache invalidation path is exercised.
40+
"""
41+
now_ns = path.stat().st_mtime_ns
42+
later = (now_ns + 1_000_000_000, now_ns + 1_000_000_000)
43+
os.utime(path, ns=later)
44+
45+
46+
def test_first_call_loads_from_disk(tmp_path: Path) -> None:
47+
(tmp_path / "cross_links.json").write_text(json.dumps({"foo": ["bar"]}))
48+
result = _load_cross_links(tmp_path)
49+
assert result == {"foo": ["bar"]}
50+
51+
52+
def test_second_call_with_unchanged_file_hits_cache(tmp_path: Path, monkeypatch) -> None:
53+
"""When mtime hasn't changed, the second call must not re-read the file."""
54+
cl = tmp_path / "cross_links.json"
55+
cl.write_text(json.dumps({"foo": ["bar"]}))
56+
_load_cross_links(tmp_path) # prime the cache
57+
58+
read_calls = 0
59+
real_read = Path.read_text
60+
61+
def counting_read(self, *args, **kwargs):
62+
nonlocal read_calls
63+
if self == cl:
64+
read_calls += 1
65+
return real_read(self, *args, **kwargs)
66+
67+
monkeypatch.setattr(Path, "read_text", counting_read)
68+
_load_cross_links(tmp_path)
69+
_load_cross_links(tmp_path)
70+
assert read_calls == 0, "cache hit should not re-read the file"
71+
72+
73+
def test_mtime_change_triggers_reload(tmp_path: Path) -> None:
74+
cl = tmp_path / "cross_links.json"
75+
cl.write_text(json.dumps({"v": 1}))
76+
first = _load_cross_links(tmp_path)
77+
assert first == {"v": 1}
78+
79+
cl.write_text(json.dumps({"v": 2}))
80+
_bump_mtime(cl)
81+
second = _load_cross_links(tmp_path)
82+
assert second == {"v": 2}
83+
84+
85+
def test_missing_file_returns_empty_without_caching(tmp_path: Path) -> None:
86+
"""Missing file returns empty AND doesn't seed the cache; a later
87+
creation of the file is picked up on the next call.
88+
"""
89+
assert _load_cross_links(tmp_path) == {}
90+
assert str(tmp_path) not in _CROSS_LINKS_CACHE
91+
92+
# Now create the file — the next call must pick it up
93+
(tmp_path / "cross_links.json").write_text(json.dumps({"new": ["entry"]}))
94+
assert _load_cross_links(tmp_path) == {"new": ["entry"]}
95+
96+
97+
def test_invalidate_clears_cache(tmp_path: Path) -> None:
98+
cl = tmp_path / "cross_links.json"
99+
cl.write_text(json.dumps({"x": 1}))
100+
_load_cross_links(tmp_path)
101+
assert str(tmp_path) in _CROSS_LINKS_CACHE
102+
invalidate_cross_links_cache()
103+
assert str(tmp_path) not in _CROSS_LINKS_CACHE
104+
105+
106+
# ---------------------------------------------------------------------------
107+
# Parsed-template (path, mtime) cache
108+
# ---------------------------------------------------------------------------
109+
110+
111+
def test_parsed_template_cached_by_mtime(tmp_path: Path, monkeypatch) -> None:
112+
"""``_parse_template_file`` must reuse cached output when mtime is stable."""
113+
from attune_help import templates
114+
115+
templates.invalidate_template_cache()
116+
117+
f = tmp_path / "concept.md"
118+
f.write_text("---\ntype: concept\n---\n# Title\n\n## Section\n\nbody\n")
119+
120+
parse_calls = 0
121+
real_load = None
122+
123+
import frontmatter as fm
124+
125+
real_load = fm.load
126+
127+
def counting_load(*args, **kwargs):
128+
nonlocal parse_calls
129+
parse_calls += 1
130+
return real_load(*args, **kwargs)
131+
132+
monkeypatch.setattr(fm, "load", counting_load)
133+
134+
a = templates._parse_template_file(f)
135+
b = templates._parse_template_file(f)
136+
c = templates._parse_template_file(f)
137+
138+
assert a == b == c
139+
assert parse_calls == 1, "second/third call should hit cache, not re-parse"
140+
141+
142+
def test_parsed_template_reloads_after_mtime_change(tmp_path: Path) -> None:
143+
from attune_help import templates
144+
145+
templates.invalidate_template_cache()
146+
f = tmp_path / "concept.md"
147+
f.write_text("---\ntype: concept\n---\n# v1\n")
148+
149+
first = templates._parse_template_file(f)
150+
assert first["title"] == "v1"
151+
152+
f.write_text("---\ntype: concept\n---\n# v2\n")
153+
_bump_mtime(f)
154+
155+
second = templates._parse_template_file(f)
156+
assert second["title"] == "v2"
157+
158+
159+
def test_invalidate_template_cache_clears_entries(tmp_path: Path) -> None:
160+
from attune_help import templates
161+
162+
f = tmp_path / "x.md"
163+
f.write_text("---\ntype: concept\n---\n# A\n")
164+
templates._parse_template_file(f)
165+
assert any(str(f) == k[0] for k in templates._PARSED_TEMPLATE_CACHE)
166+
167+
templates.invalidate_template_cache()
168+
assert templates._PARSED_TEMPLATE_CACHE == {}

0 commit comments

Comments
 (0)