From c51dd42ce345f185cef3c1c8cdd1f86e482f7e46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Skj=C3=B8lberg?= Date: Wed, 9 Sep 2026 18:20:30 +0200 Subject: [PATCH 1/5] Improve performance: JWT decoder cache - benchmark --- ...ecodedJwtCacheJwtDecoderBenchmarkTest.java | 205 ++++++++++++++++++ ...ecodedJwtCacheHttpBenchmarkCachedTest.java | 141 ++++++++++++ ...codedJwtCacheHttpBenchmarkNoCacheTest.java | 129 +++++++++++ .../jwt/spring/perf/HttpBenchmarkSupport.java | 42 ++++ 4 files changed, 517 insertions(+) create mode 100644 jwt-server/spring/jwt-spring-common/src/test/java/org/entur/jwt/spring/decode/cache/DecodedJwtCacheJwtDecoderBenchmarkTest.java create mode 100644 jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/DecodedJwtCacheHttpBenchmarkCachedTest.java create mode 100644 jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/DecodedJwtCacheHttpBenchmarkNoCacheTest.java create mode 100644 jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/HttpBenchmarkSupport.java diff --git a/jwt-server/spring/jwt-spring-common/src/test/java/org/entur/jwt/spring/decode/cache/DecodedJwtCacheJwtDecoderBenchmarkTest.java b/jwt-server/spring/jwt-spring-common/src/test/java/org/entur/jwt/spring/decode/cache/DecodedJwtCacheJwtDecoderBenchmarkTest.java new file mode 100644 index 000000000..bbd3569e0 --- /dev/null +++ b/jwt-server/spring/jwt-spring-common/src/test/java/org/entur/jwt/spring/decode/cache/DecodedJwtCacheJwtDecoderBenchmarkTest.java @@ -0,0 +1,205 @@ +package org.entur.jwt.spring.decode.cache; + +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.source.CachingJWKSetSource; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.interfaces.RSAPublicKey; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Back-to-back-request benchmark of {@link DecodedJwtCacheJwtDecoder}, run manually (not + * part of the regular build) to get a ballpark of the speedup the decoded-JWT cache gives + * over always fully re-verifying the JWT signature (i.e. behaviour without this cache, + * as on {@code master}). + *

