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: + *

+ * 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); + 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()); + } + } +}