feat(vector-sync): scan provisioned users immediately

Background vector sync discovered newly provisioned users only on the
periodic user-manager poll (VECTOR_SYNC_USER_POLL_INTERVAL, default 60s),
delaying first indexing by up to a minute. Add a ProvisionSignal doorbell
that provisioning paths ring after storing a user's app password, waking
user_manager_task to re-poll and spawn the user's scanner at once. The
periodic poll remains the backstop (covers cross-replica provisioning).

- ProvisionSignal (stable reference, wait-and-re-arm) held on
  VectorSyncState; closes the lost-wakeup window (no await between observing
  the ring and re-arming; anyio.Event stickiness covers a mid-poll ring)
- user_manager_task races its poll timeout against the doorbell + shutdown
- notify_user_provisioned() rung from the three app-password provisioning
  sites: Login Flow v2 web, MCP provisioning tool, management/BasicAuth API

Note: the pre-existing scanner_wake_event was never .set() and only wakes
existing scanners; a brand-new user has none, so the manager is what must
be nudged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-06-12 09:48:56 +02:00
co-authored by Claude Opus 4.8
parent 4688f2f95a
commit d8e3e9bc33
7 changed files with 503 additions and 2 deletions
+32
View File
@@ -128,6 +128,7 @@ from nextcloud_mcp_server.server.auth_tools import register_auth_tools
from nextcloud_mcp_server.server.oauth_tools import register_oauth_tools
from nextcloud_mcp_server.vector.metrics_publisher import vector_sync_metrics_task
from nextcloud_mcp_server.vector.oauth_sync import (
ProvisionSignal,
oauth_processor_task,
user_manager_task,
)
@@ -344,6 +345,12 @@ class VectorSyncState:
task_producer: "TaskProducer | None" = None
shutdown_event: anyio.Event | None = None
scanner_wake_event: anyio.Event | None = None
# Rung by a provisioning request to wake ``user_manager_task`` immediately so
# a just-provisioned user's scanner is spawned without waiting out the
# ``VECTOR_SYNC_USER_POLL_INTERVAL`` poll. ``None`` when no user manager is
# running (single-user mode or vector sync disabled), in which case
# ``notify_user_provisioned`` is a no-op.
provision_signal: "ProvisionSignal | None" = None
# Long-lived task group used for fire-and-forget background work spawned
# from the request path (e.g. ADR-019 verify-on-read eviction). Set by the
# starlette lifespan after entering its task group; cleared on shutdown.
@@ -354,6 +361,23 @@ class VectorSyncState:
_vector_sync_state = VectorSyncState()
def notify_user_provisioned() -> None:
"""Wake the user manager to discover a just-provisioned user immediately.
Provisioning call sites invoke this after a successful app-password store so
``user_manager_task`` re-polls at once instead of waiting out
``VECTOR_SYNC_USER_POLL_INTERVAL``. The 60s poll remains the backstop, so a
missed signal (e.g. provisioning handled on a different replica than the
manager) only delays the scan, never skips it.
No-op when ``provision_signal`` is ``None`` — single-user mode or vector
sync disabled, where no user manager is running.
"""
signal = _vector_sync_state.provision_signal
if signal is not None:
signal.ring()
def _wire_vector_sync_state(
app: Starlette,
transport: IngestTransport,
@@ -416,6 +440,7 @@ def _clear_vector_sync_state() -> None:
# closed lifespan; the next startup's _wire_vector_sync_state rebinds them.
_vector_sync_state.shutdown_event = None
_vector_sync_state.scanner_wake_event = None
_vector_sync_state.provision_signal = None
# =============================================================================
@@ -2060,6 +2085,12 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
# that choice — this path is now backend-agnostic.
shutdown_event = anyio.Event()
scanner_wake_event = anyio.Event()
# Doorbell the provisioning request path rings (via
# notify_user_provisioned) to wake the user manager immediately
# for a newly provisioned user. Held on the singleton only — both
# the manager and the signal helper reach it there.
provision_signal = ProvisionSignal()
_vector_sync_state.provision_signal = provision_signal
# User state tracking for user manager
user_states: dict = {}
@@ -2095,6 +2126,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
nextcloud_host_for_sync,
user_states,
tg,
provision_signal,
)
# In-process consumer pool. ``run_consumers`` is a no-op for