Skip to content

perf: parallelize startup cache warming to fix slow cold load#170

Open
hongwei1 wants to merge 11 commits into
OpenBankProject:developfrom
hongwei1:feature/parallel-startup-cache
Open

perf: parallelize startup cache warming to fix slow cold load#170
hongwei1 wants to merge 11 commits into
OpenBankProject:developfrom
hongwei1:feature/parallel-startup-cache

Conversation

@hongwei1

@hongwei1 hongwei1 commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

Summary

Cold loads of API Explorer (reported: 60s+ with "0 endpoints" and a long spinner) block app.mount() until resource docs for every active API version, all message docs, and all message docs JSON schemas are fetched sequentially — one await per version/connector. With 20-30+ active versions at roughly 0.8-1.2s of doc-generation time each, the cold path scales linearly with version count.

On top of that, a pre-existing bug doubled the cost: resource-docs.ts/message-docs.ts cache() posted a worker.postMessage(...) background-refresh message before reading the cached response. On a cold cache the read throws, the catch block runs the full fetch, and the already-posted message echoes back through the worker (a pure echo, see public/js/worker/web-worker.js) and triggers the exact same full fetch a second time.

Changes

  • Fix the double-fetch bug first (independent, revertable): move worker.postMessage(...) to after the successful cache read in all three cache()/cacheJsonSchema() functions, so only a cache hit schedules a background re-warm.
  • Bounded concurrency instead of naked Promise.allSettled: added a small runWithConcurrency(items, limit, task) helper and used it to fetch resource docs (limit 5) and message docs / JSON schemas (limit 4) in parallel. A naked allSettled over 20-30+ items would fire that many concurrent, CPU-expensive doc-generation requests at the OBP backend with no ceiling anywhere in the chain (Express proxy → undici has no connection cap; the browser multiplexes over h2 unbounded). A bounded pool keeps worst-case concurrency (5 + 4 + 4 = 13) reasonable while still cutting cold-load time from sum-of-all-versions to roughly ceil(N/limit) × avg-latency.
  • Parallelize the independent startup tasks: resource docs, message docs, message docs JSON schema, and the glossary don't depend on each other, so setupData now fetches all four with Promise.all (kept intentionally, not allSettled — any one failing should still surface the existing setup-error page).
  • Added unit tests for the concurrency helper (ceiling respected, every item processed once, one failing task doesn't block the others).

Verification

  • npm test green (pre-existing unrelated failures in OBPConsentsService/OpeyClientService/ChatWidget tests are present on develop too, unaffected by this change).
  • Ran the app locally against a live OBP-API backend: on cold cache (Cache Storage cleared), the network waterfall shows resource-docs requests fetched in bounded parallel batches, each API version requested exactly once. On a warm reload, the background re-warm still fires exactly once per version (confirms the double-fetch fix), and the page renders instantly from cache.

Follow-up fixes (after review)

A round of review turned up a few bugs, some introduced by this PR and some in the
Opey backend-proxy work already sitting on this branch:

  • runWithConcurrency's own comment promised Promise.allSettled-style isolation, but
    a rejecting task actually broke its worker's loop and the whole pool rejected
    immediately, abandoning items still queued for other workers. It now keeps draining
    the queue and only re-throws the first error after everything has settled.
  • message-docs.ts's per-connector fetch had no try/catch (unlike resource-docs.ts),
    so one connector returning an error payload could abort caching for every other
    connector and fail the whole app startup. Wrapped and isolated per connector.
  • cacheDoc/cacheDocJsonSchema each call getConnectors() independently; now that
    they run concurrently the two calls fired at the same instant. Memoized so they share
    one in-flight request.
  • Simplified the repeated isDynamicEntity ternary branching in resource-docs.ts's
    cacheOneVersion.
  • OpeyClientService.invoke() never sent a session cookie even though /stream was
    updated to, despite createOpeySession's own doc comment saying both endpoints need
    one. stream() also created a brand-new Opey session on every chat message instead of
    reusing one per conversation. Added getOrCreateSessionCookie(), used by both
    stream() and invoke(), and an establishSession() called eagerly from
    /opey/consent so a broken session fails at login time instead of surfacing as a
    generic error on the user's first chat message.

Out of scope (noted for follow-up)

  • Mounting the app after only the default version's docs load, then warming the rest in the background — the biggest remaining win, but a larger change: it alters cache()'s return semantics and requires a "not ready yet" state in the version-switch UI.
  • Hardening superagent requests with a response timeout — today a hung request stalls the whole cache-warm step (unchanged behavior, pre-existing, just no longer masked by parallelism since a hang would only stall one lane's pool worker rather than the whole page).

hongwei1 added 7 commits July 7, 2026 16:25
- chat.ts: POST /api/opey/stream (same-origin via Vite proxy) instead of
  calling Opey directly from the browser, which failed on Opey's
  cross-origin session cookie. Drop the direct browser create-session.
- oauth2.ts: store baseUri/version in session.clientConfig so the consent
  service builds a valid OBP URL (was 'undefined/obp/...').
- OpeyClientService.ts: add createOpeySession() to establish an Opey
  session with the Consent-JWT and forward its cookie on /stream (Opey
  rejects /stream without a session).
- add @grpc/grpc-js (the Express server fails to start without it).
Previously the worker message that schedules a background docs refresh
was posted before reading the cached response. On a cold cache the read
throws, the catch block performs the full fetch, and the already-posted
message echoes back and runs the same full fetch a second time. Post the
message only after the cached response has been read successfully.
Cold-start resource docs fetching awaited one API version at a time,
so cold load time scaled linearly with the number of active versions.
Add a small bounded-concurrency helper and use it to fetch up to 5
versions in parallel, keeping the same per-version error isolation,
response-shape checks, and single cache write at the end.
The serial reduce loops over connectors made message-docs and
message-docs-json-schema caching scale linearly with connector count.
Replace both with the bounded-concurrency helper (limit 4), keeping
the existing error-shape check and grouping logic.
Resource docs, message docs, message docs JSON schema, and the
glossary were awaited one after another during app setup even though
none of them depend on each other. Fetch all four concurrently with
Promise.all, keeping the existing fail-fast behavior where any one
failing surfaces the setup error page.
Cover the concurrency ceiling, that every item is processed exactly
once, and that one failing task does not prevent the others from
running.
@hongwei1 hongwei1 force-pushed the feature/parallel-startup-cache branch from 63cf632 to 25cd823 Compare July 7, 2026 14:26
hongwei1 added 4 commits July 7, 2026 16:37
runWithConcurrency's own comment promised Promise.allSettled-style
isolation, but a rejecting task actually broke its worker's loop and
Promise.all(workers) rejected immediately, abandoning any queued items
still owned by other workers. Catch each task's error inside the loop
so every item is still attempted, and only re-throw the first error
once every item has settled.
Unlike resource-docs.ts, message-docs.ts's per-connector fetch had no
try/catch, so one connector returning an error payload (which get()
turns into {error: ...} instead of throwing) tripped getGroupedMessageDocs
on an undefined field and aborted caching for every other connector too.
Wrap each connector's fetch and skip it on failure instead.

Also memoize getConnectors(): cacheDoc and cacheDocJsonSchema each call
it independently, and now that main.ts runs them concurrently the two
calls fire at the same instant instead of staggered sequentially.
…caching

cacheOneVersion branched on isDynamicEntity separately for each log
line. Compute the 'kind' label once and reuse it, cutting the ternary
count without splitting the shared try/catch/concurrency skeleton into
two functions.
Two gaps in the Opey session handling added when chat was routed
through the backend proxy:

- invoke() sent no session cookie at all, even though createOpeySession's
  own doc comment says both /stream and /invoke need one - only stream()
  was updated to establish and forward it.
- stream() created a brand new Opey session on every single chat message
  instead of reusing one for the conversation, doubling backend requests
  and adding a round trip of latency per message.
- handleAuthentication() on the client used to prove the session actually
  worked (by creating it directly) before marking the user authenticated;
  once that moved server-side, a truthy consent response alone was enough
  to flip userIsAuthenticated, so a broken session only surfaced later as
  a generic error on the first chat message.

Add getOrCreateSessionCookie(), which reuses a cookie already cached on
the config or creates and persists one, and call it from both stream()
and invoke(). Call it eagerly from /opey/consent via a new
establishSession() so a rejected session fails the request immediately
instead of being deferred to the first message.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant