Skip to content

fix: avoid heap exhaustion resolving cyclic beans under a system package - #5297

Open
fgrilli wants to merge 1 commit into
swagger-api:masterfrom
fgrilli:fix/5292-subtype-cache-oom
Open

fix: avoid heap exhaustion resolving cyclic beans under a system package#5297
fgrilli wants to merge 1 commit into
swagger-api:masterfrom
fgrilli:fix/5292-subtype-cache-oom

Conversation

@fgrilli

@fgrilli fgrilli commented Aug 25, 2026

Copy link
Copy Markdown

Fixes #5292.

Root cause

ReflectionUtils.isSystemType() treats any javax.* class as a JDK "system" type, so a third-party JSR API living under javax.* (e.g. the JCR API) never gets a component name and can never be collapsed to a $ref. The resolver's cache still correctly reuses, by reference, the one already-built Schema instance for a given class across every differently-named property that reaches it — but it then had to deep-clone that shared instance (via a JSON round-trip, AnnotationsUtils.clone) to give each property occurrence its own name. Cloning a value that is itself shared/cyclic this way re-expands every shared subtree at every occurrence, blowing up combinatorially with depth and exhausting the heap.

Confirmed via the exact repro from the issue (ModelConverters.getInstance().readAll(javax.jcr.Node.class), -Xmx512m): OOMs on master (v2.2.53), resolves in well under a second with this fix.

This is a different mechanism than the issue's own analysis suggests (AnnotatedType.isSubtype fragmenting the cache, per #5004): I instrumented AnnotatedType.subtype(boolean) directly and confirmed isSubtype never becomes true during this resolution. The resolve() call count also stays modest (~127 calls total, matching the issue's own instrumentation) — this is a single value's object graph exploding when cloned, not a call-count explosion like #5091.

Fix

ModelResolver.cloneResolvedProperty() detects this specific case (blank name, no $ref) and temporarily swaps nested schema/collection-valued fields for shape-preserving-but-empty stand-ins before cloning, so the JSON round-trip still sees enough shape for ModelDeserializer's type discrimination (e.g. a non-empty allOf list still reads as ComposedSchema, non-null additionalProperties still reads as MapSchema) without carrying the huge/shared content underneath — then reattaches the real nested values by reference afterward.

This is deliberately narrower than the existing clone() helper (left unchanged, still used at every other call site): it relies on property being the resolver's own already-normalized cached value for this exact property resolution, not a freshly-built one-off wrapper (e.g. the allOf envelope built for SchemaResolution.ALL_OF still needs the plain, full round-trip, since it's never been through this normalization before).

Testing

  • Added Issue5292Test with a javax.jcr:jcr:2.0 test dependency, covering the exact types from the issue's repro table (Node, Session, Workspace, Item), plus the resolveAsRef(true) path used by io.swagger.v3.jaxrs2.Reader.
  • Full swagger-core module test suite: 750/750 passing, no regressions.

Known residual limitation

The resolved schema is still, internally, a graph with shared/cyclic object references (unavoidable without giving javax.* types a component name, which would defeat the "system types are inlined, not $ref'd" convention). Handing that result to a generic JSON serializer — e.g. Json.pretty() directly on the resolved schema, or on a full OpenAPI document that embeds it — can still exhaust the heap, since ordinary Jackson bean serialization has no notion of shared references and re-expands every occurrence. Fixing that would mean changing general-purpose Schema serialization itself, not just the resolver's internal bookkeeping, so it's left as a follow-up rather than folded into this fix.

…age (swagger-api#5292)

ReflectionUtils.isSystemType() treats any javax.* class as a JDK "system"
type, so a third-party JSR API living under javax.* (e.g. the JCR API) never
gets a component name and can never be collapsed to a $ref. The resolver's
cache still correctly reuses, by reference, the one already-built Schema
instance for a given class across every differently-named property that
reaches it - but it then had to deep-clone that shared instance (via a JSON
round-trip, AnnotationsUtils.clone) to give each property occurrence its own
name. Cloning a value that is itself shared/cyclic this way re-expands every
shared subtree at every occurrence, blowing up combinatorially with depth
and exhausting the heap - confirmed via the exact repro from the issue
(ModelConverters.getInstance().readAll(javax.jcr.Node.class), -Xmx512m).

This is a different mechanism than the one the issue's own analysis
suggested (AnnotatedType.isSubtype fragmenting the cache, per swagger-api#5004): direct
instrumentation showed isSubtype never becomes true during this resolution,
and the resolve() call count stays modest (~127 calls) - it's a single
value's object graph that explodes when cloned, not a call-count explosion
like swagger-api#5091.

ModelResolver.cloneResolvedProperty() detects this case (blank name, no
$ref) and temporarily swaps nested schema/collection-valued fields for
shape-preserving-but-empty stand-ins before cloning, so the JSON round-trip
still sees enough shape for ModelDeserializer's type discrimination (e.g. a
non-empty allOf list still reads as ComposedSchema) without carrying the
huge/shared content underneath, then reattaches the real nested values by
reference afterward. This is narrower than the existing clone() helper
(still used, unchanged, at every other call site) since it relies on
`property` being the resolver's own already-normalized cached value, not a
freshly-built one-off wrapper.

Adds a javax.jcr:jcr test dependency and Issue5292Test reproducing the
exact types from the issue.

Known residual limitation: the resolved schema is still, internally, a
graph with shared/cyclic references. Serializing it directly (or as part of
a full OpenAPI document) can still exhaust the heap, since ordinary Jackson
bean serialization has no notion of shared references. Fixing that would
mean changing general-purpose Schema serialization itself, not just the
resolver's internal bookkeeping - left as a follow-up.
@fgrilli
fgrilli force-pushed the fix/5292-subtype-cache-oom branch from 09a8457 to e253190 Compare August 25, 2026 09:00
@ewaostrowska ewaostrowska added the backlog label Aug 27, 2026 — with Claude
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

OutOfMemoryError resolving javax.jcr.Node since 2.2.41 — AnnotatedType.isSubtype in equals()/hashCode() splits the resolver cache

2 participants