Skip to content

Commit f8a5bd2

Browse files
authored
Support virtual threads (#215)
1 parent f40aea0 commit f8a5bd2

File tree

7 files changed

+93
-19
lines changed

7 files changed

+93
-19
lines changed

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,9 @@ openapi.validation.validation-report-metric-additional-tags=service=example,team
102102
# Fail requests on request/response violations. Defaults to false.
103103
openapi.validation.should-fail-on-request-violation=true
104104
openapi.validation.should-fail-on-response-violation=true
105+
106+
# Enable virtual threads for async validation. Defaults to false.
107+
openapi.validation.enable-virtual-threads=true
105108
```
106109

107110
### DataDog metrics

openapi-validation-core/src/main/java/com/getyourguide/openapi/validation/core/OpenApiRequestValidator.java

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,26 +14,26 @@
1414
import java.net.URLDecoder;
1515
import java.nio.charset.StandardCharsets;
1616
import java.util.List;
17+
import java.util.concurrent.Executor;
1718
import java.util.concurrent.RejectedExecutionException;
18-
import java.util.concurrent.ThreadPoolExecutor;
1919
import javax.annotation.Nullable;
2020
import lombok.extern.slf4j.Slf4j;
2121
import org.apache.http.client.utils.URLEncodedUtils;
2222

2323
@Slf4j
2424
public class OpenApiRequestValidator {
25-
private final ThreadPoolExecutor threadPoolExecutor;
25+
private final Executor executor;
2626
private final OpenApiInteractionValidatorWrapper validator;
2727
private final ValidationReportToOpenApiViolationsMapper mapper;
2828

2929
public OpenApiRequestValidator(
30-
ThreadPoolExecutor threadPoolExecutor,
30+
Executor executor,
3131
MetricsReporter metricsReporter,
3232
OpenApiInteractionValidatorWrapper validator,
3333
ValidationReportToOpenApiViolationsMapper mapper,
3434
OpenApiRequestValidationConfiguration configuration
3535
) {
36-
this.threadPoolExecutor = threadPoolExecutor;
36+
this.executor = executor;
3737
this.validator = validator;
3838
this.mapper = mapper;
3939

@@ -74,7 +74,7 @@ public void validateResponseObjectAsync(
7474

7575
private void executeAsync(Runnable command) {
7676
try {
77-
threadPoolExecutor.execute(command);
77+
executor.execute(command);
7878
} catch (RejectedExecutionException ignored) {
7979
// ignored
8080
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package com.getyourguide.openapi.validation.core.executor;
2+
3+
import java.util.concurrent.Executor;
4+
import java.util.concurrent.atomic.AtomicInteger;
5+
6+
public class VirtualThreadLimitedExecutor implements Executor {
7+
private static final int DEFAULT_MAX_CONCURRENT = 2;
8+
private final int maxConcurrent;
9+
private final AtomicInteger runningCount = new AtomicInteger(0);
10+
11+
public VirtualThreadLimitedExecutor() {
12+
this(DEFAULT_MAX_CONCURRENT);
13+
}
14+
15+
public VirtualThreadLimitedExecutor(int maxConcurrent) {
16+
checkVirtualThreadSupport();
17+
this.maxConcurrent = maxConcurrent;
18+
}
19+
20+
public static boolean isSupported() {
21+
try {
22+
checkVirtualThreadSupport();
23+
return true;
24+
} catch (UnsupportedOperationException | NoSuchMethodError e) {
25+
return false;
26+
}
27+
}
28+
29+
private static void checkVirtualThreadSupport() {
30+
// This will throw NoSuchMethodError on Java < 21
31+
//noinspection ResultOfMethodCallIgnored
32+
Thread.ofVirtual();
33+
}
34+
35+
@Override
36+
public void execute(Runnable command) {
37+
if (runningCount.get() >= maxConcurrent) {
38+
return;
39+
}
40+
41+
if (runningCount.incrementAndGet() > maxConcurrent) {
42+
runningCount.decrementAndGet();
43+
return;
44+
}
45+
46+
Thread.ofVirtual().start(() -> {
47+
try {
48+
command.run();
49+
} finally {
50+
runningCount.decrementAndGet();
51+
}
52+
});
53+
}
54+
}

openapi-validation-core/src/test/java/com/getyourguide/openapi/validation/core/OpenApiRequestValidatorTest.java

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,30 +14,30 @@
1414
import java.net.URI;
1515
import java.util.HashMap;
1616
import java.util.List;
17+
import java.util.concurrent.Executor;
1718
import java.util.concurrent.RejectedExecutionException;
18-
import java.util.concurrent.ThreadPoolExecutor;
1919
import org.junit.jupiter.api.BeforeEach;
2020
import org.junit.jupiter.api.Test;
2121
import org.mockito.ArgumentCaptor;
2222
import org.mockito.Mockito;
2323

2424
public class OpenApiRequestValidatorTest {
2525

26-
private ThreadPoolExecutor threadPoolExecutor;
26+
private Executor executor;
2727
private OpenApiInteractionValidatorWrapper validator;
2828

2929
private OpenApiRequestValidator openApiRequestValidator;
3030

3131
@BeforeEach
3232
public void setup() {
33-
threadPoolExecutor = mock();
33+
executor = mock();
3434
validator = mock();
3535
MetricsReporter metricsReporter = mock();
3636
var mapper = mock(ValidationReportToOpenApiViolationsMapper.class);
3737
when(mapper.map(any(), any(), any(), any(), any())).thenReturn(List.of());
3838

3939
openApiRequestValidator = new OpenApiRequestValidator(
40-
threadPoolExecutor,
40+
executor,
4141
metricsReporter,
4242
validator,
4343
mapper,
@@ -47,7 +47,7 @@ public void setup() {
4747

4848
@Test
4949
public void testWhenThreadPoolExecutorRejectsExecutionThenItShouldNotThrow() {
50-
Mockito.doThrow(new RejectedExecutionException()).when(threadPoolExecutor).execute(any());
50+
Mockito.doThrow(new RejectedExecutionException()).when(executor).execute(any());
5151

5252
openApiRequestValidator.validateRequestObjectAsync(mock(), null, null, mock());
5353
}

spring-boot-starter/spring-boot-starter-core/src/main/java/com/getyourguide/openapi/validation/OpenApiValidationApplicationProperties.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ public class OpenApiValidationApplicationProperties {
3838
private List<String> excludedHeaders;
3939
private Boolean shouldFailOnRequestViolation;
4040
private Boolean shouldFailOnResponseViolation;
41+
private Boolean enableVirtualThreads;
4142

4243
public double getSampleRate() {
4344
return sampleRate != null ? sampleRate : SAMPLE_RATE_DEFAULT;
@@ -84,6 +85,10 @@ public List<ExcludedHeader> getExcludedHeaders() {
8485
.toList();
8586
}
8687

88+
public boolean isEnableVirtualThreads() {
89+
return enableVirtualThreads != null ? enableVirtualThreads : false;
90+
}
91+
8792
public OpenApiRequestValidationConfiguration toOpenApiRequestValidationConfiguration() {
8893
return OpenApiRequestValidationConfiguration.builder()
8994
.sampleRate(getSampleRate())

spring-boot-starter/spring-boot-starter-core/src/main/java/com/getyourguide/openapi/validation/autoconfigure/LibraryAutoConfiguration.java

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,14 @@
1919
import com.getyourguide.openapi.validation.core.OpenApiInteractionValidatorFactory;
2020
import com.getyourguide.openapi.validation.core.OpenApiRequestValidator;
2121
import com.getyourguide.openapi.validation.core.exclusions.InternalViolationExclusions;
22+
import com.getyourguide.openapi.validation.core.executor.VirtualThreadLimitedExecutor;
2223
import com.getyourguide.openapi.validation.core.log.DefaultOpenApiViolationHandler;
2324
import com.getyourguide.openapi.validation.core.log.ExclusionsOpenApiViolationHandler;
2425
import com.getyourguide.openapi.validation.core.log.ThrottlingOpenApiViolationHandler;
2526
import com.getyourguide.openapi.validation.core.mapper.ValidationReportToOpenApiViolationsMapper;
2627
import com.getyourguide.openapi.validation.core.metrics.DefaultMetricsReporter;
2728
import java.util.Optional;
29+
import java.util.concurrent.Executor;
2830
import java.util.concurrent.LinkedBlockingQueue;
2931
import java.util.concurrent.ThreadPoolExecutor;
3032
import java.util.concurrent.TimeUnit;
@@ -104,14 +106,7 @@ public OpenApiRequestValidator openApiRequestValidator(
104106
MetricsReporter metricsReporter,
105107
ValidatorConfiguration validatorConfiguration
106108
) {
107-
var threadPoolExecutor = new ThreadPoolExecutor(
108-
2,
109-
2,
110-
1000L,
111-
TimeUnit.MILLISECONDS,
112-
new LinkedBlockingQueue<>(10),
113-
new ThreadPoolExecutor.DiscardPolicy()
114-
);
109+
var threadPoolExecutor = createThreadPoolExecutor();
115110

116111
return new OpenApiRequestValidator(
117112
threadPoolExecutor,
@@ -122,4 +117,20 @@ public OpenApiRequestValidator openApiRequestValidator(
122117
properties.toOpenApiRequestValidationConfiguration()
123118
);
124119
}
120+
121+
private Executor createThreadPoolExecutor() {
122+
if (properties.isEnableVirtualThreads() && VirtualThreadLimitedExecutor.isSupported()) {
123+
return new VirtualThreadLimitedExecutor();
124+
}
125+
126+
// Fallback to ThreadPoolExecutor with regular threads
127+
return new ThreadPoolExecutor(
128+
2,
129+
2,
130+
1000L,
131+
TimeUnit.MILLISECONDS,
132+
new LinkedBlockingQueue<>(10),
133+
new ThreadPoolExecutor.DiscardPolicy()
134+
);
135+
}
125136
}

spring-boot-starter/spring-boot-starter-core/src/test/java/com/getyourguide/openapi/validation/OpenApiValidationApplicationPropertiesTest.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ void getters() {
3434
EXCLUDED_PATHS,
3535
EXCLUDED_HEADERS,
3636
true,
37-
false
37+
false,
38+
true
3839
);
3940

4041
assertEquals(SAMPLE_RATE, loggingConfiguration.getSampleRate());

0 commit comments

Comments
 (0)