Skip to content

stitchSchemas mutates the caller's resolvers, leaking a schema generation per supergraph reload #2592

Description

@kpiotr

stitchSchemas mutates the resolver objects it is given. addLocalFieldResolvers wraps every user resolver whose field returns a merged type so the merged type's fields get resolved, and writes the wrapper back into the caller's field config:

const baseResolve = originalResolve ?? defaultMergedResolver;
const wrappedResolve = (parent, args, context, info) =>
  handleMaybePromise(
    () => baseResolve(parent, args, context, info),
    (result) => resolveLocalFieldResult(result, context, info, stitchingInfo, providedFields),
  );
if (existing != null && typeof existing === 'object') {
  existing.resolve = wrappedResolve;   // the caller's object
}

wrappedResolve closes over baseResolve, which is whatever existing.resolve was when the call started, and over this call's stitchingInfo. Pass the same resolvers object to stitchSchemas twice and the second wrapper wraps the first; pass it N times and you get a chain of N wrappers, each one holding a different stitchingInfo.

Observed on @graphql-tools/stitch 10.2.2; 10.2.3 has the same code (packages/stitch/src/stitchSchemas.ts).

Why this is a problem

@graphql-mesh/fusion-runtime does exactly that. UnifiedGraphManager hands this.opts.additionalResolvers to every unified-graph build, so a Hive Gateway that reloads its supergraph at runtime (polling, Hive CDN, any dynamic source) re-stitches with the same module-level resolver map on every reload. Each reload adds one wrapper to every stitched field config, and each wrapper keeps its build's stitchingInfo reachable: the subschema map, every subschema's GraphQLSchema, executors and transports of a generation that gracefulSchemaReload has already disposed. Nothing in the old generation can be collected.

What that looks like on a production gateway (2.11.2, a few dozen subgraphs, multi-MB supergraph), post-GC heapUsed per reload:

216 MB → 350 → 486 → 621 → 757 → 893 → FATAL ERROR: Ineffective mark-compacts near heap limit

+135 MB per reload, linear, until Node aborts at the old-space cap; a day with a handful of supergraph publications restarts every instance. Heap snapshots taken between reloads show every schema-shaped constructor growing by exactly one generation (GraphQLSchema 468 → 702 across two reloads, one executor closure per subgraph, the supergraph SDL's parse tokens), and the shortest retainer path to every stale GraphQLSchema is the same:

additionalResolvers.<Type>.<field>.resolve      fn wrappedResolve
  → fn wrappedResolve → fn wrappedResolve       one per reload
    → stitchingInfo → subschemaMap → Subschema → schema / executor / transports

Before runtime supergraph reloads, every schema change restarted the process, which is why a leak in a function that is normally called once never surfaced. Any consumer that reuses a resolvers object across stitchSchemas calls has it, not only fusion-runtime.

Proposal

Clone the merged resolver map before addLocalFieldResolvers runs — the type maps of object and interface types, and their field config objects, one level each — so the wrapper lands on the copy that addResolversToSchema consumes and the caller's objects stay untouched. Everything else (enum value maps, scalar instances or configs, union and input type resolvers, functions) passes through by reference, since it is never mutated and enum internal values must keep their identity. The clone must not test for plain objects: mergeResolvers deep-merges a type that appears in more than one map into an object whose prototype is not Object.prototype, while still reusing the field config objects by reference, and that is precisely the shape a type with both user resolvers and @resolveTo-generated ones has. A first attempt with a plain-object guard still leaked through such a type.

PR with the implementation, a regression test that fails on main (expected [Function wrappedResolve] to be [Function resolveOwner]) and a changeset: #2591. With the change applied, the same production-shaped gateway holds at 216–219 MB across four reloads.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions