From 52c3f7928be1b8207a917dd7dd2eb2ea7a3bd9cc Mon Sep 17 00:00:00 2001 From: coaxk Date: Fri, 4 Sep 2026 19:52:37 +1000 Subject: [PATCH] perf: stop the ollama health probe fetching /api/tags twice per poll The integrations-health probe read the installed model list for its badges, then reset the vision cache and called resolve_vision_model, which fetched the identical /api/tags payload again via installed_models. The Settings page polls that endpoint every 8 seconds, so every poll cost two identical GETs, forever, for data already in hand. resolve_vision_model now accepts an optional installed list and the probe passes the names it just derived. Behaviour is unchanged in both directions: the cache is still reset on each poll, so a model pulled outside subarr is still noticed, and any caller that passes nothing still fetches exactly as before, which is why the existing vision suite needed no edits. The test asserts the probe issues exactly one GET /api/tags while still resolving the vision model, pinning the cost and the behaviour together so neither can regress alone. Confirmed red first, reporting ['/api/tags', '/api/tags', '/api/version'] Full suite: 1985 passed, 6 skipped. ruff check and format clean. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 3 ++ src/subarr/integrations/ollama.py | 14 +++++++-- src/subarr/routers/integrations.py | 8 ++++- tests/test_integrations_health_probe.py | 41 +++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f7eb20f..222d3fc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ breaking config changes. ### Security - **The published image no longer ships pip.** Nothing in a running subarr container installs packages, so pip was only ever needed while the image was being built, but it stayed in the finished image carrying six published advisories. The most serious of them lets a malicious package overwrite files outside its install directory during an install. It was invisible to the patching already in place: the build refreshes Debian packages every time, and pip is not a Debian package here, so that refresh could never see it. Upgrading pip does not fix it either, because pip carries its own dependencies bundled inside itself and every release to date pins a version of one of them with its own unfixed advisory, so an upgrade trades six findings for two. pip is now removed once it has finished installing subarr, which clears all six and introduces none. Verified by scanning the published image, an upgrade-only build and this one. The application itself is completely unchanged. +### Fixed +- **The Settings page asked Ollama for the same model list twice on every refresh.** The integrations health check reads your installed models for the status panel, then worked out which vision model to use by asking Ollama for that exact list a second time. That page refreshes every 8 seconds, so it was a permanently doubled request rate for data it was already holding. It now reuses what it just fetched. Nothing you see changes, and it still notices a model you pull outside subarr. + ## [2.6.1] - 2026-09-03 **Multi-library and multi-Sonarr setups could act on the wrong file.** diff --git a/src/subarr/integrations/ollama.py b/src/subarr/integrations/ollama.py index 05ed19d2..e99d18e9 100644 --- a/src/subarr/integrations/ollama.py +++ b/src/subarr/integrations/ollama.py @@ -201,7 +201,7 @@ async def installed_models(self) -> list[str]: models = data.get("models", []) if isinstance(data, dict) else [] return [m.get("name", "") for m in models if isinstance(m, dict)] - async def resolve_vision_model(self) -> str | None: + async def resolve_vision_model(self, installed: list[str] | None = None) -> str | None: """#232: figure out which vision-capable model to use right now. Logic: @@ -216,13 +216,21 @@ async def resolve_vision_model(self) -> str | None: state, the text model would hallucinate. Result is cached on the instance until reset_vision_cache() is - called (Settings save / model pull completion clears it).""" + called (Settings save / model pull completion clears it). + + `installed` lets a caller that has ALREADY fetched /api/tags hand the + model names in rather than making us fetch the identical payload a + second time. The integrations-health probe does exactly that: it reads + the model list for its badges, resets this cache so an externally + pulled model is noticed, and would otherwise re-GET /api/tags on every + poll for data it is already holding.""" if self._vision_model_resolved is not None: return self._vision_model_resolved or None if not self._configured: self._vision_model_resolved = "" return None - installed = await self.installed_models() + if installed is None: + installed = await self.installed_models() if not installed: self._vision_model_resolved = "" return None diff --git a/src/subarr/routers/integrations.py b/src/subarr/routers/integrations.py index fcf298a8..64ae62f9 100644 --- a/src/subarr/routers/integrations.py +++ b/src/subarr/routers/integrations.py @@ -219,8 +219,14 @@ async def _probe(name: str, client, summary_kind: str = "version") -> dict[str, # panel can show "Vision pre-filter active / inactive" with # the resolved model name, instead of users discovering it # only when a vision call fails. + # Reset so a model pulled outside subarr is picked up, then hand + # resolve_vision_model the names from the tags call above. Without + # that argument it re-GETs /api/tags for the same payload, which at + # the Settings page's 8s poll doubled the request rate forever. client.reset_vision_cache() - vision_resolved = await client.resolve_vision_model() + vision_resolved = await client.resolve_vision_model( + installed=[m.get("name", "") for m in models if isinstance(m, dict)] + ) return { "name": name, "online": True, diff --git a/tests/test_integrations_health_probe.py b/tests/test_integrations_health_probe.py index 736f0640..292c3238 100644 --- a/tests/test_integrations_health_probe.py +++ b/tests/test_integrations_health_probe.py @@ -6,9 +6,11 @@ from __future__ import annotations +import httpx import pytest from subarr.integrations import IntegrationError +from subarr.integrations.ollama import OllamaClient from subarr.routers.integrations import _probe @@ -84,3 +86,42 @@ async def test_both_fail_marks_offline_no_dangling_task(): "bazarr_badges", ) assert out["online"] is False + + +# The ollama probe must cost ONE /api/tags per poll, not two. + + +@pytest.mark.asyncio +async def test_ollama_probe_fetches_tags_once(): + """The ollama probe used to GET /api/tags TWICE on every poll: once for the + model list, then it reset the vision cache and called resolve_vision_model(), + which re-fetched the identical payload through installed_models(). The + Settings page polls this every 8s, so that is a doubled request forever for + data already in hand. The resolution itself must still work, hence the + vision_model_resolved assertion below.""" + calls: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + calls.append(request.url.path) + if request.url.path == "/api/tags": + return httpx.Response( + 200, + json={"models": [{"name": "qwen2.5vl:7b"}, {"name": "qwen2.5:7b"}]}, + ) + if request.url.path == "/api/version": + return httpx.Response(200, json={"version": "0.33.3"}) + return httpx.Response(404) + + c = OllamaClient(base_url="http://ollama.test", model="qwen2.5:7b", vision_model="auto") + c._configured = True + c._client = httpx.AsyncClient(base_url="http://ollama.test", transport=httpx.MockTransport(handler)) + + out = await _probe("ollama", c, "ollama_models") + + assert out["online"] is True + assert out["badges"]["models"] == 2 + assert out["badges"]["vision_model_resolved"] == "qwen2.5vl:7b" + assert calls.count("/api/tags") == 1, "expected 1 /api/tags per probe, got %d: %r" % ( + calls.count("/api/tags"), + calls, + )