From 7858def45fe74e94ebbec99b899459b3200e5764 Mon Sep 17 00:00:00 2001 From: Patrick Boos Date: Tue, 17 Jun 2025 10:27:39 +0200 Subject: [PATCH 1/6] [NO-TICKET] Use virtual threads --- .../core/OpenApiRequestValidator.java | 10 +++--- .../core/OpenApiRequestValidatorTest.java | 10 +++--- .../LibraryAutoConfiguration.java | 36 ++++++++++++++----- .../controller/DefaultRestController.java | 6 ++++ 4 files changed, 44 insertions(+), 18 deletions(-) diff --git a/openapi-validation-core/src/main/java/com/getyourguide/openapi/validation/core/OpenApiRequestValidator.java b/openapi-validation-core/src/main/java/com/getyourguide/openapi/validation/core/OpenApiRequestValidator.java index 1991fda4..1d3e922a 100644 --- a/openapi-validation-core/src/main/java/com/getyourguide/openapi/validation/core/OpenApiRequestValidator.java +++ b/openapi-validation-core/src/main/java/com/getyourguide/openapi/validation/core/OpenApiRequestValidator.java @@ -14,26 +14,26 @@ import java.net.URLDecoder; import java.nio.charset.StandardCharsets; import java.util.List; +import java.util.concurrent.Executor; import java.util.concurrent.RejectedExecutionException; -import java.util.concurrent.ThreadPoolExecutor; import javax.annotation.Nullable; import lombok.extern.slf4j.Slf4j; import org.apache.http.client.utils.URLEncodedUtils; @Slf4j public class OpenApiRequestValidator { - private final ThreadPoolExecutor threadPoolExecutor; + private final Executor executor; private final OpenApiInteractionValidatorWrapper validator; private final ValidationReportToOpenApiViolationsMapper mapper; public OpenApiRequestValidator( - ThreadPoolExecutor threadPoolExecutor, + Executor executor, MetricsReporter metricsReporter, OpenApiInteractionValidatorWrapper validator, ValidationReportToOpenApiViolationsMapper mapper, OpenApiRequestValidationConfiguration configuration ) { - this.threadPoolExecutor = threadPoolExecutor; + this.executor = executor; this.validator = validator; this.mapper = mapper; @@ -74,7 +74,7 @@ public void validateResponseObjectAsync( private void executeAsync(Runnable command) { try { - threadPoolExecutor.execute(command); + executor.execute(command); } catch (RejectedExecutionException ignored) { // ignored } diff --git a/openapi-validation-core/src/test/java/com/getyourguide/openapi/validation/core/OpenApiRequestValidatorTest.java b/openapi-validation-core/src/test/java/com/getyourguide/openapi/validation/core/OpenApiRequestValidatorTest.java index 3ba4fbfd..9cb2c386 100644 --- a/openapi-validation-core/src/test/java/com/getyourguide/openapi/validation/core/OpenApiRequestValidatorTest.java +++ b/openapi-validation-core/src/test/java/com/getyourguide/openapi/validation/core/OpenApiRequestValidatorTest.java @@ -14,8 +14,8 @@ import java.net.URI; import java.util.HashMap; import java.util.List; +import java.util.concurrent.Executor; import java.util.concurrent.RejectedExecutionException; -import java.util.concurrent.ThreadPoolExecutor; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -23,21 +23,21 @@ public class OpenApiRequestValidatorTest { - private ThreadPoolExecutor threadPoolExecutor; + private Executor executor; private OpenApiInteractionValidatorWrapper validator; private OpenApiRequestValidator openApiRequestValidator; @BeforeEach public void setup() { - threadPoolExecutor = mock(); + executor = mock(); validator = mock(); MetricsReporter metricsReporter = mock(); var mapper = mock(ValidationReportToOpenApiViolationsMapper.class); when(mapper.map(any(), any(), any(), any(), any())).thenReturn(List.of()); openApiRequestValidator = new OpenApiRequestValidator( - threadPoolExecutor, + executor, metricsReporter, validator, mapper, @@ -47,7 +47,7 @@ public void setup() { @Test public void testWhenThreadPoolExecutorRejectsExecutionThenItShouldNotThrow() { - Mockito.doThrow(new RejectedExecutionException()).when(threadPoolExecutor).execute(any()); + Mockito.doThrow(new RejectedExecutionException()).when(executor).execute(any()); openApiRequestValidator.validateRequestObjectAsync(mock(), null, null, mock()); } diff --git a/spring-boot-starter/spring-boot-starter-core/src/main/java/com/getyourguide/openapi/validation/autoconfigure/LibraryAutoConfiguration.java b/spring-boot-starter/spring-boot-starter-core/src/main/java/com/getyourguide/openapi/validation/autoconfigure/LibraryAutoConfiguration.java index 29d8aa10..2d6375d9 100644 --- a/spring-boot-starter/spring-boot-starter-core/src/main/java/com/getyourguide/openapi/validation/autoconfigure/LibraryAutoConfiguration.java +++ b/spring-boot-starter/spring-boot-starter-core/src/main/java/com/getyourguide/openapi/validation/autoconfigure/LibraryAutoConfiguration.java @@ -25,6 +25,7 @@ import com.getyourguide.openapi.validation.core.mapper.ValidationReportToOpenApiViolationsMapper; import com.getyourguide.openapi.validation.core.metrics.DefaultMetricsReporter; import java.util.Optional; +import java.util.concurrent.Executor; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; @@ -104,14 +105,7 @@ public OpenApiRequestValidator openApiRequestValidator( MetricsReporter metricsReporter, ValidatorConfiguration validatorConfiguration ) { - var threadPoolExecutor = new ThreadPoolExecutor( - 2, - 2, - 1000L, - TimeUnit.MILLISECONDS, - new LinkedBlockingQueue<>(10), - new ThreadPoolExecutor.DiscardPolicy() - ); + var threadPoolExecutor = createThreadPoolExecutor(); return new OpenApiRequestValidator( threadPoolExecutor, @@ -122,4 +116,30 @@ public OpenApiRequestValidator openApiRequestValidator( properties.toOpenApiRequestValidationConfiguration() ); } + + private Executor createThreadPoolExecutor() { + try { + // Try to use virtual threads if available (Java 21+) + var virtualThreadFactory = Thread.ofVirtual().factory(); + return new ThreadPoolExecutor( + 2, + 2, + 1000L, + TimeUnit.MILLISECONDS, + new LinkedBlockingQueue<>(10), + virtualThreadFactory, + new ThreadPoolExecutor.DiscardPolicy() + ); + } catch (UnsupportedOperationException | NoSuchMethodError e) { + // Fallback to ThreadPoolExecutor with regular threads for older Java versions + return new ThreadPoolExecutor( + 2, + 2, + 1000L, + TimeUnit.MILLISECONDS, + new LinkedBlockingQueue<>(10), + new ThreadPoolExecutor.DiscardPolicy() + ); + } + } } diff --git a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/controller/DefaultRestController.java b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/controller/DefaultRestController.java index 7b1a3675..eb60942c 100644 --- a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/controller/DefaultRestController.java +++ b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/controller/DefaultRestController.java @@ -4,6 +4,7 @@ import com.getyourguide.openapi.validation.test.exception.WithoutResponseStatusException; import com.getyourguide.openapi.validation.test.openapi.web.DefaultApi; import com.getyourguide.openapi.validation.test.openapi.web.model.PostTestRequest; +import com.getyourguide.openapi.validation.test.openapi.web.model.TestEmailResponse; import com.getyourguide.openapi.validation.test.openapi.web.model.TestResponse; import java.time.LocalDate; import java.util.Objects; @@ -26,6 +27,11 @@ public ResponseEntity getTest(String testCase, LocalDate date, Str return ResponseEntity.ok(new TestResponse().value(responseValue)); } + @Override + public ResponseEntity getTestEmail() { + return ResponseEntity.ok(new TestEmailResponse().email("mail@example.com")); + } + @Override public ResponseEntity postTest(PostTestRequest postTestRequest) { var responseStatus = postTestRequest.getResponseStatusCode(); From 17a09a4f969f9392c566ab60b8b6113d635add8e Mon Sep 17 00:00:00 2001 From: Patrick Boos Date: Tue, 17 Jun 2025 11:00:56 +0200 Subject: [PATCH 2/6] [NO-TICKET] Use virtual threads (better solution without a pool) --- .../VirtualThreadLimitedExecutor.java | 54 +++++++++++++++++++ .../LibraryAutoConfiguration.java | 35 +++++------- 2 files changed, 67 insertions(+), 22 deletions(-) create mode 100644 openapi-validation-core/src/main/java/com/getyourguide/openapi/validation/core/executor/VirtualThreadLimitedExecutor.java diff --git a/openapi-validation-core/src/main/java/com/getyourguide/openapi/validation/core/executor/VirtualThreadLimitedExecutor.java b/openapi-validation-core/src/main/java/com/getyourguide/openapi/validation/core/executor/VirtualThreadLimitedExecutor.java new file mode 100644 index 00000000..9aab3a9d --- /dev/null +++ b/openapi-validation-core/src/main/java/com/getyourguide/openapi/validation/core/executor/VirtualThreadLimitedExecutor.java @@ -0,0 +1,54 @@ +package com.getyourguide.openapi.validation.core.executor; + +import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicInteger; + +public class VirtualThreadLimitedExecutor implements Executor { + private static final int DEFAULT_MAX_CONCURRENT = 2; + private final int maxConcurrent; + private final AtomicInteger runningCount = new AtomicInteger(0); + + public VirtualThreadLimitedExecutor() { + this(DEFAULT_MAX_CONCURRENT); + } + + public VirtualThreadLimitedExecutor(int maxConcurrent) { + checkVirtualThreadSupport(); + this.maxConcurrent = maxConcurrent; + } + + public static boolean isSupported() { + try { + checkVirtualThreadSupport(); + return true; + } catch (UnsupportedOperationException | NoSuchMethodError e) { + return false; + } + } + + private static void checkVirtualThreadSupport() { + // This will throw NoSuchMethodError on Java < 21 + //noinspection ResultOfMethodCallIgnored + Thread.ofVirtual(); + } + + @Override + public void execute(Runnable command) { + if (runningCount.get() >= maxConcurrent) { + return; + } + + if (runningCount.incrementAndGet() > maxConcurrent) { + runningCount.decrementAndGet(); + return; + } + + Thread.ofVirtual().start(() -> { + try { + command.run(); + } finally { + runningCount.decrementAndGet(); + } + }); + } +} diff --git a/spring-boot-starter/spring-boot-starter-core/src/main/java/com/getyourguide/openapi/validation/autoconfigure/LibraryAutoConfiguration.java b/spring-boot-starter/spring-boot-starter-core/src/main/java/com/getyourguide/openapi/validation/autoconfigure/LibraryAutoConfiguration.java index 2d6375d9..64118695 100644 --- a/spring-boot-starter/spring-boot-starter-core/src/main/java/com/getyourguide/openapi/validation/autoconfigure/LibraryAutoConfiguration.java +++ b/spring-boot-starter/spring-boot-starter-core/src/main/java/com/getyourguide/openapi/validation/autoconfigure/LibraryAutoConfiguration.java @@ -19,6 +19,7 @@ import com.getyourguide.openapi.validation.core.OpenApiInteractionValidatorFactory; import com.getyourguide.openapi.validation.core.OpenApiRequestValidator; import com.getyourguide.openapi.validation.core.exclusions.InternalViolationExclusions; +import com.getyourguide.openapi.validation.core.executor.VirtualThreadLimitedExecutor; import com.getyourguide.openapi.validation.core.log.DefaultOpenApiViolationHandler; import com.getyourguide.openapi.validation.core.log.ExclusionsOpenApiViolationHandler; import com.getyourguide.openapi.validation.core.log.ThrottlingOpenApiViolationHandler; @@ -118,28 +119,18 @@ public OpenApiRequestValidator openApiRequestValidator( } private Executor createThreadPoolExecutor() { - try { - // Try to use virtual threads if available (Java 21+) - var virtualThreadFactory = Thread.ofVirtual().factory(); - return new ThreadPoolExecutor( - 2, - 2, - 1000L, - TimeUnit.MILLISECONDS, - new LinkedBlockingQueue<>(10), - virtualThreadFactory, - new ThreadPoolExecutor.DiscardPolicy() - ); - } catch (UnsupportedOperationException | NoSuchMethodError e) { - // Fallback to ThreadPoolExecutor with regular threads for older Java versions - return new ThreadPoolExecutor( - 2, - 2, - 1000L, - TimeUnit.MILLISECONDS, - new LinkedBlockingQueue<>(10), - new ThreadPoolExecutor.DiscardPolicy() - ); + if (VirtualThreadLimitedExecutor.isSupported()) { + return new VirtualThreadLimitedExecutor(); } + + // Fallback to ThreadPoolExecutor with regular threads for older Java versions + return new ThreadPoolExecutor( + 2, + 2, + 1000L, + TimeUnit.MILLISECONDS, + new LinkedBlockingQueue<>(10), + new ThreadPoolExecutor.DiscardPolicy() + ); } } From 9d10d6a7972bd92a9ba76d0275b946875863e800 Mon Sep 17 00:00:00 2001 From: Patrick Boos Date: Tue, 17 Jun 2025 11:48:22 +0200 Subject: [PATCH 3/6] [NO-TICKET] Remove unnecessary change --- .../integration/controller/DefaultRestController.java | 6 ------ 1 file changed, 6 deletions(-) diff --git a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/controller/DefaultRestController.java b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/controller/DefaultRestController.java index eb60942c..7b1a3675 100644 --- a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/controller/DefaultRestController.java +++ b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/controller/DefaultRestController.java @@ -4,7 +4,6 @@ import com.getyourguide.openapi.validation.test.exception.WithoutResponseStatusException; import com.getyourguide.openapi.validation.test.openapi.web.DefaultApi; import com.getyourguide.openapi.validation.test.openapi.web.model.PostTestRequest; -import com.getyourguide.openapi.validation.test.openapi.web.model.TestEmailResponse; import com.getyourguide.openapi.validation.test.openapi.web.model.TestResponse; import java.time.LocalDate; import java.util.Objects; @@ -27,11 +26,6 @@ public ResponseEntity getTest(String testCase, LocalDate date, Str return ResponseEntity.ok(new TestResponse().value(responseValue)); } - @Override - public ResponseEntity getTestEmail() { - return ResponseEntity.ok(new TestEmailResponse().email("mail@example.com")); - } - @Override public ResponseEntity postTest(PostTestRequest postTestRequest) { var responseStatus = postTestRequest.getResponseStatusCode(); From 7ba2d55ffcb61d8b8062cc7058400f263437d3b1 Mon Sep 17 00:00:00 2001 From: Patrick Boos Date: Tue, 24 Jun 2025 14:42:51 +0200 Subject: [PATCH 4/6] Make using virtual threads optional --- .../validation/OpenApiValidationApplicationProperties.java | 5 +++++ .../validation/autoconfigure/LibraryAutoConfiguration.java | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/spring-boot-starter/spring-boot-starter-core/src/main/java/com/getyourguide/openapi/validation/OpenApiValidationApplicationProperties.java b/spring-boot-starter/spring-boot-starter-core/src/main/java/com/getyourguide/openapi/validation/OpenApiValidationApplicationProperties.java index 6e0f891c..6448612a 100644 --- a/spring-boot-starter/spring-boot-starter-core/src/main/java/com/getyourguide/openapi/validation/OpenApiValidationApplicationProperties.java +++ b/spring-boot-starter/spring-boot-starter-core/src/main/java/com/getyourguide/openapi/validation/OpenApiValidationApplicationProperties.java @@ -38,6 +38,7 @@ public class OpenApiValidationApplicationProperties { private List excludedHeaders; private Boolean shouldFailOnRequestViolation; private Boolean shouldFailOnResponseViolation; + private Boolean enableVirtualThreads; public double getSampleRate() { return sampleRate != null ? sampleRate : SAMPLE_RATE_DEFAULT; @@ -84,6 +85,10 @@ public List getExcludedHeaders() { .toList(); } + public boolean isEnableVirtualThreads() { + return enableVirtualThreads != null ? enableVirtualThreads : false; + } + public OpenApiRequestValidationConfiguration toOpenApiRequestValidationConfiguration() { return OpenApiRequestValidationConfiguration.builder() .sampleRate(getSampleRate()) diff --git a/spring-boot-starter/spring-boot-starter-core/src/main/java/com/getyourguide/openapi/validation/autoconfigure/LibraryAutoConfiguration.java b/spring-boot-starter/spring-boot-starter-core/src/main/java/com/getyourguide/openapi/validation/autoconfigure/LibraryAutoConfiguration.java index 64118695..130d8b94 100644 --- a/spring-boot-starter/spring-boot-starter-core/src/main/java/com/getyourguide/openapi/validation/autoconfigure/LibraryAutoConfiguration.java +++ b/spring-boot-starter/spring-boot-starter-core/src/main/java/com/getyourguide/openapi/validation/autoconfigure/LibraryAutoConfiguration.java @@ -119,11 +119,11 @@ public OpenApiRequestValidator openApiRequestValidator( } private Executor createThreadPoolExecutor() { - if (VirtualThreadLimitedExecutor.isSupported()) { + if (properties.isEnableVirtualThreads() && VirtualThreadLimitedExecutor.isSupported()) { return new VirtualThreadLimitedExecutor(); } - // Fallback to ThreadPoolExecutor with regular threads for older Java versions + // Fallback to ThreadPoolExecutor with regular threads return new ThreadPoolExecutor( 2, 2, From 5148cda1229cbb7d201c3692988d077af1329b8c Mon Sep 17 00:00:00 2001 From: Patrick Boos Date: Wed, 25 Jun 2025 08:27:52 +0200 Subject: [PATCH 5/6] Fix test --- .../validation/OpenApiValidationApplicationPropertiesTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/spring-boot-starter/spring-boot-starter-core/src/test/java/com/getyourguide/openapi/validation/OpenApiValidationApplicationPropertiesTest.java b/spring-boot-starter/spring-boot-starter-core/src/test/java/com/getyourguide/openapi/validation/OpenApiValidationApplicationPropertiesTest.java index f5f95c15..0f0a00f9 100644 --- a/spring-boot-starter/spring-boot-starter-core/src/test/java/com/getyourguide/openapi/validation/OpenApiValidationApplicationPropertiesTest.java +++ b/spring-boot-starter/spring-boot-starter-core/src/test/java/com/getyourguide/openapi/validation/OpenApiValidationApplicationPropertiesTest.java @@ -34,7 +34,8 @@ void getters() { EXCLUDED_PATHS, EXCLUDED_HEADERS, true, - false + false, + true ); assertEquals(SAMPLE_RATE, loggingConfiguration.getSampleRate()); From 266e3b8605271e1d761291f3c02908faadb38543 Mon Sep 17 00:00:00 2001 From: Patrick Boos Date: Wed, 25 Jun 2025 08:37:07 +0200 Subject: [PATCH 6/6] Add info in README.md --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 60cb14e4..170c60e0 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,9 @@ openapi.validation.validation-report-metric-additional-tags=service=example,team # Fail requests on request/response violations. Defaults to false. openapi.validation.should-fail-on-request-violation=true openapi.validation.should-fail-on-response-violation=true + +# Enable virtual threads for async validation. Defaults to false. +openapi.validation.enable-virtual-threads=true ``` ### DataDog metrics