+ * Three scenarios are simulated, each issuing {@link #REQUESTS} decode calls back to back + * on a single thread, after a warm-up phase to let the JIT settle: + *

+ * To run: remove/comment out {@code @Disabled} and execute this test class directly. + */ +@Tag("performance") +@Disabled("Manual benchmark - not meant to run as part of the regular build") +public class DecodedJwtCacheJwtDecoderBenchmarkTest { + + private static final int WARMUP_REQUESTS = 20_000; + private static final int REQUESTS = 200_000; + private static final int TOKEN_POOL_SIZE = 50; + + @Test + public void baselineNoCache() throws Exception { + RSAKey signingKey = generateRsaKey("kid-0"); + JwtDecoder delegate = nimbusDecoder(signingKey); + String token = signedToken(signingKey); + + Stats stats = measure("baseline (no cache, every request fully verified)", + REQUESTS, WARMUP_REQUESTS, i -> delegate.decode(token)); + + assertTrue(stats.opsPerSecond > 0); + } + + @Test + public void singleTokenReused() throws Exception { + RSAKey signingKey = generateRsaKey("kid-0"); + JwtDecoder delegate = nimbusDecoder(signingKey); + String token = signedToken(signingKey); + + DecodedJwtCacheJwtDecoder cachedDecoder = cachedDecoder(delegate, signingKey); + cachedDecoder.decode(token); // populate cache (first call is necessarily a miss) + + Stats baseline = measure("baseline (no cache, every request fully verified)", + REQUESTS, WARMUP_REQUESTS, i -> delegate.decode(token)); + + Stats cached = measure("single token reused (100% cache-hit rate)", + REQUESTS, WARMUP_REQUESTS, i -> cachedDecoder.decode(token)); + + printSpeedup(baseline, cached); + + assertTrue(cached.opsPerSecond > baseline.opsPerSecond, + "Cache-hit decoding is expected to be substantially faster than full re-verification"); + } + + @Test + public void tokenPoolReused() throws Exception { + RSAKey signingKey = generateRsaKey("kid-0"); + JwtDecoder delegate = nimbusDecoder(signingKey); + + List tokens = new ArrayList<>(TOKEN_POOL_SIZE); + for (int i = 0; i < TOKEN_POOL_SIZE; i++) { + tokens.add(signedToken(signingKey)); + } + + DecodedJwtCacheJwtDecoder cachedDecoder = cachedDecoder(delegate, signingKey); + for (String token : tokens) { + cachedDecoder.decode(token); // populate cache for each token in the pool + } + + Stats baseline = measure("baseline (no cache, every request fully verified)", + REQUESTS, WARMUP_REQUESTS, i -> delegate.decode(tokens.get(i % tokens.size()))); + + Stats cached = measure("token pool of " + TOKEN_POOL_SIZE + " reused round-robin (simulated multi-client traffic)", + REQUESTS, WARMUP_REQUESTS, i -> cachedDecoder.decode(tokens.get(i % tokens.size()))); + + printSpeedup(baseline, cached); + + assertTrue(cached.opsPerSecond > baseline.opsPerSecond, + "Cache-hit decoding with a reused token pool is expected to be substantially faster than full re-verification"); + } + + // --- helpers ------------------------------------------------------------------- + + private interface DecodeCall { + void decode(int i) throws Exception; + } + + private record Stats(String label, double avgLatencyMicros, double opsPerSecond) { + } + + /** + * Runs {@code call} back to back {@code requests} times (after a warm-up phase of + * {@code warmup} calls to let the JIT settle), then reports average per-call latency + * and throughput. + */ + private Stats measure(String label, int requests, int warmup, DecodeCall call) throws Exception { + for (int i = 0; i < warmup; i++) { + call.decode(i); + } + + long start = System.nanoTime(); + for (int i = 0; i < requests; i++) { + call.decode(i); + } + long durationNanos = System.nanoTime() - start; + + double avgLatencyMicros = (durationNanos / 1000.0) / requests; + double opsPerSecond = requests / (durationNanos / 1_000_000_000.0); + + System.out.printf("[%s] %.3f us/op, %.0f ops/sec (%d requests back to back)%n", + label, avgLatencyMicros, opsPerSecond, requests); + + return new Stats(label, avgLatencyMicros, opsPerSecond); + } + + private void printSpeedup(Stats baseline, Stats cached) { + System.out.printf("Speedup vs baseline: %.1fx (latency), %.1fx (throughput)%n%n", + baseline.avgLatencyMicros / cached.avgLatencyMicros, + cached.opsPerSecond / baseline.opsPerSecond); + } + + private JwtDecoder nimbusDecoder(RSAKey signingKey) throws Exception { + return NimbusJwtDecoder.withPublicKey((RSAPublicKey) signingKey.toRSAPublicKey()).build(); + } + + private String signedToken(RSAKey signingKey) throws Exception { + JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(signingKey.getKeyID()).build(); + JWTClaimsSet claims = new JWTClaimsSet.Builder() + .subject("test-subject") + .issuer("https://issuer.example.com") + .audience("test-audience") + .expirationTime(Date.from(Instant.now().plusSeconds(3600))) + .issueTime(new Date()) + .build(); + + SignedJWT signedJWT = new SignedJWT(header, claims); + signedJWT.sign(new RSASSASigner(signingKey)); + return signedJWT.serialize(); + } + + /** + * Wraps {@code delegate} with a {@link DecodedJwtCacheJwtDecoder}, and feeds it a + * simulated JWKS refresh so it treats {@code signingKey}'s key id as an active + * signing key (required before it will actually cache any decoded JWT). + */ + @SuppressWarnings("unchecked") + private DecodedJwtCacheJwtDecoder cachedDecoder(JwtDecoder delegate, RSAKey signingKey) { + DecodedJwtCacheJwtDecoder cachedDecoder = new DecodedJwtCacheJwtDecoder( + delegate, jwt -> OAuth2TokenValidatorResult.success(), 0L, -1); + + JWKSet jwkSet = new JWKSet(List.of(signingKey.toPublicJWK())); + CachingJWKSetSource.RefreshCompletedEvent event = mock(CachingJWKSetSource.RefreshCompletedEvent.class); + when(event.getJWKSet()).thenReturn(jwkSet); + cachedDecoder.notify(event); + + return cachedDecoder; + } + + private RSAKey generateRsaKey(String kid) throws Exception { + KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA"); + gen.initialize(2048); + KeyPair pair = gen.generateKeyPair(); + return new RSAKey.Builder((RSAPublicKey) pair.getPublic()) + .privateKey(pair.getPrivate()) + .keyID(kid) + .build(); + } +} diff --git a/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/DecodedJwtCacheHttpBenchmarkCachedTest.java b/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/DecodedJwtCacheHttpBenchmarkCachedTest.java new file mode 100644 index 000000000..055ad8505 --- /dev/null +++ b/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/DecodedJwtCacheHttpBenchmarkCachedTest.java @@ -0,0 +1,141 @@ +package org.entur.jwt.spring.perf; + +import org.entur.jwt.junit5.AccessToken; +import org.entur.jwt.junit5.AuthorizationServer; +import org.entur.jwt.spring.actuate.AbstractActuatorTest; +import org.entur.jwt.spring.decode.ClosableJwtDecoders; +import org.entur.jwt.spring.decode.cache.DecodedJwtCacheJwtDecoder; +import org.entur.jwt.spring.rest.Greeting; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Back-to-back HTTP request benchmark against a real, fully started Spring Boot context + * (embedded server on a random port, real security filter chain, real JWKS mock issuer), + * with the decoded-JWT cache enabled (this branch's optimization). Repeated tokens + * hit the decoded-JWT cache instead of being fully re-verified, still going through the + * whole HTTP stack end to end. + *

+ * See {@link DecodedJwtCacheHttpBenchmarkNoCacheTest} for the equivalent benchmark with + * the cache disabled, for direct comparison. + *

+ * Test methods run in a fixed, explicit order ({@link Order}) and each scenario has a + * large, independent warm-up phase, so that results are not skewed by JIT/GC/connection-pool + * warm-up carried over from a previously run method sharing the same Spring context/JVM. + *

+ * Disabled by default - not part of the regular build. Remove {@code @Disabled} to run + * manually. + */ +@Tag("performance") +@Disabled("Manual benchmark - not meant to run as part of the regular build") +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +@AuthorizationServer("a") +@ExtendWith(SpringExtension.class) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureTestRestTemplate +@TestPropertySource(properties = { + "entur.jwt.tenants.a.decoder-cache.enabled=true", + "entur.jwt.jwk.cache.preemptive.eager.enabled=true", + // suppress per-request log noise so it doesn't distort request timings/output + "logging.level.org.entur=WARN", + "logging.level.org.springframework=WARN", +}) +public class DecodedJwtCacheHttpBenchmarkCachedTest extends AbstractActuatorTest { + + // large enough that each scenario independently reaches JIT/GC/connection-pool steady + // state, regardless of execution order or warm-up carried over from other methods + private static final int WARMUP_REQUESTS = 20_000; + private static final int REQUESTS = 10_000; + + @LocalServerPort + private int randomServerPort; + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private ClosableJwtDecoders closableJwtDecoders; + + @BeforeEach + public void readinessProbe() throws Exception { + // make sure JWKs are loaded before timing anything + HttpHeaders headers = new HttpHeaders(); + HttpEntity entity = new HttpEntity<>(headers); + + String url = "http://localhost:" + randomServerPort + "/actuator/health/readiness"; + ResponseEntity response = restTemplate.exchange(url, HttpMethod.GET, entity, String.class); + + if (!response.getStatusCode().is2xxSuccessful()) { + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + waitForHealth(); + + response = restTemplate.exchange(url, HttpMethod.GET, entity, String.class); + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + + JwtDecoder decoder = closableJwtDecoders.getJwtDecoders().get("https://mock.issuer.a.xyz"); + assertThat(decoder).isInstanceOf(DecodedJwtCacheJwtDecoder.class); + ((DecodedJwtCacheJwtDecoder) decoder).clear(); + } + + @Test + @Order(1) + public void singleTokenReused(@AccessToken(by = "a", audience = "mock.my.audience") String token) { + HttpBenchmarkSupport.Stats stats = HttpBenchmarkSupport.measure( + "decoded-JWT cache enabled, single token reused (100% cache-hit rate)", + REQUESTS, WARMUP_REQUESTS, i -> assertProtectedRequestSucceeds(token)); + + assertTrue(stats.opsPerSecond() > 0); + } + + @Test + @Order(2) + public void tokenPoolReused( + @AccessToken(by = "a", audience = "mock.my.audience", scope = "1") String token1, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "2") String token2, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "3") String token3, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "4") String token4, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "5") String token5) { + + String[] tokens = {token1, token2, token3, token4, token5}; + + HttpBenchmarkSupport.Stats stats = HttpBenchmarkSupport.measure( + "decoded-JWT cache enabled, pool of " + tokens.length + " tokens reused round-robin", + REQUESTS, WARMUP_REQUESTS, i -> assertProtectedRequestSucceeds(tokens[i % tokens.length])); + + assertTrue(stats.opsPerSecond() > 0); + } + + private void assertProtectedRequestSucceeds(String token) { + HttpHeaders headers = new HttpHeaders(); + headers.add("Authorization", token); + HttpEntity entity = new HttpEntity<>(headers); + + String url = "http://localhost:" + randomServerPort + "/protected"; + ResponseEntity response = restTemplate.exchange(url, HttpMethod.GET, entity, Greeting.class); + + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + } +} diff --git a/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/DecodedJwtCacheHttpBenchmarkNoCacheTest.java b/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/DecodedJwtCacheHttpBenchmarkNoCacheTest.java new file mode 100644 index 000000000..44b14ab79 --- /dev/null +++ b/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/DecodedJwtCacheHttpBenchmarkNoCacheTest.java @@ -0,0 +1,129 @@ +package org.entur.jwt.spring.perf; + +import org.entur.jwt.junit5.AccessToken; +import org.entur.jwt.junit5.AuthorizationServer; +import org.entur.jwt.spring.actuate.AbstractActuatorTest; +import org.entur.jwt.spring.rest.Greeting; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Back-to-back HTTP request benchmark against a real, fully started Spring Boot context + * (embedded server on a random port, real security filter chain, real JWKS mock issuer), + * with the decoded-JWT cache disabled. Every request is fully re-verified end to + * end over the wire, approximating {@code master} behaviour (which has no decoded-JWT + * cache at all). + *

+ * See {@link DecodedJwtCacheHttpBenchmarkCachedTest} for the equivalent benchmark with the + * cache enabled, for direct comparison. + *

+ * Test methods run in a fixed, explicit order ({@link Order}) and each scenario has a + * large, independent warm-up phase, so that results are not skewed by JIT/GC/connection-pool + * warm-up carried over from a previously run method sharing the same Spring context/JVM. + *

+ * Disabled by default - not part of the regular build. Remove {@code @Disabled} to run + * manually. + */ +@Tag("performance") +@Disabled("Manual benchmark - not meant to run as part of the regular build") +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +@AuthorizationServer("a") +@ExtendWith(SpringExtension.class) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureTestRestTemplate +@TestPropertySource(properties = { + // suppress per-request log noise so it doesn't distort request timings/output + "logging.level.org.entur=WARN", + "logging.level.org.springframework=WARN", +}) +public class DecodedJwtCacheHttpBenchmarkNoCacheTest extends AbstractActuatorTest { + + // large enough that each scenario independently reaches JIT/GC/connection-pool steady + // state, regardless of execution order or warm-up carried over from other methods + private static final int WARMUP_REQUESTS = 20_000; + private static final int REQUESTS = 10_000; + + @LocalServerPort + private int randomServerPort; + + @Autowired + private TestRestTemplate restTemplate; + + @BeforeEach + public void readinessProbe() throws Exception { + // make sure JWKs are loaded before timing anything + HttpHeaders headers = new HttpHeaders(); + HttpEntity entity = new HttpEntity<>(headers); + + String url = "http://localhost:" + randomServerPort + "/actuator/health/readiness"; + ResponseEntity response = restTemplate.exchange(url, HttpMethod.GET, entity, String.class); + + if (!response.getStatusCode().is2xxSuccessful()) { + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + waitForHealth(); + + response = restTemplate.exchange(url, HttpMethod.GET, entity, String.class); + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + } + + @Test + @Order(1) + public void singleTokenReused(@AccessToken(by = "a", audience = "mock.my.audience") String token) { + HttpBenchmarkSupport.Stats stats = HttpBenchmarkSupport.measure( + "no decoded-JWT cache, single token reused", + REQUESTS, WARMUP_REQUESTS, i -> assertProtectedRequestSucceeds(token)); + + assertTrue(stats.opsPerSecond() > 0); + } + + @Test + @Order(2) + public void tokenPoolReused( + @AccessToken(by = "a", audience = "mock.my.audience", scope = "1") String token1, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "2") String token2, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "3") String token3, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "4") String token4, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "5") String token5) { + + String[] tokens = {token1, token2, token3, token4, token5}; + + HttpBenchmarkSupport.Stats stats = HttpBenchmarkSupport.measure( + "no decoded-JWT cache, pool of " + tokens.length + " tokens reused round-robin", + REQUESTS, WARMUP_REQUESTS, i -> assertProtectedRequestSucceeds(tokens[i % tokens.length])); + + assertTrue(stats.opsPerSecond() > 0); + } + + private void assertProtectedRequestSucceeds(String token) { + HttpHeaders headers = new HttpHeaders(); + headers.add("Authorization", token); + HttpEntity entity = new HttpEntity<>(headers); + + String url = "http://localhost:" + randomServerPort + "/protected"; + ResponseEntity response = restTemplate.exchange(url, HttpMethod.GET, entity, Greeting.class); + + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + } +} diff --git a/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/HttpBenchmarkSupport.java b/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/HttpBenchmarkSupport.java new file mode 100644 index 000000000..adc72dfb8 --- /dev/null +++ b/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/HttpBenchmarkSupport.java @@ -0,0 +1,42 @@ +package org.entur.jwt.spring.perf; + +/** + * Shared timing helper for the HTTP back-to-back-request benchmarks in this package. + */ +final class HttpBenchmarkSupport { + + interface HttpCall { + void request(int i); + } + + record Stats(String label, double avgLatencyMicros, double opsPerSecond) { + } + + private HttpBenchmarkSupport() { + } + + /** + * Issues {@code requests} real HTTP requests back to back (after a warm-up phase of + * {@code warmup} requests, to let the JIT and connection pool settle), then reports + * average per-request latency and throughput. + */ + static Stats measure(String label, int requests, int warmup, HttpCall call) { + for (int i = 0; i < warmup; i++) { + call.request(i); + } + + long start = System.nanoTime(); + for (int i = 0; i < requests; i++) { + call.request(i); + } + long durationNanos = System.nanoTime() - start; + + double avgLatencyMicros = (durationNanos / 1000.0) / requests; + double opsPerSecond = requests / (durationNanos / 1_000_000_000.0); + + System.out.printf("[%s] %.1f us/request, %.0f requests/sec (%d requests back to back)%n", + label, avgLatencyMicros, opsPerSecond, requests); + + return new Stats(label, avgLatencyMicros, opsPerSecond); + } +} From cd60665d1b114de180371de53fd1b851d6dd97ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Skj=C3=B8lberg?= Date: Fri, 18 Sep 2026 22:23:12 +0200 Subject: [PATCH 2/5] Add gRPC and cold-start smoke performance tests for decoded-JWT cache Mirrors the existing HTTP smoke benchmarks (DecodedJwtCacheHttpBenchmark*) for real gRPC calls over a TCP loopback channel, and adds cold-start (no warm-up) wall-clock interval benchmarks (1-5, 10, 15s) for both HTTP and gRPC, using a round-robin pool of 20 tokens to simulate a realistic number of concurrent clients. New: - jwt-spring-grpc-native/.../perf/GrpcBenchmarkSupport.java - jwt-spring-grpc-native/.../perf/DecodedJwtCacheGrpcBenchmarkCachedTest.java - jwt-spring-grpc-native/.../perf/DecodedJwtCacheGrpcBenchmarkNoCacheTest.java - jwt-spring-grpc-native/.../perf/DecodedJwtCacheGrpcBenchmarkColdStartCachedTest.java - jwt-spring-grpc-native/.../perf/DecodedJwtCacheGrpcBenchmarkColdStartNoCacheTest.java - jwt-spring-web/.../perf/DecodedJwtCacheHttpBenchmarkColdStartCachedTest.java - jwt-spring-web/.../perf/DecodedJwtCacheHttpBenchmarkColdStartNoCacheTest.java Changed: - jwt-spring-web/.../perf/HttpBenchmarkSupport.java: added measureColdStartIntervals() helper (mirrored into GrpcBenchmarkSupport). All tests are @Tag("performance") and @Disabled by default, matching the existing manual-benchmark convention. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...ecodedJwtCacheGrpcBenchmarkCachedTest.java | 126 ++++++++++++++++ ...CacheGrpcBenchmarkColdStartCachedTest.java | 121 +++++++++++++++ ...acheGrpcBenchmarkColdStartNoCacheTest.java | 110 ++++++++++++++ ...codedJwtCacheGrpcBenchmarkNoCacheTest.java | 115 +++++++++++++++ .../grpc/perf/GrpcBenchmarkSupport.java | 86 +++++++++++ ...CacheHttpBenchmarkColdStartCachedTest.java | 138 ++++++++++++++++++ ...acheHttpBenchmarkColdStartNoCacheTest.java | 125 ++++++++++++++++ .../jwt/spring/perf/HttpBenchmarkSupport.java | 41 ++++++ 8 files changed, 862 insertions(+) create mode 100644 jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkCachedTest.java create mode 100644 jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkColdStartCachedTest.java create mode 100644 jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkColdStartNoCacheTest.java create mode 100644 jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkNoCacheTest.java create mode 100644 jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/GrpcBenchmarkSupport.java create mode 100644 jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/DecodedJwtCacheHttpBenchmarkColdStartCachedTest.java create mode 100644 jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/DecodedJwtCacheHttpBenchmarkColdStartNoCacheTest.java diff --git a/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkCachedTest.java b/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkCachedTest.java new file mode 100644 index 000000000..8cf05d01e --- /dev/null +++ b/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkCachedTest.java @@ -0,0 +1,126 @@ +package org.entur.jwt.spring.grpc.perf; + +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.JWKSelector; +import com.nimbusds.jose.jwk.source.JWKSource; +import org.entur.jwt.junit5.AccessToken; +import org.entur.jwt.junit5.AuthorizationServer; +import org.entur.jwt.spring.JwkSourceMap; +import org.entur.jwt.spring.decode.cache.DecodedJwtCacheJwtDecoder; +import org.entur.jwt.spring.grpc.AbstractGrpcTest; +import org.entur.jwt.spring.grpc.test.GreetingResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.TestPropertySource; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Back-to-back gRPC call benchmark against a real, fully started Spring Boot context + * (embedded gRPC server on a real TCP loopback port, real security interceptor chain, + * real JWKS mock issuer), with the decoded-JWT cache enabled (this branch's + * optimization). Repeated tokens hit the decoded-JWT cache instead of being fully + * re-verified, still going through the whole gRPC stack end to end over the network + * interface (not an in-process channel). + *

+ * See {@link DecodedJwtCacheGrpcBenchmarkNoCacheTest} for the equivalent benchmark with + * the cache disabled, for direct comparison. + *

+ * Test methods run in a fixed, explicit order ({@link Order}) and each scenario has a + * large, independent warm-up phase, so that results are not skewed by JIT/GC/channel + * warm-up carried over from a previously run method sharing the same Spring context/JVM. + *

+ * Disabled by default - not part of the regular build. Remove {@code @Disabled} to run + * manually. + */ +@Tag("performance") +@Disabled("Manual benchmark - not meant to run as part of the regular build") +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +@AuthorizationServer("a") +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@TestPropertySource(properties = { + "entur.jwt.tenants.a.decoder-cache.enabled=true", + "entur.jwt.jwk.cache.preemptive.eager.enabled=true", + // suppress per-request log noise so it doesn't distort request timings/output + "logging.level.org.entur=WARN", + "logging.level.org.springframework=WARN", + "logging.level.io.grpc=WARN", +}) +@DirtiesContext +public class DecodedJwtCacheGrpcBenchmarkCachedTest extends AbstractGrpcTest { + + // large enough that each scenario independently reaches JIT/GC/channel steady state, + // regardless of execution order or warm-up carried over from other methods + private static final int WARMUP_REQUESTS = 20_000; + private static final int REQUESTS = 10_000; + + @Autowired + private JwtDecoder jwtDecoder; + + @Autowired + private JwkSourceMap jwkSourceMap; + + @BeforeEach + public void readinessProbe() throws Exception { + // make sure JWKs are loaded before timing anything + Map jwkSources = jwkSourceMap.getJwkSources(); + for (Map.Entry entry : jwkSources.entrySet()) { + JWKSelector selector = mock(JWKSelector.class); + when(selector.select(any())).thenReturn(List.of(mock(JWK.class))); + entry.getValue().get(selector, null); + } + + assertThat(jwtDecoder).isInstanceOf(DecodedJwtCacheJwtDecoder.class); + ((DecodedJwtCacheJwtDecoder) jwtDecoder).clear(); + } + + @Test + @Order(1) + public void singleTokenReused(@AccessToken(by = "a", audience = "https://my.audience") String token) { + GrpcBenchmarkSupport.Stats stats = GrpcBenchmarkSupport.measure( + "decoded-JWT cache enabled, single token reused (100% cache-hit rate)", + REQUESTS, WARMUP_REQUESTS, i -> assertProtectedCallSucceeds(token)); + + assertTrue(stats.opsPerSecond() > 0); + } + + @Test + @Order(2) + public void tokenPoolReused( + @AccessToken(by = "a", audience = "https://my.audience", scope = "1") String token1, + @AccessToken(by = "a", audience = "https://my.audience", scope = "2") String token2, + @AccessToken(by = "a", audience = "https://my.audience", scope = "3") String token3, + @AccessToken(by = "a", audience = "https://my.audience", scope = "4") String token4, + @AccessToken(by = "a", audience = "https://my.audience", scope = "5") String token5) { + + String[] tokens = {token1, token2, token3, token4, token5}; + + GrpcBenchmarkSupport.Stats stats = GrpcBenchmarkSupport.measure( + "decoded-JWT cache enabled, pool of " + tokens.length + " tokens reused round-robin", + REQUESTS, WARMUP_REQUESTS, i -> assertProtectedCallSucceeds(tokens[i % tokens.length])); + + assertTrue(stats.opsPerSecond() > 0); + } + + private void assertProtectedCallSucceeds(String token) { + GreetingResponse response = stub(token).protectedWithPartnerTenant(greetingRequest); + + assertThat(response.getMessage()).isEqualTo("Hello protected tenant"); + } +} diff --git a/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkColdStartCachedTest.java b/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkColdStartCachedTest.java new file mode 100644 index 000000000..71035d525 --- /dev/null +++ b/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkColdStartCachedTest.java @@ -0,0 +1,121 @@ +package org.entur.jwt.spring.grpc.perf; + +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.JWKSelector; +import com.nimbusds.jose.jwk.source.JWKSource; +import org.entur.jwt.junit5.AccessToken; +import org.entur.jwt.junit5.AuthorizationServer; +import org.entur.jwt.spring.JwkSourceMap; +import org.entur.jwt.spring.decode.cache.DecodedJwtCacheJwtDecoder; +import org.entur.jwt.spring.grpc.AbstractGrpcTest; +import org.entur.jwt.spring.grpc.test.GreetingResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.TestPropertySource; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Cold-start gRPC call benchmark, with the decoded-JWT cache enabled. Unlike + * {@link DecodedJwtCacheGrpcBenchmarkCachedTest}, there is no warm-up phase at all: the + * very first call is the very first thing timed. A single continuous run of back-to-back + * calls is made, round-robin across a pool of 20 tokens (a realistic + * number of distinct client tokens seen concurrently in production), and throughput is reported at cumulative + * wall-clock checkpoints of 1, 2, 3, 4, 5, 10 and 15 seconds - both the throughput of + * that individual segment and the cumulative throughput since the first call - so the + * cache warm-up curve (cold JIT/channel/first-decode vs. steady state) is visible within + * a single run. + *

+ * See {@link DecodedJwtCacheGrpcBenchmarkColdStartNoCacheTest} for the equivalent + * benchmark with the cache disabled, for direct comparison. + *

+ * Disabled by default - not part of the regular build. Remove {@code @Disabled} to run + * manually. + */ +@Tag("performance") +@Disabled("Manual benchmark - not meant to run as part of the regular build") +@AuthorizationServer("a") +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@TestPropertySource(properties = { + "entur.jwt.tenants.a.decoder-cache.enabled=true", + "entur.jwt.jwk.cache.preemptive.eager.enabled=true", + // suppress per-request log noise so it doesn't distort request timings/output + "logging.level.org.entur=WARN", + "logging.level.org.springframework=WARN", + "logging.level.io.grpc=WARN", +}) +@DirtiesContext +public class DecodedJwtCacheGrpcBenchmarkColdStartCachedTest extends AbstractGrpcTest { + + private static final int[] CHECKPOINT_SECONDS = {1, 2, 3, 4, 5, 10, 15}; + + @Autowired + private JwtDecoder jwtDecoder; + + @Autowired + private JwkSourceMap jwkSourceMap; + + @BeforeEach + public void readinessProbe() throws Exception { + // make sure JWKs are loaded before timing anything - this is not itself part of + // the "cold start", it merely establishes the same server-side readiness as the + // other benchmarks in this package + Map jwkSources = jwkSourceMap.getJwkSources(); + for (Map.Entry entry : jwkSources.entrySet()) { + JWKSelector selector = mock(JWKSelector.class); + when(selector.select(any())).thenReturn(List.of(mock(JWK.class))); + entry.getValue().get(selector, null); + } + + assertThat(jwtDecoder).isInstanceOf(DecodedJwtCacheJwtDecoder.class); + ((DecodedJwtCacheJwtDecoder) jwtDecoder).clear(); + } + + @Test + public void coldStart( + @AccessToken(by = "a", audience = "https://my.audience", scope = "1") String token1, + @AccessToken(by = "a", audience = "https://my.audience", scope = "2") String token2, + @AccessToken(by = "a", audience = "https://my.audience", scope = "3") String token3, + @AccessToken(by = "a", audience = "https://my.audience", scope = "4") String token4, + @AccessToken(by = "a", audience = "https://my.audience", scope = "5") String token5, + @AccessToken(by = "a", audience = "https://my.audience", scope = "6") String token6, + @AccessToken(by = "a", audience = "https://my.audience", scope = "7") String token7, + @AccessToken(by = "a", audience = "https://my.audience", scope = "8") String token8, + @AccessToken(by = "a", audience = "https://my.audience", scope = "9") String token9, + @AccessToken(by = "a", audience = "https://my.audience", scope = "10") String token10, + @AccessToken(by = "a", audience = "https://my.audience", scope = "11") String token11, + @AccessToken(by = "a", audience = "https://my.audience", scope = "12") String token12, + @AccessToken(by = "a", audience = "https://my.audience", scope = "13") String token13, + @AccessToken(by = "a", audience = "https://my.audience", scope = "14") String token14, + @AccessToken(by = "a", audience = "https://my.audience", scope = "15") String token15, + @AccessToken(by = "a", audience = "https://my.audience", scope = "16") String token16, + @AccessToken(by = "a", audience = "https://my.audience", scope = "17") String token17, + @AccessToken(by = "a", audience = "https://my.audience", scope = "18") String token18, + @AccessToken(by = "a", audience = "https://my.audience", scope = "19") String token19, + @AccessToken(by = "a", audience = "https://my.audience", scope = "20") String token20) { + + String[] tokens = {token1, token2, token3, token4, token5, token6, token7, token8, token9, token10, token11, token12, token13, token14, token15, token16, token17, token18, token19, token20}; + + GrpcBenchmarkSupport.measureColdStartIntervals( + "decoded-JWT cache enabled, cold start, pool of 20 tokens reused round-robin", + CHECKPOINT_SECONDS, i -> assertProtectedCallSucceeds(tokens[i % tokens.length])); + } + + private void assertProtectedCallSucceeds(String token) { + GreetingResponse response = stub(token).protectedWithPartnerTenant(greetingRequest); + + assertThat(response.getMessage()).isEqualTo("Hello protected tenant"); + } +} diff --git a/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkColdStartNoCacheTest.java b/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkColdStartNoCacheTest.java new file mode 100644 index 000000000..b318378d2 --- /dev/null +++ b/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkColdStartNoCacheTest.java @@ -0,0 +1,110 @@ +package org.entur.jwt.spring.grpc.perf; + +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.JWKSelector; +import com.nimbusds.jose.jwk.source.JWKSource; +import org.entur.jwt.junit5.AccessToken; +import org.entur.jwt.junit5.AuthorizationServer; +import org.entur.jwt.spring.JwkSourceMap; +import org.entur.jwt.spring.grpc.AbstractGrpcTest; +import org.entur.jwt.spring.grpc.test.GreetingResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.TestPropertySource; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Cold-start gRPC call benchmark, with the decoded-JWT cache disabled, + * approximating {@code master} behaviour (which has no decoded-JWT cache at all). There + * is no warm-up phase at all: the very first call is the very first thing timed. A + * single continuous run of back-to-back calls is made, round-robin across a pool of 20 + * tokens (a realistic number of distinct client tokens seen concurrently in production), + * and throughput is reported at cumulative wall-clock checkpoints of 1, 2, 3, 4, 5, 10 + * and 15 seconds - both the throughput of that individual segment and the cumulative + * throughput since the first call. + *

+ * See {@link DecodedJwtCacheGrpcBenchmarkColdStartCachedTest} for the equivalent + * benchmark with the cache enabled, for direct comparison. + *

+ * Disabled by default - not part of the regular build. Remove {@code @Disabled} to run + * manually. + */ +@Tag("performance") +@Disabled("Manual benchmark - not meant to run as part of the regular build") +@AuthorizationServer("a") +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@TestPropertySource(properties = { + // suppress per-request log noise so it doesn't distort request timings/output + "logging.level.org.entur=WARN", + "logging.level.org.springframework=WARN", + "logging.level.io.grpc=WARN", +}) +@DirtiesContext +public class DecodedJwtCacheGrpcBenchmarkColdStartNoCacheTest extends AbstractGrpcTest { + + private static final int[] CHECKPOINT_SECONDS = {1, 2, 3, 4, 5, 10, 15}; + + @Autowired + private JwkSourceMap jwkSourceMap; + + @BeforeEach + public void readinessProbe() throws Exception { + // make sure JWKs are loaded before timing anything - this is not itself part of + // the "cold start", it merely establishes the same server-side readiness as the + // other benchmarks in this package + Map jwkSources = jwkSourceMap.getJwkSources(); + for (Map.Entry entry : jwkSources.entrySet()) { + JWKSelector selector = mock(JWKSelector.class); + when(selector.select(any())).thenReturn(List.of(mock(JWK.class))); + entry.getValue().get(selector, null); + } + } + + @Test + public void coldStart( + @AccessToken(by = "a", audience = "https://my.audience", scope = "1") String token1, + @AccessToken(by = "a", audience = "https://my.audience", scope = "2") String token2, + @AccessToken(by = "a", audience = "https://my.audience", scope = "3") String token3, + @AccessToken(by = "a", audience = "https://my.audience", scope = "4") String token4, + @AccessToken(by = "a", audience = "https://my.audience", scope = "5") String token5, + @AccessToken(by = "a", audience = "https://my.audience", scope = "6") String token6, + @AccessToken(by = "a", audience = "https://my.audience", scope = "7") String token7, + @AccessToken(by = "a", audience = "https://my.audience", scope = "8") String token8, + @AccessToken(by = "a", audience = "https://my.audience", scope = "9") String token9, + @AccessToken(by = "a", audience = "https://my.audience", scope = "10") String token10, + @AccessToken(by = "a", audience = "https://my.audience", scope = "11") String token11, + @AccessToken(by = "a", audience = "https://my.audience", scope = "12") String token12, + @AccessToken(by = "a", audience = "https://my.audience", scope = "13") String token13, + @AccessToken(by = "a", audience = "https://my.audience", scope = "14") String token14, + @AccessToken(by = "a", audience = "https://my.audience", scope = "15") String token15, + @AccessToken(by = "a", audience = "https://my.audience", scope = "16") String token16, + @AccessToken(by = "a", audience = "https://my.audience", scope = "17") String token17, + @AccessToken(by = "a", audience = "https://my.audience", scope = "18") String token18, + @AccessToken(by = "a", audience = "https://my.audience", scope = "19") String token19, + @AccessToken(by = "a", audience = "https://my.audience", scope = "20") String token20) { + + String[] tokens = {token1, token2, token3, token4, token5, token6, token7, token8, token9, token10, token11, token12, token13, token14, token15, token16, token17, token18, token19, token20}; + + GrpcBenchmarkSupport.measureColdStartIntervals( + "no decoded-JWT cache, cold start, pool of 20 tokens reused round-robin", + CHECKPOINT_SECONDS, i -> assertProtectedCallSucceeds(tokens[i % tokens.length])); + } + + private void assertProtectedCallSucceeds(String token) { + GreetingResponse response = stub(token).protectedWithPartnerTenant(greetingRequest); + + assertThat(response.getMessage()).isEqualTo("Hello protected tenant"); + } +} diff --git a/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkNoCacheTest.java b/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkNoCacheTest.java new file mode 100644 index 000000000..fd1dfd3cf --- /dev/null +++ b/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkNoCacheTest.java @@ -0,0 +1,115 @@ +package org.entur.jwt.spring.grpc.perf; + +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.JWKSelector; +import com.nimbusds.jose.jwk.source.JWKSource; +import org.entur.jwt.junit5.AccessToken; +import org.entur.jwt.junit5.AuthorizationServer; +import org.entur.jwt.spring.JwkSourceMap; +import org.entur.jwt.spring.grpc.AbstractGrpcTest; +import org.entur.jwt.spring.grpc.test.GreetingResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.TestPropertySource; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Back-to-back gRPC call benchmark against a real, fully started Spring Boot context + * (embedded gRPC server on a real TCP loopback port, real security interceptor chain, + * real JWKS mock issuer), with the decoded-JWT cache disabled. Every call is fully + * re-verified end to end over the network interface (not an in-process channel), + * approximating {@code master} behaviour (which has no decoded-JWT cache at all). + *

+ * See {@link DecodedJwtCacheGrpcBenchmarkCachedTest} for the equivalent benchmark with the + * cache enabled, for direct comparison. + *

+ * Test methods run in a fixed, explicit order ({@link Order}) and each scenario has a + * large, independent warm-up phase, so that results are not skewed by JIT/GC/channel + * warm-up carried over from a previously run method sharing the same Spring context/JVM. + *

+ * Disabled by default - not part of the regular build. Remove {@code @Disabled} to run + * manually. + */ +@Tag("performance") +@Disabled("Manual benchmark - not meant to run as part of the regular build") +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +@AuthorizationServer("a") +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@TestPropertySource(properties = { + // suppress per-request log noise so it doesn't distort request timings/output + "logging.level.org.entur=WARN", + "logging.level.org.springframework=WARN", + "logging.level.io.grpc=WARN", +}) +@DirtiesContext +public class DecodedJwtCacheGrpcBenchmarkNoCacheTest extends AbstractGrpcTest { + + // large enough that each scenario independently reaches JIT/GC/channel steady state, + // regardless of execution order or warm-up carried over from other methods + private static final int WARMUP_REQUESTS = 20_000; + private static final int REQUESTS = 10_000; + + @Autowired + private JwkSourceMap jwkSourceMap; + + @BeforeEach + public void readinessProbe() throws Exception { + // make sure JWKs are loaded before timing anything + Map jwkSources = jwkSourceMap.getJwkSources(); + for (Map.Entry entry : jwkSources.entrySet()) { + JWKSelector selector = mock(JWKSelector.class); + when(selector.select(any())).thenReturn(List.of(mock(JWK.class))); + entry.getValue().get(selector, null); + } + } + + @Test + @Order(1) + public void singleTokenReused(@AccessToken(by = "a", audience = "https://my.audience") String token) { + GrpcBenchmarkSupport.Stats stats = GrpcBenchmarkSupport.measure( + "no decoded-JWT cache, single token reused", + REQUESTS, WARMUP_REQUESTS, i -> assertProtectedCallSucceeds(token)); + + assertTrue(stats.opsPerSecond() > 0); + } + + @Test + @Order(2) + public void tokenPoolReused( + @AccessToken(by = "a", audience = "https://my.audience", scope = "1") String token1, + @AccessToken(by = "a", audience = "https://my.audience", scope = "2") String token2, + @AccessToken(by = "a", audience = "https://my.audience", scope = "3") String token3, + @AccessToken(by = "a", audience = "https://my.audience", scope = "4") String token4, + @AccessToken(by = "a", audience = "https://my.audience", scope = "5") String token5) { + + String[] tokens = {token1, token2, token3, token4, token5}; + + GrpcBenchmarkSupport.Stats stats = GrpcBenchmarkSupport.measure( + "no decoded-JWT cache, pool of " + tokens.length + " tokens reused round-robin", + REQUESTS, WARMUP_REQUESTS, i -> assertProtectedCallSucceeds(tokens[i % tokens.length])); + + assertTrue(stats.opsPerSecond() > 0); + } + + private void assertProtectedCallSucceeds(String token) { + GreetingResponse response = stub(token).protectedWithPartnerTenant(greetingRequest); + + assertThat(response.getMessage()).isEqualTo("Hello protected tenant"); + } +} diff --git a/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/GrpcBenchmarkSupport.java b/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/GrpcBenchmarkSupport.java new file mode 100644 index 000000000..83e2923f5 --- /dev/null +++ b/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/GrpcBenchmarkSupport.java @@ -0,0 +1,86 @@ +package org.entur.jwt.spring.grpc.perf; + +/** + * Shared timing helper for the gRPC back-to-back-request benchmarks in this package. + *

+ * Mirrors {@code org.entur.jwt.spring.perf.HttpBenchmarkSupport} in jwt-spring-web, but + * for real gRPC calls over a TCP loopback channel instead of HTTP. + */ +final class GrpcBenchmarkSupport { + + interface GrpcCall { + void call(int i); + } + + record Stats(String label, double avgLatencyMicros, double opsPerSecond) { + } + + private GrpcBenchmarkSupport() { + } + + /** + * Issues {@code requests} real gRPC calls back to back (after a warm-up phase of + * {@code warmup} calls, to let the JIT and connection settle), then reports average + * per-call latency and throughput. + */ + static Stats measure(String label, int requests, int warmup, GrpcCall call) { + for (int i = 0; i < warmup; i++) { + call.call(i); + } + + long start = System.nanoTime(); + for (int i = 0; i < requests; i++) { + call.call(i); + } + long durationNanos = System.nanoTime() - start; + + double avgLatencyMicros = (durationNanos / 1000.0) / requests; + double opsPerSecond = requests / (durationNanos / 1_000_000_000.0); + + System.out.printf("[%s] %.1f us/call, %.0f calls/sec (%d calls back to back)%n", + label, avgLatencyMicros, opsPerSecond, requests); + + return new Stats(label, avgLatencyMicros, opsPerSecond); + } + + /** + * Issues real gRPC calls back to back starting from a completely cold state (no + * warm-up at all), for a single continuous run, reporting throughput at each + * cumulative wall-clock checkpoint in {@code checkpointSeconds} (e.g. + * {@code {1, 2, 3, 4, 5, 10, 15}}). For each checkpoint this logs both the + * throughput of that individual segment (since the previous checkpoint) and the + * cumulative throughput since the very first call, so the cache warm-up curve is + * visible within a single run. + */ + static void measureColdStartIntervals(String label, int[] checkpointSeconds, GrpcCall call) { + long runStart = System.nanoTime(); + long segmentStart = runStart; + int callIndex = 0; + int callsAtSegmentStart = 0; + + for (int checkpointSecond : checkpointSeconds) { + long checkpointNanos = runStart + checkpointSecond * 1_000_000_000L; + + while (System.nanoTime() < checkpointNanos) { + call.call(callIndex++); + } + + long now = System.nanoTime(); + + int segmentCalls = callIndex - callsAtSegmentStart; + double segmentSeconds = (now - segmentStart) / 1_000_000_000.0; + double segmentOpsPerSecond = segmentCalls / segmentSeconds; + + double cumulativeSeconds = (now - runStart) / 1_000_000_000.0; + double cumulativeOpsPerSecond = callIndex / cumulativeSeconds; + + System.out.printf( + "[%s] t=%ds: segment %,d calls in %.2fs (%.0f calls/sec), cumulative %,d calls in %.2fs (%.0f calls/sec)%n", + label, checkpointSecond, segmentCalls, segmentSeconds, segmentOpsPerSecond, + callIndex, cumulativeSeconds, cumulativeOpsPerSecond); + + segmentStart = now; + callsAtSegmentStart = callIndex; + } + } +} diff --git a/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/DecodedJwtCacheHttpBenchmarkColdStartCachedTest.java b/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/DecodedJwtCacheHttpBenchmarkColdStartCachedTest.java new file mode 100644 index 000000000..0846cc112 --- /dev/null +++ b/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/DecodedJwtCacheHttpBenchmarkColdStartCachedTest.java @@ -0,0 +1,138 @@ +package org.entur.jwt.spring.perf; + +import org.entur.jwt.junit5.AccessToken; +import org.entur.jwt.junit5.AuthorizationServer; +import org.entur.jwt.spring.actuate.AbstractActuatorTest; +import org.entur.jwt.spring.decode.ClosableJwtDecoders; +import org.entur.jwt.spring.decode.cache.DecodedJwtCacheJwtDecoder; +import org.entur.jwt.spring.rest.Greeting; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Cold-start HTTP request benchmark, with the decoded-JWT cache enabled. Unlike + * {@link DecodedJwtCacheHttpBenchmarkCachedTest}, there is no warm-up phase at all: the + * very first request is the very first thing timed. A single continuous run of + * back-to-back requests is made, round-robin across a pool of 20 tokens (a realistic + * number of distinct client tokens seen concurrently in production), and throughput is reported at + * cumulative wall-clock checkpoints of 1, 2, 3, 4, 5, 10 and 15 seconds - both the + * throughput of that individual segment and the cumulative throughput since the first + * request - so the cache warm-up curve (cold JIT/connection-pool/first-decode vs. steady + * state) is visible within a single run. + *

+ * See {@link DecodedJwtCacheHttpBenchmarkColdStartNoCacheTest} for the equivalent + * benchmark with the cache disabled, for direct comparison. + *

+ * Disabled by default - not part of the regular build. Remove {@code @Disabled} to run + * manually. + */ +@Tag("performance") +@Disabled("Manual benchmark - not meant to run as part of the regular build") +@AuthorizationServer("a") +@ExtendWith(SpringExtension.class) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureTestRestTemplate +@TestPropertySource(properties = { + "entur.jwt.tenants.a.decoder-cache.enabled=true", + "entur.jwt.jwk.cache.preemptive.eager.enabled=true", + // suppress per-request log noise so it doesn't distort request timings/output + "logging.level.org.entur=WARN", + "logging.level.org.springframework=WARN", +}) +public class DecodedJwtCacheHttpBenchmarkColdStartCachedTest extends AbstractActuatorTest { + + private static final int[] CHECKPOINT_SECONDS = {1, 2, 3, 4, 5, 10, 15}; + + @LocalServerPort + private int randomServerPort; + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private ClosableJwtDecoders closableJwtDecoders; + + @BeforeEach + public void readinessProbe() throws Exception { + // make sure JWKs are loaded before timing anything - this is not itself part of + // the "cold start", it merely establishes the same server-side readiness as the + // other benchmarks in this package + HttpHeaders headers = new HttpHeaders(); + HttpEntity entity = new HttpEntity<>(headers); + + String url = "http://localhost:" + randomServerPort + "/actuator/health/readiness"; + ResponseEntity response = restTemplate.exchange(url, HttpMethod.GET, entity, String.class); + + if (!response.getStatusCode().is2xxSuccessful()) { + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + waitForHealth(); + + response = restTemplate.exchange(url, HttpMethod.GET, entity, String.class); + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + + JwtDecoder decoder = closableJwtDecoders.getJwtDecoders().get("https://mock.issuer.a.xyz"); + assertThat(decoder).isInstanceOf(DecodedJwtCacheJwtDecoder.class); + ((DecodedJwtCacheJwtDecoder) decoder).clear(); + } + + @Test + public void coldStart( + @AccessToken(by = "a", audience = "mock.my.audience", scope = "1") String token1, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "2") String token2, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "3") String token3, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "4") String token4, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "5") String token5, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "6") String token6, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "7") String token7, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "8") String token8, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "9") String token9, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "10") String token10, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "11") String token11, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "12") String token12, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "13") String token13, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "14") String token14, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "15") String token15, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "16") String token16, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "17") String token17, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "18") String token18, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "19") String token19, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "20") String token20) { + + String[] tokens = {token1, token2, token3, token4, token5, token6, token7, token8, token9, token10, token11, token12, token13, token14, token15, token16, token17, token18, token19, token20}; + + HttpBenchmarkSupport.measureColdStartIntervals( + "decoded-JWT cache enabled, cold start, pool of 20 tokens reused round-robin", + CHECKPOINT_SECONDS, i -> assertProtectedRequestSucceeds(tokens[i % tokens.length])); + } + + private void assertProtectedRequestSucceeds(String token) { + HttpHeaders headers = new HttpHeaders(); + headers.add("Authorization", token); + HttpEntity entity = new HttpEntity<>(headers); + + String url = "http://localhost:" + randomServerPort + "/protected"; + ResponseEntity response = restTemplate.exchange(url, HttpMethod.GET, entity, Greeting.class); + + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + } +} diff --git a/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/DecodedJwtCacheHttpBenchmarkColdStartNoCacheTest.java b/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/DecodedJwtCacheHttpBenchmarkColdStartNoCacheTest.java new file mode 100644 index 000000000..aa6c9dd4b --- /dev/null +++ b/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/DecodedJwtCacheHttpBenchmarkColdStartNoCacheTest.java @@ -0,0 +1,125 @@ +package org.entur.jwt.spring.perf; + +import org.entur.jwt.junit5.AccessToken; +import org.entur.jwt.junit5.AuthorizationServer; +import org.entur.jwt.spring.actuate.AbstractActuatorTest; +import org.entur.jwt.spring.rest.Greeting; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Cold-start HTTP request benchmark, with the decoded-JWT cache disabled, + * approximating {@code master} behaviour (which has no decoded-JWT cache at all). There + * is no warm-up phase at all: the very first request is the very first thing timed. A + * single continuous run of back-to-back requests is made, round-robin across a pool of 20 + * tokens (a realistic number of distinct client tokens seen concurrently in production), + * and throughput is reported at cumulative wall-clock checkpoints of 1, 2, 3, 4, 5, 10 + * and 15 seconds - both the throughput of that individual segment and the cumulative + * throughput since the first request. + *

+ * See {@link DecodedJwtCacheHttpBenchmarkColdStartCachedTest} for the equivalent + * benchmark with the cache enabled, for direct comparison. + *

+ * Disabled by default - not part of the regular build. Remove {@code @Disabled} to run + * manually. + */ +@Tag("performance") +@Disabled("Manual benchmark - not meant to run as part of the regular build") +@AuthorizationServer("a") +@ExtendWith(SpringExtension.class) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureTestRestTemplate +@TestPropertySource(properties = { + // suppress per-request log noise so it doesn't distort request timings/output + "logging.level.org.entur=WARN", + "logging.level.org.springframework=WARN", +}) +public class DecodedJwtCacheHttpBenchmarkColdStartNoCacheTest extends AbstractActuatorTest { + + private static final int[] CHECKPOINT_SECONDS = {1, 2, 3, 4, 5, 10, 15}; + + @LocalServerPort + private int randomServerPort; + + @Autowired + private TestRestTemplate restTemplate; + + @BeforeEach + public void readinessProbe() throws Exception { + // make sure JWKs are loaded before timing anything - this is not itself part of + // the "cold start", it merely establishes the same server-side readiness as the + // other benchmarks in this package + HttpHeaders headers = new HttpHeaders(); + HttpEntity entity = new HttpEntity<>(headers); + + String url = "http://localhost:" + randomServerPort + "/actuator/health/readiness"; + ResponseEntity response = restTemplate.exchange(url, HttpMethod.GET, entity, String.class); + + if (!response.getStatusCode().is2xxSuccessful()) { + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + waitForHealth(); + + response = restTemplate.exchange(url, HttpMethod.GET, entity, String.class); + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + } + + @Test + public void coldStart( + @AccessToken(by = "a", audience = "mock.my.audience", scope = "1") String token1, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "2") String token2, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "3") String token3, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "4") String token4, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "5") String token5, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "6") String token6, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "7") String token7, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "8") String token8, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "9") String token9, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "10") String token10, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "11") String token11, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "12") String token12, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "13") String token13, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "14") String token14, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "15") String token15, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "16") String token16, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "17") String token17, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "18") String token18, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "19") String token19, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "20") String token20) { + + String[] tokens = {token1, token2, token3, token4, token5, token6, token7, token8, token9, token10, token11, token12, token13, token14, token15, token16, token17, token18, token19, token20}; + + HttpBenchmarkSupport.measureColdStartIntervals( + "no decoded-JWT cache, cold start, pool of 20 tokens reused round-robin", + CHECKPOINT_SECONDS, i -> assertProtectedRequestSucceeds(tokens[i % tokens.length])); + } + + private void assertProtectedRequestSucceeds(String token) { + HttpHeaders headers = new HttpHeaders(); + headers.add("Authorization", token); + HttpEntity entity = new HttpEntity<>(headers); + + String url = "http://localhost:" + randomServerPort + "/protected"; + ResponseEntity response = restTemplate.exchange(url, HttpMethod.GET, entity, Greeting.class); + + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + } +} diff --git a/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/HttpBenchmarkSupport.java b/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/HttpBenchmarkSupport.java index adc72dfb8..8f8954f5b 100644 --- a/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/HttpBenchmarkSupport.java +++ b/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/HttpBenchmarkSupport.java @@ -39,4 +39,45 @@ static Stats measure(String label, int requests, int warmup, HttpCall call) { return new Stats(label, avgLatencyMicros, opsPerSecond); } + + /** + * Issues real HTTP requests back to back starting from a completely cold state (no + * warm-up at all), for a single continuous run, reporting throughput at each + * cumulative wall-clock checkpoint in {@code checkpointSeconds} (e.g. + * {@code {1, 2, 3, 4, 5, 10, 15}}). For each checkpoint this logs both the + * throughput of that individual segment (since the previous checkpoint) and the + * cumulative throughput since the very first request, so the cache warm-up curve is + * visible within a single run. + */ + static void measureColdStartIntervals(String label, int[] checkpointSeconds, HttpCall call) { + long runStart = System.nanoTime(); + long segmentStart = runStart; + int callIndex = 0; + int callsAtSegmentStart = 0; + + for (int checkpointSecond : checkpointSeconds) { + long checkpointNanos = runStart + checkpointSecond * 1_000_000_000L; + + while (System.nanoTime() < checkpointNanos) { + call.request(callIndex++); + } + + long now = System.nanoTime(); + + int segmentCalls = callIndex - callsAtSegmentStart; + double segmentSeconds = (now - segmentStart) / 1_000_000_000.0; + double segmentOpsPerSecond = segmentCalls / segmentSeconds; + + double cumulativeSeconds = (now - runStart) / 1_000_000_000.0; + double cumulativeOpsPerSecond = callIndex / cumulativeSeconds; + + System.out.printf( + "[%s] t=%ds: segment %,d requests in %.2fs (%.0f requests/sec), cumulative %,d requests in %.2fs (%.0f requests/sec)%n", + label, checkpointSecond, segmentCalls, segmentSeconds, segmentOpsPerSecond, + callIndex, cumulativeSeconds, cumulativeOpsPerSecond); + + segmentStart = now; + callsAtSegmentStart = callIndex; + } + } } From 892f13806a3fdf236b9c719422e862cbb6ba3b7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Skj=C3=B8lberg?= Date: Fri, 18 Sep 2026 23:11:21 +0200 Subject: [PATCH 3/5] Parallelize JWT cache benchmarks per-client, size to abt-core peak load Redesign the HTTP and gRPC decoded-JWT cache benchmarks to use one dedicated connection (client) per thread running concurrently, instead of a single thread round-robining through a token pool. Each parallel client is synchronized to start simultaneously via a CyclicBarrier, so measured throughput reflects real concurrent load rather than serialized round-trip latency. Client/token count is sized to abt-core's observed peak-hour (08:00/ 16:00) concurrency per Entur Compass: 15 parallel clients, 2 tokens each (30 tokens total). Also replace TestRestTemplate with a minimal JDK HttpClient-based RawHttpClientSupport in the HTTP benchmarks' timed hot path, removing interceptor/Jackson overhead that was dominating measurements. measureColdStartIntervals(Parallel) now reports only segment throughput per checkpoint (not cumulative-since-start). --- ...ecodedJwtCacheGrpcBenchmarkCachedTest.java | 101 +++++++++-- ...CacheGrpcBenchmarkColdStartCachedTest.java | 61 ++++++- ...acheGrpcBenchmarkColdStartNoCacheTest.java | 61 ++++++- ...codedJwtCacheGrpcBenchmarkNoCacheTest.java | 95 ++++++++-- .../grpc/perf/GrpcBenchmarkSupport.java | 163 ++++++++++++++---- ...ecodedJwtCacheHttpBenchmarkCachedTest.java | 75 +++++--- ...CacheHttpBenchmarkColdStartCachedTest.java | 53 ++++-- ...acheHttpBenchmarkColdStartNoCacheTest.java | 53 ++++-- ...codedJwtCacheHttpBenchmarkNoCacheTest.java | 75 +++++--- .../jwt/spring/perf/HttpBenchmarkSupport.java | 162 +++++++++++++---- .../jwt/spring/perf/RawHttpClientSupport.java | 51 ++++++ 11 files changed, 756 insertions(+), 194 deletions(-) create mode 100644 jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/RawHttpClientSupport.java diff --git a/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkCachedTest.java b/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkCachedTest.java index 8cf05d01e..c44c8c20e 100644 --- a/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkCachedTest.java +++ b/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkCachedTest.java @@ -3,12 +3,17 @@ import com.nimbusds.jose.jwk.JWK; import com.nimbusds.jose.jwk.JWKSelector; import com.nimbusds.jose.jwk.source.JWKSource; +import io.grpc.ManagedChannel; +import io.grpc.ManagedChannelBuilder; import org.entur.jwt.junit5.AccessToken; import org.entur.jwt.junit5.AuthorizationServer; import org.entur.jwt.spring.JwkSourceMap; import org.entur.jwt.spring.decode.cache.DecodedJwtCacheJwtDecoder; import org.entur.jwt.spring.grpc.AbstractGrpcTest; +import org.entur.jwt.spring.grpc.JwtCallCredentials; import org.entur.jwt.spring.grpc.test.GreetingResponse; +import org.entur.jwt.spring.grpc.test.GreetingServiceGrpc; +import org.entur.jwt.spring.grpc.test.GreetingServiceGrpc.GreetingServiceBlockingStub; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.MethodOrderer; @@ -70,6 +75,12 @@ public class DecodedJwtCacheGrpcBenchmarkCachedTest extends AbstractGrpcTest { private static final int WARMUP_REQUESTS = 20_000; private static final int REQUESTS = 10_000; + // 15 parallel clients, 2 tokens each (30 tokens total) - matches abt-core's observed + // peak (08:00/16:00) concurrent client count, per Entur Compass, with each client + // presenting 2 distinct tokens + private static final int CLIENTS = 15; + private static final int TOKENS_PER_CLIENT = 2; + @Autowired private JwtDecoder jwtDecoder; @@ -93,11 +104,22 @@ public void readinessProbe() throws Exception { @Test @Order(1) public void singleTokenReused(@AccessToken(by = "a", audience = "https://my.audience") String token) { - GrpcBenchmarkSupport.Stats stats = GrpcBenchmarkSupport.measure( - "decoded-JWT cache enabled, single token reused (100% cache-hit rate)", - REQUESTS, WARMUP_REQUESTS, i -> assertProtectedCallSucceeds(token)); - - assertTrue(stats.opsPerSecond() > 0); + ManagedChannel channel = ManagedChannelBuilder.forAddress("localhost", 9090).usePlaintext().build(); + try { + GreetingServiceBlockingStub stub = GreetingServiceGrpc.newBlockingStub(channel) + .withCallCredentials(new JwtCallCredentials(token)); + GrpcBenchmarkSupport.GrpcCall[] calls = { + i -> assertProtectedCallSucceeds(stub) + }; + + GrpcBenchmarkSupport.Stats stats = GrpcBenchmarkSupport.measureParallel( + "decoded-JWT cache enabled, single token reused (100% cache-hit rate)", + REQUESTS, WARMUP_REQUESTS, calls); + + assertTrue(stats.opsPerSecond() > 0); + } finally { + channel.shutdown(); + } } @Test @@ -107,19 +129,68 @@ public void tokenPoolReused( @AccessToken(by = "a", audience = "https://my.audience", scope = "2") String token2, @AccessToken(by = "a", audience = "https://my.audience", scope = "3") String token3, @AccessToken(by = "a", audience = "https://my.audience", scope = "4") String token4, - @AccessToken(by = "a", audience = "https://my.audience", scope = "5") String token5) { - - String[] tokens = {token1, token2, token3, token4, token5}; - - GrpcBenchmarkSupport.Stats stats = GrpcBenchmarkSupport.measure( - "decoded-JWT cache enabled, pool of " + tokens.length + " tokens reused round-robin", - REQUESTS, WARMUP_REQUESTS, i -> assertProtectedCallSucceeds(tokens[i % tokens.length])); + @AccessToken(by = "a", audience = "https://my.audience", scope = "5") String token5, + @AccessToken(by = "a", audience = "https://my.audience", scope = "6") String token6, + @AccessToken(by = "a", audience = "https://my.audience", scope = "7") String token7, + @AccessToken(by = "a", audience = "https://my.audience", scope = "8") String token8, + @AccessToken(by = "a", audience = "https://my.audience", scope = "9") String token9, + @AccessToken(by = "a", audience = "https://my.audience", scope = "10") String token10, + @AccessToken(by = "a", audience = "https://my.audience", scope = "11") String token11, + @AccessToken(by = "a", audience = "https://my.audience", scope = "12") String token12, + @AccessToken(by = "a", audience = "https://my.audience", scope = "13") String token13, + @AccessToken(by = "a", audience = "https://my.audience", scope = "14") String token14, + @AccessToken(by = "a", audience = "https://my.audience", scope = "15") String token15, + @AccessToken(by = "a", audience = "https://my.audience", scope = "16") String token16, + @AccessToken(by = "a", audience = "https://my.audience", scope = "17") String token17, + @AccessToken(by = "a", audience = "https://my.audience", scope = "18") String token18, + @AccessToken(by = "a", audience = "https://my.audience", scope = "19") String token19, + @AccessToken(by = "a", audience = "https://my.audience", scope = "20") String token20, + @AccessToken(by = "a", audience = "https://my.audience", scope = "21") String token21, + @AccessToken(by = "a", audience = "https://my.audience", scope = "22") String token22, + @AccessToken(by = "a", audience = "https://my.audience", scope = "23") String token23, + @AccessToken(by = "a", audience = "https://my.audience", scope = "24") String token24, + @AccessToken(by = "a", audience = "https://my.audience", scope = "25") String token25, + @AccessToken(by = "a", audience = "https://my.audience", scope = "26") String token26, + @AccessToken(by = "a", audience = "https://my.audience", scope = "27") String token27, + @AccessToken(by = "a", audience = "https://my.audience", scope = "28") String token28, + @AccessToken(by = "a", audience = "https://my.audience", scope = "29") String token29, + @AccessToken(by = "a", audience = "https://my.audience", scope = "30") String token30) { + + String[] tokens = {token1, token2, token3, token4, token5, token6, token7, token8, token9, token10, token11, token12, token13, token14, token15, token16, token17, token18, token19, token20, token21, token22, token23, token24, token25, token26, token27, token28, token29, token30}; + + ManagedChannel[] channels = new ManagedChannel[CLIENTS]; + GrpcBenchmarkSupport.GrpcCall[] calls = new GrpcBenchmarkSupport.GrpcCall[CLIENTS]; + for (int c = 0; c < CLIENTS; c++) { + ManagedChannel channel = ManagedChannelBuilder.forAddress("localhost", 9090).usePlaintext().build(); + channels[c] = channel; + String[] clientTokens = new String[TOKENS_PER_CLIENT]; + for (int t = 0; t < TOKENS_PER_CLIENT; t++) { + clientTokens[t] = tokens[c * TOKENS_PER_CLIENT + t]; + } + GreetingServiceBlockingStub[] clientStubs = new GreetingServiceBlockingStub[clientTokens.length]; + for (int t = 0; t < clientTokens.length; t++) { + clientStubs[t] = GreetingServiceGrpc.newBlockingStub(channel) + .withCallCredentials(new JwtCallCredentials(clientTokens[t])); + } + calls[c] = i -> assertProtectedCallSucceeds(clientStubs[i % clientStubs.length]); + } - assertTrue(stats.opsPerSecond() > 0); + try { + GrpcBenchmarkSupport.Stats stats = GrpcBenchmarkSupport.measureParallel( + "decoded-JWT cache enabled, " + CLIENTS + " parallel clients, " + TOKENS_PER_CLIENT + + " tokens each (" + tokens.length + " tokens total)", + REQUESTS, WARMUP_REQUESTS, calls); + + assertTrue(stats.opsPerSecond() > 0); + } finally { + for (ManagedChannel channel : channels) { + channel.shutdown(); + } + } } - private void assertProtectedCallSucceeds(String token) { - GreetingResponse response = stub(token).protectedWithPartnerTenant(greetingRequest); + private void assertProtectedCallSucceeds(GreetingServiceBlockingStub stub) { + GreetingResponse response = stub.protectedWithPartnerTenant(greetingRequest); assertThat(response.getMessage()).isEqualTo("Hello protected tenant"); } diff --git a/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkColdStartCachedTest.java b/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkColdStartCachedTest.java index 71035d525..e6a38ab3e 100644 --- a/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkColdStartCachedTest.java +++ b/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkColdStartCachedTest.java @@ -1,6 +1,8 @@ package org.entur.jwt.spring.grpc.perf; import com.nimbusds.jose.jwk.JWK; +import io.grpc.ManagedChannel; +import io.grpc.ManagedChannelBuilder; import com.nimbusds.jose.jwk.JWKSelector; import com.nimbusds.jose.jwk.source.JWKSource; import org.entur.jwt.junit5.AccessToken; @@ -8,7 +10,10 @@ import org.entur.jwt.spring.JwkSourceMap; import org.entur.jwt.spring.decode.cache.DecodedJwtCacheJwtDecoder; import org.entur.jwt.spring.grpc.AbstractGrpcTest; +import org.entur.jwt.spring.grpc.JwtCallCredentials; import org.entur.jwt.spring.grpc.test.GreetingResponse; +import org.entur.jwt.spring.grpc.test.GreetingServiceGrpc; +import org.entur.jwt.spring.grpc.test.GreetingServiceGrpc.GreetingServiceBlockingStub; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Tag; @@ -31,7 +36,7 @@ * Cold-start gRPC call benchmark, with the decoded-JWT cache enabled. Unlike * {@link DecodedJwtCacheGrpcBenchmarkCachedTest}, there is no warm-up phase at all: the * very first call is the very first thing timed. A single continuous run of back-to-back - * calls is made, round-robin across a pool of 20 tokens (a realistic + * calls is made, round-robin across a pool of 30 tokens (a realistic * number of distinct client tokens seen concurrently in production), and throughput is reported at cumulative * wall-clock checkpoints of 1, 2, 3, 4, 5, 10 and 15 seconds - both the throughput of * that individual segment and the cumulative throughput since the first call - so the @@ -61,6 +66,12 @@ public class DecodedJwtCacheGrpcBenchmarkColdStartCachedTest extends AbstractGrp private static final int[] CHECKPOINT_SECONDS = {1, 2, 3, 4, 5, 10, 15}; + // 15 parallel clients, 2 tokens each (30 tokens total) - matches abt-core's observed + // peak (08:00/16:00) concurrent client count, per Entur Compass, with each client + // presenting 2 distinct tokens + private static final int CLIENTS = 15; + private static final int TOKENS_PER_CLIENT = 2; + @Autowired private JwtDecoder jwtDecoder; @@ -104,17 +115,51 @@ public void coldStart( @AccessToken(by = "a", audience = "https://my.audience", scope = "17") String token17, @AccessToken(by = "a", audience = "https://my.audience", scope = "18") String token18, @AccessToken(by = "a", audience = "https://my.audience", scope = "19") String token19, - @AccessToken(by = "a", audience = "https://my.audience", scope = "20") String token20) { + @AccessToken(by = "a", audience = "https://my.audience", scope = "20") String token20, + @AccessToken(by = "a", audience = "https://my.audience", scope = "21") String token21, + @AccessToken(by = "a", audience = "https://my.audience", scope = "22") String token22, + @AccessToken(by = "a", audience = "https://my.audience", scope = "23") String token23, + @AccessToken(by = "a", audience = "https://my.audience", scope = "24") String token24, + @AccessToken(by = "a", audience = "https://my.audience", scope = "25") String token25, + @AccessToken(by = "a", audience = "https://my.audience", scope = "26") String token26, + @AccessToken(by = "a", audience = "https://my.audience", scope = "27") String token27, + @AccessToken(by = "a", audience = "https://my.audience", scope = "28") String token28, + @AccessToken(by = "a", audience = "https://my.audience", scope = "29") String token29, + @AccessToken(by = "a", audience = "https://my.audience", scope = "30") String token30) { + + String[] tokens = {token1, token2, token3, token4, token5, token6, token7, token8, token9, token10, token11, token12, token13, token14, token15, token16, token17, token18, token19, token20, token21, token22, token23, token24, token25, token26, token27, token28, token29, token30}; - String[] tokens = {token1, token2, token3, token4, token5, token6, token7, token8, token9, token10, token11, token12, token13, token14, token15, token16, token17, token18, token19, token20}; + ManagedChannel[] channels = new ManagedChannel[CLIENTS]; + GrpcBenchmarkSupport.GrpcCall[] calls = new GrpcBenchmarkSupport.GrpcCall[CLIENTS]; + for (int c = 0; c < CLIENTS; c++) { + ManagedChannel channel = ManagedChannelBuilder.forAddress("localhost", 9090).usePlaintext().build(); + channels[c] = channel; + String[] clientTokens = new String[TOKENS_PER_CLIENT]; + for (int t = 0; t < TOKENS_PER_CLIENT; t++) { + clientTokens[t] = tokens[c * TOKENS_PER_CLIENT + t]; + } + GreetingServiceBlockingStub[] clientStubs = new GreetingServiceBlockingStub[clientTokens.length]; + for (int t = 0; t < clientTokens.length; t++) { + clientStubs[t] = GreetingServiceGrpc.newBlockingStub(channel) + .withCallCredentials(new JwtCallCredentials(clientTokens[t])); + } + calls[c] = i -> assertProtectedCallSucceeds(clientStubs[i % clientStubs.length]); + } - GrpcBenchmarkSupport.measureColdStartIntervals( - "decoded-JWT cache enabled, cold start, pool of 20 tokens reused round-robin", - CHECKPOINT_SECONDS, i -> assertProtectedCallSucceeds(tokens[i % tokens.length])); + try { + GrpcBenchmarkSupport.measureColdStartIntervalsParallel( + "decoded-JWT cache enabled, cold start, " + CLIENTS + " parallel clients, " + TOKENS_PER_CLIENT + + " tokens each (" + tokens.length + " tokens total)", + CHECKPOINT_SECONDS, calls); + } finally { + for (ManagedChannel channel : channels) { + channel.shutdown(); + } + } } - private void assertProtectedCallSucceeds(String token) { - GreetingResponse response = stub(token).protectedWithPartnerTenant(greetingRequest); + private void assertProtectedCallSucceeds(GreetingServiceBlockingStub stub) { + GreetingResponse response = stub.protectedWithPartnerTenant(greetingRequest); assertThat(response.getMessage()).isEqualTo("Hello protected tenant"); } diff --git a/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkColdStartNoCacheTest.java b/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkColdStartNoCacheTest.java index b318378d2..c8acfe752 100644 --- a/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkColdStartNoCacheTest.java +++ b/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkColdStartNoCacheTest.java @@ -1,13 +1,18 @@ package org.entur.jwt.spring.grpc.perf; import com.nimbusds.jose.jwk.JWK; +import io.grpc.ManagedChannel; +import io.grpc.ManagedChannelBuilder; import com.nimbusds.jose.jwk.JWKSelector; import com.nimbusds.jose.jwk.source.JWKSource; import org.entur.jwt.junit5.AccessToken; import org.entur.jwt.junit5.AuthorizationServer; import org.entur.jwt.spring.JwkSourceMap; import org.entur.jwt.spring.grpc.AbstractGrpcTest; +import org.entur.jwt.spring.grpc.JwtCallCredentials; import org.entur.jwt.spring.grpc.test.GreetingResponse; +import org.entur.jwt.spring.grpc.test.GreetingServiceGrpc; +import org.entur.jwt.spring.grpc.test.GreetingServiceGrpc.GreetingServiceBlockingStub; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Tag; @@ -29,7 +34,7 @@ * Cold-start gRPC call benchmark, with the decoded-JWT cache disabled, * approximating {@code master} behaviour (which has no decoded-JWT cache at all). There * is no warm-up phase at all: the very first call is the very first thing timed. A - * single continuous run of back-to-back calls is made, round-robin across a pool of 20 + * single continuous run of back-to-back calls is made, round-robin across a pool of 30 * tokens (a realistic number of distinct client tokens seen concurrently in production), * and throughput is reported at cumulative wall-clock checkpoints of 1, 2, 3, 4, 5, 10 * and 15 seconds - both the throughput of that individual segment and the cumulative @@ -56,6 +61,12 @@ public class DecodedJwtCacheGrpcBenchmarkColdStartNoCacheTest extends AbstractGr private static final int[] CHECKPOINT_SECONDS = {1, 2, 3, 4, 5, 10, 15}; + // 15 parallel clients, 2 tokens each (30 tokens total) - matches abt-core's observed + // peak (08:00/16:00) concurrent client count, per Entur Compass, with each client + // presenting 2 distinct tokens + private static final int CLIENTS = 15; + private static final int TOKENS_PER_CLIENT = 2; + @Autowired private JwkSourceMap jwkSourceMap; @@ -93,17 +104,51 @@ public void coldStart( @AccessToken(by = "a", audience = "https://my.audience", scope = "17") String token17, @AccessToken(by = "a", audience = "https://my.audience", scope = "18") String token18, @AccessToken(by = "a", audience = "https://my.audience", scope = "19") String token19, - @AccessToken(by = "a", audience = "https://my.audience", scope = "20") String token20) { + @AccessToken(by = "a", audience = "https://my.audience", scope = "20") String token20, + @AccessToken(by = "a", audience = "https://my.audience", scope = "21") String token21, + @AccessToken(by = "a", audience = "https://my.audience", scope = "22") String token22, + @AccessToken(by = "a", audience = "https://my.audience", scope = "23") String token23, + @AccessToken(by = "a", audience = "https://my.audience", scope = "24") String token24, + @AccessToken(by = "a", audience = "https://my.audience", scope = "25") String token25, + @AccessToken(by = "a", audience = "https://my.audience", scope = "26") String token26, + @AccessToken(by = "a", audience = "https://my.audience", scope = "27") String token27, + @AccessToken(by = "a", audience = "https://my.audience", scope = "28") String token28, + @AccessToken(by = "a", audience = "https://my.audience", scope = "29") String token29, + @AccessToken(by = "a", audience = "https://my.audience", scope = "30") String token30) { + + String[] tokens = {token1, token2, token3, token4, token5, token6, token7, token8, token9, token10, token11, token12, token13, token14, token15, token16, token17, token18, token19, token20, token21, token22, token23, token24, token25, token26, token27, token28, token29, token30}; - String[] tokens = {token1, token2, token3, token4, token5, token6, token7, token8, token9, token10, token11, token12, token13, token14, token15, token16, token17, token18, token19, token20}; + ManagedChannel[] channels = new ManagedChannel[CLIENTS]; + GrpcBenchmarkSupport.GrpcCall[] calls = new GrpcBenchmarkSupport.GrpcCall[CLIENTS]; + for (int c = 0; c < CLIENTS; c++) { + ManagedChannel channel = ManagedChannelBuilder.forAddress("localhost", 9090).usePlaintext().build(); + channels[c] = channel; + String[] clientTokens = new String[TOKENS_PER_CLIENT]; + for (int t = 0; t < TOKENS_PER_CLIENT; t++) { + clientTokens[t] = tokens[c * TOKENS_PER_CLIENT + t]; + } + GreetingServiceBlockingStub[] clientStubs = new GreetingServiceBlockingStub[clientTokens.length]; + for (int t = 0; t < clientTokens.length; t++) { + clientStubs[t] = GreetingServiceGrpc.newBlockingStub(channel) + .withCallCredentials(new JwtCallCredentials(clientTokens[t])); + } + calls[c] = i -> assertProtectedCallSucceeds(clientStubs[i % clientStubs.length]); + } - GrpcBenchmarkSupport.measureColdStartIntervals( - "no decoded-JWT cache, cold start, pool of 20 tokens reused round-robin", - CHECKPOINT_SECONDS, i -> assertProtectedCallSucceeds(tokens[i % tokens.length])); + try { + GrpcBenchmarkSupport.measureColdStartIntervalsParallel( + "no decoded-JWT cache, cold start, " + CLIENTS + " parallel clients, " + TOKENS_PER_CLIENT + + " tokens each (" + tokens.length + " tokens total)", + CHECKPOINT_SECONDS, calls); + } finally { + for (ManagedChannel channel : channels) { + channel.shutdown(); + } + } } - private void assertProtectedCallSucceeds(String token) { - GreetingResponse response = stub(token).protectedWithPartnerTenant(greetingRequest); + private void assertProtectedCallSucceeds(GreetingServiceBlockingStub stub) { + GreetingResponse response = stub.protectedWithPartnerTenant(greetingRequest); assertThat(response.getMessage()).isEqualTo("Hello protected tenant"); } diff --git a/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkNoCacheTest.java b/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkNoCacheTest.java index fd1dfd3cf..2535b2c3e 100644 --- a/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkNoCacheTest.java +++ b/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkNoCacheTest.java @@ -1,13 +1,18 @@ package org.entur.jwt.spring.grpc.perf; import com.nimbusds.jose.jwk.JWK; +import io.grpc.ManagedChannel; +import io.grpc.ManagedChannelBuilder; import com.nimbusds.jose.jwk.JWKSelector; import com.nimbusds.jose.jwk.source.JWKSource; import org.entur.jwt.junit5.AccessToken; import org.entur.jwt.junit5.AuthorizationServer; import org.entur.jwt.spring.JwkSourceMap; import org.entur.jwt.spring.grpc.AbstractGrpcTest; +import org.entur.jwt.spring.grpc.JwtCallCredentials; import org.entur.jwt.spring.grpc.test.GreetingResponse; +import org.entur.jwt.spring.grpc.test.GreetingServiceGrpc; +import org.entur.jwt.spring.grpc.test.GreetingServiceGrpc.GreetingServiceBlockingStub; import org.springframework.beans.factory.annotation.Autowired; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; @@ -65,6 +70,12 @@ public class DecodedJwtCacheGrpcBenchmarkNoCacheTest extends AbstractGrpcTest { private static final int WARMUP_REQUESTS = 20_000; private static final int REQUESTS = 10_000; + // 15 parallel clients, 2 tokens each (30 tokens total) - matches abt-core's observed + // peak (08:00/16:00) concurrent client count, per Entur Compass, with each client + // presenting 2 distinct tokens + private static final int CLIENTS = 15; + private static final int TOKENS_PER_CLIENT = 2; + @Autowired private JwkSourceMap jwkSourceMap; @@ -82,11 +93,22 @@ public void readinessProbe() throws Exception { @Test @Order(1) public void singleTokenReused(@AccessToken(by = "a", audience = "https://my.audience") String token) { - GrpcBenchmarkSupport.Stats stats = GrpcBenchmarkSupport.measure( - "no decoded-JWT cache, single token reused", - REQUESTS, WARMUP_REQUESTS, i -> assertProtectedCallSucceeds(token)); + ManagedChannel channel = ManagedChannelBuilder.forAddress("localhost", 9090).usePlaintext().build(); + try { + GreetingServiceBlockingStub stub = GreetingServiceGrpc.newBlockingStub(channel) + .withCallCredentials(new JwtCallCredentials(token)); + GrpcBenchmarkSupport.GrpcCall[] calls = { + i -> assertProtectedCallSucceeds(stub) + }; + + GrpcBenchmarkSupport.Stats stats = GrpcBenchmarkSupport.measureParallel( + "no decoded-JWT cache, single token reused", + REQUESTS, WARMUP_REQUESTS, calls); - assertTrue(stats.opsPerSecond() > 0); + assertTrue(stats.opsPerSecond() > 0); + } finally { + channel.shutdown(); + } } @Test @@ -96,19 +118,68 @@ public void tokenPoolReused( @AccessToken(by = "a", audience = "https://my.audience", scope = "2") String token2, @AccessToken(by = "a", audience = "https://my.audience", scope = "3") String token3, @AccessToken(by = "a", audience = "https://my.audience", scope = "4") String token4, - @AccessToken(by = "a", audience = "https://my.audience", scope = "5") String token5) { + @AccessToken(by = "a", audience = "https://my.audience", scope = "5") String token5, + @AccessToken(by = "a", audience = "https://my.audience", scope = "6") String token6, + @AccessToken(by = "a", audience = "https://my.audience", scope = "7") String token7, + @AccessToken(by = "a", audience = "https://my.audience", scope = "8") String token8, + @AccessToken(by = "a", audience = "https://my.audience", scope = "9") String token9, + @AccessToken(by = "a", audience = "https://my.audience", scope = "10") String token10, + @AccessToken(by = "a", audience = "https://my.audience", scope = "11") String token11, + @AccessToken(by = "a", audience = "https://my.audience", scope = "12") String token12, + @AccessToken(by = "a", audience = "https://my.audience", scope = "13") String token13, + @AccessToken(by = "a", audience = "https://my.audience", scope = "14") String token14, + @AccessToken(by = "a", audience = "https://my.audience", scope = "15") String token15, + @AccessToken(by = "a", audience = "https://my.audience", scope = "16") String token16, + @AccessToken(by = "a", audience = "https://my.audience", scope = "17") String token17, + @AccessToken(by = "a", audience = "https://my.audience", scope = "18") String token18, + @AccessToken(by = "a", audience = "https://my.audience", scope = "19") String token19, + @AccessToken(by = "a", audience = "https://my.audience", scope = "20") String token20, + @AccessToken(by = "a", audience = "https://my.audience", scope = "21") String token21, + @AccessToken(by = "a", audience = "https://my.audience", scope = "22") String token22, + @AccessToken(by = "a", audience = "https://my.audience", scope = "23") String token23, + @AccessToken(by = "a", audience = "https://my.audience", scope = "24") String token24, + @AccessToken(by = "a", audience = "https://my.audience", scope = "25") String token25, + @AccessToken(by = "a", audience = "https://my.audience", scope = "26") String token26, + @AccessToken(by = "a", audience = "https://my.audience", scope = "27") String token27, + @AccessToken(by = "a", audience = "https://my.audience", scope = "28") String token28, + @AccessToken(by = "a", audience = "https://my.audience", scope = "29") String token29, + @AccessToken(by = "a", audience = "https://my.audience", scope = "30") String token30) { - String[] tokens = {token1, token2, token3, token4, token5}; + String[] tokens = {token1, token2, token3, token4, token5, token6, token7, token8, token9, token10, token11, token12, token13, token14, token15, token16, token17, token18, token19, token20, token21, token22, token23, token24, token25, token26, token27, token28, token29, token30}; - GrpcBenchmarkSupport.Stats stats = GrpcBenchmarkSupport.measure( - "no decoded-JWT cache, pool of " + tokens.length + " tokens reused round-robin", - REQUESTS, WARMUP_REQUESTS, i -> assertProtectedCallSucceeds(tokens[i % tokens.length])); + ManagedChannel[] channels = new ManagedChannel[CLIENTS]; + GrpcBenchmarkSupport.GrpcCall[] calls = new GrpcBenchmarkSupport.GrpcCall[CLIENTS]; + for (int c = 0; c < CLIENTS; c++) { + ManagedChannel channel = ManagedChannelBuilder.forAddress("localhost", 9090).usePlaintext().build(); + channels[c] = channel; + String[] clientTokens = new String[TOKENS_PER_CLIENT]; + for (int t = 0; t < TOKENS_PER_CLIENT; t++) { + clientTokens[t] = tokens[c * TOKENS_PER_CLIENT + t]; + } + GreetingServiceBlockingStub[] clientStubs = new GreetingServiceBlockingStub[clientTokens.length]; + for (int t = 0; t < clientTokens.length; t++) { + clientStubs[t] = GreetingServiceGrpc.newBlockingStub(channel) + .withCallCredentials(new JwtCallCredentials(clientTokens[t])); + } + calls[c] = i -> assertProtectedCallSucceeds(clientStubs[i % clientStubs.length]); + } + + try { + GrpcBenchmarkSupport.Stats stats = GrpcBenchmarkSupport.measureParallel( + "no decoded-JWT cache, " + CLIENTS + " parallel clients, " + TOKENS_PER_CLIENT + + " tokens each (" + tokens.length + " tokens total)", + REQUESTS, WARMUP_REQUESTS, calls); - assertTrue(stats.opsPerSecond() > 0); + assertTrue(stats.opsPerSecond() > 0); + } finally { + for (ManagedChannel channel : channels) { + channel.shutdown(); + } + } } - private void assertProtectedCallSucceeds(String token) { - GreetingResponse response = stub(token).protectedWithPartnerTenant(greetingRequest); + private void assertProtectedCallSucceeds(GreetingServiceBlockingStub stub) { + GreetingResponse response = stub.protectedWithPartnerTenant(greetingRequest); assertThat(response.getMessage()).isEqualTo("Hello protected tenant"); } diff --git a/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/GrpcBenchmarkSupport.java b/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/GrpcBenchmarkSupport.java index 83e2923f5..142f21f67 100644 --- a/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/GrpcBenchmarkSupport.java +++ b/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/GrpcBenchmarkSupport.java @@ -1,10 +1,19 @@ package org.entur.jwt.spring.grpc.perf; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + /** * Shared timing helper for the gRPC back-to-back-request benchmarks in this package. *

* Mirrors {@code org.entur.jwt.spring.perf.HttpBenchmarkSupport} in jwt-spring-web, but - * for real gRPC calls over a TCP loopback channel instead of HTTP. + * for real gRPC calls over a TCP loopback channel instead of HTTP. All measurements here + * run one dedicated client thread per benchmark "client" (each with its own gRPC + * channel/connection), all synchronized to start at the same instant via a + * {@link CyclicBarrier}, so the numbers reflect real concurrent client load rather than + * a single thread round-robining across tokens. */ final class GrpcBenchmarkSupport { @@ -19,68 +28,148 @@ private GrpcBenchmarkSupport() { } /** - * Issues {@code requests} real gRPC calls back to back (after a warm-up phase of - * {@code warmup} calls, to let the JIT and connection settle), then reports average - * per-call latency and throughput. + * Runs {@code calls.length} client threads in parallel (one dedicated call/client + * per thread), each issuing {@code requestsPerClient} back-to-back calls (after an + * independent {@code warmupPerClient}-call warm-up), all starting at the same + * instant. Reports aggregate throughput across all client threads combined. */ - static Stats measure(String label, int requests, int warmup, GrpcCall call) { - for (int i = 0; i < warmup; i++) { - call.call(i); + static Stats measureParallel(String label, int requestsPerClient, int warmupPerClient, GrpcCall[] calls) { + int clients = calls.length; + CyclicBarrier barrier = new CyclicBarrier(clients); + Thread[] threads = new Thread[clients]; + long[] durationsNanos = new long[clients]; + + for (int c = 0; c < clients; c++) { + int idx = c; + threads[c] = new Thread(() -> { + for (int i = 0; i < warmupPerClient; i++) { + calls[idx].call(i); + } + await(barrier); + long start = System.nanoTime(); + for (int i = 0; i < requestsPerClient; i++) { + calls[idx].call(i); + } + durationsNanos[idx] = System.nanoTime() - start; + }, "grpc-bench-client-" + c); + } + + for (Thread t : threads) { + t.start(); + } + for (Thread t : threads) { + join(t); } - long start = System.nanoTime(); - for (int i = 0; i < requests; i++) { - call.call(i); + long maxDurationNanos = 0; + for (long d : durationsNanos) { + maxDurationNanos = Math.max(maxDurationNanos, d); } - long durationNanos = System.nanoTime() - start; - double avgLatencyMicros = (durationNanos / 1000.0) / requests; - double opsPerSecond = requests / (durationNanos / 1_000_000_000.0); + long totalCalls = (long) requestsPerClient * clients; + double avgLatencyMicros = (maxDurationNanos / 1000.0) / requestsPerClient; + double opsPerSecond = totalCalls / (maxDurationNanos / 1_000_000_000.0); - System.out.printf("[%s] %.1f us/call, %.0f calls/sec (%d calls back to back)%n", - label, avgLatencyMicros, opsPerSecond, requests); + System.out.printf( + "[%s] %d parallel client(s), %.1f us/call (per client), %.0f calls/sec aggregate (%,d calls total)%n", + label, clients, avgLatencyMicros, opsPerSecond, totalCalls); return new Stats(label, avgLatencyMicros, opsPerSecond); } /** - * Issues real gRPC calls back to back starting from a completely cold state (no - * warm-up at all), for a single continuous run, reporting throughput at each - * cumulative wall-clock checkpoint in {@code checkpointSeconds} (e.g. - * {@code {1, 2, 3, 4, 5, 10, 15}}). For each checkpoint this logs both the - * throughput of that individual segment (since the previous checkpoint) and the - * cumulative throughput since the very first call, so the cache warm-up curve is - * visible within a single run. + * Runs {@code calls.length} client threads in parallel (one dedicated call/client + * per thread), each hammering the endpoint continuously starting from a completely + * cold state (no warm-up at all), all synchronized to start at the same instant. + * Reports the throughput of each individual segment (since the previous checkpoint, + * not cumulative since the start of the run), summed across all client threads, at + * each wall-clock checkpoint in {@code checkpointSeconds} (e.g. + * {@code {1, 2, 3, 4, 5, 10, 15}}), so the cache warm-up curve is visible within a + * single run. */ - static void measureColdStartIntervals(String label, int[] checkpointSeconds, GrpcCall call) { + static void measureColdStartIntervalsParallel(String label, int[] checkpointSeconds, GrpcCall[] calls) { + int clients = calls.length; + AtomicInteger[] counters = new AtomicInteger[clients]; + for (int c = 0; c < clients; c++) { + counters[c] = new AtomicInteger(); + } + AtomicBoolean stop = new AtomicBoolean(false); + CyclicBarrier barrier = new CyclicBarrier(clients + 1); + + Thread[] threads = new Thread[clients]; + for (int c = 0; c < clients; c++) { + int idx = c; + threads[c] = new Thread(() -> { + await(barrier); + int i = 0; + while (!stop.get()) { + calls[idx].call(i++); + counters[idx].incrementAndGet(); + } + }, "grpc-bench-client-" + c); + threads[c].start(); + } + + await(barrier); long runStart = System.nanoTime(); long segmentStart = runStart; - int callIndex = 0; - int callsAtSegmentStart = 0; + int[] callsAtSegmentStart = new int[clients]; for (int checkpointSecond : checkpointSeconds) { long checkpointNanos = runStart + checkpointSecond * 1_000_000_000L; - - while (System.nanoTime() < checkpointNanos) { - call.call(callIndex++); - } + sleepUntil(checkpointNanos); long now = System.nanoTime(); + int segmentCalls = 0; + for (int c = 0; c < clients; c++) { + int current = counters[c].get(); + segmentCalls += current - callsAtSegmentStart[c]; + callsAtSegmentStart[c] = current; + } - int segmentCalls = callIndex - callsAtSegmentStart; double segmentSeconds = (now - segmentStart) / 1_000_000_000.0; double segmentOpsPerSecond = segmentCalls / segmentSeconds; - double cumulativeSeconds = (now - runStart) / 1_000_000_000.0; - double cumulativeOpsPerSecond = callIndex / cumulativeSeconds; - System.out.printf( - "[%s] t=%ds: segment %,d calls in %.2fs (%.0f calls/sec), cumulative %,d calls in %.2fs (%.0f calls/sec)%n", - label, checkpointSecond, segmentCalls, segmentSeconds, segmentOpsPerSecond, - callIndex, cumulativeSeconds, cumulativeOpsPerSecond); + "[%s] t=%ds: %d parallel clients, segment %,d calls in %.2fs (%.0f calls/sec)%n", + label, checkpointSecond, clients, segmentCalls, segmentSeconds, segmentOpsPerSecond); segmentStart = now; - callsAtSegmentStart = callIndex; + } + + stop.set(true); + for (Thread t : threads) { + join(t); + } + } + + private static void await(CyclicBarrier barrier) { + try { + barrier.await(); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + private static void join(Thread t) { + try { + t.join(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } + } + + private static void sleepUntil(long targetNanoTime) { + long remaining = targetNanoTime - System.nanoTime(); + if (remaining <= 0) { + return; + } + try { + TimeUnit.NANOSECONDS.sleep(remaining); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); } } } diff --git a/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/DecodedJwtCacheHttpBenchmarkCachedTest.java b/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/DecodedJwtCacheHttpBenchmarkCachedTest.java index 055ad8505..a471f3fc3 100644 --- a/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/DecodedJwtCacheHttpBenchmarkCachedTest.java +++ b/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/DecodedJwtCacheHttpBenchmarkCachedTest.java @@ -5,7 +5,6 @@ import org.entur.jwt.spring.actuate.AbstractActuatorTest; import org.entur.jwt.spring.decode.ClosableJwtDecoders; import org.entur.jwt.spring.decode.cache.DecodedJwtCacheJwtDecoder; -import org.entur.jwt.spring.rest.Greeting; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.MethodOrderer; @@ -69,6 +68,12 @@ public class DecodedJwtCacheHttpBenchmarkCachedTest extends AbstractActuatorTest private static final int WARMUP_REQUESTS = 20_000; private static final int REQUESTS = 10_000; + // 15 parallel clients, 2 tokens each (30 tokens total) - matches abt-core's observed + // peak (08:00/16:00) concurrent client count, per Entur Compass, with each client + // presenting 2 distinct tokens + private static final int CLIENTS = 15; + private static final int TOKENS_PER_CLIENT = 2; + @LocalServerPort private int randomServerPort; @@ -103,9 +108,14 @@ public void readinessProbe() throws Exception { @Test @Order(1) public void singleTokenReused(@AccessToken(by = "a", audience = "mock.my.audience") String token) { - HttpBenchmarkSupport.Stats stats = HttpBenchmarkSupport.measure( + RawHttpClientSupport client = new RawHttpClientSupport(); + HttpBenchmarkSupport.HttpCall[] calls = { + i -> client.assertProtectedRequestSucceeds(randomServerPort, token, "Hello protected") + }; + + HttpBenchmarkSupport.Stats stats = HttpBenchmarkSupport.measureParallel( "decoded-JWT cache enabled, single token reused (100% cache-hit rate)", - REQUESTS, WARMUP_REQUESTS, i -> assertProtectedRequestSucceeds(token)); + REQUESTS, WARMUP_REQUESTS, calls); assertTrue(stats.opsPerSecond() > 0); } @@ -117,25 +127,50 @@ public void tokenPoolReused( @AccessToken(by = "a", audience = "mock.my.audience", scope = "2") String token2, @AccessToken(by = "a", audience = "mock.my.audience", scope = "3") String token3, @AccessToken(by = "a", audience = "mock.my.audience", scope = "4") String token4, - @AccessToken(by = "a", audience = "mock.my.audience", scope = "5") String token5) { - - String[] tokens = {token1, token2, token3, token4, token5}; + @AccessToken(by = "a", audience = "mock.my.audience", scope = "5") String token5, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "6") String token6, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "7") String token7, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "8") String token8, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "9") String token9, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "10") String token10, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "11") String token11, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "12") String token12, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "13") String token13, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "14") String token14, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "15") String token15, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "16") String token16, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "17") String token17, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "18") String token18, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "19") String token19, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "20") String token20, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "21") String token21, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "22") String token22, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "23") String token23, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "24") String token24, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "25") String token25, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "26") String token26, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "27") String token27, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "28") String token28, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "29") String token29, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "30") String token30) { + + String[] tokens = {token1, token2, token3, token4, token5, token6, token7, token8, token9, token10, token11, token12, token13, token14, token15, token16, token17, token18, token19, token20, token21, token22, token23, token24, token25, token26, token27, token28, token29, token30}; + + HttpBenchmarkSupport.HttpCall[] calls = new HttpBenchmarkSupport.HttpCall[CLIENTS]; + for (int c = 0; c < CLIENTS; c++) { + RawHttpClientSupport client = new RawHttpClientSupport(); + String[] clientTokens = new String[TOKENS_PER_CLIENT]; + for (int t = 0; t < TOKENS_PER_CLIENT; t++) { + clientTokens[t] = tokens[c * TOKENS_PER_CLIENT + t]; + } + calls[c] = i -> client.assertProtectedRequestSucceeds(randomServerPort, clientTokens[i % clientTokens.length], "Hello protected"); + } - HttpBenchmarkSupport.Stats stats = HttpBenchmarkSupport.measure( - "decoded-JWT cache enabled, pool of " + tokens.length + " tokens reused round-robin", - REQUESTS, WARMUP_REQUESTS, i -> assertProtectedRequestSucceeds(tokens[i % tokens.length])); + HttpBenchmarkSupport.Stats stats = HttpBenchmarkSupport.measureParallel( + "decoded-JWT cache enabled, " + CLIENTS + " parallel clients, " + TOKENS_PER_CLIENT + " tokens each (" + + tokens.length + " tokens total)", + REQUESTS, WARMUP_REQUESTS, calls); assertTrue(stats.opsPerSecond() > 0); } - - private void assertProtectedRequestSucceeds(String token) { - HttpHeaders headers = new HttpHeaders(); - headers.add("Authorization", token); - HttpEntity entity = new HttpEntity<>(headers); - - String url = "http://localhost:" + randomServerPort + "/protected"; - ResponseEntity response = restTemplate.exchange(url, HttpMethod.GET, entity, Greeting.class); - - assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); - } } diff --git a/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/DecodedJwtCacheHttpBenchmarkColdStartCachedTest.java b/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/DecodedJwtCacheHttpBenchmarkColdStartCachedTest.java index 0846cc112..31267a3fd 100644 --- a/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/DecodedJwtCacheHttpBenchmarkColdStartCachedTest.java +++ b/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/DecodedJwtCacheHttpBenchmarkColdStartCachedTest.java @@ -5,7 +5,6 @@ import org.entur.jwt.spring.actuate.AbstractActuatorTest; import org.entur.jwt.spring.decode.ClosableJwtDecoders; import org.entur.jwt.spring.decode.cache.DecodedJwtCacheJwtDecoder; -import org.entur.jwt.spring.rest.Greeting; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Tag; @@ -32,7 +31,7 @@ * Cold-start HTTP request benchmark, with the decoded-JWT cache enabled. Unlike * {@link DecodedJwtCacheHttpBenchmarkCachedTest}, there is no warm-up phase at all: the * very first request is the very first thing timed. A single continuous run of - * back-to-back requests is made, round-robin across a pool of 20 tokens (a realistic + * back-to-back requests is made, round-robin across a pool of 30 tokens (a realistic * number of distinct client tokens seen concurrently in production), and throughput is reported at * cumulative wall-clock checkpoints of 1, 2, 3, 4, 5, 10 and 15 seconds - both the * throughput of that individual segment and the cumulative throughput since the first @@ -62,6 +61,12 @@ public class DecodedJwtCacheHttpBenchmarkColdStartCachedTest extends AbstractAct private static final int[] CHECKPOINT_SECONDS = {1, 2, 3, 4, 5, 10, 15}; + // 15 parallel clients, 2 tokens each (30 tokens total) - matches abt-core's observed + // peak (08:00/16:00) concurrent client count, per Entur Compass, with each client + // presenting 2 distinct tokens + private static final int CLIENTS = 15; + private static final int TOKENS_PER_CLIENT = 2; + @LocalServerPort private int randomServerPort; @@ -116,23 +121,33 @@ public void coldStart( @AccessToken(by = "a", audience = "mock.my.audience", scope = "17") String token17, @AccessToken(by = "a", audience = "mock.my.audience", scope = "18") String token18, @AccessToken(by = "a", audience = "mock.my.audience", scope = "19") String token19, - @AccessToken(by = "a", audience = "mock.my.audience", scope = "20") String token20) { - - String[] tokens = {token1, token2, token3, token4, token5, token6, token7, token8, token9, token10, token11, token12, token13, token14, token15, token16, token17, token18, token19, token20}; - - HttpBenchmarkSupport.measureColdStartIntervals( - "decoded-JWT cache enabled, cold start, pool of 20 tokens reused round-robin", - CHECKPOINT_SECONDS, i -> assertProtectedRequestSucceeds(tokens[i % tokens.length])); - } - - private void assertProtectedRequestSucceeds(String token) { - HttpHeaders headers = new HttpHeaders(); - headers.add("Authorization", token); - HttpEntity entity = new HttpEntity<>(headers); - - String url = "http://localhost:" + randomServerPort + "/protected"; - ResponseEntity response = restTemplate.exchange(url, HttpMethod.GET, entity, Greeting.class); + @AccessToken(by = "a", audience = "mock.my.audience", scope = "20") String token20, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "21") String token21, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "22") String token22, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "23") String token23, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "24") String token24, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "25") String token25, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "26") String token26, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "27") String token27, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "28") String token28, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "29") String token29, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "30") String token30) { + + String[] tokens = {token1, token2, token3, token4, token5, token6, token7, token8, token9, token10, token11, token12, token13, token14, token15, token16, token17, token18, token19, token20, token21, token22, token23, token24, token25, token26, token27, token28, token29, token30}; + + HttpBenchmarkSupport.HttpCall[] calls = new HttpBenchmarkSupport.HttpCall[CLIENTS]; + for (int c = 0; c < CLIENTS; c++) { + RawHttpClientSupport client = new RawHttpClientSupport(); + String[] clientTokens = new String[TOKENS_PER_CLIENT]; + for (int t = 0; t < TOKENS_PER_CLIENT; t++) { + clientTokens[t] = tokens[c * TOKENS_PER_CLIENT + t]; + } + calls[c] = i -> client.assertProtectedRequestSucceeds(randomServerPort, clientTokens[i % clientTokens.length], "Hello protected"); + } - assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + HttpBenchmarkSupport.measureColdStartIntervalsParallel( + "decoded-JWT cache enabled, cold start, " + CLIENTS + " parallel clients, " + TOKENS_PER_CLIENT + + " tokens each (" + tokens.length + " tokens total)", + CHECKPOINT_SECONDS, calls); } } diff --git a/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/DecodedJwtCacheHttpBenchmarkColdStartNoCacheTest.java b/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/DecodedJwtCacheHttpBenchmarkColdStartNoCacheTest.java index aa6c9dd4b..9c42c6c9d 100644 --- a/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/DecodedJwtCacheHttpBenchmarkColdStartNoCacheTest.java +++ b/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/DecodedJwtCacheHttpBenchmarkColdStartNoCacheTest.java @@ -3,7 +3,6 @@ import org.entur.jwt.junit5.AccessToken; import org.entur.jwt.junit5.AuthorizationServer; import org.entur.jwt.spring.actuate.AbstractActuatorTest; -import org.entur.jwt.spring.rest.Greeting; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Tag; @@ -29,7 +28,7 @@ * Cold-start HTTP request benchmark, with the decoded-JWT cache disabled, * approximating {@code master} behaviour (which has no decoded-JWT cache at all). There * is no warm-up phase at all: the very first request is the very first thing timed. A - * single continuous run of back-to-back requests is made, round-robin across a pool of 20 + * single continuous run of back-to-back requests is made, round-robin across a pool of 30 * tokens (a realistic number of distinct client tokens seen concurrently in production), * and throughput is reported at cumulative wall-clock checkpoints of 1, 2, 3, 4, 5, 10 * and 15 seconds - both the throughput of that individual segment and the cumulative @@ -56,6 +55,12 @@ public class DecodedJwtCacheHttpBenchmarkColdStartNoCacheTest extends AbstractAc private static final int[] CHECKPOINT_SECONDS = {1, 2, 3, 4, 5, 10, 15}; + // 15 parallel clients, 2 tokens each (30 tokens total) - matches abt-core's observed + // peak (08:00/16:00) concurrent client count, per Entur Compass, with each client + // presenting 2 distinct tokens + private static final int CLIENTS = 15; + private static final int TOKENS_PER_CLIENT = 2; + @LocalServerPort private int randomServerPort; @@ -103,23 +108,33 @@ public void coldStart( @AccessToken(by = "a", audience = "mock.my.audience", scope = "17") String token17, @AccessToken(by = "a", audience = "mock.my.audience", scope = "18") String token18, @AccessToken(by = "a", audience = "mock.my.audience", scope = "19") String token19, - @AccessToken(by = "a", audience = "mock.my.audience", scope = "20") String token20) { - - String[] tokens = {token1, token2, token3, token4, token5, token6, token7, token8, token9, token10, token11, token12, token13, token14, token15, token16, token17, token18, token19, token20}; - - HttpBenchmarkSupport.measureColdStartIntervals( - "no decoded-JWT cache, cold start, pool of 20 tokens reused round-robin", - CHECKPOINT_SECONDS, i -> assertProtectedRequestSucceeds(tokens[i % tokens.length])); - } - - private void assertProtectedRequestSucceeds(String token) { - HttpHeaders headers = new HttpHeaders(); - headers.add("Authorization", token); - HttpEntity entity = new HttpEntity<>(headers); - - String url = "http://localhost:" + randomServerPort + "/protected"; - ResponseEntity response = restTemplate.exchange(url, HttpMethod.GET, entity, Greeting.class); + @AccessToken(by = "a", audience = "mock.my.audience", scope = "20") String token20, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "21") String token21, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "22") String token22, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "23") String token23, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "24") String token24, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "25") String token25, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "26") String token26, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "27") String token27, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "28") String token28, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "29") String token29, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "30") String token30) { + + String[] tokens = {token1, token2, token3, token4, token5, token6, token7, token8, token9, token10, token11, token12, token13, token14, token15, token16, token17, token18, token19, token20, token21, token22, token23, token24, token25, token26, token27, token28, token29, token30}; + + HttpBenchmarkSupport.HttpCall[] calls = new HttpBenchmarkSupport.HttpCall[CLIENTS]; + for (int c = 0; c < CLIENTS; c++) { + RawHttpClientSupport client = new RawHttpClientSupport(); + String[] clientTokens = new String[TOKENS_PER_CLIENT]; + for (int t = 0; t < TOKENS_PER_CLIENT; t++) { + clientTokens[t] = tokens[c * TOKENS_PER_CLIENT + t]; + } + calls[c] = i -> client.assertProtectedRequestSucceeds(randomServerPort, clientTokens[i % clientTokens.length], "Hello protected"); + } - assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + HttpBenchmarkSupport.measureColdStartIntervalsParallel( + "no decoded-JWT cache, cold start, " + CLIENTS + " parallel clients, " + TOKENS_PER_CLIENT + + " tokens each (" + tokens.length + " tokens total)", + CHECKPOINT_SECONDS, calls); } } diff --git a/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/DecodedJwtCacheHttpBenchmarkNoCacheTest.java b/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/DecodedJwtCacheHttpBenchmarkNoCacheTest.java index 44b14ab79..698fdd699 100644 --- a/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/DecodedJwtCacheHttpBenchmarkNoCacheTest.java +++ b/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/DecodedJwtCacheHttpBenchmarkNoCacheTest.java @@ -3,7 +3,6 @@ import org.entur.jwt.junit5.AccessToken; import org.entur.jwt.junit5.AuthorizationServer; import org.entur.jwt.spring.actuate.AbstractActuatorTest; -import org.entur.jwt.spring.rest.Greeting; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.MethodOrderer; @@ -64,6 +63,12 @@ public class DecodedJwtCacheHttpBenchmarkNoCacheTest extends AbstractActuatorTes private static final int WARMUP_REQUESTS = 20_000; private static final int REQUESTS = 10_000; + // 15 parallel clients, 2 tokens each (30 tokens total) - matches abt-core's observed + // peak (08:00/16:00) concurrent client count, per Entur Compass, with each client + // presenting 2 distinct tokens + private static final int CLIENTS = 15; + private static final int TOKENS_PER_CLIENT = 2; + @LocalServerPort private int randomServerPort; @@ -91,9 +96,14 @@ public void readinessProbe() throws Exception { @Test @Order(1) public void singleTokenReused(@AccessToken(by = "a", audience = "mock.my.audience") String token) { - HttpBenchmarkSupport.Stats stats = HttpBenchmarkSupport.measure( + RawHttpClientSupport client = new RawHttpClientSupport(); + HttpBenchmarkSupport.HttpCall[] calls = { + i -> client.assertProtectedRequestSucceeds(randomServerPort, token, "Hello protected") + }; + + HttpBenchmarkSupport.Stats stats = HttpBenchmarkSupport.measureParallel( "no decoded-JWT cache, single token reused", - REQUESTS, WARMUP_REQUESTS, i -> assertProtectedRequestSucceeds(token)); + REQUESTS, WARMUP_REQUESTS, calls); assertTrue(stats.opsPerSecond() > 0); } @@ -105,25 +115,50 @@ public void tokenPoolReused( @AccessToken(by = "a", audience = "mock.my.audience", scope = "2") String token2, @AccessToken(by = "a", audience = "mock.my.audience", scope = "3") String token3, @AccessToken(by = "a", audience = "mock.my.audience", scope = "4") String token4, - @AccessToken(by = "a", audience = "mock.my.audience", scope = "5") String token5) { - - String[] tokens = {token1, token2, token3, token4, token5}; + @AccessToken(by = "a", audience = "mock.my.audience", scope = "5") String token5, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "6") String token6, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "7") String token7, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "8") String token8, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "9") String token9, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "10") String token10, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "11") String token11, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "12") String token12, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "13") String token13, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "14") String token14, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "15") String token15, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "16") String token16, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "17") String token17, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "18") String token18, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "19") String token19, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "20") String token20, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "21") String token21, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "22") String token22, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "23") String token23, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "24") String token24, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "25") String token25, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "26") String token26, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "27") String token27, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "28") String token28, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "29") String token29, + @AccessToken(by = "a", audience = "mock.my.audience", scope = "30") String token30) { + + String[] tokens = {token1, token2, token3, token4, token5, token6, token7, token8, token9, token10, token11, token12, token13, token14, token15, token16, token17, token18, token19, token20, token21, token22, token23, token24, token25, token26, token27, token28, token29, token30}; + + HttpBenchmarkSupport.HttpCall[] calls = new HttpBenchmarkSupport.HttpCall[CLIENTS]; + for (int c = 0; c < CLIENTS; c++) { + RawHttpClientSupport client = new RawHttpClientSupport(); + String[] clientTokens = new String[TOKENS_PER_CLIENT]; + for (int t = 0; t < TOKENS_PER_CLIENT; t++) { + clientTokens[t] = tokens[c * TOKENS_PER_CLIENT + t]; + } + calls[c] = i -> client.assertProtectedRequestSucceeds(randomServerPort, clientTokens[i % clientTokens.length], "Hello protected"); + } - HttpBenchmarkSupport.Stats stats = HttpBenchmarkSupport.measure( - "no decoded-JWT cache, pool of " + tokens.length + " tokens reused round-robin", - REQUESTS, WARMUP_REQUESTS, i -> assertProtectedRequestSucceeds(tokens[i % tokens.length])); + HttpBenchmarkSupport.Stats stats = HttpBenchmarkSupport.measureParallel( + "no decoded-JWT cache, " + CLIENTS + " parallel clients, " + TOKENS_PER_CLIENT + " tokens each (" + + tokens.length + " tokens total)", + REQUESTS, WARMUP_REQUESTS, calls); assertTrue(stats.opsPerSecond() > 0); } - - private void assertProtectedRequestSucceeds(String token) { - HttpHeaders headers = new HttpHeaders(); - headers.add("Authorization", token); - HttpEntity entity = new HttpEntity<>(headers); - - String url = "http://localhost:" + randomServerPort + "/protected"; - ResponseEntity response = restTemplate.exchange(url, HttpMethod.GET, entity, Greeting.class); - - assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); - } } diff --git a/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/HttpBenchmarkSupport.java b/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/HttpBenchmarkSupport.java index 8f8954f5b..0e60aec30 100644 --- a/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/HttpBenchmarkSupport.java +++ b/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/HttpBenchmarkSupport.java @@ -1,7 +1,17 @@ package org.entur.jwt.spring.perf; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + /** * Shared timing helper for the HTTP back-to-back-request benchmarks in this package. + *

+ * All measurements here run one dedicated client thread per token (each with its own + * {@code HttpClient}/connection), all synchronized to start at the same instant via a + * {@link CyclicBarrier}, so the numbers reflect real concurrent client load rather than + * a single thread round-robining across tokens. */ final class HttpBenchmarkSupport { @@ -16,68 +26,148 @@ private HttpBenchmarkSupport() { } /** - * Issues {@code requests} real HTTP requests back to back (after a warm-up phase of - * {@code warmup} requests, to let the JIT and connection pool settle), then reports - * average per-request latency and throughput. + * Runs {@code calls.length} client threads in parallel (one dedicated call/client + * per thread), each issuing {@code requestsPerClient} back-to-back requests (after + * an independent {@code warmupPerClient}-request warm-up), all starting at the same + * instant. Reports aggregate throughput across all client threads combined. */ - static Stats measure(String label, int requests, int warmup, HttpCall call) { - for (int i = 0; i < warmup; i++) { - call.request(i); + static Stats measureParallel(String label, int requestsPerClient, int warmupPerClient, HttpCall[] calls) { + int clients = calls.length; + CyclicBarrier barrier = new CyclicBarrier(clients); + Thread[] threads = new Thread[clients]; + long[] durationsNanos = new long[clients]; + + for (int c = 0; c < clients; c++) { + int idx = c; + threads[c] = new Thread(() -> { + for (int i = 0; i < warmupPerClient; i++) { + calls[idx].request(i); + } + await(barrier); + long start = System.nanoTime(); + for (int i = 0; i < requestsPerClient; i++) { + calls[idx].request(i); + } + durationsNanos[idx] = System.nanoTime() - start; + }, "http-bench-client-" + c); + } + + for (Thread t : threads) { + t.start(); + } + for (Thread t : threads) { + join(t); } - long start = System.nanoTime(); - for (int i = 0; i < requests; i++) { - call.request(i); + long maxDurationNanos = 0; + for (long d : durationsNanos) { + maxDurationNanos = Math.max(maxDurationNanos, d); } - long durationNanos = System.nanoTime() - start; - double avgLatencyMicros = (durationNanos / 1000.0) / requests; - double opsPerSecond = requests / (durationNanos / 1_000_000_000.0); + long totalRequests = (long) requestsPerClient * clients; + double avgLatencyMicros = (maxDurationNanos / 1000.0) / requestsPerClient; + double opsPerSecond = totalRequests / (maxDurationNanos / 1_000_000_000.0); - System.out.printf("[%s] %.1f us/request, %.0f requests/sec (%d requests back to back)%n", - label, avgLatencyMicros, opsPerSecond, requests); + System.out.printf( + "[%s] %d parallel client(s), %.1f us/request (per client), %.0f requests/sec aggregate (%,d requests total)%n", + label, clients, avgLatencyMicros, opsPerSecond, totalRequests); return new Stats(label, avgLatencyMicros, opsPerSecond); } /** - * Issues real HTTP requests back to back starting from a completely cold state (no - * warm-up at all), for a single continuous run, reporting throughput at each - * cumulative wall-clock checkpoint in {@code checkpointSeconds} (e.g. - * {@code {1, 2, 3, 4, 5, 10, 15}}). For each checkpoint this logs both the - * throughput of that individual segment (since the previous checkpoint) and the - * cumulative throughput since the very first request, so the cache warm-up curve is - * visible within a single run. + * Runs {@code calls.length} client threads in parallel (one dedicated call/client + * per thread), each hammering the endpoint continuously starting from a completely + * cold state (no warm-up at all), all synchronized to start at the same instant. + * Reports the throughput of each individual segment (since the previous checkpoint, + * not cumulative since the start of the run), summed across all client threads, at + * each wall-clock checkpoint in {@code checkpointSeconds} (e.g. + * {@code {1, 2, 3, 4, 5, 10, 15}}), so the cache warm-up curve is visible within a + * single run. */ - static void measureColdStartIntervals(String label, int[] checkpointSeconds, HttpCall call) { + static void measureColdStartIntervalsParallel(String label, int[] checkpointSeconds, HttpCall[] calls) { + int clients = calls.length; + AtomicInteger[] counters = new AtomicInteger[clients]; + for (int c = 0; c < clients; c++) { + counters[c] = new AtomicInteger(); + } + AtomicBoolean stop = new AtomicBoolean(false); + CyclicBarrier barrier = new CyclicBarrier(clients + 1); + + Thread[] threads = new Thread[clients]; + for (int c = 0; c < clients; c++) { + int idx = c; + threads[c] = new Thread(() -> { + await(barrier); + int i = 0; + while (!stop.get()) { + calls[idx].request(i++); + counters[idx].incrementAndGet(); + } + }, "http-bench-client-" + c); + threads[c].start(); + } + + await(barrier); long runStart = System.nanoTime(); long segmentStart = runStart; - int callIndex = 0; - int callsAtSegmentStart = 0; + int[] callsAtSegmentStart = new int[clients]; for (int checkpointSecond : checkpointSeconds) { long checkpointNanos = runStart + checkpointSecond * 1_000_000_000L; - - while (System.nanoTime() < checkpointNanos) { - call.request(callIndex++); - } + sleepUntil(checkpointNanos); long now = System.nanoTime(); + int segmentCalls = 0; + for (int c = 0; c < clients; c++) { + int current = counters[c].get(); + segmentCalls += current - callsAtSegmentStart[c]; + callsAtSegmentStart[c] = current; + } - int segmentCalls = callIndex - callsAtSegmentStart; double segmentSeconds = (now - segmentStart) / 1_000_000_000.0; double segmentOpsPerSecond = segmentCalls / segmentSeconds; - double cumulativeSeconds = (now - runStart) / 1_000_000_000.0; - double cumulativeOpsPerSecond = callIndex / cumulativeSeconds; - System.out.printf( - "[%s] t=%ds: segment %,d requests in %.2fs (%.0f requests/sec), cumulative %,d requests in %.2fs (%.0f requests/sec)%n", - label, checkpointSecond, segmentCalls, segmentSeconds, segmentOpsPerSecond, - callIndex, cumulativeSeconds, cumulativeOpsPerSecond); + "[%s] t=%ds: %d parallel clients, segment %,d requests in %.2fs (%.0f requests/sec)%n", + label, checkpointSecond, clients, segmentCalls, segmentSeconds, segmentOpsPerSecond); segmentStart = now; - callsAtSegmentStart = callIndex; + } + + stop.set(true); + for (Thread t : threads) { + join(t); + } + } + + private static void await(CyclicBarrier barrier) { + try { + barrier.await(); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + private static void join(Thread t) { + try { + t.join(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } + } + + private static void sleepUntil(long targetNanoTime) { + long remaining = targetNanoTime - System.nanoTime(); + if (remaining <= 0) { + return; + } + try { + TimeUnit.NANOSECONDS.sleep(remaining); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); } } } diff --git a/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/RawHttpClientSupport.java b/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/RawHttpClientSupport.java new file mode 100644 index 000000000..d2df7c5f9 --- /dev/null +++ b/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/RawHttpClientSupport.java @@ -0,0 +1,51 @@ +package org.entur.jwt.spring.perf; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +/** + * Minimal, low-overhead HTTP client for the benchmarks in this package. + *

+ * {@code TestRestTemplate} (backed by Spring's {@code RestTemplate}) adds noticeable + * client-side overhead of its own on top of the network round trip - interceptors, + * message converter/Jackson resolution, error handling, root-URI resolution, etc. - which + * would otherwise dominate the timed measurements and mask the effect of the decoded-JWT + * cache. This uses the JDK's {@link HttpClient} directly instead, with no response body + * deserialization in the hot path - only a cheap {@code String.contains} check to confirm + * the call actually succeeded end to end. + *

+ * Each instance owns its own {@code HttpClient} (and thus its own HTTP/1.1 keep-alive + * connection), so that in the parallel benchmarks each client thread gets a dedicated + * connection rather than sharing one across threads. + */ +final class RawHttpClientSupport { + + private final HttpClient client = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_1_1) + .build(); + + void assertProtectedRequestSucceeds(int port, String token, String expectedMessage) { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + port + "/protected")) + .header("Authorization", token) + .GET() + .build(); + + HttpResponse response; + try { + response = client.send(request, HttpResponse.BodyHandlers.ofString()); + } catch (IOException | InterruptedException e) { + throw new IllegalStateException("Request to /protected failed", e); + } + + if (response.statusCode() != 200) { + throw new IllegalStateException("Expected status 200 but got " + response.statusCode()); + } + if (!response.body().contains(expectedMessage)) { + throw new IllegalStateException("Unexpected response body: " + response.body()); + } + } +} From 9d29e6fee7e26e339c6bf2d69960bbbe8da27679 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Skj=C3=B8lberg?= Date: Fri, 18 Sep 2026 23:42:38 +0200 Subject: [PATCH 4/5] Use 10 parallel clients/2 tokens, fail benchmarks if JaCoCo is attached Reduce benchmark concurrency to 10 parallel clients, 2 tokens each (20 tokens total). Add HttpBenchmarkSupport.assertJacocoAgentDisabled() and GrpcBenchmarkSupport.assertJacocoAgentDisabled(), called from each benchmark's readiness probe. They inspect the JVM's input arguments and fail fast with a clear message if the JaCoCo agent is attached, since coverage instrumentation adds substantial, uneven per-call overhead that invalidates timing measurements. Benchmarks must be run with -Djacoco.skip=true. --- ...ecodedJwtCacheGrpcBenchmarkCachedTest.java | 26 ++++++--------- ...CacheGrpcBenchmarkColdStartCachedTest.java | 32 +++++++------------ ...acheGrpcBenchmarkColdStartNoCacheTest.java | 28 ++++++---------- ...codedJwtCacheGrpcBenchmarkNoCacheTest.java | 26 ++++++--------- .../grpc/perf/GrpcBenchmarkSupport.java | 16 ++++++++++ ...ecodedJwtCacheHttpBenchmarkCachedTest.java | 26 ++++++--------- ...CacheHttpBenchmarkColdStartCachedTest.java | 32 +++++++------------ ...acheHttpBenchmarkColdStartNoCacheTest.java | 30 +++++++---------- ...codedJwtCacheHttpBenchmarkNoCacheTest.java | 26 ++++++--------- .../jwt/spring/perf/HttpBenchmarkSupport.java | 16 ++++++++++ 10 files changed, 113 insertions(+), 145 deletions(-) diff --git a/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkCachedTest.java b/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkCachedTest.java index c44c8c20e..7af6cbbcc 100644 --- a/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkCachedTest.java +++ b/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkCachedTest.java @@ -75,10 +75,10 @@ public class DecodedJwtCacheGrpcBenchmarkCachedTest extends AbstractGrpcTest { private static final int WARMUP_REQUESTS = 20_000; private static final int REQUESTS = 10_000; - // 15 parallel clients, 2 tokens each (30 tokens total) - matches abt-core's observed - // peak (08:00/16:00) concurrent client count, per Entur Compass, with each client - // presenting 2 distinct tokens - private static final int CLIENTS = 15; + // 10 parallel clients, 2 tokens each (20 tokens total) - reduced from abt-core's + // observed peak (08:00/16:00) concurrent client count, per Entur Compass, to + // investigate throughput scaling with fewer concurrent clients + private static final int CLIENTS = 10; private static final int TOKENS_PER_CLIENT = 2; @Autowired @@ -89,6 +89,8 @@ public class DecodedJwtCacheGrpcBenchmarkCachedTest extends AbstractGrpcTest { @BeforeEach public void readinessProbe() throws Exception { + GrpcBenchmarkSupport.assertJacocoAgentDisabled(); + // make sure JWKs are loaded before timing anything Map jwkSources = jwkSourceMap.getJwkSources(); for (Map.Entry entry : jwkSources.entrySet()) { @@ -144,19 +146,9 @@ public void tokenPoolReused( @AccessToken(by = "a", audience = "https://my.audience", scope = "17") String token17, @AccessToken(by = "a", audience = "https://my.audience", scope = "18") String token18, @AccessToken(by = "a", audience = "https://my.audience", scope = "19") String token19, - @AccessToken(by = "a", audience = "https://my.audience", scope = "20") String token20, - @AccessToken(by = "a", audience = "https://my.audience", scope = "21") String token21, - @AccessToken(by = "a", audience = "https://my.audience", scope = "22") String token22, - @AccessToken(by = "a", audience = "https://my.audience", scope = "23") String token23, - @AccessToken(by = "a", audience = "https://my.audience", scope = "24") String token24, - @AccessToken(by = "a", audience = "https://my.audience", scope = "25") String token25, - @AccessToken(by = "a", audience = "https://my.audience", scope = "26") String token26, - @AccessToken(by = "a", audience = "https://my.audience", scope = "27") String token27, - @AccessToken(by = "a", audience = "https://my.audience", scope = "28") String token28, - @AccessToken(by = "a", audience = "https://my.audience", scope = "29") String token29, - @AccessToken(by = "a", audience = "https://my.audience", scope = "30") String token30) { - - String[] tokens = {token1, token2, token3, token4, token5, token6, token7, token8, token9, token10, token11, token12, token13, token14, token15, token16, token17, token18, token19, token20, token21, token22, token23, token24, token25, token26, token27, token28, token29, token30}; + @AccessToken(by = "a", audience = "https://my.audience", scope = "20") String token20) { + + String[] tokens = {token1, token2, token3, token4, token5, token6, token7, token8, token9, token10, token11, token12, token13, token14, token15, token16, token17, token18, token19, token20}; ManagedChannel[] channels = new ManagedChannel[CLIENTS]; GrpcBenchmarkSupport.GrpcCall[] calls = new GrpcBenchmarkSupport.GrpcCall[CLIENTS]; diff --git a/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkColdStartCachedTest.java b/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkColdStartCachedTest.java index e6a38ab3e..40e94a846 100644 --- a/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkColdStartCachedTest.java +++ b/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkColdStartCachedTest.java @@ -36,9 +36,9 @@ * Cold-start gRPC call benchmark, with the decoded-JWT cache enabled. Unlike * {@link DecodedJwtCacheGrpcBenchmarkCachedTest}, there is no warm-up phase at all: the * very first call is the very first thing timed. A single continuous run of back-to-back - * calls is made, round-robin across a pool of 30 tokens (a realistic + * calls is made, round-robin across a pool of 20 tokens (a realistic * number of distinct client tokens seen concurrently in production), and throughput is reported at cumulative - * wall-clock checkpoints of 1, 2, 3, 4, 5, 10 and 15 seconds - both the throughput of + * wall-clock checkpoints of 1, 2, 3, 4, 5, 10, 15, 30 and 60 seconds - both the throughput of * that individual segment and the cumulative throughput since the first call - so the * cache warm-up curve (cold JIT/channel/first-decode vs. steady state) is visible within * a single run. @@ -64,12 +64,12 @@ @DirtiesContext public class DecodedJwtCacheGrpcBenchmarkColdStartCachedTest extends AbstractGrpcTest { - private static final int[] CHECKPOINT_SECONDS = {1, 2, 3, 4, 5, 10, 15}; + private static final int[] CHECKPOINT_SECONDS = {1, 2, 3, 4, 5, 10, 15, 30, 60}; - // 15 parallel clients, 2 tokens each (30 tokens total) - matches abt-core's observed - // peak (08:00/16:00) concurrent client count, per Entur Compass, with each client - // presenting 2 distinct tokens - private static final int CLIENTS = 15; + // 10 parallel clients, 2 tokens each (20 tokens total) - reduced from abt-core's + // observed peak (08:00/16:00) concurrent client count, per Entur Compass, to + // investigate throughput scaling with fewer concurrent clients + private static final int CLIENTS = 10; private static final int TOKENS_PER_CLIENT = 2; @Autowired @@ -80,6 +80,8 @@ public class DecodedJwtCacheGrpcBenchmarkColdStartCachedTest extends AbstractGrp @BeforeEach public void readinessProbe() throws Exception { + GrpcBenchmarkSupport.assertJacocoAgentDisabled(); + // make sure JWKs are loaded before timing anything - this is not itself part of // the "cold start", it merely establishes the same server-side readiness as the // other benchmarks in this package @@ -115,19 +117,9 @@ public void coldStart( @AccessToken(by = "a", audience = "https://my.audience", scope = "17") String token17, @AccessToken(by = "a", audience = "https://my.audience", scope = "18") String token18, @AccessToken(by = "a", audience = "https://my.audience", scope = "19") String token19, - @AccessToken(by = "a", audience = "https://my.audience", scope = "20") String token20, - @AccessToken(by = "a", audience = "https://my.audience", scope = "21") String token21, - @AccessToken(by = "a", audience = "https://my.audience", scope = "22") String token22, - @AccessToken(by = "a", audience = "https://my.audience", scope = "23") String token23, - @AccessToken(by = "a", audience = "https://my.audience", scope = "24") String token24, - @AccessToken(by = "a", audience = "https://my.audience", scope = "25") String token25, - @AccessToken(by = "a", audience = "https://my.audience", scope = "26") String token26, - @AccessToken(by = "a", audience = "https://my.audience", scope = "27") String token27, - @AccessToken(by = "a", audience = "https://my.audience", scope = "28") String token28, - @AccessToken(by = "a", audience = "https://my.audience", scope = "29") String token29, - @AccessToken(by = "a", audience = "https://my.audience", scope = "30") String token30) { - - String[] tokens = {token1, token2, token3, token4, token5, token6, token7, token8, token9, token10, token11, token12, token13, token14, token15, token16, token17, token18, token19, token20, token21, token22, token23, token24, token25, token26, token27, token28, token29, token30}; + @AccessToken(by = "a", audience = "https://my.audience", scope = "20") String token20) { + + String[] tokens = {token1, token2, token3, token4, token5, token6, token7, token8, token9, token10, token11, token12, token13, token14, token15, token16, token17, token18, token19, token20}; ManagedChannel[] channels = new ManagedChannel[CLIENTS]; GrpcBenchmarkSupport.GrpcCall[] calls = new GrpcBenchmarkSupport.GrpcCall[CLIENTS]; diff --git a/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkColdStartNoCacheTest.java b/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkColdStartNoCacheTest.java index c8acfe752..016367991 100644 --- a/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkColdStartNoCacheTest.java +++ b/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkColdStartNoCacheTest.java @@ -34,7 +34,7 @@ * Cold-start gRPC call benchmark, with the decoded-JWT cache disabled, * approximating {@code master} behaviour (which has no decoded-JWT cache at all). There * is no warm-up phase at all: the very first call is the very first thing timed. A - * single continuous run of back-to-back calls is made, round-robin across a pool of 30 + * single continuous run of back-to-back calls is made, round-robin across a pool of 20 * tokens (a realistic number of distinct client tokens seen concurrently in production), * and throughput is reported at cumulative wall-clock checkpoints of 1, 2, 3, 4, 5, 10 * and 15 seconds - both the throughput of that individual segment and the cumulative @@ -59,12 +59,12 @@ @DirtiesContext public class DecodedJwtCacheGrpcBenchmarkColdStartNoCacheTest extends AbstractGrpcTest { - private static final int[] CHECKPOINT_SECONDS = {1, 2, 3, 4, 5, 10, 15}; + private static final int[] CHECKPOINT_SECONDS = {1, 2, 3, 4, 5, 10, 15, 30, 60}; - // 15 parallel clients, 2 tokens each (30 tokens total) - matches abt-core's observed - // peak (08:00/16:00) concurrent client count, per Entur Compass, with each client - // presenting 2 distinct tokens - private static final int CLIENTS = 15; + // 10 parallel clients, 2 tokens each (20 tokens total) - reduced from abt-core's + // observed peak (08:00/16:00) concurrent client count, per Entur Compass, to + // investigate throughput scaling with fewer concurrent clients + private static final int CLIENTS = 10; private static final int TOKENS_PER_CLIENT = 2; @Autowired @@ -72,6 +72,8 @@ public class DecodedJwtCacheGrpcBenchmarkColdStartNoCacheTest extends AbstractGr @BeforeEach public void readinessProbe() throws Exception { + GrpcBenchmarkSupport.assertJacocoAgentDisabled(); + // make sure JWKs are loaded before timing anything - this is not itself part of // the "cold start", it merely establishes the same server-side readiness as the // other benchmarks in this package @@ -104,19 +106,9 @@ public void coldStart( @AccessToken(by = "a", audience = "https://my.audience", scope = "17") String token17, @AccessToken(by = "a", audience = "https://my.audience", scope = "18") String token18, @AccessToken(by = "a", audience = "https://my.audience", scope = "19") String token19, - @AccessToken(by = "a", audience = "https://my.audience", scope = "20") String token20, - @AccessToken(by = "a", audience = "https://my.audience", scope = "21") String token21, - @AccessToken(by = "a", audience = "https://my.audience", scope = "22") String token22, - @AccessToken(by = "a", audience = "https://my.audience", scope = "23") String token23, - @AccessToken(by = "a", audience = "https://my.audience", scope = "24") String token24, - @AccessToken(by = "a", audience = "https://my.audience", scope = "25") String token25, - @AccessToken(by = "a", audience = "https://my.audience", scope = "26") String token26, - @AccessToken(by = "a", audience = "https://my.audience", scope = "27") String token27, - @AccessToken(by = "a", audience = "https://my.audience", scope = "28") String token28, - @AccessToken(by = "a", audience = "https://my.audience", scope = "29") String token29, - @AccessToken(by = "a", audience = "https://my.audience", scope = "30") String token30) { + @AccessToken(by = "a", audience = "https://my.audience", scope = "20") String token20) { - String[] tokens = {token1, token2, token3, token4, token5, token6, token7, token8, token9, token10, token11, token12, token13, token14, token15, token16, token17, token18, token19, token20, token21, token22, token23, token24, token25, token26, token27, token28, token29, token30}; + String[] tokens = {token1, token2, token3, token4, token5, token6, token7, token8, token9, token10, token11, token12, token13, token14, token15, token16, token17, token18, token19, token20}; ManagedChannel[] channels = new ManagedChannel[CLIENTS]; GrpcBenchmarkSupport.GrpcCall[] calls = new GrpcBenchmarkSupport.GrpcCall[CLIENTS]; diff --git a/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkNoCacheTest.java b/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkNoCacheTest.java index 2535b2c3e..bcefecdf0 100644 --- a/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkNoCacheTest.java +++ b/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkNoCacheTest.java @@ -70,10 +70,10 @@ public class DecodedJwtCacheGrpcBenchmarkNoCacheTest extends AbstractGrpcTest { private static final int WARMUP_REQUESTS = 20_000; private static final int REQUESTS = 10_000; - // 15 parallel clients, 2 tokens each (30 tokens total) - matches abt-core's observed - // peak (08:00/16:00) concurrent client count, per Entur Compass, with each client - // presenting 2 distinct tokens - private static final int CLIENTS = 15; + // 10 parallel clients, 2 tokens each (20 tokens total) - reduced from abt-core's + // observed peak (08:00/16:00) concurrent client count, per Entur Compass, to + // investigate throughput scaling with fewer concurrent clients + private static final int CLIENTS = 10; private static final int TOKENS_PER_CLIENT = 2; @Autowired @@ -81,6 +81,8 @@ public class DecodedJwtCacheGrpcBenchmarkNoCacheTest extends AbstractGrpcTest { @BeforeEach public void readinessProbe() throws Exception { + GrpcBenchmarkSupport.assertJacocoAgentDisabled(); + // make sure JWKs are loaded before timing anything Map jwkSources = jwkSourceMap.getJwkSources(); for (Map.Entry entry : jwkSources.entrySet()) { @@ -133,19 +135,9 @@ public void tokenPoolReused( @AccessToken(by = "a", audience = "https://my.audience", scope = "17") String token17, @AccessToken(by = "a", audience = "https://my.audience", scope = "18") String token18, @AccessToken(by = "a", audience = "https://my.audience", scope = "19") String token19, - @AccessToken(by = "a", audience = "https://my.audience", scope = "20") String token20, - @AccessToken(by = "a", audience = "https://my.audience", scope = "21") String token21, - @AccessToken(by = "a", audience = "https://my.audience", scope = "22") String token22, - @AccessToken(by = "a", audience = "https://my.audience", scope = "23") String token23, - @AccessToken(by = "a", audience = "https://my.audience", scope = "24") String token24, - @AccessToken(by = "a", audience = "https://my.audience", scope = "25") String token25, - @AccessToken(by = "a", audience = "https://my.audience", scope = "26") String token26, - @AccessToken(by = "a", audience = "https://my.audience", scope = "27") String token27, - @AccessToken(by = "a", audience = "https://my.audience", scope = "28") String token28, - @AccessToken(by = "a", audience = "https://my.audience", scope = "29") String token29, - @AccessToken(by = "a", audience = "https://my.audience", scope = "30") String token30) { - - String[] tokens = {token1, token2, token3, token4, token5, token6, token7, token8, token9, token10, token11, token12, token13, token14, token15, token16, token17, token18, token19, token20, token21, token22, token23, token24, token25, token26, token27, token28, token29, token30}; + @AccessToken(by = "a", audience = "https://my.audience", scope = "20") String token20) { + + String[] tokens = {token1, token2, token3, token4, token5, token6, token7, token8, token9, token10, token11, token12, token13, token14, token15, token16, token17, token18, token19, token20}; ManagedChannel[] channels = new ManagedChannel[CLIENTS]; GrpcBenchmarkSupport.GrpcCall[] calls = new GrpcBenchmarkSupport.GrpcCall[CLIENTS]; diff --git a/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/GrpcBenchmarkSupport.java b/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/GrpcBenchmarkSupport.java index 142f21f67..a63798319 100644 --- a/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/GrpcBenchmarkSupport.java +++ b/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/GrpcBenchmarkSupport.java @@ -1,5 +1,6 @@ package org.entur.jwt.spring.grpc.perf; +import java.lang.management.ManagementFactory; import java.util.concurrent.CyclicBarrier; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -27,6 +28,21 @@ record Stats(String label, double avgLatencyMicros, double opsPerSecond) { private GrpcBenchmarkSupport() { } + /** + * Fails fast if the JaCoCo instrumentation agent is attached to this JVM: coverage + * instrumentation adds substantial, uneven per-call overhead that invalidates timing + * measurements. Run with {@code -Djacoco.skip=true} (or otherwise without the agent). + */ + static void assertJacocoAgentDisabled() { + boolean jacocoActive = ManagementFactory.getRuntimeMXBean().getInputArguments().stream() + .anyMatch(arg -> arg.contains("jacoco")); + if (jacocoActive) { + throw new IllegalStateException( + "JaCoCo agent is attached to this JVM - re-run with -Djacoco.skip=true, " + + "coverage instrumentation invalidates benchmark timings"); + } + } + /** * Runs {@code calls.length} client threads in parallel (one dedicated call/client * per thread), each issuing {@code requestsPerClient} back-to-back calls (after an diff --git a/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/DecodedJwtCacheHttpBenchmarkCachedTest.java b/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/DecodedJwtCacheHttpBenchmarkCachedTest.java index a471f3fc3..06935320e 100644 --- a/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/DecodedJwtCacheHttpBenchmarkCachedTest.java +++ b/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/DecodedJwtCacheHttpBenchmarkCachedTest.java @@ -68,10 +68,10 @@ public class DecodedJwtCacheHttpBenchmarkCachedTest extends AbstractActuatorTest private static final int WARMUP_REQUESTS = 20_000; private static final int REQUESTS = 10_000; - // 15 parallel clients, 2 tokens each (30 tokens total) - matches abt-core's observed - // peak (08:00/16:00) concurrent client count, per Entur Compass, with each client - // presenting 2 distinct tokens - private static final int CLIENTS = 15; + // 10 parallel clients, 2 tokens each (20 tokens total) - reduced from abt-core's + // observed peak (08:00/16:00) concurrent client count, per Entur Compass, to + // investigate throughput scaling with fewer concurrent clients + private static final int CLIENTS = 10; private static final int TOKENS_PER_CLIENT = 2; @LocalServerPort @@ -85,6 +85,8 @@ public class DecodedJwtCacheHttpBenchmarkCachedTest extends AbstractActuatorTest @BeforeEach public void readinessProbe() throws Exception { + HttpBenchmarkSupport.assertJacocoAgentDisabled(); + // make sure JWKs are loaded before timing anything HttpHeaders headers = new HttpHeaders(); HttpEntity entity = new HttpEntity<>(headers); @@ -142,19 +144,9 @@ public void tokenPoolReused( @AccessToken(by = "a", audience = "mock.my.audience", scope = "17") String token17, @AccessToken(by = "a", audience = "mock.my.audience", scope = "18") String token18, @AccessToken(by = "a", audience = "mock.my.audience", scope = "19") String token19, - @AccessToken(by = "a", audience = "mock.my.audience", scope = "20") String token20, - @AccessToken(by = "a", audience = "mock.my.audience", scope = "21") String token21, - @AccessToken(by = "a", audience = "mock.my.audience", scope = "22") String token22, - @AccessToken(by = "a", audience = "mock.my.audience", scope = "23") String token23, - @AccessToken(by = "a", audience = "mock.my.audience", scope = "24") String token24, - @AccessToken(by = "a", audience = "mock.my.audience", scope = "25") String token25, - @AccessToken(by = "a", audience = "mock.my.audience", scope = "26") String token26, - @AccessToken(by = "a", audience = "mock.my.audience", scope = "27") String token27, - @AccessToken(by = "a", audience = "mock.my.audience", scope = "28") String token28, - @AccessToken(by = "a", audience = "mock.my.audience", scope = "29") String token29, - @AccessToken(by = "a", audience = "mock.my.audience", scope = "30") String token30) { - - String[] tokens = {token1, token2, token3, token4, token5, token6, token7, token8, token9, token10, token11, token12, token13, token14, token15, token16, token17, token18, token19, token20, token21, token22, token23, token24, token25, token26, token27, token28, token29, token30}; + @AccessToken(by = "a", audience = "mock.my.audience", scope = "20") String token20) { + + String[] tokens = {token1, token2, token3, token4, token5, token6, token7, token8, token9, token10, token11, token12, token13, token14, token15, token16, token17, token18, token19, token20}; HttpBenchmarkSupport.HttpCall[] calls = new HttpBenchmarkSupport.HttpCall[CLIENTS]; for (int c = 0; c < CLIENTS; c++) { diff --git a/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/DecodedJwtCacheHttpBenchmarkColdStartCachedTest.java b/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/DecodedJwtCacheHttpBenchmarkColdStartCachedTest.java index 31267a3fd..cb8475b0d 100644 --- a/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/DecodedJwtCacheHttpBenchmarkColdStartCachedTest.java +++ b/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/DecodedJwtCacheHttpBenchmarkColdStartCachedTest.java @@ -31,9 +31,9 @@ * Cold-start HTTP request benchmark, with the decoded-JWT cache enabled. Unlike * {@link DecodedJwtCacheHttpBenchmarkCachedTest}, there is no warm-up phase at all: the * very first request is the very first thing timed. A single continuous run of - * back-to-back requests is made, round-robin across a pool of 30 tokens (a realistic + * back-to-back requests is made, round-robin across a pool of 20 tokens (a realistic * number of distinct client tokens seen concurrently in production), and throughput is reported at - * cumulative wall-clock checkpoints of 1, 2, 3, 4, 5, 10 and 15 seconds - both the + * cumulative wall-clock checkpoints of 1, 2, 3, 4, 5, 10, 15, 30 and 60 seconds - both the * throughput of that individual segment and the cumulative throughput since the first * request - so the cache warm-up curve (cold JIT/connection-pool/first-decode vs. steady * state) is visible within a single run. @@ -59,12 +59,12 @@ }) public class DecodedJwtCacheHttpBenchmarkColdStartCachedTest extends AbstractActuatorTest { - private static final int[] CHECKPOINT_SECONDS = {1, 2, 3, 4, 5, 10, 15}; + private static final int[] CHECKPOINT_SECONDS = {1, 2, 3, 4, 5, 10, 15, 30, 60}; - // 15 parallel clients, 2 tokens each (30 tokens total) - matches abt-core's observed - // peak (08:00/16:00) concurrent client count, per Entur Compass, with each client - // presenting 2 distinct tokens - private static final int CLIENTS = 15; + // 10 parallel clients, 2 tokens each (20 tokens total) - reduced from abt-core's + // observed peak (08:00/16:00) concurrent client count, per Entur Compass, to + // investigate throughput scaling with fewer concurrent clients + private static final int CLIENTS = 10; private static final int TOKENS_PER_CLIENT = 2; @LocalServerPort @@ -78,6 +78,8 @@ public class DecodedJwtCacheHttpBenchmarkColdStartCachedTest extends AbstractAct @BeforeEach public void readinessProbe() throws Exception { + HttpBenchmarkSupport.assertJacocoAgentDisabled(); + // make sure JWKs are loaded before timing anything - this is not itself part of // the "cold start", it merely establishes the same server-side readiness as the // other benchmarks in this package @@ -121,19 +123,9 @@ public void coldStart( @AccessToken(by = "a", audience = "mock.my.audience", scope = "17") String token17, @AccessToken(by = "a", audience = "mock.my.audience", scope = "18") String token18, @AccessToken(by = "a", audience = "mock.my.audience", scope = "19") String token19, - @AccessToken(by = "a", audience = "mock.my.audience", scope = "20") String token20, - @AccessToken(by = "a", audience = "mock.my.audience", scope = "21") String token21, - @AccessToken(by = "a", audience = "mock.my.audience", scope = "22") String token22, - @AccessToken(by = "a", audience = "mock.my.audience", scope = "23") String token23, - @AccessToken(by = "a", audience = "mock.my.audience", scope = "24") String token24, - @AccessToken(by = "a", audience = "mock.my.audience", scope = "25") String token25, - @AccessToken(by = "a", audience = "mock.my.audience", scope = "26") String token26, - @AccessToken(by = "a", audience = "mock.my.audience", scope = "27") String token27, - @AccessToken(by = "a", audience = "mock.my.audience", scope = "28") String token28, - @AccessToken(by = "a", audience = "mock.my.audience", scope = "29") String token29, - @AccessToken(by = "a", audience = "mock.my.audience", scope = "30") String token30) { - - String[] tokens = {token1, token2, token3, token4, token5, token6, token7, token8, token9, token10, token11, token12, token13, token14, token15, token16, token17, token18, token19, token20, token21, token22, token23, token24, token25, token26, token27, token28, token29, token30}; + @AccessToken(by = "a", audience = "mock.my.audience", scope = "20") String token20) { + + String[] tokens = {token1, token2, token3, token4, token5, token6, token7, token8, token9, token10, token11, token12, token13, token14, token15, token16, token17, token18, token19, token20}; HttpBenchmarkSupport.HttpCall[] calls = new HttpBenchmarkSupport.HttpCall[CLIENTS]; for (int c = 0; c < CLIENTS; c++) { diff --git a/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/DecodedJwtCacheHttpBenchmarkColdStartNoCacheTest.java b/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/DecodedJwtCacheHttpBenchmarkColdStartNoCacheTest.java index 9c42c6c9d..e5e159d2f 100644 --- a/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/DecodedJwtCacheHttpBenchmarkColdStartNoCacheTest.java +++ b/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/DecodedJwtCacheHttpBenchmarkColdStartNoCacheTest.java @@ -28,7 +28,7 @@ * Cold-start HTTP request benchmark, with the decoded-JWT cache disabled, * approximating {@code master} behaviour (which has no decoded-JWT cache at all). There * is no warm-up phase at all: the very first request is the very first thing timed. A - * single continuous run of back-to-back requests is made, round-robin across a pool of 30 + * single continuous run of back-to-back requests is made, round-robin across a pool of 20 * tokens (a realistic number of distinct client tokens seen concurrently in production), * and throughput is reported at cumulative wall-clock checkpoints of 1, 2, 3, 4, 5, 10 * and 15 seconds - both the throughput of that individual segment and the cumulative @@ -53,12 +53,12 @@ }) public class DecodedJwtCacheHttpBenchmarkColdStartNoCacheTest extends AbstractActuatorTest { - private static final int[] CHECKPOINT_SECONDS = {1, 2, 3, 4, 5, 10, 15}; + private static final int[] CHECKPOINT_SECONDS = {1, 2, 3, 4, 5, 10, 15, 30, 60}; - // 15 parallel clients, 2 tokens each (30 tokens total) - matches abt-core's observed - // peak (08:00/16:00) concurrent client count, per Entur Compass, with each client - // presenting 2 distinct tokens - private static final int CLIENTS = 15; + // 10 parallel clients, 2 tokens each (20 tokens total) - reduced from abt-core's + // observed peak (08:00/16:00) concurrent client count, per Entur Compass, to + // investigate throughput scaling with fewer concurrent clients + private static final int CLIENTS = 10; private static final int TOKENS_PER_CLIENT = 2; @LocalServerPort @@ -69,6 +69,8 @@ public class DecodedJwtCacheHttpBenchmarkColdStartNoCacheTest extends AbstractAc @BeforeEach public void readinessProbe() throws Exception { + HttpBenchmarkSupport.assertJacocoAgentDisabled(); + // make sure JWKs are loaded before timing anything - this is not itself part of // the "cold start", it merely establishes the same server-side readiness as the // other benchmarks in this package @@ -108,19 +110,9 @@ public void coldStart( @AccessToken(by = "a", audience = "mock.my.audience", scope = "17") String token17, @AccessToken(by = "a", audience = "mock.my.audience", scope = "18") String token18, @AccessToken(by = "a", audience = "mock.my.audience", scope = "19") String token19, - @AccessToken(by = "a", audience = "mock.my.audience", scope = "20") String token20, - @AccessToken(by = "a", audience = "mock.my.audience", scope = "21") String token21, - @AccessToken(by = "a", audience = "mock.my.audience", scope = "22") String token22, - @AccessToken(by = "a", audience = "mock.my.audience", scope = "23") String token23, - @AccessToken(by = "a", audience = "mock.my.audience", scope = "24") String token24, - @AccessToken(by = "a", audience = "mock.my.audience", scope = "25") String token25, - @AccessToken(by = "a", audience = "mock.my.audience", scope = "26") String token26, - @AccessToken(by = "a", audience = "mock.my.audience", scope = "27") String token27, - @AccessToken(by = "a", audience = "mock.my.audience", scope = "28") String token28, - @AccessToken(by = "a", audience = "mock.my.audience", scope = "29") String token29, - @AccessToken(by = "a", audience = "mock.my.audience", scope = "30") String token30) { - - String[] tokens = {token1, token2, token3, token4, token5, token6, token7, token8, token9, token10, token11, token12, token13, token14, token15, token16, token17, token18, token19, token20, token21, token22, token23, token24, token25, token26, token27, token28, token29, token30}; + @AccessToken(by = "a", audience = "mock.my.audience", scope = "20") String token20) { + + String[] tokens = {token1, token2, token3, token4, token5, token6, token7, token8, token9, token10, token11, token12, token13, token14, token15, token16, token17, token18, token19, token20}; HttpBenchmarkSupport.HttpCall[] calls = new HttpBenchmarkSupport.HttpCall[CLIENTS]; for (int c = 0; c < CLIENTS; c++) { diff --git a/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/DecodedJwtCacheHttpBenchmarkNoCacheTest.java b/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/DecodedJwtCacheHttpBenchmarkNoCacheTest.java index 698fdd699..485f4cdb8 100644 --- a/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/DecodedJwtCacheHttpBenchmarkNoCacheTest.java +++ b/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/DecodedJwtCacheHttpBenchmarkNoCacheTest.java @@ -63,10 +63,10 @@ public class DecodedJwtCacheHttpBenchmarkNoCacheTest extends AbstractActuatorTes private static final int WARMUP_REQUESTS = 20_000; private static final int REQUESTS = 10_000; - // 15 parallel clients, 2 tokens each (30 tokens total) - matches abt-core's observed - // peak (08:00/16:00) concurrent client count, per Entur Compass, with each client - // presenting 2 distinct tokens - private static final int CLIENTS = 15; + // 10 parallel clients, 2 tokens each (20 tokens total) - reduced from abt-core's + // observed peak (08:00/16:00) concurrent client count, per Entur Compass, to + // investigate throughput scaling with fewer concurrent clients + private static final int CLIENTS = 10; private static final int TOKENS_PER_CLIENT = 2; @LocalServerPort @@ -77,6 +77,8 @@ public class DecodedJwtCacheHttpBenchmarkNoCacheTest extends AbstractActuatorTes @BeforeEach public void readinessProbe() throws Exception { + HttpBenchmarkSupport.assertJacocoAgentDisabled(); + // make sure JWKs are loaded before timing anything HttpHeaders headers = new HttpHeaders(); HttpEntity entity = new HttpEntity<>(headers); @@ -130,19 +132,9 @@ public void tokenPoolReused( @AccessToken(by = "a", audience = "mock.my.audience", scope = "17") String token17, @AccessToken(by = "a", audience = "mock.my.audience", scope = "18") String token18, @AccessToken(by = "a", audience = "mock.my.audience", scope = "19") String token19, - @AccessToken(by = "a", audience = "mock.my.audience", scope = "20") String token20, - @AccessToken(by = "a", audience = "mock.my.audience", scope = "21") String token21, - @AccessToken(by = "a", audience = "mock.my.audience", scope = "22") String token22, - @AccessToken(by = "a", audience = "mock.my.audience", scope = "23") String token23, - @AccessToken(by = "a", audience = "mock.my.audience", scope = "24") String token24, - @AccessToken(by = "a", audience = "mock.my.audience", scope = "25") String token25, - @AccessToken(by = "a", audience = "mock.my.audience", scope = "26") String token26, - @AccessToken(by = "a", audience = "mock.my.audience", scope = "27") String token27, - @AccessToken(by = "a", audience = "mock.my.audience", scope = "28") String token28, - @AccessToken(by = "a", audience = "mock.my.audience", scope = "29") String token29, - @AccessToken(by = "a", audience = "mock.my.audience", scope = "30") String token30) { - - String[] tokens = {token1, token2, token3, token4, token5, token6, token7, token8, token9, token10, token11, token12, token13, token14, token15, token16, token17, token18, token19, token20, token21, token22, token23, token24, token25, token26, token27, token28, token29, token30}; + @AccessToken(by = "a", audience = "mock.my.audience", scope = "20") String token20) { + + String[] tokens = {token1, token2, token3, token4, token5, token6, token7, token8, token9, token10, token11, token12, token13, token14, token15, token16, token17, token18, token19, token20}; HttpBenchmarkSupport.HttpCall[] calls = new HttpBenchmarkSupport.HttpCall[CLIENTS]; for (int c = 0; c < CLIENTS; c++) { diff --git a/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/HttpBenchmarkSupport.java b/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/HttpBenchmarkSupport.java index 0e60aec30..df32a3c77 100644 --- a/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/HttpBenchmarkSupport.java +++ b/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/HttpBenchmarkSupport.java @@ -1,5 +1,6 @@ package org.entur.jwt.spring.perf; +import java.lang.management.ManagementFactory; import java.util.concurrent.CyclicBarrier; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -25,6 +26,21 @@ record Stats(String label, double avgLatencyMicros, double opsPerSecond) { private HttpBenchmarkSupport() { } + /** + * Fails fast if the JaCoCo instrumentation agent is attached to this JVM: coverage + * instrumentation adds substantial, uneven per-call overhead that invalidates timing + * measurements. Run with {@code -Djacoco.skip=true} (or otherwise without the agent). + */ + static void assertJacocoAgentDisabled() { + boolean jacocoActive = ManagementFactory.getRuntimeMXBean().getInputArguments().stream() + .anyMatch(arg -> arg.contains("jacoco")); + if (jacocoActive) { + throw new IllegalStateException( + "JaCoCo agent is attached to this JVM - re-run with -Djacoco.skip=true, " + + "coverage instrumentation invalidates benchmark timings"); + } + } + /** * Runs {@code calls.length} client threads in parallel (one dedicated call/client * per thread), each issuing {@code requestsPerClient} back-to-back requests (after From f8b1fcdf42b2ce901b762d9f7fe381631b2c0b39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Skj=C3=B8lberg?= Date: Thu, 1 Oct 2026 00:51:29 +0200 Subject: [PATCH 5/5] Adapt benchmarks to DecodedJwtCacheJwkEventListener and GrpcJwtDecoderHolder Co-Authored-By: Claude Opus 5.5 --- .../decode/cache/DecodedJwtCacheJwtDecoderBenchmarkTest.java | 2 +- .../grpc/perf/DecodedJwtCacheGrpcBenchmarkCachedTest.java | 4 +++- .../perf/DecodedJwtCacheGrpcBenchmarkColdStartCachedTest.java | 4 +++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/jwt-server/spring/jwt-spring-common/src/test/java/org/entur/jwt/spring/decode/cache/DecodedJwtCacheJwtDecoderBenchmarkTest.java b/jwt-server/spring/jwt-spring-common/src/test/java/org/entur/jwt/spring/decode/cache/DecodedJwtCacheJwtDecoderBenchmarkTest.java index bbd3569e0..2552fc084 100644 --- a/jwt-server/spring/jwt-spring-common/src/test/java/org/entur/jwt/spring/decode/cache/DecodedJwtCacheJwtDecoderBenchmarkTest.java +++ b/jwt-server/spring/jwt-spring-common/src/test/java/org/entur/jwt/spring/decode/cache/DecodedJwtCacheJwtDecoderBenchmarkTest.java @@ -188,7 +188,7 @@ private DecodedJwtCacheJwtDecoder cachedDecoder(JwtDecoder delegate, RSAKey sign JWKSet jwkSet = new JWKSet(List.of(signingKey.toPublicJWK())); CachingJWKSetSource.RefreshCompletedEvent event = mock(CachingJWKSetSource.RefreshCompletedEvent.class); when(event.getJWKSet()).thenReturn(jwkSet); - cachedDecoder.notify(event); + new DecodedJwtCacheJwkEventListener(cachedDecoder).notify(event); return cachedDecoder; } diff --git a/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkCachedTest.java b/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkCachedTest.java index 7af6cbbcc..8b88e2458 100644 --- a/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkCachedTest.java +++ b/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkCachedTest.java @@ -9,6 +9,7 @@ import org.entur.jwt.junit5.AuthorizationServer; import org.entur.jwt.spring.JwkSourceMap; import org.entur.jwt.spring.decode.cache.DecodedJwtCacheJwtDecoder; +import org.entur.jwt.spring.grpc.netty.GrpcJwtDecoderHolder; import org.entur.jwt.spring.grpc.AbstractGrpcTest; import org.entur.jwt.spring.grpc.JwtCallCredentials; import org.entur.jwt.spring.grpc.test.GreetingResponse; @@ -82,7 +83,7 @@ public class DecodedJwtCacheGrpcBenchmarkCachedTest extends AbstractGrpcTest { private static final int TOKENS_PER_CLIENT = 2; @Autowired - private JwtDecoder jwtDecoder; + private GrpcJwtDecoderHolder grpcJwtDecoderHolder; @Autowired private JwkSourceMap jwkSourceMap; @@ -99,6 +100,7 @@ public void readinessProbe() throws Exception { entry.getValue().get(selector, null); } + JwtDecoder jwtDecoder = grpcJwtDecoderHolder.getJwtDecoder(); assertThat(jwtDecoder).isInstanceOf(DecodedJwtCacheJwtDecoder.class); ((DecodedJwtCacheJwtDecoder) jwtDecoder).clear(); } diff --git a/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkColdStartCachedTest.java b/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkColdStartCachedTest.java index 40e94a846..3e7b1003a 100644 --- a/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkColdStartCachedTest.java +++ b/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkColdStartCachedTest.java @@ -9,6 +9,7 @@ import org.entur.jwt.junit5.AuthorizationServer; import org.entur.jwt.spring.JwkSourceMap; import org.entur.jwt.spring.decode.cache.DecodedJwtCacheJwtDecoder; +import org.entur.jwt.spring.grpc.netty.GrpcJwtDecoderHolder; import org.entur.jwt.spring.grpc.AbstractGrpcTest; import org.entur.jwt.spring.grpc.JwtCallCredentials; import org.entur.jwt.spring.grpc.test.GreetingResponse; @@ -73,7 +74,7 @@ public class DecodedJwtCacheGrpcBenchmarkColdStartCachedTest extends AbstractGrp private static final int TOKENS_PER_CLIENT = 2; @Autowired - private JwtDecoder jwtDecoder; + private GrpcJwtDecoderHolder grpcJwtDecoderHolder; @Autowired private JwkSourceMap jwkSourceMap; @@ -92,6 +93,7 @@ public void readinessProbe() throws Exception { entry.getValue().get(selector, null); } + JwtDecoder jwtDecoder = grpcJwtDecoderHolder.getJwtDecoder(); assertThat(jwtDecoder).isInstanceOf(DecodedJwtCacheJwtDecoder.class); ((DecodedJwtCacheJwtDecoder) jwtDecoder).clear(); }