← specs · onedrive-walk-concurrency · NOS-21123 Jul 2026

OneDrive matter-document search — bounded-concurrency tree walk — design spec

Status: In progress · Author: Claude (NOS-211 orchestrator) · Date: 2026-07-23 · Repo: north-os (packages/agent-runtime, apps/connectors-api)
Related: Linear NOS-211 · 2026-07-18-mcp-partial-state-design.html (deadline / partial-state envelope, already on main) · cross-matter document access spec §5.3 (shared walk budget) · revised after adversarial plan review (omp/GPT-5.6-Sol, 2026-07-23)

Measured problem

search_matter_documents / read_matter_document walk the mapped OneDrive deal folder live. Three serial layers stack up (measured on the dev box against the real HSE org, 2026-07-23):

  1. Serial BFS. searchMatterTree awaits listChildren one folder node at a time (matter-drive-documents.ts:815) — up to MAX_VISITED_ITEMS = 500 sequential connectors-api → Graph round-trips at ~200–400 ms each.
  2. Serial fan-out. Multi-matter mode loops matters one after another (:1255, up to MAX_FANOUT_MATTERS = 6); the connex auto-widen loops up to 3 sibling matters serially (:987).
  3. Per-node credential fence. Every /children request runs getRefreshedBundle in connectors-api (routes/drives.ts:121) — a connector-row read plus the refreshConnectorCredentials fence per listed folder.

Live baseline (medians): SUSS-117 deep walk (67 listings) 34.7 s; ELHM-150 (55 listings) 27.6 s; 4-matter fan-out 42.4 s; small matter (TESX-102) 1.2 s. The mcp-latency spec measured matter_documents_search p95 50.5 s. read_matter_document's containment walk (findDocumentUnderMatter) pays the same serial cost on every deep-matter read.

Chosen design

1 · Level-synchronized concurrent BFS in searchMatterTree

Mapped folder roots stay serial in DB order (as today). Within one root, the walk proceeds in deterministic BFS level batches: take the frontier in FIFO order, list up to WALK_CONCURRENCY = 6 nodes concurrently (each call routed through a search-wide limiter), settle the batch, assemble children in parent order, then continue. Never an unbounded Promise.all over Graph.

This replaces the earlier free-running worker-pool draft: the plan review showed a work-conserving pool changes which nodes are visited under budget exhaustion (fast branches outrun slow same-depth siblings), whereas FIFO level batches with in-order admission visit exactly the serial BFS prefix when the budget runs out. A genuinely narrow chain gains nothing from any traversal parallelism (a child is unknowable until its parent settles) — there the win comes from the credential cache (§3).

Claim protocol (per node, inside its limiter permit, synchronously before the fetch starts):

  1. stop check — pool-local abort (target found / fatal error), upstream signal, deadlineAt;
  2. per-root seen check and insertion;
  3. budget.remaining check-and-decrement (JS single-threaded — race-free);
  4. visitedCount increment;
  5. listChildren starts immediately.

Nothing reserves budget while parked in the limiter queue, no listing can start after the deadline, and visitedCount equals actual budget consumption — the three properties the review showed the reserve-then-queue ordering would break.

Two batch-mechanics clauses (re-check round):

Invariants preserved:

searchMatterTree returns an additional visitedCount, and accepts an optional shared limiter + pool-local abort wiring so one tool call spans all its walks with ≤6 in-flight Graph listings in total.

2 · Parallel multi-matter fan-out and connex widen — one shared limiter

The ticket explicitly mandates parallelizing the fan-out and the widen under the shared budget (NOS-211 fix outline; cross-matter budget sharing per spec §5.3). Semantics, fully specified after the review:

The connex widen resolves its ≤3 siblings serially (each passes the disclosure gate before any listing, unchanged) and then walks them concurrently under the same shared budget + limiter; blocks/matches assemble in candidate order.

One semantic delta, stated honestly and accepted by the ticket: when the shared budget exhausts mid-fan-out, the serial code let earlier matters starve later ones; the parallel code spreads the same ≤500 listings across in-flight walks, so per-matter coverage distribution under exhaustion is timing-dependent. Coverage reporting stays truthful per matter, the budget total is unchanged, and non-interrupted searches return byte-identical results. The pinned test "offers a solo retry when prior matters consumed the shared walk budget" is updated deliberately for this (its envelope contract — truthful completed/pending + executable continuation — is unchanged).

3 · Credential bundle: TTL + single-flight cache, /children only

connectors-api gets a small in-memory cache around getRefreshedBundle, used only by the /children route (the measured walk bottleneck — /content, /text, drive/folder listing keep per-request fences; broadening staleness for byte reads buys nothing here). lib/bundle-cache.ts, keyed organizationId:connectorId, TTL 30 s:

4 · Cancellation propagated end to end

Today an abort stops only the agent-runtime→connectors-api fetch; the Graph request (and SDK retries) keeps running server-side, so a timed-out search plus its continuation could hold 2× the intended Graph pressure. Fixed across both hops:

5 · Graph throttling (429 / Retry-After)

Verified: createGraphClient uses the Graph SDK default middleware chain, which includes RetryHandler (3 retries, honors Retry-After); mapGraphError maps exhausted throttles to ConnectorRateLimitError. The bounded width (≤6 in-flight per search, enforced by the shared limiter) plus end-to-end cancellation keeps worst-case Graph pressure at 6 concurrent calls per search. A node failure after retries aborts the walk exactly as today.

Alternatives rejected

Gates