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 f15efe5984..703d7a4a8c 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 @@ -53,6 +53,10 @@ *
  • {@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.

    + * * @hidden */ public class JsonSchemaUtils { @@ -61,6 +65,13 @@ public class JsonSchemaUtils { private static final SchemaGenerator schemaGenerator; + /** + * 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. + */ + private static final Object SCHEMA_LOCK = new Object(); + static { // JacksonModule to support @JsonProperty, @JsonPropertyDescription annotations JacksonModule jacksonModule = @@ -95,7 +106,10 @@ public class JsonSchemaUtils { */ public static Map generateSchemaFromClass(Class clazz) { try { - JsonNode schemaNode = schemaGenerator.generateSchema(clazz); + JsonNode schemaNode; + synchronized (SCHEMA_LOCK) { + schemaNode = schemaGenerator.generateSchema(clazz); + } return JsonUtils.getJsonCodec() .convertValue(schemaNode, new TypeReference>() {}); } catch (Exception e) { @@ -130,7 +144,10 @@ public static Map generateSchemaFromJsonNode(JsonNode schema) { */ public static Map generateSchemaFromType(Type type) { try { - JsonNode schemaNode = schemaGenerator.generateSchema(type); + JsonNode schemaNode; + synchronized (SCHEMA_LOCK) { + schemaNode = schemaGenerator.generateSchema(type); + } 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 c5efd261b9..0d62c2f107 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 @@ -23,12 +23,23 @@ import com.fasterxml.jackson.core.type.TypeReference; import java.lang.reflect.Type; +import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.function.IntFunction; import org.junit.jupiter.api.Test; class JsonSchemaUtilsTest { + private static final int CONCURRENT_THREAD_COUNT = 12; + + private static final int CONCURRENT_CALL_COUNT = 240; + static class SimpleModel { public String name; public int age; @@ -156,4 +167,92 @@ void testGenerateSchemaFromType() { assertNotNull(mapSchema); assertEquals("object", mapSchema.get("type")); } + + static class ConcurrentClassA { + public String name; + public int age; + } + + static class ConcurrentClassB { + public String title; + public List tags; + } + + static class ConcurrentClassC { + public String id; + public boolean active; + } + + static class ConcurrentClassD { + public double score; + } + + @Test + void testGenerateSchemaFromClassConcurrently() throws Exception { + List> targetClasses = List.of(ConcurrentClassA.class, ConcurrentClassB.class); + + List> schemas = + generateConcurrently( + index -> + JsonSchemaUtils.generateSchemaFromClass( + targetClasses.get(index % targetClasses.size()))); + + assertEquals(CONCURRENT_CALL_COUNT, schemas.size()); + for (Map schema : schemas) { + assertNotNull(schema); + assertEquals("object", schema.get("type")); + assertNotNull(schema.get("properties")); + } + } + + @Test + void testGenerateSchemaFromTypeConcurrently() throws Exception { + List targetTypes = + List.of( + new TypeReference() {}.getType(), + new TypeReference>() {}.getType()); + + List> schemas = + generateConcurrently( + index -> + JsonSchemaUtils.generateSchemaFromType( + targetTypes.get(index % targetTypes.size()))); + + assertEquals(CONCURRENT_CALL_COUNT, schemas.size()); + for (Map schema : schemas) { + assertNotNull(schema); + assertNotNull(schema.get("type")); + } + } + + /** + * Runs the given generator on a fixed thread pool, with all tasks released at the same + * time to maximize the chance of overlapping schema generation. Any exception thrown + * inside a task propagates through {@code Future#get} and fails the test. + */ + private static List> generateConcurrently( + IntFunction> generator) throws Exception { + ExecutorService executor = Executors.newFixedThreadPool(CONCURRENT_THREAD_COUNT); + CountDownLatch start = new CountDownLatch(1); + try { + List>> futures = new ArrayList<>(); + for (int i = 0; i < CONCURRENT_CALL_COUNT; i++) { + final int index = i; + futures.add( + executor.submit( + () -> { + start.await(); + return generator.apply(index); + })); + } + start.countDown(); + List> results = new ArrayList<>(); + for (Future> future : futures) { + results.add(future.get(30, TimeUnit.SECONDS)); + } + return results; + } finally { + executor.shutdownNow(); + } + } }