Skip to content

perf(web): fetch the scope tree once on /lore instead of twice - #693

Merged
mthines merged 5 commits into
mainfrom
weekly-improve/2026-10-17-dedupe-lore-scopes-fetch
Oct 17, 2026
Merged

mthines merged 5 commits into
mainfrom
weekly-improve/2026-10-17-dedupe-lore-scopes-fetch

Conversation

@dash0-dev

@dash0-dev dash0-dev Bot commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Every load of the dashboard's Lore Explorer (/lore, the default landing route) sent GET /functions/v1/memories/scopes twice at the same time, so the lorekit_memory_scopes Postgres RPC ran twice and the two copies slowed each other down. This affects every signed-in dashboard user on their first page. With this change useLoreData reads the scope tree through the shared ['lore-scopes'] query, so when both hooks mount together the browser sends one request.

Evidence (production telemetry)

  • Signal: share of CALL lorekit_memory_scopes DB client spans (service api, deployment.environment.name=production) that land in the same second as another call: 45.5% in the current window 2026-10-10T08:33:09Z..2026-10-17T08:33:09Z (111 of 244 calls) vs 36.7% in the baseline 2026-09-19T08:33:09Z..2026-10-10T08:33:09Z (277 of 754 calls). Related: memories.scopes REST p95 is 2.137 s now vs 1.740 s in the baseline. The RPC averages ~640 ms, and 21.7% of calls take over 1 s. Browser→edge GET /functions/v1/memories/scopes averages 1059 ms (98 calls, current window).
  • Query:
SELECT sum(c) AS calls, sumIf(c, c >= 2) AS dup_calls, round(100 * sumIf(c, c >= 2) / sum(c), 1) AS dup_pct, count() AS seconds FROM (SELECT substring(toString(timestamp), 1, 19) AS sec, count() AS c FROM spans WHERE resource_attributes['service.name'] = 'api' AND resource_attributes['deployment.environment.name'] = 'production' AND kind = 'CLIENT' AND name LIKE 'CALL lorekit_memory_scopes%' GROUP BY sec)
  • Examples: trace d04200003f11316c908f8bc0b4356136: two lorekit_memory_scopes calls started 5 ms apart (2026-10-11T06:01:32.346Z and .351Z) and each took 4.13 s for 66 rows. Web /lore SSR trace 371aa694f8c77fa430b38fa02d9f7b2f.

