From 3afe968d673864603b41c83948e14def48b5bf29 Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:23:57 +0200 Subject: [PATCH 1/3] perf(api): rank related plots against one cached catalogue index /insights/related cached its response per spec, library, mode and limit. Those thousands of keys evicted each other from the shared 1,000-entry cache, so nearly every call reloaded all specs (p50 755 ms, p99 1.6 s over the weekend of 2026-10-18/19). The catalogue is now cached once as a plain-value index under one key and each request ranks against it in memory (about 0.6 ms). Outputs are identical on the live catalogue for 1,266 spec/library/mode/limit combinations. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GdMSqLR5ww4ji74EUSmk9M --- api/routers/insights.py | 115 ++++++++++++++--------- changelog.d/related-shared-index.md | 11 +++ docs/reference/api.md | 4 +- tests/unit/api/test_insights_helpers.py | 120 ++++++++++++------------ tests/unit/api/test_routers.py | 45 +++++++++ 5 files changed, 188 insertions(+), 107 deletions(-) create mode 100644 changelog.d/related-shared-index.md diff --git a/api/routers/insights.py b/api/routers/insights.py index 8c6bb53d4b2..29932aeed1a 100644 --- a/api/routers/insights.py +++ b/api/routers/insights.py @@ -13,6 +13,7 @@ import logging import random from collections import Counter, defaultdict +from dataclasses import dataclass from datetime import date, datetime, timedelta, timezone import httpx @@ -553,58 +554,81 @@ async def _fetch() -> PlotOfTheDayResponse | None: # ============================================================================= -def _collect_impl_tags(spec: Spec, library: str | None = None) -> set[str]: - """Collect spec-level tags + impl-level tags for a spec. +@dataclass(frozen=True) +class _RelatedEntry: + """One spec as the related-plots ranking reads it: plain values, no ORM objects. - If library is specified, only include that library's impl_tags. - Otherwise, collect impl_tags from all implementations. + `preview` is the best-scored implementation that has a preview, as + `(preview_url, preview_url_light, preview_url_dark, library_id, language)`. """ - tags = _flatten_tags(spec.tags) - for impl in spec.impls: - if library and impl.library_id != library: - continue - if impl.impl_tags and isinstance(impl.impl_tags, dict): - tags |= _flatten_tags(impl.impl_tags) - return tags + id: str + title: str + spec_tags: frozenset[str] + impl_tags: dict[str, frozenset[str]] + all_tags: frozenset[str] + preview: tuple[str | None, str | None, str | None, str, str | None] | None + + def tags(self, mode: str, library: str | None) -> frozenset[str]: + """The tags to compare: spec tags, plus impl tags in full mode (one library's, or all).""" + if mode != "full": + return self.spec_tags + if library: + return self.spec_tags | self.impl_tags.get(library, frozenset()) + return self.all_tags + + +def _related_entry(spec: Spec) -> _RelatedEntry: + spec_tags = frozenset(_flatten_tags(spec.tags)) + impl_tags = { + impl.library_id: frozenset(_flatten_tags(impl.impl_tags)) + for impl in spec.impls + if impl.impl_tags and isinstance(impl.impl_tags, dict) + } + impls_with_preview = [i for i in spec.impls if i.preview_url] + best = max(impls_with_preview, key=lambda i: i.quality_score or 0) if impls_with_preview else None + return _RelatedEntry( + id=spec.id, + title=spec.title, + spec_tags=spec_tags, + impl_tags=impl_tags, + all_tags=spec_tags.union(*impl_tags.values()), + preview=( + ( + best.preview_url, + best.preview_url_light, + best.preview_url_dark, + best.library_id, + best.library.language if best.library else None, + ) + if best + else None + ), + ) -async def _build_related( - repo: SpecRepository, spec_id: str, limit: int, mode: str, library: str | None = None + +def _build_related( + index: list[_RelatedEntry], spec_id: str, limit: int, mode: str, library: str | None = None ) -> RelatedSpecsResponse: """Find related specs using Jaccard similarity on tags. mode="spec": only spec-level tags (for overview page) mode="full": spec + impl tags for the given library (for impl detail page) """ - all_specs = await repo.get_all() - - # Find target spec - target = None - for s in all_specs: - if s.id == spec_id: - target = s - break + target = next((entry for entry in index if entry.id == spec_id), None) if target is None: return RelatedSpecsResponse(related=[]) - # Build target tags based on mode - if mode == "full": - target_tags = _collect_impl_tags(target, library) - else: - target_tags = _flatten_tags(target.tags) + target_tags = target.tags(mode, library) if not target_tags: return RelatedSpecsResponse(related=[]) # Compute similarity for all other specs - scored: list[tuple[float, list[str], Spec]] = [] - for spec in all_specs: + scored: list[tuple[float, list[str], _RelatedEntry]] = [] + for spec in index: if spec.id == spec_id: continue - # For other specs: use same library's impl_tags if available, else best match - if mode == "full": - other_tags = _collect_impl_tags(spec, library) - else: - other_tags = _flatten_tags(spec.tags) + other_tags = spec.tags(mode, library) if not other_tags: continue intersection = target_tags & other_tags @@ -629,17 +653,16 @@ async def _build_related( # Build response with best-quality preview per spec related: list[RelatedSpecItem] = [] for similarity, shared_tags, spec in top_pool[:limit]: - impls_with_preview = [i for i in spec.impls if i.preview_url] - best_impl = max(impls_with_preview, key=lambda i: i.quality_score or 0) if impls_with_preview else None + preview_url, preview_url_light, preview_url_dark, library_id, language = spec.preview or (None,) * 5 related.append( RelatedSpecItem( id=spec.id, title=spec.title, - preview_url=best_impl.preview_url if best_impl else None, - preview_url_light=best_impl.preview_url_light if best_impl else None, - preview_url_dark=best_impl.preview_url_dark if best_impl else None, - library_id=best_impl.library_id if best_impl else None, - language=(best_impl.library.language if best_impl and best_impl.library else None), + preview_url=preview_url, + preview_url_light=preview_url_light, + preview_url_dark=preview_url_dark, + library_id=library_id, + language=language, similarity=round(similarity, 3), shared_tags=shared_tags, ) @@ -662,13 +685,19 @@ async def get_related_specs( mode=spec: only spec-level tags (plot_type, domain, etc.) mode=full: spec tags + impl tags for the given library library: in full mode, use this library's impl_tags (required for accurate tag matching) + + The catalogue is cached once as a plain-value index under a single key and every + request ranks against it in memory. A response cache keyed per spec, library, mode + and limit had thousands of keys, which evicted each other from the shared cache, so + nearly every call reloaded all specs from the database. """ repo = SpecRepository(db) - async def _fetch() -> RelatedSpecsResponse: - return await _build_related(repo, spec_id, limit, mode, library) + async def _fetch_index() -> list[_RelatedEntry]: + return [_related_entry(spec) for spec in await repo.get_all()] - return await get_or_set_cache(cache_key("insights", "related", spec_id, str(limit), mode, library or ""), _fetch) + index = await get_or_set_cache(cache_key("insights", "related_index"), _fetch_index) + return _build_related(index, spec_id, limit, mode, library) # ============================================================================= diff --git a/changelog.d/related-shared-index.md b/changelog.d/related-shared-index.md new file mode 100644 index 00000000000..f8588034ed8 --- /dev/null +++ b/changelog.d/related-shared-index.md @@ -0,0 +1,11 @@ +### Fixed + +- **Related plots load in about a millisecond instead of about 750 ms.** The + related-plots block on every spec and implementation page was cached per spec, + library, mode and limit. Those thousands of keys evicted each other from the + shared 1,000-entry cache, so nearly every call reloaded all specs from the + database (p50 755 ms, p99 1.6 s in the LinkedIn traffic of 2026-10-18/19). + `/insights/related` now caches the catalogue once as a plain-value index under + a single key and ranks against it in memory. The results are identical: 1,266 + spec, library, mode and limit combinations on the live catalogue gave + byte-equal responses. diff --git a/docs/reference/api.md b/docs/reference/api.md index fd304c286a2..ee5dbfc079c 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -304,11 +304,11 @@ Deterministically selects an implementation based on today's date. It prefers th | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `limit` | int | 6 | Number of results (1-12) | +| `limit` | int | 6 | Number of results (1-24) | | `mode` | string | `spec` | `spec` = spec tags only, `full` = spec + impl tags | | `library` | string | null | In `full` mode, match against this library's impl_tags | -Returns related specs sorted by Jaccard similarity with preview thumbnails and shared tags. +Returns related specs sorted by Jaccard similarity with preview thumbnails and shared tags. The catalogue is cached once as an in-memory index under a single key, which is cleared with the rest of the cache after a database sync. Each request ranks against that index in about a millisecond. ### GET `/specs/{spec_id}/{library}/code` diff --git a/tests/unit/api/test_insights_helpers.py b/tests/unit/api/test_insights_helpers.py index 683a42dc63c..9ccd9a88c58 100644 --- a/tests/unit/api/test_insights_helpers.py +++ b/tests/unit/api/test_insights_helpers.py @@ -6,8 +6,9 @@ """ from datetime import timezone +from unittest.mock import MagicMock -from api.routers.insights import _collect_impl_tags, _flatten_tags, _parse_iso, _score_bucket +from api.routers.insights import _flatten_tags, _parse_iso, _related_entry, _score_bucket class TestScoreBucket: @@ -105,76 +106,71 @@ def test_date_only(self) -> None: assert result.year == 2025 -class TestCollectImplTags: - """Tests for _collect_impl_tags.""" +def _impl(library_id: str, impl_tags: object, score: float | None = None, preview: str | None = None) -> MagicMock: + impl = MagicMock() + impl.library_id = library_id + impl.impl_tags = impl_tags + impl.quality_score = score + impl.preview_url = preview + impl.preview_url_light = preview + impl.preview_url_dark = None + impl.library.language = "python" + return impl - def test_spec_with_no_tags(self) -> None: - from unittest.mock import MagicMock - - spec = MagicMock() - spec.tags = None - spec.impls = [] - result = _collect_impl_tags(spec) - assert result == set() - - def test_spec_with_tags_and_impl_tags(self) -> None: - from unittest.mock import MagicMock - - impl = MagicMock() - impl.library_id = "matplotlib" - impl.impl_tags = {"techniques": ["annotations"]} - - spec = MagicMock() - spec.tags = {"plot_type": ["scatter"]} - spec.impls = [impl] - - result = _collect_impl_tags(spec) - assert "plot_type:scatter" in result - assert "techniques:annotations" in result - - def test_filter_by_library(self) -> None: - from unittest.mock import MagicMock - impl1 = MagicMock() - impl1.library_id = "matplotlib" - impl1.impl_tags = {"techniques": ["annotations"]} +def _spec(tags: dict | None, impls: list[MagicMock]) -> MagicMock: + spec = MagicMock() + spec.id = "scatter-basic" + spec.title = "Basic Scatter" + spec.tags = tags + spec.impls = impls + return spec - impl2 = MagicMock() - impl2.library_id = "seaborn" - impl2.impl_tags = {"techniques": ["regression"]} - spec = MagicMock() - spec.tags = {"plot_type": ["scatter"]} - spec.impls = [impl1, impl2] +class TestRelatedEntryTags: + """Tests for the tag sets of _related_entry.""" - result = _collect_impl_tags(spec, library="matplotlib") - assert "techniques:annotations" in result - assert "techniques:regression" not in result + def test_spec_with_no_tags(self) -> None: + entry = _related_entry(_spec(None, [])) + assert entry.tags("spec", None) == set() + assert entry.tags("full", None) == set() + + def test_spec_mode_ignores_impl_tags(self) -> None: + entry = _related_entry( + _spec({"plot_type": ["scatter"]}, [_impl("matplotlib", {"techniques": ["annotations"]})]) + ) + assert entry.tags("spec", "matplotlib") == {"plot_type:scatter"} + + def test_full_mode_without_library_collects_every_impl(self) -> None: + impls = [_impl("matplotlib", {"techniques": ["annotations"]}), _impl("seaborn", {"techniques": ["regression"]})] + entry = _related_entry(_spec({"plot_type": ["scatter"]}, impls)) + assert entry.tags("full", None) == {"plot_type:scatter", "techniques:annotations", "techniques:regression"} + + def test_full_mode_filters_by_library(self) -> None: + impls = [_impl("matplotlib", {"techniques": ["annotations"]}), _impl("seaborn", {"techniques": ["regression"]})] + entry = _related_entry(_spec({"plot_type": ["scatter"]}, impls)) + assert entry.tags("full", "matplotlib") == {"plot_type:scatter", "techniques:annotations"} + assert entry.tags("full", "plotly") == {"plot_type:scatter"} def test_impl_tags_none(self) -> None: - from unittest.mock import MagicMock - - impl = MagicMock() - impl.library_id = "matplotlib" - impl.impl_tags = None - - spec = MagicMock() - spec.tags = {"plot_type": ["scatter"]} - spec.impls = [impl] - - result = _collect_impl_tags(spec) - assert result == {"plot_type:scatter"} + entry = _related_entry(_spec({"plot_type": ["scatter"]}, [_impl("matplotlib", None)])) + assert entry.tags("full", None) == {"plot_type:scatter"} def test_impl_tags_not_dict(self) -> None: - from unittest.mock import MagicMock + entry = _related_entry(_spec({}, [_impl("matplotlib", "not-a-dict")])) + assert entry.tags("full", None) == set() + - impl = MagicMock() - impl.library_id = "matplotlib" - impl.impl_tags = "not-a-dict" +class TestRelatedEntryPreview: + """Tests for the preview _related_entry picks.""" - spec = MagicMock() - spec.tags = {} - spec.impls = [impl] + def test_best_scored_impl_with_a_preview(self) -> None: + impls = [ + _impl("matplotlib", None, score=88.0, preview="m.png"), + _impl("seaborn", None, score=95.0, preview="s.png"), + _impl("plotly", None, score=99.0, preview=None), + ] + assert _related_entry(_spec(None, impls)).preview == ("s.png", "s.png", None, "seaborn", "python") - result = _collect_impl_tags(spec) - assert result == set() + def test_no_preview(self) -> None: + assert _related_entry(_spec(None, [_impl("matplotlib", None, score=90.0)])).preview is None diff --git a/tests/unit/api/test_routers.py b/tests/unit/api/test_routers.py index a8b4338d8b0..7df60653575 100644 --- a/tests/unit/api/test_routers.py +++ b/tests/unit/api/test_routers.py @@ -2375,6 +2375,51 @@ def test_related_full_mode_with_library(self, client: TestClient) -> None: assert len(data["related"]) == 1 assert "annotations" in data["related"][0]["shared_tags"] + def test_related_loads_the_catalogue_once(self, client: TestClient) -> None: + """Every spec, library, mode and limit ranks against one cached index, so the DB is read once.""" + from api.cache import clear_cache + + def spec(spec_id: str, plot_type: str) -> MagicMock: + impl = MagicMock() + impl.library_id = "matplotlib" + impl.quality_score = 90.0 + impl.preview_url = TEST_IMAGE_URL + impl.preview_url_light = TEST_IMAGE_URL + impl.preview_url_dark = None + impl.library.language = "python" + impl.impl_tags = {"techniques": ["annotations"]} + mock_spec = MagicMock() + mock_spec.id = spec_id + mock_spec.title = spec_id + mock_spec.tags = {"plot_type": [plot_type], "domain": ["statistics"]} + mock_spec.impls = [impl] + return mock_spec + + mock_spec_repo = MagicMock() + mock_spec_repo.get_all = AsyncMock( + return_value=[spec("scatter-basic", "scatter"), spec("bar-basic", "bar"), spec("line-basic", "line")] + ) + + clear_cache() + try: + with ( + patch(DB_CONFIG_PATCH, return_value=True), + patch("api.routers.insights.SpecRepository", return_value=mock_spec_repo), + ): + urls = [ + "/insights/related/scatter-basic", + "/insights/related/bar-basic?limit=1", + "/insights/related/line-basic?mode=full&library=matplotlib", + "/insights/related/unknown-spec", + ] + responses = [client.get(url) for url in urls] + finally: + clear_cache() + + assert [r.status_code for r in responses] == [200, 200, 200, 200] + assert [len(r.json()["related"]) for r in responses] == [2, 1, 2, 0] + assert mock_spec_repo.get_all.await_count == 1 + def test_visitors_no_api_key(self, client: TestClient) -> None: """Visitors endpoint returns an empty points list when no Plausible key is set, so the frontend can show the "visitor data unavailable" placeholder.""" From 2773ba7141ff5084917aa54a59666e07a741f09e Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:25:09 +0200 Subject: [PATCH 2/3] chore: reference PR #12214 in the changelog fragment Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GdMSqLR5ww4ji74EUSmk9M --- changelog.d/related-shared-index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/related-shared-index.md b/changelog.d/related-shared-index.md index f8588034ed8..c070f4f1838 100644 --- a/changelog.d/related-shared-index.md +++ b/changelog.d/related-shared-index.md @@ -8,4 +8,4 @@ `/insights/related` now caches the catalogue once as a plain-value index under a single key and ranks against it in memory. The results are identical: 1,266 spec, library, mode and limit combinations on the live catalogue gave - byte-equal responses. + byte-equal responses. (#12214) From f0448d13e880c6170c51b92a1ebbd1aeb406f190 Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:30:05 +0200 Subject: [PATCH 3/3] perf(api): refresh the related index in the background, union per library Second-opinion follow-ups (Fable, Gemini 4 Argon): - refresh_after/refresh_factory on the index key like the other whole-catalogue caches, so it never goes stale past an hour and a TTL expiry no longer costs a live request the full DB load; - impl tags are unioned per library (the unique key is spec, language, library), where the dict comprehension kept only the last impl; - eq=False on the frozen dataclass so the dict field never meets a hash; - the router test also pins that clear_cache() forces a reload. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GdMSqLR5ww4ji74EUSmk9M --- api/routers/insights.py | 31 +++++++++++++++++------ docs/reference/api.md | 2 +- tests/unit/api/test_insights_helpers.py | 5 ++++ tests/unit/api/test_routers.py | 33 ++++++++++++------------- 4 files changed, 45 insertions(+), 26 deletions(-) diff --git a/api/routers/insights.py b/api/routers/insights.py index 29932aeed1a..c0b213fb170 100644 --- a/api/routers/insights.py +++ b/api/routers/insights.py @@ -554,7 +554,7 @@ async def _fetch() -> PlotOfTheDayResponse | None: # ============================================================================= -@dataclass(frozen=True) +@dataclass(frozen=True, eq=False) class _RelatedEntry: """One spec as the related-plots ranking reads it: plain values, no ORM objects. @@ -580,11 +580,11 @@ def tags(self, mode: str, library: str | None) -> frozenset[str]: def _related_entry(spec: Spec) -> _RelatedEntry: spec_tags = frozenset(_flatten_tags(spec.tags)) - impl_tags = { - impl.library_id: frozenset(_flatten_tags(impl.impl_tags)) - for impl in spec.impls - if impl.impl_tags and isinstance(impl.impl_tags, dict) - } + # Union per library: the unique key is (spec, language, library), so one library may appear twice. + impl_tags: dict[str, frozenset[str]] = {} + for impl in spec.impls: + if impl.impl_tags and isinstance(impl.impl_tags, dict): + impl_tags[impl.library_id] = impl_tags.get(impl.library_id, frozenset()) | _flatten_tags(impl.impl_tags) impls_with_preview = [i for i in spec.impls if i.preview_url] best = max(impls_with_preview, key=lambda i: i.quality_score or 0) if impls_with_preview else None return _RelatedEntry( @@ -607,6 +607,16 @@ def _related_entry(spec: Spec) -> _RelatedEntry: ) +async def _load_related_index(repo: SpecRepository) -> list[_RelatedEntry]: + return [_related_entry(spec) for spec in await repo.get_all()] + + +async def _refresh_related_index() -> list[_RelatedEntry]: + """Standalone factory for background refresh.""" + async with get_db_context() as db: + return await _load_related_index(SpecRepository(db)) + + def _build_related( index: list[_RelatedEntry], spec_id: str, limit: int, mode: str, library: str | None = None ) -> RelatedSpecsResponse: @@ -694,9 +704,14 @@ async def get_related_specs( repo = SpecRepository(db) async def _fetch_index() -> list[_RelatedEntry]: - return [_related_entry(spec) for spec in await repo.get_all()] + return await _load_related_index(repo) - index = await get_or_set_cache(cache_key("insights", "related_index"), _fetch_index) + index = await get_or_set_cache( + cache_key("insights", "related_index"), + _fetch_index, + refresh_after=settings.cache_refresh_after, + refresh_factory=_refresh_related_index, + ) return _build_related(index, spec_id, limit, mode, library) diff --git a/docs/reference/api.md b/docs/reference/api.md index ee5dbfc079c..e42c9b6ff89 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -308,7 +308,7 @@ Deterministically selects an implementation based on today's date. It prefers th | `mode` | string | `spec` | `spec` = spec tags only, `full` = spec + impl tags | | `library` | string | null | In `full` mode, match against this library's impl_tags | -Returns related specs sorted by Jaccard similarity with preview thumbnails and shared tags. The catalogue is cached once as an in-memory index under a single key, which is cleared with the rest of the cache after a database sync. Each request ranks against that index in about a millisecond. +Returns related specs sorted by Jaccard similarity with preview thumbnails and shared tags. The catalogue is cached once as an in-memory index under a single key. The index is refreshed in the background once it is an hour old, and it is cleared with the rest of the cache after a database sync. Each request ranks against that index in about a millisecond. ### GET `/specs/{spec_id}/{library}/code` diff --git a/tests/unit/api/test_insights_helpers.py b/tests/unit/api/test_insights_helpers.py index 9ccd9a88c58..e7a3e6ef2d4 100644 --- a/tests/unit/api/test_insights_helpers.py +++ b/tests/unit/api/test_insights_helpers.py @@ -152,6 +152,11 @@ def test_full_mode_filters_by_library(self) -> None: assert entry.tags("full", "matplotlib") == {"plot_type:scatter", "techniques:annotations"} assert entry.tags("full", "plotly") == {"plot_type:scatter"} + def test_one_library_in_two_languages_unions_its_tags(self) -> None: + impls = [_impl("plotly", {"techniques": ["hover"]}), _impl("plotly", {"techniques": ["animation"]})] + entry = _related_entry(_spec({}, impls)) + assert entry.tags("full", "plotly") == {"techniques:hover", "techniques:animation"} + def test_impl_tags_none(self) -> None: entry = _related_entry(_spec({"plot_type": ["scatter"]}, [_impl("matplotlib", None)])) assert entry.tags("full", None) == {"plot_type:scatter"} diff --git a/tests/unit/api/test_routers.py b/tests/unit/api/test_routers.py index 7df60653575..0084eff375c 100644 --- a/tests/unit/api/test_routers.py +++ b/tests/unit/api/test_routers.py @@ -2377,7 +2377,6 @@ def test_related_full_mode_with_library(self, client: TestClient) -> None: def test_related_loads_the_catalogue_once(self, client: TestClient) -> None: """Every spec, library, mode and limit ranks against one cached index, so the DB is read once.""" - from api.cache import clear_cache def spec(spec_id: str, plot_type: str) -> MagicMock: impl = MagicMock() @@ -2400,25 +2399,25 @@ def spec(spec_id: str, plot_type: str) -> MagicMock: return_value=[spec("scatter-basic", "scatter"), spec("bar-basic", "bar"), spec("line-basic", "line")] ) - clear_cache() - try: - with ( - patch(DB_CONFIG_PATCH, return_value=True), - patch("api.routers.insights.SpecRepository", return_value=mock_spec_repo), - ): - urls = [ - "/insights/related/scatter-basic", - "/insights/related/bar-basic?limit=1", - "/insights/related/line-basic?mode=full&library=matplotlib", - "/insights/related/unknown-spec", - ] - responses = [client.get(url) for url in urls] - finally: - clear_cache() + with ( + patch(DB_CONFIG_PATCH, return_value=True), + patch("api.routers.insights.SpecRepository", return_value=mock_spec_repo), + ): + urls = [ + "/insights/related/scatter-basic", + "/insights/related/bar-basic?limit=1", + "/insights/related/line-basic?mode=full&library=matplotlib", + "/insights/related/unknown-spec", + ] + responses = [client.get(url) for url in urls] + assert mock_spec_repo.get_all.await_count == 1 + + clear_cache() # what the post-sync /debug/cache/invalidate does + assert client.get("/insights/related/scatter-basic").status_code == 200 + assert mock_spec_repo.get_all.await_count == 2 assert [r.status_code for r in responses] == [200, 200, 200, 200] assert [len(r.json()["related"]) for r in responses] == [2, 1, 2, 0] - assert mock_spec_repo.get_all.await_count == 1 def test_visitors_no_api_key(self, client: TestClient) -> None: """Visitors endpoint returns an empty points list when no Plausible key is set,