Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +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.
Expand All @@ -53,9 +56,17 @@
* <li>{@code @JsonClassDescription(...)} - add class description</li>
* </ul>
*
* <p>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.</p>
* <p>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.</p>
*
* <p>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.</p>
*
* @hidden
*/
Expand All @@ -68,10 +79,48 @@ 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_SLOT} and
* {@link #TYPE_SCHEMA_SLOT} take this lock; cache hits never do.
*/
private static final Object SCHEMA_LOCK = new Object();

/**
* 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.
*
* <p>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 ClassValue<AtomicReference<JsonNode>> CLASS_SCHEMA_SLOT =
new ClassValue<>() {
@Override
protected AtomicReference<JsonNode> computeValue(Class<?> clazz) {
return new AtomicReference<>();
}
};

/**
* 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<String>} versus {@code List<Integer>}) share the map held on that raw class, so
* these slots are scoped to a classloader in the same way.
*
* <p>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 ClassValue<Map<Type, JsonNode>> TYPE_SCHEMA_SLOT =

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Warning] The TYPE_SCHEMA_SLOT map has no size bound or eviction, and the javadoc's original bound argument no longer applies to it.

The old CLASS_SCHEMA_CACHE javadoc justified being unbounded because keys were compile-time-fixed classes. Entries here are now keyed by Type variant within a raw class, so a raw class that is commonly parameterized (List, Map, Optional) accumulates one entry per distinct generic signature ever passed to generateSchemaFromType. In the multi-tenant / runtime-loaded scenarios this PR explicitly motivates — where the classloader-leak fix is the whole point — those signatures can be produced per tenant or per dynamically loaded skill rather than being fixed at compile time.

The leak direction is genuinely improved (a slot no longer pins its class), but the per-class entry count is now the unbounded dimension. Worth either a stated bound or a sentence in the javadoc recording that this is deliberate and why the variant count stays small in practice.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Warning] The TYPE_SCHEMA_SLOT map has no size bound or eviction, and the javadoc's original bound argument no longer applies to it.

The old CLASS_SCHEMA_CACHE javadoc justified being unbounded because keys were compile-time-fixed classes. Entries here are now keyed by Type variant within a raw class, so a raw class that is commonly parameterized (List, Map, Optional) accumulates one entry per distinct generic signature ever passed to generateSchemaFromType. In the multi-tenant / runtime-loaded scenarios this PR explicitly motivates — where the classloader-leak fix is the whole point — those signatures can be produced per tenant or per dynamically loaded skill rather than being fixed at compile time.

The leak direction is genuinely improved (a slot no longer pins its class), but the per-class entry count is now the unbounded dimension. Worth either a stated bound or a sentence in the javadoc recording that this is deliberate and why the variant count stays small in practice.

new ClassValue<>() {
@Override
protected Map<Type, JsonNode> computeValue(Class<?> rawType) {
return new ConcurrentHashMap<>();
}
};

static {
// JacksonModule to support @JsonProperty, @JsonPropertyDescription annotations
JacksonModule jacksonModule =
Expand Down Expand Up @@ -106,10 +155,7 @@ public class JsonSchemaUtils {
*/
public static Map<String, Object> generateSchemaFromClass(Class<?> clazz) {
try {
JsonNode schemaNode;
synchronized (SCHEMA_LOCK) {
schemaNode = schemaGenerator.generateSchema(clazz);
}
JsonNode schemaNode = cachedSchemaNode(clazz);
return JsonUtils.getJsonCodec()
.convertValue(schemaNode, new TypeReference<Map<String, Object>>() {});
} catch (Exception e) {
Expand Down Expand Up @@ -144,10 +190,7 @@ public static Map<String, Object> generateSchemaFromJsonNode(JsonNode schema) {
*/
public static Map<String, Object> generateSchemaFromType(Type type) {
try {
JsonNode schemaNode;
synchronized (SCHEMA_LOCK) {
schemaNode = schemaGenerator.generateSchema(type);
}
JsonNode schemaNode = cachedSchemaNode(type);
return JsonUtils.getJsonCodec()
.convertValue(schemaNode, new TypeReference<Map<String, Object>>() {});
} catch (Exception e) {
Expand All @@ -156,6 +199,84 @@ public static Map<String, Object> 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<JsonNode> 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.
*
* <p>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) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Warning] Uncacheable types now regenerate on every call while holding the global SCHEMA_LOCK.

Type variables, wildcards and generic arrays have no raw class to hang a slot on, so they bypass caching entirely and take the process-wide lock for the full duration of schemaGenerator.generateSchema(type). Previously TYPE_SCHEMA_CACHE was a plain Map<Type, JsonNode> and cached these types like any other, so this is a latency regression on the fallback path — and because it holds the shared lock, it also stalls schema generation for every other class in the JVM.

The added test (testGenerateSchemaFromTypeVariableStillGenerates) proves the path is correct but asserts nothing about cost. Two options:

  1. key a second ClassValue-free ConcurrentHashMap<Type, JsonNode> fallback off the Type itself (these types are interned/equals-stable, so they are fine as keys), or
  2. confirm with a benchmark that no reachable structured-output path produces a TypeVariable/WildcardType, and say so in the javadoc so the next reader does not have to re-derive it.

Option 1 is cheap and removes the cliff.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Warning] Uncacheable types now regenerate on every call while holding the global SCHEMA_LOCK.

Type variables, wildcards and generic arrays have no raw class to hang a slot on, so they bypass caching entirely and take the process-wide lock for the full duration of schemaGenerator.generateSchema(type). Previously TYPE_SCHEMA_CACHE was a plain Map<Type, JsonNode> and cached these types like any other, so this is a latency regression on the fallback path — and because it holds the shared lock, it also stalls schema generation for every other class in the JVM.

The added test (testGenerateSchemaFromTypeVariableStillGenerates) proves the path is correct but asserts nothing about cost. Two options:

  1. key a second ClassValue-free ConcurrentHashMap<Type, JsonNode> fallback off the Type itself (these types are interned/equals-stable, so they are fine as keys), or
  2. confirm with a benchmark that no reachable structured-output path produces a TypeVariable/WildcardType, and say so in the javadoc so the next reader does not have to re-derive it.

Option 1 is cheap and removes the cliff.

// 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<Type, JsonNode> 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");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Info] rawTypeOf throws NullPointerException for a null argument, and generateSchemaFromType(null) relies on that to keep the pre-cache contract (covered by testGenerateSchemaFromTypeNullThrows).

That works, but the public method's null contract is now enforced by a private helper's exception message ("type must not be null") rather than at the entry point. Objects.requireNonNull(type, "type") as the first statement of generateSchemaFromType expresses the same guarantee where a caller reading the public signature would look, and keeps the test from silently passing if the helper is ever inlined away.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Info] rawTypeOf throws NullPointerException for a null argument, and generateSchemaFromType(null) relies on that to keep the pre-cache contract (covered by testGenerateSchemaFromTypeNullThrows).

That works, but the public method's null contract is now enforced by a private helper's exception message ("type must not be null") rather than at the entry point. Objects.requireNonNull(type, "type") as the first statement of generateSchemaFromType expresses the same guarantee where a caller reading the public signature would look, and keeps the test from silently passing if the helper is ever inlined away.

}
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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -168,6 +169,120 @@ void testGenerateSchemaFromType() {
assertEquals("object", mapSchema.get("type"));
}

@Test
void testGenerateSchemaFromClassRepeatedCallsReturnEqualIndependentMaps() {
Map<String, Object> first = JsonSchemaUtils.generateSchemaFromClass(SimpleModel.class);
Map<String, Object> 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");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independence is only asserted at the top level (first.put("description", ...)). The real mutating callers reach into nested structures — ToolSchemaGenerator/ReActAgent hoist $defs/definitions out of inner schemas and merge them into the params root, removing and replacing nested maps. Please add one case that mutates a nested entry (e.g. remove a key under properties, or perform a $defs hoist) and re-reads from a second call. Jackson's convertValue deep-copies, so the code is almost certainly fine — but that deep-copy is exactly the invariant this cache now silently depends on.

assertFalse(second.containsKey("description"));

Map<String, Object> third = JsonSchemaUtils.generateSchemaFromClass(SimpleModel.class);
assertFalse(third.containsKey("description"));
assertEquals(second, third);
}

@Test
void testGenerateSchemaFromTypeRepeatedCallsReturnEqualIndependentMaps() {
Type listType = new TypeReference<List<String>>() {}.getType();

Map<String, Object> first = JsonSchemaUtils.generateSchemaFromType(listType);
Map<String, Object> second = JsonSchemaUtils.generateSchemaFromType(listType);

assertEquals(first, second);

first.put("description", "mutated");
assertFalse(second.containsKey("description"));

Map<String, Object> third = JsonSchemaUtils.generateSchemaFromType(listType);
assertFalse(third.containsKey("description"));
assertEquals(second, third);
}

@Test
void testGenerateSchemaFromClassNestedMutationDoesNotAffectLaterCalls() {
Map<String, Object> first = JsonSchemaUtils.generateSchemaFromClass(NestedModel.class);

@SuppressWarnings("unchecked")
Map<String, Object> firstProperties = (Map<String, Object>) 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<String, Object> firstAuthor = (Map<String, Object>) firstProperties.get("author");
assertNotNull(firstAuthor);
firstAuthor.put("description", "mutated");

Map<String, Object> second = JsonSchemaUtils.generateSchemaFromClass(NestedModel.class);

@SuppressWarnings("unchecked")
Map<String, Object> secondProperties = (Map<String, Object>) second.get("properties");
assertNotNull(secondProperties);
assertTrue(secondProperties.containsKey("tags"));

@SuppressWarnings("unchecked")
Map<String, Object> secondAuthor = (Map<String, Object>) secondProperties.get("author");
assertNotNull(secondAuthor);
assertFalse(secondAuthor.containsKey("description"));
}

@Test
void testGenerateSchemaFromTypeNestedMutationDoesNotAffectLaterCalls() {
Type listType = new TypeReference<List<SimpleModel>>() {}.getType();

Map<String, Object> first = JsonSchemaUtils.generateSchemaFromType(listType);

@SuppressWarnings("unchecked")
Map<String, Object> firstItems = (Map<String, Object>) first.get("items");
assertNotNull(firstItems);
firstItems.put("description", "mutated");

Map<String, Object> second = JsonSchemaUtils.generateSchemaFromType(listType);

@SuppressWarnings("unchecked")
Map<String, Object> secondItems = (Map<String, Object>) second.get("items");
assertNotNull(secondItems);
assertFalse(secondItems.containsKey("description"));
}

@Test
void testGenerateSchemaFromClassNullThrows() {
// 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<String, Object> first = JsonSchemaUtils.generateSchemaFromType(typeVariable);
Map<String, Object> second = JsonSchemaUtils.generateSchemaFromType(typeVariable);

assertNotNull(first);
assertEquals(first, second);
}

static class ConcurrentClassA {
public String name;
public int age;
Expand Down
Loading