Root cause

  • LorePageClient.tsx#L10 mounts useScopeTree(), and #L17 mounts useLoreData() next to it.
  • useScopeTree (lore.ts#L316-L324) fetches ['lore-scopes'] through fetchScopes (#L153-L157), which calls listScopesRequest.
  • useLoreData (#L361-L369) runs fetchLoreData, which calls listScopesRequest again on its own (#L341) and builds the same tree with buildScopeTree (#L352).
  • The two queries have different keys (['lore-scopes'] and ['lore']), so TanStack Query cannot de-duplicate them. Two identical requests leave at the same moment, which is the same-second pair the signal counts.

useLoreData cannot simply be removed. NavigationCommands, MemoryExpandButton (which reads its scopes) and MemorySidebarProvider also use it, so its scopes field keeps the same shape and content.

Change

  • packages/web/src/lib/queries/lore.ts: the scope-tree query (key ['lore-scopes'], fetchScopes, 90 s staleTime, retryUnlessSignedOut) is now one shared queryOptions object. useScopeTree uses it as is.
  • fetchLoreData gets the QueryClient (from useQueryClient() in useLoreData). It now gets the scopes half from queryClient.fetchQuery(scopeTreeQueryOptions) instead of calling listScopesRequest itself. In @tanstack/query-core 5.101.4 (the version in the lockfile), fetchQuery returns the cached tree while it is fresh. While a fetch is in flight, Query.fetch returns that fetch's promise instead of starting a new one. So /lore makes one network call whichever hook starts first.
  • There is deliberately no retry override on that call. The retry policy is captured by whichever caller starts the shared fetch, so a no-retry override would leave a useScopeTree that joins a fetch started by useLoreData failing on a single transient error. The accepted cost: during a sustained scopes outage, the ['lore'] query's own retry re-runs an already-retried, backoff-spaced fetch.
  • The list and activity requests are unchanged. The archive and restore mutations already invalidate both ['lore-scopes'] and ['lore'], so after an archive or restore both queries refetch and still share one scopes request.

No user-facing docs apply: internal request de-duplication with no observable behaviour change.

Test

  • New packages/web/src/lib/queries/lore-scopes-dedupe.spec.ts. The package has no DOM test environment and no @testing-library/react, so the spec runs the real useScopeTree and useLoreData as plain functions. useQuery is replaced by a recorder of the options each hook passes, useQueryClient returns the test client, and the recorded options are fetched through one real QueryClient. listScopesRequest, listMemoriesRequest, activityRequest and the browser-token helper are mocked.
  • It asserts that /memories/scopes is requested exactly once when both hooks mount, in either order (the reverse-order case mounts useLoreData first and waits until its scopes request is in flight before mounting useScopeTree), and that useLoreData().scopes equals the scope tree. It also checks that useLoreData alone still fetches the tree, and that a useScopeTree joining a fetch started by useLoreData survives one transient failure instead of erroring.
  • Fail before: the spec was committed first and run on the unmodified lore.ts; both mount-order tests failed with expected "vi.fn()" to be called 1 times, but got 2 times. The transient-failure test failed with Error: transient while a retry: false override was in place.
  • Pass after: the spec's 4 tests pass, the full web unit suite passes (npx vitest run --root packages/web: 108 files, 1589 tests), the web typecheck (tsc --noEmit -p packages/web/tsconfig.json) passes, and ESLint on both touched files reports 0 problems.
  • Only CI and the deployed app can confirm the browser behaviour end to end, including the Storybook suites, which run on CI.

How we will know it worked

Re-run the dup_pct query above (unit: percent) over a production window after deploy. It should go down from 45.5%. The weekly loop re-measures it on or after 2026-10-24. Some same-second calls will remain: the server-side onboarding getKeyScopeCatalog() call on /lore and other pages that read scopes also run this RPC, so the number will not reach zero.

Opened by the weekly Agent0 telemetry-driven improvement automation. It is merged automatically when the review loop converges with an approving verdict and CI is green (and, for a web UI change, when the ui-verify test passes on this PR's Vercel preview), unless it carries the needs-human-merge label.

Agent0 and others added 3 commits October 17, 2026 08:50
Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com>
useLoreData fetched GET /memories/scopes itself while LorePageClient also
mounted useScopeTree, so every /lore load ran the lorekit_memory_scopes RPC
twice, concurrently. fetchLoreData now reads the scope tree through the shared
['lore-scopes'] query via queryClient.fetchQuery, which joins an in-flight
fetch or serves a fresh cached tree. One shared options object keeps the key
and stale time defined once.

Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com>
Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com>
@dash0-dev dash0-dev Bot added the agent0-weekly-improvement Opened by the weekly Agent0 telemetry-driven improvement automation label Oct 17, 2026
@dash0-dev

dash0-dev Bot commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor Author

✅ No issues found

No code change since the last review at fad5359. The PR body now matches the diff: no retry override, and the spec's 4 tests. The description warning is cleared and every gate passes.

Checked: 0 of 7 dependent files traced

Progress: open review threads 1 → 0 → 0 across the last 3 reviews

What this change reaches — 2 changed exports · 7 dependent files · 0 checked · 7 not checked
flowchart LR
  subgraph pr["Changed in this PR"]
    s1["useLoreData<br/>body changed"]:::changed
    s2["useFacetCatalog<br/>body changed"]:::changed
  end
  s1c1["packages/web/src/components/lore/ · 2 files<br/>? not checked"]:::unknown
  s1c2["packages/web/src/app/(dashboard)/lore/LorePageClient.tsx<br/>? not checked"]:::unknown
  s1c3["packages/web/src/components/command/NavigationCommands.tsx<br/>? not checked"]:::unknown
  s1c4["packages/web/src/components/providers/MemorySidebarProvider.tsx<br/>? not checked"]:::unknown
  s1c5["packages/web/src/lib/queries/lore-scopes-dedupe.spec.ts<br/>? not checked"]:::unknown
  s2c1["packages/web/src/components/lore/LoreExplorer.tsx<br/>? not checked"]:::unknown
  s2c2["packages/web/src/components/settings/GroomingRuleBuilder.tsx<br/>? not checked"]:::unknown
  s1 -.-> s1c1
  s1 -.-> s1c2
  s1 -.-> s1c3
  s1 -.-> s1c4
  s1 -.-> s1c5
  s2 -.-> s2c1
  s2 -.-> s2c2
  classDef changed fill:#eef2ff,stroke:#6366f1,color:#1e1b4b
  classDef ok fill:#e7f6ec,stroke:#16a34a,color:#14532d
  classDef partial fill:#fef9c3,stroke:#ca8a04,color:#713f12
  classDef unknown fill:#f3f4f6,stroke:#9ca3af,stroke-dasharray:4 3,color:#374151
  classDef bad fill:#fde8e8,stroke:#dc2626,color:#7f1d1d
  classDef warn fill:#fff4e5,stroke:#d97706,color:#78350f
  classDef imports fill:#f8fafc,stroke:#64748b,stroke-dasharray:4 3,color:#1e293b
Loading
  • useLoreData (packages/web/src/lib/queries/lore.ts) — body change · 6 consumer files · 6 not traced
  • useFacetCatalog (packages/web/src/lib/queries/lore.ts) — body change · 2 consumer files · 2 not traced
Review details
Gate Status Details
Description vs. code ✅ Body now matches the diff: no retry override on the shared fetch, 4 tests in the spec.
Prior review feedback ✅ Earlier review comments are resolved.
Documentation ✅ Evidence, root cause and re-measure plan; inline comment explains the retry trade-off.
Self-review signals ✅ No debug logs, leftover TODOs, or unreviewed stubs.
Code review ✅ The multi-lens review found no blocking issues.

Found

Quality — produced 0 → posted inline 0 · cleared 0 · carried forward 0 · deferred 0 · below-bar 0

Run

incremental · 0 lines in delta · tier deep · depth checkout
CI — still pending: Web — Vercel preview (changed) / Deploy Vercel preview · 5 passed
Memories — skipped (zero-delta fast path)

Nothing to report — standards (skipped), optimality (skipped), measurability (skipped), integrations (skipped), severity, 0 files skipped.

pr-reviewer · commit fad5359 · no code changes since fad5359, gate checks only · how these findings are produced · updated Oct 17, 2026 9:18am UTC

Comment thread packages/web/src/lib/queries/lore-scopes-dedupe.spec.ts
Agent0 and others added 2 commits October 17, 2026 09:06
…ally mount useLoreData first

Addresses @dash0-dev[bot]'s comment: #693 (comment)

Refs: #693
Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com>
…seScopeTree keeps its retry policy

Addresses @dash0-dev[bot]'s comment: #693 (comment)

Refs: #693
Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown
Contributor

The dashboard preview for this PR — redeployed on each push that changes the web app.

Project Deployment Actions Updated
lorekit Error Error — Oct 17, 2026 9:27am UTC

Warning

The preview deploy for fad5359 failed — no preview is available.

Vercel deploy timed out after 15m. A deployment that never becomes Ready is almost always BLOCKED by Vercel — most commonly because the commit author's email can't be matched to a GitHub account with access to the project. Check the commit author and the project's Git deployment-protection settings.

A blocked deployment is most often the commit author's email not matching a GitHub account with project access. Fix the cause, then push again or comment /web-preview to retry. · Workflow logs

@mthines
mthines marked this pull request as ready for review October 17, 2026 09:36
@mthines
mthines merged commit 17474c7 into main Oct 17, 2026
17 of 18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agent0-weekly-improvement Opened by the weekly Agent0 telemetry-driven improvement automation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant