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
+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(