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..2552fc084
--- /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:
+ *
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);
+ new DecodedJwtCacheJwkEventListener(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-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..8b88e2458
--- /dev/null
+++ b/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkCachedTest.java
@@ -0,0 +1,191 @@
+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 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.netty.GrpcJwtDecoderHolder;
+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;
+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;
+
+ // 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
+ private GrpcJwtDecoderHolder grpcJwtDecoderHolder;
+
+ @Autowired
+ private JwkSourceMap jwkSourceMap;
+
+ @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()) {
+ JWKSelector selector = mock(JWKSelector.class);
+ when(selector.select(any())).thenReturn(List.of(mock(JWK.class)));
+ entry.getValue().get(selector, null);
+ }
+
+ JwtDecoder jwtDecoder = grpcJwtDecoderHolder.getJwtDecoder();
+ assertThat(jwtDecoder).isInstanceOf(DecodedJwtCacheJwtDecoder.class);
+ ((DecodedJwtCacheJwtDecoder) jwtDecoder).clear();
+ }
+
+ @Test
+ @Order(1)
+ public void singleTokenReused(@AccessToken(by = "a", audience = "https://my.audience") String 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(
+ "decoded-JWT cache enabled, single token reused (100% cache-hit rate)",
+ REQUESTS, WARMUP_REQUESTS, calls);
+
+ assertTrue(stats.opsPerSecond() > 0);
+ } finally {
+ channel.shutdown();
+ }
+ }
+
+ @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,
+ @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};
+
+ 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(
+ "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(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
new file mode 100644
index 000000000..3e7b1003a
--- /dev/null
+++ b/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkColdStartCachedTest.java
@@ -0,0 +1,160 @@
+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.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;
+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;
+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, 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.
+ *
+ * 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, 30, 60};
+
+ // 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
+ private GrpcJwtDecoderHolder grpcJwtDecoderHolder;
+
+ @Autowired
+ private JwkSourceMap jwkSourceMap;
+
+ @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
+ 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);
+ }
+
+ JwtDecoder jwtDecoder = grpcJwtDecoderHolder.getJwtDecoder();
+ 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};
+
+ 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.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(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
new file mode 100644
index 000000000..016367991
--- /dev/null
+++ b/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkColdStartNoCacheTest.java
@@ -0,0 +1,147 @@
+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;
+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, 30, 60};
+
+ // 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
+ private JwkSourceMap jwkSourceMap;
+
+ @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
+ 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};
+
+ 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.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(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
new file mode 100644
index 000000000..bcefecdf0
--- /dev/null
+++ b/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/DecodedJwtCacheGrpcBenchmarkNoCacheTest.java
@@ -0,0 +1,178 @@
+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;
+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;
+
+ // 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
+ private JwkSourceMap jwkSourceMap;
+
+ @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()) {
+ 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) {
+ 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);
+ } finally {
+ channel.shutdown();
+ }
+ }
+
+ @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,
+ @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};
+
+ 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);
+ } finally {
+ for (ManagedChannel channel : channels) {
+ channel.shutdown();
+ }
+ }
+ }
+
+ 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
new file mode 100644
index 000000000..a63798319
--- /dev/null
+++ b/jwt-server/spring/jwt-spring-grpc-native/src/test/java/org/entur/jwt/spring/grpc/perf/GrpcBenchmarkSupport.java
@@ -0,0 +1,191 @@
+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;
+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. 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 {
+
+ interface GrpcCall {
+ void call(int i);
+ }
+
+ 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
+ * independent {@code warmupPerClient}-call warm-up), all starting at the same
+ * instant. Reports aggregate throughput across all client threads combined.
+ */
+ 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 maxDurationNanos = 0;
+ for (long d : durationsNanos) {
+ maxDurationNanos = Math.max(maxDurationNanos, d);
+ }
+
+ long totalCalls = (long) requestsPerClient * clients;
+ double avgLatencyMicros = (maxDurationNanos / 1000.0) / requestsPerClient;
+ double opsPerSecond = totalCalls / (maxDurationNanos / 1_000_000_000.0);
+
+ 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);
+ }
+
+ /**
+ * 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 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[] callsAtSegmentStart = new int[clients];
+
+ for (int checkpointSecond : checkpointSeconds) {
+ long checkpointNanos = runStart + checkpointSecond * 1_000_000_000L;
+ 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;
+ }
+
+ double segmentSeconds = (now - segmentStart) / 1_000_000_000.0;
+ double segmentOpsPerSecond = segmentCalls / segmentSeconds;
+
+ System.out.printf(
+ "[%s] t=%ds: %d parallel clients, segment %,d calls in %.2fs (%.0f calls/sec)%n",
+ label, checkpointSecond, clients, segmentCalls, segmentSeconds, segmentOpsPerSecond);
+
+ segmentStart = now;
+ }
+
+ 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
new file mode 100644
index 000000000..06935320e
--- /dev/null
+++ b/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/DecodedJwtCacheHttpBenchmarkCachedTest.java
@@ -0,0 +1,168 @@
+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.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;
+
+ // 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
+ private int randomServerPort;
+
+ @Autowired
+ private TestRestTemplate restTemplate;
+
+ @Autowired
+ private ClosableJwtDecoders closableJwtDecoders;
+
+ @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);
+
+ 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) {
+ 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, calls);
+
+ 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,
+ @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.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.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);
+ }
+}
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..cb8475b0d
--- /dev/null
+++ b/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/DecodedJwtCacheHttpBenchmarkColdStartCachedTest.java
@@ -0,0 +1,145 @@
+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.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, 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.
+ *
+ * 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, 30, 60};
+
+ // 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
+ private int randomServerPort;
+
+ @Autowired
+ private TestRestTemplate restTemplate;
+
+ @Autowired
+ private ClosableJwtDecoders closableJwtDecoders;
+
+ @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
+ 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.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.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
new file mode 100644
index 000000000..e5e159d2f
--- /dev/null
+++ b/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/DecodedJwtCacheHttpBenchmarkColdStartNoCacheTest.java
@@ -0,0 +1,132 @@
+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.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, 30, 60};
+
+ // 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
+ private int randomServerPort;
+
+ @Autowired
+ private TestRestTemplate restTemplate;
+
+ @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
+ 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.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.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
new file mode 100644
index 000000000..485f4cdb8
--- /dev/null
+++ b/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/DecodedJwtCacheHttpBenchmarkNoCacheTest.java
@@ -0,0 +1,156 @@
+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.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;
+
+ // 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
+ private int randomServerPort;
+
+ @Autowired
+ private TestRestTemplate restTemplate;
+
+ @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);
+
+ 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) {
+ 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, calls);
+
+ 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,
+ @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.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.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);
+ }
+}
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..df32a3c77
--- /dev/null
+++ b/jwt-server/spring/jwt-spring-web/src/test/java/org/entur/jwt/spring/perf/HttpBenchmarkSupport.java
@@ -0,0 +1,189 @@
+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;
+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 {
+
+ interface HttpCall {
+ void request(int i);
+ }
+
+ 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
+ * an independent {@code warmupPerClient}-request warm-up), all starting at the same
+ * instant. Reports aggregate throughput across all client threads combined.
+ */
+ 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 maxDurationNanos = 0;
+ for (long d : durationsNanos) {
+ maxDurationNanos = Math.max(maxDurationNanos, d);
+ }
+
+ long totalRequests = (long) requestsPerClient * clients;
+ double avgLatencyMicros = (maxDurationNanos / 1000.0) / requestsPerClient;
+ double opsPerSecond = totalRequests / (maxDurationNanos / 1_000_000_000.0);
+
+ 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);
+ }
+
+ /**
+ * 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 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[] callsAtSegmentStart = new int[clients];
+
+ for (int checkpointSecond : checkpointSeconds) {
+ long checkpointNanos = runStart + checkpointSecond * 1_000_000_000L;
+ 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;
+ }
+
+ double segmentSeconds = (now - segmentStart) / 1_000_000_000.0;
+ double segmentOpsPerSecond = segmentCalls / segmentSeconds;
+
+ System.out.printf(
+ "[%s] t=%ds: %d parallel clients, segment %,d requests in %.2fs (%.0f requests/sec)%n",
+ label, checkpointSecond, clients, segmentCalls, segmentSeconds, segmentOpsPerSecond);
+
+ segmentStart = now;
+ }
+
+ 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());
+ }
+ }
+}