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:
co-authored by
Claude Opus 4.8
parent
4688f2f95a
commit
d8e3e9bc33
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user