Skip to content

Draft: perf: build the executable schema once per process - #550

Draft
smartive-nicolai[bot] wants to merge 1 commit into
mainfrom
perf/memoise-executable-schema
Draft

Draft: perf: build the executable schema once per process#550
smartive-nicolai[bot] wants to merge 1 commit into
mainfrom
perf/memoise-executable-schema

Conversation

@smartive-nicolai

Copy link
Copy Markdown
Contributor

What

execute() regenerated the SDL, re-merged every resolver and called makeExecutableSchema on every operation. All three are pure functions of (models, additionalResolvers, resolverWrapper).

  • createExecutor() builds the schema explicitly, for consumers that want to pay the cost at boot rather than on the first request. Pass it to execute via executor.
  • execute() otherwise caches per process, keyed on the identity of all three inputs.

Why

Measured in zwei-wealth-platform (~80 entity models), per operation before any SQL runs:

ms
generate(models) 6.7
makeExecutableSchema 37.5
parse 0.2
validate 3.9
fixed cost per operation ~48

Because the cost is per operation and independent of payload, it dominates: a query doing 1 SQL and returning 30 bytes cost 54 ms. The six operations one page render issues:

before after
6 operations, real executeGraphQL 367.1 ms 16.3 ms

Measured end-to-end against the consumer with this branch built and linked in, not just in isolation. A control arm still rebuilding the schema per call measured 498 ms in the same run, so the drop is the change and not a warmer host. All six operations returned byte-identical data before and after.

Why the cache is safe

  • Per-request state travels in contextValue, never into the schema; getResolvers(models) closes over models only.
  • introspection selects validation rules, not the schema, so validate stays per call and the introspection guard is unchanged.
  • Keyed on all three inputs, because a consumer may execute against more than one model set or resolver map in the same process.
  • WeakMaps throughout. A caller passing a freshly created resolverWrapper per call (an inline arrow) falls back to the previous behaviour — a rebuild — and the entry is collected along with the wrapper, so it cannot accumulate. This was the main hazard of a naive identity cache and is covered by a test.

Tests

tests/api/execute.spec.ts, 5 cases. The load-bearing one is cross-request isolation: two users, one cached schema, each must get its own data — the failure mode a naive singleton would introduce, and it would be silent. Also covered: schema built once for repeated calls, rebuilt when the wrapper identity changes, a pre-built executor is used as-is, and introspection is still rejected on a cached schema.

Verified the cache assertion actually fails without the change (bypassing the cache turns it red), so it is a real regression guard.

Full suite green locally: lint + 164 tests + build.

Note for reviewers

Opened as a draft — I have not marked it ready or merged. Landing this in zwei-wealth-platform additionally needs a release plus a bump there, since it pins 29.3.0 exactly.

🤖 Generated with Claude Code

`execute()` regenerated the SDL, re-merged every resolver and called
`makeExecutableSchema` on every operation. All three are pure functions of the
models, the additional resolvers and the resolver wrapper — resolvers receive
request state as their context argument, so nothing request-scoped is captured
— which means the work was repeated per request for no reason.

Measured in a consumer with ~80 entity models: ~48 ms of fixed cost per
operation (generate 6.7 ms, makeExecutableSchema 37.5 ms, validate 3.9 ms).
Six operations for one page render went from 367 ms to 18.6 ms, with
byte-identical results.

- `createExecutor()` builds the schema explicitly, for consumers that want to
  pay the cost at boot rather than on the first request, and can be passed to
  `execute` via `executor`.
- `execute()` otherwise caches per process, keyed on the identity of all three
  inputs. WeakMaps throughout, so a caller passing a freshly created
  `resolverWrapper` per call falls back to the previous behaviour — a rebuild —
  and the entry is collected along with the wrapper rather than accumulating.

`validate` stays per call, since `introspection` selects rules rather than
affecting the schema, so the introspection guard is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 29.3.1-next.1 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant