fix(vector): gate scanner app polls on per-user enabled apps

The vector-sync scanner polled every indexed app (Notes, Files, News,
Deck) for every provisioned user on each scan cycle. When a user lacks
an app, its REST API returns 404; these were caught (indexing
continued) but flooded tenant logs with repeated 404s, scaling with
users x disabled-apps x scan-frequency and masking real failures.

Add NextcloudClient.get_enabled_apps(), which reads the per-user
/ocs/v2.php/core/navigation/apps endpoint (respects group
restrictions). Chosen over /cloud/capabilities because the News app
advertises no capability and never appears there.

scan_user_documents now resolves the enabled-app set once per cycle and
skips the Notes/News/Deck scans for apps the user lacks. Files stays
unconditional (core Tags API, not a 404 source). Detection failures
fall back to scanning every app (prior behaviour), so a transient
nav-endpoint blip never silently halts indexing; the per-app 404 guards
remain as the safety net.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-06-07 19:57:00 +02:00
co-authored by Claude Opus 4.8
parent 90d2192347
commit 2e609cbea7
4 changed files with 243 additions and 42 deletions
+27
View File
@@ -199,6 +199,33 @@ class NextcloudClient:
return response.json()
async def get_enabled_apps(self) -> set[str]:
"""Return the set of app ids enabled for the authenticated user.
Uses the per-user core navigation endpoint, which lists only apps the
current user can access (respecting group restrictions). The vector
scanner uses this to skip polling apps the user lacks, which would 404
and flood tenant logs. Preferred over ``/cloud/capabilities`` because
the News app advertises no capability and so never appears there.
"""
response = await self._client.get(
"/ocs/v2.php/core/navigation/apps",
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
)
response.raise_for_status()
data = response.json()
entries = data.get("ocs", {}).get("data", []) or []
enabled: set[str] = set()
for entry in entries:
# ``app`` is the canonical app id; ``id`` matches it for the apps we
# gate. Union both so an unexpected nav-entry shape never hides an
# enabled app — a false "disabled" would skip real indexing.
for key in ("app", "id"):
value = entry.get(key)
if value:
enabled.add(value)
return enabled
async def notes_search_notes(self, *, query: str):
"""Search notes using token-based matching with relevance ranking."""
all_notes = self.notes.get_all_notes()
+92 -41
View File
@@ -247,6 +247,28 @@ async def scanner_task(
logger.info("Scanner task stopped - stream closed")
async def _get_enabled_apps_or_none(
nc_client: NextcloudClient, user_id: str, scan_id: int
) -> set[str] | None:
"""Enabled-app id set for gating, or ``None`` when detection fails.
``None`` signals "couldn't determine" — callers must then scan every app
(the prior behaviour), so a transient navigation-endpoint failure never
silently halts indexing. The per-app 404 guards in ``scan_user_documents``
remain the safety net for that fallback path.
"""
try:
return await nc_client.get_enabled_apps()
except Exception as e:
logger.warning(
"[SCAN-%s] Could not determine enabled apps for %s (%s); scanning all apps",
scan_id,
user_id,
e,
)
return None
async def scan_user_documents(
user_id: str,
send_stream: TaskProducer,
@@ -328,6 +350,14 @@ async def scan_user_documents(
logger.debug("Found %s indexed documents in Qdrant", len(indexed_doc_ids))
# Determine which apps are enabled for this user so we skip polling
# apps they lack — those polls 404 and flood tenant logs. ``None`` means
# detection failed: fall back to scanning every app (prior behaviour).
enabled_apps = await _get_enabled_apps_or_none(nc_client, user_id, scan_id)
def _app_enabled(app_id: str) -> bool:
return enabled_apps is None or app_id in enabled_apps
# Notes (isolated so an uninstalled or disabled Notes app — whose API
# returns 404 — cannot abort scanning of the other apps; this mirrors the
# per-app try/except guards already wrapping files/news/deck below).
@@ -336,29 +366,36 @@ async def scan_user_documents(
current_time = time.time()
queued = 0
try:
queued += await scan_notes(
user_id=user_id,
send_stream=send_stream,
nc_client=nc_client,
initial_sync=initial_sync,
scan_id=scan_id,
prune_before=prune_before,
indexed_doc_ids=indexed_doc_ids,
grace_period=grace_period,
current_time=current_time,
)
except HTTPStatusError as e:
if e.response.status_code == 404:
logger.info(
"[SCAN-%s] Notes app unavailable for %s (HTTP 404); skipping notes",
scan_id,
user_id,
if _app_enabled("notes"):
try:
queued += await scan_notes(
user_id=user_id,
send_stream=send_stream,
nc_client=nc_client,
initial_sync=initial_sync,
scan_id=scan_id,
prune_before=prune_before,
indexed_doc_ids=indexed_doc_ids,
grace_period=grace_period,
current_time=current_time,
)
else:
except HTTPStatusError as e:
if e.response.status_code == 404:
logger.info(
"[SCAN-%s] Notes app unavailable for %s (HTTP 404); skipping notes",
scan_id,
user_id,
)
else:
logger.warning("Failed to scan notes for %s: %s", user_id, e)
except Exception as e:
logger.warning("Failed to scan notes for %s: %s", user_id, e)
except Exception as e:
logger.warning("Failed to scan notes for %s: %s", user_id, e)
else:
logger.debug(
"[SCAN-%s] Notes app not enabled for %s; skipping notes",
scan_id,
user_id,
)
if initial_sync:
logger.info("Sent %s documents for initial sync: %s", queued, user_id)
@@ -666,31 +703,45 @@ async def scan_user_documents(
# Scan News items (starred + unread)
news_queued = 0
try:
news_queued = await scan_news_items(
user_id=user_id,
send_stream=send_stream,
nc_client=nc_client,
initial_sync=initial_sync,
scan_id=scan_id,
if _app_enabled("news"):
try:
news_queued = await scan_news_items(
user_id=user_id,
send_stream=send_stream,
nc_client=nc_client,
initial_sync=initial_sync,
scan_id=scan_id,
)
queued += news_queued
except Exception as e:
logger.warning("Failed to scan news items for %s: %s", user_id, e)
else:
logger.debug(
"[SCAN-%s] News app not enabled for %s; skipping news items",
scan_id,
user_id,
)
queued += news_queued
except Exception as e:
logger.warning("Failed to scan news items for %s: %s", user_id, e)
# Scan Deck cards
deck_queued = 0
try:
deck_queued = await scan_deck_cards(
user_id=user_id,
send_stream=send_stream,
nc_client=nc_client,
initial_sync=initial_sync,
scan_id=scan_id,
if _app_enabled("deck"):
try:
deck_queued = await scan_deck_cards(
user_id=user_id,
send_stream=send_stream,
nc_client=nc_client,
initial_sync=initial_sync,
scan_id=scan_id,
)
queued += deck_queued
except Exception as e:
logger.warning("Failed to scan deck cards for %s: %s", user_id, e)
else:
logger.debug(
"[SCAN-%s] Deck app not enabled for %s; skipping deck cards",
scan_id,
user_id,
)
queued += deck_queued
except Exception as e:
logger.warning("Failed to scan deck cards for %s: %s", user_id, e)
if queued > 0:
logger.info(