From a647264babcf871a040f5f2439f567bee42d9e3b Mon Sep 17 00:00:00 2001 From: duanjienan Date: Tue, 8 Sep 2026 14:54:35 +0800 Subject: [PATCH 1/4] perf(core): cache generated JSON schemas to remove lock from hot path PR #2796 made JsonSchemaUtils thread-safe by serializing every schema generation on SCHEMA_LOCK. That is correct, but it re-runs reflective generation on every structured-output and tool call, since ReActAgent regenerates the schema for the same class on each call. Cache the generated JsonNode per Class/Type in a ConcurrentHashMap so the lock is taken only on a cache miss (the first time a class or type is seen). A cache hit converts a fresh, independently mutable Map from the cached, never-mutated node and needs no lock, so callers that mutate the returned map in place (e.g. ToolSchemaGenerator) cannot corrupt the cache or interfere with one another. The cache is unbounded by design: keys are the compile-time-fixed structured-output and tool-parameter classes declared by application code, so the entry count is bounded by that small finite set, not by request volume or untrusted input. No invalidation is needed because a schema is a deterministic function of the class and the static generator config. Tests: adds regression coverage for equal-but-independent maps across repeated calls (Class and Type) and for null-argument behavior parity with the pre-cache path. The existing 12-thread concurrency tests still pass against the cache, and the full agentscope-core suite is green. Follow-up to #2796. --- .../agentscope/core/util/JsonSchemaUtils.java | 57 +++++++++++++++---- .../core/util/JsonSchemaUtilsTest.java | 50 ++++++++++++++++ 2 files changed, 95 insertions(+), 12 deletions(-) diff --git a/agentscope-core/src/main/java/io/agentscope/core/util/JsonSchemaUtils.java b/agentscope-core/src/main/java/io/agentscope/core/util/JsonSchemaUtils.java index 703d7a4a8c..58f1c96d72 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/util/JsonSchemaUtils.java +++ b/agentscope-core/src/main/java/io/agentscope/core/util/JsonSchemaUtils.java @@ -29,6 +29,7 @@ import io.agentscope.core.tool.ToolSchemaModule; import java.lang.reflect.Type; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; /** * Utility class for JSON Schema operations. @@ -53,9 +54,11 @@ *
  • {@code @JsonClassDescription(...)} - add class description
  • * * - *

    All public methods are thread-safe. Schema generation through the shared victools - * {@code SchemaGenerator} is serialized by an internal lock, because the generator itself - * is not designed for concurrent use.

    + *

    All public methods are thread-safe. Generated schemas are cached per {@link Class} / + * {@link Type}; the shared victools {@code SchemaGenerator} is not designed for concurrent use, + * so the internal lock is taken only on a cache miss (the first time a given class or type is + * seen). A cache hit converts a fresh, independently mutable {@code Map} from the cached, + * never-mutated {@link JsonNode}, so it needs no lock.

    * * @hidden */ @@ -68,10 +71,32 @@ public class JsonSchemaUtils { /** * Guards the shared victools {@link SchemaGenerator}, which is not thread-safe: its * JacksonModule keeps an unsynchronized introspection cache, so concurrent schema - * generation must be serialized. + * generation must be serialized. Only cache misses in {@link #CLASS_SCHEMA_CACHE} and + * {@link #TYPE_SCHEMA_CACHE} take this lock; cache hits never do. */ private static final Object SCHEMA_LOCK = new Object(); + /** + * Caches the schema {@link JsonNode} generated for each class. A schema is a deterministic + * function of the class and the static, never-changing generator config, so entries never + * need invalidation. Cached nodes are never mutated after being stored: every call still + * converts a fresh, independently mutable {@link Map} from the cached node, so callers that + * mutate the returned map (e.g. {@code ToolSchemaGenerator}) cannot corrupt the cache or + * interfere with one another. + * + *

    Unbounded by design: keys are the compile-time-fixed structured-output and + * tool-parameter classes declared by application code, so the entry count is bounded by the + * small, finite set of such classes the JVM loads for that purpose — not by request volume + * or untrusted input. + */ + private static final Map, JsonNode> CLASS_SCHEMA_CACHE = new ConcurrentHashMap<>(); + + /** + * Same caching strategy and bound rationale as {@link #CLASS_SCHEMA_CACHE}, keyed by generic + * {@link Type} to support parameterized structured-output and tool-parameter types. + */ + private static final Map TYPE_SCHEMA_CACHE = new ConcurrentHashMap<>(); + static { // JacksonModule to support @JsonProperty, @JsonPropertyDescription annotations JacksonModule jacksonModule = @@ -106,10 +131,14 @@ public class JsonSchemaUtils { */ public static Map generateSchemaFromClass(Class clazz) { try { - JsonNode schemaNode; - synchronized (SCHEMA_LOCK) { - schemaNode = schemaGenerator.generateSchema(clazz); - } + JsonNode schemaNode = + CLASS_SCHEMA_CACHE.computeIfAbsent( + clazz, + c -> { + synchronized (SCHEMA_LOCK) { + return schemaGenerator.generateSchema(c); + } + }); return JsonUtils.getJsonCodec() .convertValue(schemaNode, new TypeReference>() {}); } catch (Exception e) { @@ -144,10 +173,14 @@ public static Map generateSchemaFromJsonNode(JsonNode schema) { */ public static Map generateSchemaFromType(Type type) { try { - JsonNode schemaNode; - synchronized (SCHEMA_LOCK) { - schemaNode = schemaGenerator.generateSchema(type); - } + JsonNode schemaNode = + TYPE_SCHEMA_CACHE.computeIfAbsent( + type, + t -> { + synchronized (SCHEMA_LOCK) { + return schemaGenerator.generateSchema(t); + } + }); return JsonUtils.getJsonCodec() .convertValue(schemaNode, new TypeReference>() {}); } catch (Exception e) { diff --git a/agentscope-core/src/test/java/io/agentscope/core/util/JsonSchemaUtilsTest.java b/agentscope-core/src/test/java/io/agentscope/core/util/JsonSchemaUtilsTest.java index 0d62c2f107..da63bfed80 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/util/JsonSchemaUtilsTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/util/JsonSchemaUtilsTest.java @@ -17,6 +17,7 @@ package io.agentscope.core.util; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -168,6 +169,55 @@ void testGenerateSchemaFromType() { assertEquals("object", mapSchema.get("type")); } + @Test + void testGenerateSchemaFromClassRepeatedCallsReturnEqualIndependentMaps() { + Map first = JsonSchemaUtils.generateSchemaFromClass(SimpleModel.class); + Map second = JsonSchemaUtils.generateSchemaFromClass(SimpleModel.class); + + // A repeated class must yield an equal schema, so caching cannot change the result. + assertEquals(first, second); + + // Each call must return a fresh, independently mutable map: mutating one must not leak + // into another, matching in-place-mutating callers such as ToolSchemaGenerator. + first.put("description", "mutated"); + assertFalse(second.containsKey("description")); + + Map third = JsonSchemaUtils.generateSchemaFromClass(SimpleModel.class); + assertFalse(third.containsKey("description")); + assertEquals(second, third); + } + + @Test + void testGenerateSchemaFromTypeRepeatedCallsReturnEqualIndependentMaps() { + Type listType = new TypeReference>() {}.getType(); + + Map first = JsonSchemaUtils.generateSchemaFromType(listType); + Map second = JsonSchemaUtils.generateSchemaFromType(listType); + + assertEquals(first, second); + + first.put("description", "mutated"); + assertFalse(second.containsKey("description")); + + Map third = JsonSchemaUtils.generateSchemaFromType(listType); + assertFalse(third.containsKey("description")); + assertEquals(second, third); + } + + @Test + void testGenerateSchemaFromClassNullThrows() { + // Caching routes a null class through ConcurrentHashMap#computeIfAbsent, which rejects + // null keys; the resulting NPE must match the pre-cache behavior for a null argument. + assertThrows( + NullPointerException.class, () -> JsonSchemaUtils.generateSchemaFromClass(null)); + } + + @Test + void testGenerateSchemaFromTypeNullThrows() { + assertThrows( + NullPointerException.class, () -> JsonSchemaUtils.generateSchemaFromType(null)); + } + static class ConcurrentClassA { public String name; public int age; From e5c404c7d484e36662f2e5e3567ff2bc2292a40d Mon Sep 17 00:00:00 2001 From: duanjienan Date: Sat, 12 Sep 2026 00:02:00 +0800 Subject: [PATCH 2/4] refactor(core): scope schema cache to classes and drop computeIfAbsent Replace the static Class/Type-keyed cache maps with ClassValue slots: an entry is held on the class it describes, so it cannot keep that class or the classloader that defined it reachable once the application releases them. Publish values with a double-checked read under the schema lock instead of ConcurrentHashMap#computeIfAbsent, which generates while holding the map's bin lock: unrelated classes hashing to the same bin no longer wait for a whole generation, and a mapping function no longer mutates its own map. The Type cache groups entries by raw class, so the parametrizations of one class share a slot and follow class unloading. Types with no raw class -- type variables, wildcards, generic arrays -- are generated without caching. Follow-up to #2796, addressing review findings on #3041. --- .../agentscope/core/util/JsonSchemaUtils.java | 152 ++++++++++++++---- .../core/util/JsonSchemaUtilsTest.java | 69 +++++++- 2 files changed, 187 insertions(+), 34 deletions(-) diff --git a/agentscope-core/src/main/java/io/agentscope/core/util/JsonSchemaUtils.java b/agentscope-core/src/main/java/io/agentscope/core/util/JsonSchemaUtils.java index 58f1c96d72..85451cb615 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/util/JsonSchemaUtils.java +++ b/agentscope-core/src/main/java/io/agentscope/core/util/JsonSchemaUtils.java @@ -27,9 +27,11 @@ import com.github.victools.jsonschema.module.jackson.JacksonModule; import com.github.victools.jsonschema.module.jackson.JacksonOption; import io.agentscope.core.tool.ToolSchemaModule; +import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicReference; /** * Utility class for JSON Schema operations. @@ -60,6 +62,12 @@ * seen). A cache hit converts a fresh, independently mutable {@code Map} from the cached, * never-mutated {@link JsonNode}, so it needs no lock.

    * + *

    Cache entries are scoped to the class they describe instead of living in a static map keyed + * by {@code Class}, so an entry cannot outlive that class or pin the classloader that defined it. + * That matters because the structured-output and tool-parameter classes reaching this utility are + * not always compile-time-fixed: extensions can load skills and tools at runtime, and + * multi-tenant deployments may load classes per tenant.

    + * * @hidden */ public class JsonSchemaUtils { @@ -71,31 +79,47 @@ public class JsonSchemaUtils { /** * Guards the shared victools {@link SchemaGenerator}, which is not thread-safe: its * JacksonModule keeps an unsynchronized introspection cache, so concurrent schema - * generation must be serialized. Only cache misses in {@link #CLASS_SCHEMA_CACHE} and - * {@link #TYPE_SCHEMA_CACHE} take this lock; cache hits never do. + * generation must be serialized. Only cache misses in {@link #CLASS_SCHEMA_SLOT} and + * {@link #TYPE_SCHEMA_SLOT} take this lock; cache hits never do. */ private static final Object SCHEMA_LOCK = new Object(); /** - * Caches the schema {@link JsonNode} generated for each class. A schema is a deterministic - * function of the class and the static, never-changing generator config, so entries never - * need invalidation. Cached nodes are never mutated after being stored: every call still - * converts a fresh, independently mutable {@link Map} from the cached node, so callers that - * mutate the returned map (e.g. {@code ToolSchemaGenerator}) cannot corrupt the cache or - * interfere with one another. + * Schema cache slot of each class. A schema is a deterministic function of the class and the + * static, never-changing generator config, so entries never need invalidation. Cached nodes + * are never mutated after being stored: every call still converts a fresh, independently + * mutable {@link Map} from the cached node, so callers that mutate the returned map (e.g. + * {@code ToolSchemaGenerator}) cannot corrupt the cache or interfere with one another. * - *

    Unbounded by design: keys are the compile-time-fixed structured-output and - * tool-parameter classes declared by application code, so the entry count is bounded by the - * small, finite set of such classes the JVM loads for that purpose — not by request volume - * or untrusted input. + *

    Creating the slot through {@link ClassValue} holds it on the class it describes, rather + * than in a static map that strongly references the class as a key, so a slot cannot keep that + * class — or the classloader which defined it — reachable once the rest of the application has + * let go of them. */ - private static final Map, JsonNode> CLASS_SCHEMA_CACHE = new ConcurrentHashMap<>(); + private static final ClassValue> CLASS_SCHEMA_SLOT = + new ClassValue<>() { + @Override + protected AtomicReference computeValue(Class clazz) { + return new AtomicReference<>(); + } + }; /** - * Same caching strategy and bound rationale as {@link #CLASS_SCHEMA_CACHE}, keyed by generic - * {@link Type} to support parameterized structured-output and tool-parameter types. + * Same caching strategy as {@link #CLASS_SCHEMA_SLOT}, keyed by generic {@link Type} to support + * parameterized structured-output and tool-parameter types. The variants of one raw class (e.g. + * {@code List} versus {@code List}) share the map held on that raw class, so + * these slots are scoped to a classloader in the same way. + * + *

    Because unrelated raw classes never share a map, a miss takes {@link #SCHEMA_LOCK} without + * grouping unrelated types behind the same lock. */ - private static final Map TYPE_SCHEMA_CACHE = new ConcurrentHashMap<>(); + private static final ClassValue> TYPE_SCHEMA_SLOT = + new ClassValue<>() { + @Override + protected Map computeValue(Class rawType) { + return new ConcurrentHashMap<>(); + } + }; static { // JacksonModule to support @JsonProperty, @JsonPropertyDescription annotations @@ -131,14 +155,7 @@ public class JsonSchemaUtils { */ public static Map generateSchemaFromClass(Class clazz) { try { - JsonNode schemaNode = - CLASS_SCHEMA_CACHE.computeIfAbsent( - clazz, - c -> { - synchronized (SCHEMA_LOCK) { - return schemaGenerator.generateSchema(c); - } - }); + JsonNode schemaNode = cachedSchemaNode(clazz); return JsonUtils.getJsonCodec() .convertValue(schemaNode, new TypeReference>() {}); } catch (Exception e) { @@ -173,14 +190,7 @@ public static Map generateSchemaFromJsonNode(JsonNode schema) { */ public static Map generateSchemaFromType(Type type) { try { - JsonNode schemaNode = - TYPE_SCHEMA_CACHE.computeIfAbsent( - type, - t -> { - synchronized (SCHEMA_LOCK) { - return schemaGenerator.generateSchema(t); - } - }); + JsonNode schemaNode = cachedSchemaNode(type); return JsonUtils.getJsonCodec() .convertValue(schemaNode, new TypeReference>() {}); } catch (Exception e) { @@ -189,6 +199,84 @@ public static Map generateSchemaFromType(Type type) { } } + /** + * Returns the cached schema node for a class, generating it under {@link #SCHEMA_LOCK} on a + * miss. The slot is re-read inside the lock so that a class several threads reach at once is + * still generated exactly once. + */ + private static JsonNode cachedSchemaNode(Class clazz) { + AtomicReference slot = CLASS_SCHEMA_SLOT.get(clazz); + JsonNode cached = slot.get(); + if (cached != null) { + return cached; + } + + synchronized (SCHEMA_LOCK) { + cached = slot.get(); + if (cached == null) { + cached = schemaGenerator.generateSchema(clazz); + slot.set(cached); + } + } + return cached; + } + + /** + * Returns the cached schema node for a type, generating it under {@link #SCHEMA_LOCK} on a + * miss. + * + *

    The miss path re-reads the slot inside the lock rather than calling {@code + * ConcurrentHashMap#computeIfAbsent}, which runs its mapping function while holding the map's + * bin lock: nesting the schema lock inside it would block unrelated types hashing to the same + * bin for the whole generation, and would break the contract that a mapping function must not + * modify its own map. + */ + private static JsonNode cachedSchemaNode(Type type) { + Class rawType = rawTypeOf(type); + if (rawType == null) { + // Type variables, wildcards and generic arrays have no raw class to hang a slot on; + // generate them without caching. + synchronized (SCHEMA_LOCK) { + return schemaGenerator.generateSchema(type); + } + } + + Map slot = TYPE_SCHEMA_SLOT.get(rawType); + JsonNode cached = slot.get(type); + if (cached != null) { + return cached; + } + + synchronized (SCHEMA_LOCK) { + cached = slot.get(type); + if (cached == null) { + cached = schemaGenerator.generateSchema(type); + slot.put(type, cached); + } + } + return cached; + } + + /** + * Returns the raw class whose cache a type's schema belongs to, or {@code null} when the type + * has no raw class and therefore cannot be cached. + * + * @throws NullPointerException if the type is null + */ + private static Class rawTypeOf(Type type) { + if (type == null) { + throw new NullPointerException("type must not be null"); + } + if (type instanceof Class clazz) { + return clazz; + } + if (type instanceof ParameterizedType parameterizedType + && parameterizedType.getRawType() instanceof Class rawClass) { + return rawClass; + } + return null; + } + /** * Convert Map to typed object. * diff --git a/agentscope-core/src/test/java/io/agentscope/core/util/JsonSchemaUtilsTest.java b/agentscope-core/src/test/java/io/agentscope/core/util/JsonSchemaUtilsTest.java index da63bfed80..e25a1fcc19 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/util/JsonSchemaUtilsTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/util/JsonSchemaUtilsTest.java @@ -204,20 +204,85 @@ void testGenerateSchemaFromTypeRepeatedCallsReturnEqualIndependentMaps() { assertEquals(second, third); } + @Test + void testGenerateSchemaFromClassNestedMutationDoesNotAffectLaterCalls() { + Map first = JsonSchemaUtils.generateSchemaFromClass(NestedModel.class); + + @SuppressWarnings("unchecked") + Map firstProperties = (Map) first.get("properties"); + assertNotNull(firstProperties); + + // Real callers mutate below the top level: ToolSchemaGenerator hoists "$defs" out of + // nested schemas and ReActAgent rewrites nested properties in place. A later call must + // still observe the pristine schema, which is exactly the deep-copy invariant the cache + // relies on. + assertNotNull(firstProperties.remove("tags")); + @SuppressWarnings("unchecked") + Map firstAuthor = (Map) firstProperties.get("author"); + assertNotNull(firstAuthor); + firstAuthor.put("description", "mutated"); + + Map second = JsonSchemaUtils.generateSchemaFromClass(NestedModel.class); + + @SuppressWarnings("unchecked") + Map secondProperties = (Map) second.get("properties"); + assertNotNull(secondProperties); + assertTrue(secondProperties.containsKey("tags")); + + @SuppressWarnings("unchecked") + Map secondAuthor = (Map) secondProperties.get("author"); + assertNotNull(secondAuthor); + assertFalse(secondAuthor.containsKey("description")); + } + + @Test + void testGenerateSchemaFromTypeNestedMutationDoesNotAffectLaterCalls() { + Type listType = new TypeReference>() {}.getType(); + + Map first = JsonSchemaUtils.generateSchemaFromType(listType); + + @SuppressWarnings("unchecked") + Map firstItems = (Map) first.get("items"); + assertNotNull(firstItems); + firstItems.put("description", "mutated"); + + Map second = JsonSchemaUtils.generateSchemaFromType(listType); + + @SuppressWarnings("unchecked") + Map secondItems = (Map) second.get("items"); + assertNotNull(secondItems); + assertFalse(secondItems.containsKey("description")); + } + @Test void testGenerateSchemaFromClassNullThrows() { - // Caching routes a null class through ConcurrentHashMap#computeIfAbsent, which rejects - // null keys; the resulting NPE must match the pre-cache behavior for a null argument. + // Caching routes a null class through ClassValue#get, which rejects null keys; the + // resulting NPE must match the pre-cache behavior for a null argument. assertThrows( NullPointerException.class, () -> JsonSchemaUtils.generateSchemaFromClass(null)); } @Test void testGenerateSchemaFromTypeNullThrows() { + // A null type has no raw class to cache under, so the NPE surfaces from the raw-class + // lookup, matching the pre-cache behavior for a null argument. assertThrows( NullPointerException.class, () -> JsonSchemaUtils.generateSchemaFromType(null)); } + @Test + void testGenerateSchemaFromTypeVariableStillGenerates() { + // A type variable has no raw class to hang an entry on, so it is generated without + // caching; that fallback must still yield a usable scheme. + Type typeVariable = List.class.getTypeParameters()[0]; + + Map first = JsonSchemaUtils.generateSchemaFromType(typeVariable); + Map second = JsonSchemaUtils.generateSchemaFromType(typeVariable); + + assertNotNull(first); + assertEquals(first, second); + } + static class ConcurrentClassA { public String name; public int age; From 90ebf59e663da5214a92bcefba2c91987feaa491 Mon Sep 17 00:00:00 2001 From: duanjienan Date: Sat, 12 Sep 2026 21:01:58 +0800 Subject: [PATCH 3/4] refactor(core): resolve cache scope for types without a raw class Classify the types that carry no raw class so they can be cached instead of regenerated under the schema lock on every call: a type variable now resolves to its declaring class, or to the declaring class of its declaring method, and a generic array to whichever class its component type resolves to. Only a type with no attributing class at all -- a wildcard synthesized by a caller -- is still generated without caching, and no reflective signature can reach it. A static Map fallback for these types would have reintroduced the type-to-declaration strong reference that scoping the cache to classes removed, since a type variable holds its declaration. Also state the cache's entry bound in the class javadoc and reject a null argument in generateSchemaFromType before it reaches the cache. Tests: cover the three newly attributed shapes and the uncached path, and turn the concurrent type test into two variants of the same raw class so it exercises the shared slot map. Follow-up to #2796, addressing review findings on #3041. --- .../agentscope/core/util/JsonSchemaUtils.java | 75 +++++++++++++----- .../core/util/JsonSchemaUtilsTest.java | 78 ++++++++++++++++--- 2 files changed, 125 insertions(+), 28 deletions(-) diff --git a/agentscope-core/src/main/java/io/agentscope/core/util/JsonSchemaUtils.java b/agentscope-core/src/main/java/io/agentscope/core/util/JsonSchemaUtils.java index 85451cb615..aced22724b 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/util/JsonSchemaUtils.java +++ b/agentscope-core/src/main/java/io/agentscope/core/util/JsonSchemaUtils.java @@ -27,9 +27,14 @@ import com.github.victools.jsonschema.module.jackson.JacksonModule; import com.github.victools.jsonschema.module.jackson.JacksonOption; import io.agentscope.core.tool.ToolSchemaModule; +import java.lang.reflect.GenericArrayType; +import java.lang.reflect.GenericDeclaration; +import java.lang.reflect.Method; import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; +import java.lang.reflect.TypeVariable; import java.util.Map; +import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicReference; @@ -68,6 +73,14 @@ * not always compile-time-fixed: extensions can load skills and tools at runtime, and * multi-tenant deployments may load classes per tenant.

    * + *

    The number of entries a single class's cache can hold is bounded by the distinct generic + * signatures the code produces against it: every {@link Type} reaching this utility originates in + * a {@link TypeReference} literal or a reflective method signature, and two structurally equal + * signatures share one entry. Loading a class at runtime therefore adds a class with its own + * cache rather than another entry on an existing one. A caller that synthesizes {@code Type} + * instances at runtime can add entries beyond that bound, but those entries are released together + * with the class that owns the cache.

    + * * @hidden */ public class JsonSchemaUtils { @@ -105,18 +118,23 @@ protected AtomicReference computeValue(Class clazz) { }; /** - * Same caching strategy as {@link #CLASS_SCHEMA_SLOT}, keyed by generic {@link Type} to support - * parameterized structured-output and tool-parameter types. The variants of one raw class (e.g. - * {@code List} versus {@code List}) share the map held on that raw class, so - * these slots are scoped to a classloader in the same way. + * Schema cache slot of each class, keyed by generic {@link Type} to support parameterized + * structured-output and tool-parameter types. The variants of one class (e.g. {@code + * List} versus {@code List}, or a type variable that class declares) share the + * map held on that class, so these slots are scoped to a classloader in the same way as {@link + * #CLASS_SCHEMA_SLOT}. + * + *

    The map grows with the distinct generic signatures the code writes against that class, + * not with anything a caller supplies at runtime, so it carries the same bound the previous + * static {@code Map} relied on. See the class javadoc for the full argument. * - *

    Because unrelated raw classes never share a map, a miss takes {@link #SCHEMA_LOCK} without + *

    Because unrelated classes never share a map, a miss takes {@link #SCHEMA_LOCK} without * grouping unrelated types behind the same lock. */ private static final ClassValue> TYPE_SCHEMA_SLOT = new ClassValue<>() { @Override - protected Map computeValue(Class rawType) { + protected Map computeValue(Class scopeClass) { return new ConcurrentHashMap<>(); } }; @@ -187,8 +205,10 @@ public static Map generateSchemaFromJsonNode(JsonNode schema) { * * @param type The type to generate schema for * @return JSON Schema as a Map + * @throws NullPointerException if the type is null */ public static Map generateSchemaFromType(Type type) { + Objects.requireNonNull(type, "type"); try { JsonNode schemaNode = cachedSchemaNode(type); return JsonUtils.getJsonCodec() @@ -232,16 +252,18 @@ private static JsonNode cachedSchemaNode(Class clazz) { * modify its own map. */ private static JsonNode cachedSchemaNode(Type type) { - Class rawType = rawTypeOf(type); - if (rawType == null) { - // Type variables, wildcards and generic arrays have no raw class to hang a slot on; - // generate them without caching. + Class scope = scopeClassOf(type); + if (scope == null) { + // No class to hang a slot on, so this type cannot be cached. Every type a method + // signature can declare is attributed, so this path is only reachable for a type a + // caller synthesizes: a wildcard is only ever a type argument, never a parameter or + // return type. synchronized (SCHEMA_LOCK) { return schemaGenerator.generateSchema(type); } } - Map slot = TYPE_SCHEMA_SLOT.get(rawType); + Map slot = TYPE_SCHEMA_SLOT.get(scope); JsonNode cached = slot.get(type); if (cached != null) { return cached; @@ -258,15 +280,20 @@ private static JsonNode cachedSchemaNode(Type type) { } /** - * Returns the raw class whose cache a type's schema belongs to, or {@code null} when the type - * has no raw class and therefore cannot be cached. + * Returns the class whose cache a type's schema belongs to, or {@code null} when the type + * cannot be attributed to one. * - * @throws NullPointerException if the type is null + *

    A parameterized type belongs to its raw class. The types with no raw class belong to the + * class that declares them: a type variable to its declaring class or to the class declaring + * its method, and a generic array to whichever class its component type resolves to. Caching + * those under a class rather than in a static map keyed by the type itself keeps the + * no-classloader-pinning property of {@link #CLASS_SCHEMA_SLOT}, since a type variable holds + * its declaration and would otherwise pin it. + * + * @param type the type to attribute; must not be {@code null} + * @return the class to cache the schema under, or {@code null} if the type has none */ - private static Class rawTypeOf(Type type) { - if (type == null) { - throw new NullPointerException("type must not be null"); - } + private static Class scopeClassOf(Type type) { if (type instanceof Class clazz) { return clazz; } @@ -274,6 +301,18 @@ private static Class rawTypeOf(Type type) { && parameterizedType.getRawType() instanceof Class rawClass) { return rawClass; } + if (type instanceof GenericArrayType arrayType) { + return scopeClassOf(arrayType.getGenericComponentType()); + } + if (type instanceof TypeVariable typeVariable) { + GenericDeclaration declaration = typeVariable.getGenericDeclaration(); + if (declaration instanceof Class declaringClass) { + return declaringClass; + } + if (declaration instanceof Method method) { + return method.getDeclaringClass(); + } + } return null; } diff --git a/agentscope-core/src/test/java/io/agentscope/core/util/JsonSchemaUtilsTest.java b/agentscope-core/src/test/java/io/agentscope/core/util/JsonSchemaUtilsTest.java index e25a1fcc19..b9c6948aa7 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/util/JsonSchemaUtilsTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/util/JsonSchemaUtilsTest.java @@ -18,11 +18,13 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import com.fasterxml.jackson.core.type.TypeReference; +import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; import java.util.ArrayList; import java.util.List; @@ -52,6 +54,15 @@ static class NestedModel { public List tags; } + /** Holder for the type shapes a method signature can declare but no class does. */ + static class GenericHolder { + public T[] values; + + public R identity(R input) { + return input; + } + } + @Test void testGenerateSchemaFromClassSimple() { Map schema = JsonSchemaUtils.generateSchemaFromClass(SimpleModel.class); @@ -264,16 +275,16 @@ void testGenerateSchemaFromClassNullThrows() { @Test void testGenerateSchemaFromTypeNullThrows() { - // A null type has no raw class to cache under, so the NPE surfaces from the raw-class - // lookup, matching the pre-cache behavior for a null argument. + // The public entry point rejects null before it reaches the cache, matching the pre-cache + // behavior for a null argument. assertThrows( NullPointerException.class, () -> JsonSchemaUtils.generateSchemaFromType(null)); } @Test - void testGenerateSchemaFromTypeVariableStillGenerates() { - // A type variable has no raw class to hang an entry on, so it is generated without - // caching; that fallback must still yield a usable scheme. + void testGenerateSchemaFromTypeVariableIsCachedUnderItsDeclaringClass() { + // A type variable has no raw class, so it is cached under the class that declares it. Both + // calls must agree, whether the entry is served from the cache or generated again. Type typeVariable = List.class.getTypeParameters()[0]; Map first = JsonSchemaUtils.generateSchemaFromType(typeVariable); @@ -283,6 +294,47 @@ void testGenerateSchemaFromTypeVariableStillGenerates() { assertEquals(first, second); } + @Test + void testGenerateSchemaFromMethodTypeVariableIsCachedUnderItsDeclaringClass() throws Exception { + // A type variable declared on a method has no raw class either, and its declaration is the + // method rather than a class, so it is cached under the method's declaring class. + Type typeVariable = + GenericHolder.class.getMethod("identity", Object.class).getTypeParameters()[0]; + + Map first = JsonSchemaUtils.generateSchemaFromType(typeVariable); + Map second = JsonSchemaUtils.generateSchemaFromType(typeVariable); + + assertNotNull(first); + assertEquals(first, second); + } + + @Test + void testGenerateSchemaFromGenericArrayIsCachedUnderItsComponentClass() throws Exception { + // A generic array carries no raw class of its own; it resolves through its component type. + Type genericArray = GenericHolder.class.getField("values").getGenericType(); + + Map first = JsonSchemaUtils.generateSchemaFromType(genericArray); + Map second = JsonSchemaUtils.generateSchemaFromType(genericArray); + + assertNotNull(first); + assertEquals(first, second); + } + + @Test + void testGenerateSchemaFromWildcardIsGeneratedWithoutCaching() { + // A wildcard is only ever a type argument, never a parameter or return type, so a + // reflective signature cannot produce one at the top level. Only a caller synthesizing one + // reaches the uncached path; both calls must still agree. + Type wildcard = + ((ParameterizedType) new TypeReference>() {}.getType()) + .getActualTypeArguments()[0]; + + Map first = JsonSchemaUtils.generateSchemaFromType(wildcard); + Map second = JsonSchemaUtils.generateSchemaFromType(wildcard); + + assertEquals(first, second); + } + static class ConcurrentClassA { public String name; public int age; @@ -322,10 +374,11 @@ void testGenerateSchemaFromClassConcurrently() throws Exception { @Test void testGenerateSchemaFromTypeConcurrently() throws Exception { - List targetTypes = - List.of( - new TypeReference() {}.getType(), - new TypeReference>() {}.getType()); + // Two variants of the same class. They share the per-class slot map, so concurrent calls + // exercise the structure the cache's correctness rests on. + Type listOfC = new TypeReference>() {}.getType(); + Type listOfD = new TypeReference>() {}.getType(); + List targetTypes = List.of(listOfC, listOfD); List> schemas = generateConcurrently( @@ -336,8 +389,13 @@ void testGenerateSchemaFromTypeConcurrently() throws Exception { assertEquals(CONCURRENT_CALL_COUNT, schemas.size()); for (Map schema : schemas) { assertNotNull(schema); - assertNotNull(schema.get("type")); + assertEquals("array", schema.get("type")); } + + // Variants sharing one slot must stay independent of each other. + assertNotEquals( + JsonSchemaUtils.generateSchemaFromType(listOfC), + JsonSchemaUtils.generateSchemaFromType(listOfD)); } /** From 602c3c42938f4a02f1c8b0ba9f5b197af7a60a21 Mon Sep 17 00:00:00 2001 From: duanjienan Date: Sun, 13 Sep 2026 08:47:25 +0800 Subject: [PATCH 4/4] refactor(core): scope schema cache to a class that can be unloaded A parameterized type was cached on its raw class, so List was held on java.util.List. A class from the bootstrap loader is never unloaded, so that entry cannot be released, and its key keeps the application class it mentions -- and the classloader that defined it -- reachable for the lifetime of the JVM. That is the same pinning the ClassValue change removed, moved one level down. A type is now scoped to the deepest application class it mentions: type arguments are searched before the raw type, recursing through nested parameterized types, generic arrays and wildcard bounds, and the raw class is only the fallback for a signature whose classes all come from the bootstrap loader. List is held on TenantDto; List stays on java.util.List. The class javadoc states that fallback and the multi-class case instead of claiming the entry bound holds unconditionally. Also: - resolve a type variable declared by a constructor: Constructor is a GenericDeclaration but not a Method, so it took the uncached path and the global lock on every call - delegate a bare Class from generateSchemaFromType to generateSchemaFromClass, so a class reached through both entry points occupies one slot, not two Tests: assert which class each type is cached under, read from the slot maps directly because a returned schema is always a fresh copy. All four fail against 90ebf59e. The concurrent type test keeps two variants of one raw class, now JDK-only ones, since the scoped variants no longer share a slot. Follow-up to #2796, addressing review findings on #3041. --- .../agentscope/core/util/JsonSchemaUtils.java | 126 +++++++++++++++--- .../core/util/JsonSchemaUtilsTest.java | 104 ++++++++++++++- 2 files changed, 206 insertions(+), 24 deletions(-) diff --git a/agentscope-core/src/main/java/io/agentscope/core/util/JsonSchemaUtils.java b/agentscope-core/src/main/java/io/agentscope/core/util/JsonSchemaUtils.java index aced22724b..f2ea59f9eb 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/util/JsonSchemaUtils.java +++ b/agentscope-core/src/main/java/io/agentscope/core/util/JsonSchemaUtils.java @@ -27,12 +27,13 @@ import com.github.victools.jsonschema.module.jackson.JacksonModule; import com.github.victools.jsonschema.module.jackson.JacksonOption; import io.agentscope.core.tool.ToolSchemaModule; +import java.lang.reflect.Executable; import java.lang.reflect.GenericArrayType; import java.lang.reflect.GenericDeclaration; -import java.lang.reflect.Method; import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; import java.lang.reflect.TypeVariable; +import java.lang.reflect.WildcardType; import java.util.Map; import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; @@ -73,13 +74,25 @@ * not always compile-time-fixed: extensions can load skills and tools at runtime, and * multi-tenant deployments may load classes per tenant.

    * + *

    A parameterized type is scoped to the deepest application class it mentions rather than to + * its raw class: {@code List} is held on {@code TenantDto} and dies with it. Scoping + * such an entry to {@code java.util.List} instead would park it, and the {@code TenantDto} its key + * references, on a class the JVM never unloads, which is the same classloader pinning in a + * different place. See {@link #scopeClassOf} for how the class is picked.

    + * *

    The number of entries a single class's cache can hold is bounded by the distinct generic * signatures the code produces against it: every {@link Type} reaching this utility originates in * a {@link TypeReference} literal or a reflective method signature, and two structurally equal * signatures share one entry. Loading a class at runtime therefore adds a class with its own * cache rather than another entry on an existing one. A caller that synthesizes {@code Type} * instances at runtime can add entries beyond that bound, but those entries are released together - * with the class that owns the cache.

    + * with the class that owns the cache, which is the first application class the type mentions when + * it mentions more than one.

    + * + *

    One case is deliberately outside that bound: a signature built only from JDK classes, such as + * {@code List}, has no application class to be scoped to, so it stays on its raw class + * ({@code java.util.List}), which the JVM never unloads. Those entries live as long as the JVM and + * cannot pin an application class, because no application class appears in them.

    * * @hidden */ @@ -119,14 +132,16 @@ protected AtomicReference computeValue(Class clazz) { /** * Schema cache slot of each class, keyed by generic {@link Type} to support parameterized - * structured-output and tool-parameter types. The variants of one class (e.g. {@code - * List} versus {@code List}, or a type variable that class declares) share the - * map held on that class, so these slots are scoped to a classloader in the same way as {@link - * #CLASS_SCHEMA_SLOT}. + * structured-output and tool-parameter types. The variants of one class (the type variables it + * declares, and every signature mentioning it) share the map held on that class, so these slots + * are scoped to a classloader in the same way as {@link #CLASS_SCHEMA_SLOT}. {@link + * #scopeClassOf} picks that class: the deepest application class the type mentions, which keeps + * {@code List} on {@code TenantDto} instead of on {@code java.util.List}. * *

    The map grows with the distinct generic signatures the code writes against that class, * not with anything a caller supplies at runtime, so it carries the same bound the previous - * static {@code Map} relied on. See the class javadoc for the full argument. + * static {@code Map} relied on. See the class javadoc for the full argument, + * including the signatures that stay on a JDK class because they mention no other. * *

    Because unrelated classes never share a map, a miss takes {@link #SCHEMA_LOCK} without * grouping unrelated types behind the same lock. @@ -203,12 +218,18 @@ public static Map generateSchemaFromJsonNode(JsonNode schema) { /** * Generate JSON Schema from a Java Type (supports Generics). * + *

    A bare {@link Class} is delegated to {@link #generateSchemaFromClass(Class)}, so a class + * reached through either entry point is generated once and stored in one slot. + * * @param type The type to generate schema for * @return JSON Schema as a Map * @throws NullPointerException if the type is null */ public static Map generateSchemaFromType(Type type) { Objects.requireNonNull(type, "type"); + if (type instanceof Class clazz) { + return generateSchemaFromClass(clazz); + } try { JsonNode schemaNode = cachedSchemaNode(type); return JsonUtils.getJsonCodec() @@ -283,12 +304,17 @@ private static JsonNode cachedSchemaNode(Type type) { * Returns the class whose cache a type's schema belongs to, or {@code null} when the type * cannot be attributed to one. * - *

    A parameterized type belongs to its raw class. The types with no raw class belong to the - * class that declares them: a type variable to its declaring class or to the class declaring - * its method, and a generic array to whichever class its component type resolves to. Caching - * those under a class rather than in a static map keyed by the type itself keeps the - * no-classloader-pinning property of {@link #CLASS_SCHEMA_SLOT}, since a type variable holds - * its declaration and would otherwise pin it. + *

    A parameterized type belongs to the deepest application class it mentions, a type argument + * in preference to the raw type, so {@code List} is held on {@code TenantDto}. Its + * raw class is the fallback for a signature built exclusively from JDK classes, such as {@code + * List}: that entry is permanent, but it cannot pin an application class because none + * appears in it. See the class javadoc for the full argument. + * + *

    The types with no raw class belong to the class that declares them: a type variable to its + * declaring class or to the class declaring its constructor or method, and a generic array to + * whichever class its component type resolves to. Caching those under a class rather than in a + * static map keyed by the type itself keeps the no-classloader-pinning property of {@link + * #CLASS_SCHEMA_SLOT}, since a type variable holds its declaration and would otherwise pin it. * * @param type the type to attribute; must not be {@code null} * @return the class to cache the schema under, or {@code null} if the type has none @@ -297,21 +323,79 @@ private static Class scopeClassOf(Type type) { if (type instanceof Class clazz) { return clazz; } - if (type instanceof ParameterizedType parameterizedType - && parameterizedType.getRawType() instanceof Class rawClass) { - return rawClass; + if (type instanceof ParameterizedType parameterizedType) { + Class applicationClass = applicationClassIn(parameterizedType); + if (applicationClass != null) { + return applicationClass; + } + return parameterizedType.getRawType() instanceof Class rawClass ? rawClass : null; } if (type instanceof GenericArrayType arrayType) { return scopeClassOf(arrayType.getGenericComponentType()); } if (type instanceof TypeVariable typeVariable) { - GenericDeclaration declaration = typeVariable.getGenericDeclaration(); - if (declaration instanceof Class declaringClass) { - return declaringClass; + return declaringClassOf(typeVariable); + } + return null; + } + + /** + * Returns the deepest class loaded by an application classloader that the given type mentions, + * or {@code null} when every class it mentions comes from the bootstrap loader. Only a class + * that can itself be unloaded may scope a cache entry: a slot on {@code java.util.List} is never + * released, so an entry there would keep the application classes named by its key reachable for + * the lifetime of the JVM. + */ + private static Class applicationClassIn(Type type) { + if (type instanceof Class clazz) { + return clazz.getClassLoader() == null ? null : clazz; + } + if (type instanceof ParameterizedType parameterizedType) { + for (Type argument : parameterizedType.getActualTypeArguments()) { + Class argumentClass = applicationClassIn(argument); + if (argumentClass != null) { + return argumentClass; + } } - if (declaration instanceof Method method) { - return method.getDeclaringClass(); + return applicationClassIn(parameterizedType.getRawType()); + } + if (type instanceof GenericArrayType arrayType) { + return applicationClassIn(arrayType.getGenericComponentType()); + } + if (type instanceof WildcardType wildcardType) { + for (Type bound : wildcardType.getUpperBounds()) { + Class boundClass = applicationClassIn(bound); + if (boundClass != null) { + return boundClass; + } } + for (Type bound : wildcardType.getLowerBounds()) { + Class boundClass = applicationClassIn(bound); + if (boundClass != null) { + return boundClass; + } + } + return null; + } + if (type instanceof TypeVariable typeVariable) { + Class declaringClass = declaringClassOf(typeVariable); + return declaringClass == null ? null : applicationClassIn(declaringClass); + } + return null; + } + + /** + * Returns the class that declares the given type variable, or {@code null} when the declaration + * is neither a class, a method nor a constructor. A constructor declares type variables just as + * a method does, and both are {@link Executable} rather than {@code Method}. + */ + private static Class declaringClassOf(TypeVariable typeVariable) { + GenericDeclaration declaration = typeVariable.getGenericDeclaration(); + if (declaration instanceof Class declaringClass) { + return declaringClass; + } + if (declaration instanceof Executable executable) { + return executable.getDeclaringClass(); } return null; } diff --git a/agentscope-core/src/test/java/io/agentscope/core/util/JsonSchemaUtilsTest.java b/agentscope-core/src/test/java/io/agentscope/core/util/JsonSchemaUtilsTest.java index b9c6948aa7..ba021c060b 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/util/JsonSchemaUtilsTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/util/JsonSchemaUtilsTest.java @@ -24,6 +24,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import java.lang.reflect.Field; import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; import java.util.ArrayList; @@ -34,6 +36,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.IntFunction; import org.junit.jupiter.api.Test; @@ -335,6 +338,92 @@ void testGenerateSchemaFromWildcardIsGeneratedWithoutCaching() { assertEquals(first, second); } + @Test + void testGenerateSchemaFromParameterizedTypeIsHeldOnItsApplicationClass() throws Exception { + // List mentions java.util.List, which is never unloaded, and TenantElement. + // The entry has to live on the application class: parked on List, it would keep the element + // class, and the classloader that defined it, reachable for the lifetime of the JVM. + Type parameterized = new TypeReference>() {}.getType(); + + JsonSchemaUtils.generateSchemaFromType(parameterized); + + assertTrue(typeSlot(TenantElement.class).containsKey(parameterized)); + assertFalse(typeSlot(List.class).containsKey(parameterized)); + } + + @Test + void testGenerateSchemaFromBoundedWildcardIsHeldOnItsBoundClass() throws Exception { + // A wildcard is only ever a type argument, so the bound it declares decides which class the + // signature mentioning it belongs to. + Type boundedWildcard = new TypeReference>() {}.getType(); + + JsonSchemaUtils.generateSchemaFromType(boundedWildcard); + + assertTrue(typeSlot(TenantElement.class).containsKey(boundedWildcard)); + assertFalse(typeSlot(List.class).containsKey(boundedWildcard)); + } + + @Test + void testGenerateSchemaFromConstructorTypeVariableIsHeldOnItsDeclaringClass() throws Exception { + // A constructor declares type variables just as a method does, and it is a + // GenericDeclaration without being a Method. Matching only Method would send this type to + // the uncached path, which takes the global lock on every call. + Type typeVariable = + GenericConstructorHolder.class.getConstructor(Object.class).getTypeParameters()[0]; + + JsonSchemaUtils.generateSchemaFromType(typeVariable); + + assertTrue(typeSlot(GenericConstructorHolder.class).containsKey(typeVariable)); + } + + @Test + void testGenerateSchemaFromBareClassIsHeldOnTheClassSlotOnly() throws Exception { + // Both entry points describe the same class, so they must share one slot rather than + // generating and storing the same schema twice. + JsonSchemaUtils.generateSchemaFromType(BareClassFixture.class); + + assertNotNull(classSlot(BareClassFixture.class)); + assertFalse(typeSlot(BareClassFixture.class).containsKey(BareClassFixture.class)); + } + + /** + * Reads the private type slot of a class. Asserting the slot directly is the only way to check + * which class a type is cached under: every schema the public API returns is a fresh copy, so + * the entry it came from is invisible in behaviour. + */ + @SuppressWarnings("unchecked") + private static Map typeSlot(Class scopeClass) throws Exception { + Field slot = JsonSchemaUtils.class.getDeclaredField("TYPE_SCHEMA_SLOT"); + slot.setAccessible(true); + ClassValue> slots = (ClassValue>) slot.get(null); + return slots.get(scopeClass); + } + + /** Reads the private class slot of a class. */ + @SuppressWarnings("unchecked") + private static JsonNode classSlot(Class clazz) throws Exception { + Field slot = JsonSchemaUtils.class.getDeclaredField("CLASS_SCHEMA_SLOT"); + slot.setAccessible(true); + ClassValue> slots = + (ClassValue>) slot.get(null); + return slots.get(clazz).get(); + } + + /** Element class of the signatures used to check which class a type is cached under. */ + static class TenantElement { + public String name; + } + + /** Declares a type variable on a constructor, which is a GenericDeclaration but not a Method. */ + static class GenericConstructorHolder { + public GenericConstructorHolder(T value) {} + } + + /** Reached as a bare class through {@code generateSchemaFromType}. */ + static class BareClassFixture { + public String label; + } + static class ConcurrentClassA { public String name; public int age; @@ -374,11 +463,15 @@ void testGenerateSchemaFromClassConcurrently() throws Exception { @Test void testGenerateSchemaFromTypeConcurrently() throws Exception { - // Two variants of the same class. They share the per-class slot map, so concurrent calls - // exercise the structure the cache's correctness rests on. + // The first two are variants of the same raw class and mention no application class, so + // both are held on java.util.List and share its slot map: they exercise the structure the + // cache's correctness rests on. The last two are held on the class each one mentions, so + // the scoped slots are stressed as well. + Type listOfString = new TypeReference>() {}.getType(); + Type listOfInteger = new TypeReference>() {}.getType(); Type listOfC = new TypeReference>() {}.getType(); Type listOfD = new TypeReference>() {}.getType(); - List targetTypes = List.of(listOfC, listOfD); + List targetTypes = List.of(listOfString, listOfInteger, listOfC, listOfD); List> schemas = generateConcurrently( @@ -393,6 +486,11 @@ void testGenerateSchemaFromTypeConcurrently() throws Exception { } // Variants sharing one slot must stay independent of each other. + assertTrue(typeSlot(List.class).containsKey(listOfString)); + assertTrue(typeSlot(List.class).containsKey(listOfInteger)); + assertNotEquals( + JsonSchemaUtils.generateSchemaFromType(listOfString), + JsonSchemaUtils.generateSchemaFromType(listOfInteger)); assertNotEquals( JsonSchemaUtils.generateSchemaFromType(listOfC), JsonSchemaUtils.generateSchemaFromType(listOfD));