diff --git a/docs/performance/apple-login-323.md b/docs/performance/apple-login-323.md new file mode 100644 index 00000000..7489b08a --- /dev/null +++ b/docs/performance/apple-login-323.md @@ -0,0 +1,77 @@ +# Apple Login Performance Evidence for Issue #323 + +## Measurement Contract + +This document records before/after evidence for reducing Apple login latency by removing repeated Apple provider round trips from the returning Apple User login path. + +Primary benchmark environment: + +- Backend: local Spring Boot process with `bench` profile +- Database: isolated Docker MySQL database `ontime_bench` +- Apple provider: local stub +- Returning Apple Users: `social_type=APPLE`, `social_id=bench-apple-user-` +- Stub delay: + - `GET /auth/keys`: 80 ms + - `POST /auth/token`: 300 ms +- Quick run: default harness settings, 2 minutes warmup and 5 minutes measurement, 1 run per scenario +- Full evidence run: bounded local run, 30 seconds warmup and 60 seconds measurement, 3 runs per scenario +- Before checkout: `origin/main` (`ab5fc7f`) plus benchmark harness only (`85b2b18`) +- After checkout: same benchmark harness plus the #323 fix under test + +The benchmark harness lives in `scripts/benchmarks/apple-login/`. + +## Acceptance Gate + +Primary scenario: returning Apple User, warm cache, concurrency 1. + +- JWKS network calls/request: `before 1.0 -> after 0.0` +- Apple token exchange calls/request: `before 1.0 -> after 0.0` +- Error rate: `0%` +- p95 latency: `after <= before * 0.70` + +Secondary scenarios: returning Apple User, concurrency 10 and 20. + +- Error rate: `0%` +- Apple token exchange calls/request: `after 0.0` +- p95 latency: `after <= before` + +## Results + +The full rows aggregate 3 runs per scenario by summing requests/provider calls and averaging per-run p50/p95 latency. + +| mode | scenario | version | runs | requests | error_rate | p50_ms | p95_ms | jwks_calls/request | token_exchange_calls/request | +| --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| quick | c1 returning warm-cache | before | 1 | 590 | 0.000 | 405.6 | 425.5 | 1.000 | 1.000 | +| quick | c1 returning warm-cache | after | 1 | 2,327 | 0.000 | 26.0 | 40.1 | 0.000 | 0.000 | +| quick | c10 returning warm-cache | before | 1 | 5,773 | 0.000 | 417.9 | 435.1 | 1.000 | 1.000 | +| quick | c10 returning warm-cache | after | 1 | 22,366 | 0.000 | 33.7 | 46.5 | 0.000 | 0.000 | +| quick | c20 returning warm-cache | before | 1 | 11,745 | 0.000 | 408.0 | 431.2 | 1.000 | 1.000 | +| quick | c20 returning warm-cache | after | 1 | 47,844 | 0.000 | 20.7 | 46.8 | 0.000 | 0.000 | +| full | c1 returning warm-cache | before | 3 | 346 | 0.000 | 413.6 | 435.8 | 1.000 | 1.000 | +| full | c1 returning warm-cache | after | 3 | 1,407 | 0.000 | 23.1 | 46.5 | 0.000 | 0.000 | +| full | c10 returning warm-cache | before | 3 | 3,492 | 0.000 | 415.1 | 429.1 | 1.000 | 1.000 | +| full | c10 returning warm-cache | after | 3 | 13,425 | 0.000 | 29.3 | 58.5 | 0.000 | 0.000 | +| full | c20 returning warm-cache | before | 3 | 6,829 | 0.000 | 420.4 | 506.9 | 1.000 | 1.000 | +| full | c20 returning warm-cache | after | 3 | 27,824 | 0.000 | 19.9 | 60.4 | 0.000 | 0.000 | + +## Result Files + +- Quick before: `scripts/benchmarks/apple-login/results/20260628T043649Z-before-quick/summary.csv` +- Quick after: `scripts/benchmarks/apple-login/results/20260628T045808Z-after-quick/summary.csv` +- Full before: `scripts/benchmarks/apple-login/results/20260628T051949Z-before-full/summary.csv` +- Full after: `scripts/benchmarks/apple-login/results/20260628T053451Z-after-full/summary.csv` + +## Decision + +The #323 fix passes the acceptance gate in both quick and bounded full runs: + +- c1 p95 improved from 435.8 ms to 46.5 ms in the full run, an 89.3% reduction. +- Returning Apple User login used 0.0 JWKS network calls/request after warmup. +- Returning Apple User login used 0.0 Apple token exchange calls/request after the fix. +- Error rate stayed at 0% across c1, c10, and c20. + +## Notes + +- Real Apple network calls are intentionally excluded from the primary benchmark because provider and network variability would obscure backend request-path changes. +- The script labels results as `before` or `after` but does not change git refs. Select the checkout explicitly before running. +- The original single-user concurrency harness caused c10 failures unrelated to #323 because concurrent logins for one **User** raced on single-active-session token rotation. The harness now seeds one returning Apple User per VU so c10/c20 measure Apple provider round trips rather than same-user session contention. diff --git a/ontime-back/src/main/java/devkor/ontime_back/global/oauth/apple/AppleLoginFilter.java b/ontime-back/src/main/java/devkor/ontime_back/global/oauth/apple/AppleLoginFilter.java index ad842cce..41847b46 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/global/oauth/apple/AppleLoginFilter.java +++ b/ontime-back/src/main/java/devkor/ontime_back/global/oauth/apple/AppleLoginFilter.java @@ -74,8 +74,9 @@ public Authentication attemptAuthentication(HttpServletRequest request, HttpServ } try { - // Apple Identity Token 검증 + long startedAt = System.nanoTime(); Claims tokenClaims = appleLoginService.verifyIdentityToken(oAuthAppleRequestDto.getIdToken()); + long verifiedAt = System.nanoTime(); if (tokenClaims.getSubject() == null) { throw new IllegalStateException("Apple 로그인 검증 실패"); } @@ -85,18 +86,21 @@ public Authentication attemptAuthentication(HttpServletRequest request, HttpServ oAuthAppleRequestDto.getEmail(), tokenClaims.get("email", String.class) ); - String appleRefreshToken = exchangeAppleRefreshToken(oAuthAppleRequestDto); - OAuthAppleUserDto oAuthAppleUserDto = new OAuthAppleUserDto(appleUserId, email, oAuthAppleRequestDto.getFullName()); Optional existingUser = userRepository.findBySocialTypeAndSocialId(SocialType.APPLE, appleUserId); + long userLookupAt = System.nanoTime(); if (existingUser.isPresent()) { - return appleLoginService.handleLogin(appleRefreshToken, existingUser.get(), response); - } else { - return appleLoginService.handleRegister(appleRefreshToken, oAuthAppleUserDto, response); + logAppleLoginTiming("existing_user", startedAt, verifiedAt, userLookupAt, userLookupAt); + return appleLoginService.handleLogin(null, existingUser.get(), response); } + String appleRefreshToken = exchangeAppleRefreshToken(oAuthAppleRequestDto); + long credentialExchangeAt = System.nanoTime(); + logAppleLoginTiming("new_user", startedAt, verifiedAt, userLookupAt, credentialExchangeAt); + return appleLoginService.handleRegister(appleRefreshToken, oAuthAppleUserDto, response); + } catch (Exception e) { log.error("Apple login failed", e); throw new AppleLoginException(); @@ -115,6 +119,21 @@ private String exchangeAppleRefreshToken(OAuthAppleRequestDto oAuthAppleRequestD } } + private void logAppleLoginTiming(String result, long startedAt, long verifiedAt, long userLookupAt, long credentialExchangeAt) { + log.info( + "Apple login stage timing result={} verifyMs={} dbLookupMs={} credentialExchangeMs={} totalMs={}", + result, + elapsedMillis(startedAt, verifiedAt), + elapsedMillis(verifiedAt, userLookupAt), + elapsedMillis(userLookupAt, credentialExchangeAt), + elapsedMillis(startedAt, credentialExchangeAt) + ); + } + + private long elapsedMillis(long from, long to) { + return (to - from) / 1_000_000; + } + private String firstNonBlank(String first, String second) { if (first != null && !first.isBlank()) { return first; diff --git a/ontime-back/src/main/java/devkor/ontime_back/global/oauth/apple/AppleLoginService.java b/ontime-back/src/main/java/devkor/ontime_back/global/oauth/apple/AppleLoginService.java index 1cd59a86..24c35658 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/global/oauth/apple/AppleLoginService.java +++ b/ontime-back/src/main/java/devkor/ontime_back/global/oauth/apple/AppleLoginService.java @@ -43,6 +43,7 @@ import java.security.PrivateKey; import java.security.PublicKey; import java.security.spec.PKCS8EncodedKeySpec; +import java.time.Duration; import java.time.Instant; import java.util.*; @@ -53,7 +54,12 @@ public class AppleLoginService { private static final String APPLE_KEYS_URL = "https://appleid.apple.com/auth/keys"; private static final String APPLE_TOKEN_URL = "https://appleid.apple.com/auth/token"; + private static final Duration APPLE_PUBLIC_KEY_CACHE_TTL = Duration.ofHours(24); private String issuer = "https://appleid.apple.com"; + @Value("${apple.keys-url:" + APPLE_KEYS_URL + "}") + private String appleKeysUrl = APPLE_KEYS_URL; + @Value("${apple.token-url:" + APPLE_TOKEN_URL + "}") + private String appleTokenUrl = APPLE_TOKEN_URL; @Value("${apple.client.id}") private String clientId; @Value("${apple.team.id}") @@ -76,6 +82,9 @@ public class AppleLoginService { private final AuthTokenService authTokenService; private final RestTemplate restTemplate = new RestTemplate(); + private ApplePublicKeyResponse cachedApplePublicKeys; + private Instant cachedApplePublicKeysAt; + public Authentication handleLogin(String appleRefreshToken, User user, HttpServletResponse response) throws IOException { log.info("handleLogin"); if (appleRefreshToken != null && !appleRefreshToken.isBlank()) { @@ -166,9 +175,7 @@ public Claims verifyIdentityToken(String identityToken) throws Exception { log.info("Verify Apple identity credential"); Map headers = jwtUtils.parseHeaders(identityToken); - // apple publickey - ApplePublicKeyResponse applePublicKeyResponse = restTemplate.getForObject(APPLE_KEYS_URL, ApplePublicKeyResponse.class); - PublicKey publicKey = applePublicKeyGenerator.generatePublicKey(headers, applePublicKeyResponse); + PublicKey publicKey = resolveApplePublicKey(headers); // claim Claims tokenClaims = jwtUtils.getTokenClaims(identityToken, publicKey); // iss 확인 @@ -188,6 +195,30 @@ public Claims verifyIdentityToken(String identityToken) throws return tokenClaims; } + private PublicKey resolveApplePublicKey(Map headers) throws Exception { + ApplePublicKeyResponse applePublicKeys = getCachedApplePublicKeys(); + try { + return applePublicKeyGenerator.generatePublicKey(headers, applePublicKeys); + } catch (IllegalArgumentException e) { + log.info("Apple public key cache miss for signed credential header; refreshing key set"); + return applePublicKeyGenerator.generatePublicKey(headers, refreshApplePublicKeys()); + } + } + + private synchronized ApplePublicKeyResponse getCachedApplePublicKeys() { + if (cachedApplePublicKeys == null || cachedApplePublicKeysAt == null || + cachedApplePublicKeysAt.plus(APPLE_PUBLIC_KEY_CACHE_TTL).isBefore(Instant.now())) { + return refreshApplePublicKeys(); + } + return cachedApplePublicKeys; + } + + private synchronized ApplePublicKeyResponse refreshApplePublicKeys() { + cachedApplePublicKeys = restTemplate.getForObject(appleKeysUrl, ApplePublicKeyResponse.class); + cachedApplePublicKeysAt = Instant.now(); + return cachedApplePublicKeys; + } + // apple 서버로부터 accesstoken, refreshtoken 발급 public AppleTokenResponseDto getAppleAccessTokenAndRefreshToken(String authCode) throws Exception { // clientSecret @@ -201,7 +232,7 @@ public AppleTokenResponseDto getAppleAccessTokenAndRefreshToken(String authCode) HttpEntity> requestEntity = new HttpEntity<>(requestBody, headers); ResponseEntity responseEntity = restTemplate.exchange( - APPLE_TOKEN_URL, HttpMethod.POST, requestEntity, JsonNode.class); + appleTokenUrl, HttpMethod.POST, requestEntity, JsonNode.class); JsonNode response = responseEntity.getBody(); diff --git a/ontime-back/src/main/resources/application-bench.properties b/ontime-back/src/main/resources/application-bench.properties new file mode 100644 index 00000000..4eb48855 --- /dev/null +++ b/ontime-back/src/main/resources/application-bench.properties @@ -0,0 +1,53 @@ +# Database Configuration +spring.datasource.url=${SPRING_DATASOURCE_URL} +spring.datasource.username=${SPRING_DATASOURCE_USERNAME} +spring.datasource.password=${SPRING_DATASOURCE_PASSWORD} +spring.datasource.driver-class-name=${SPRING_DATASOURCE_DRIVER_CLASS_NAME:com.mysql.cj.jdbc.Driver} + +# JPA / Hibernate +spring.jpa.database=mysql +spring.jpa.database-platform=org.hibernate.dialect.MySQL8Dialect +spring.jpa.hibernate.ddl-auto=validate +spring.jpa.show-sql=false +spring.jpa.properties.hibernate.format_sql=false + +# Flyway +spring.flyway.enabled=true +spring.flyway.baseline-on-migrate=false + +# JWT Configuration +jwt.secret.key=${JWT_SECRET_KEY} +jwt.access.expiration=${JWT_ACCESS_EXPIRATION:3600000} +jwt.refresh.expiration=${JWT_REFRESH_EXPIRATION:1209600000} +jwt.access.header=${JWT_ACCESS_HEADER:Authorization} +jwt.refresh.header=${JWT_REFRESH_HEADER:Authorization-refresh} + +# OAuth +google.web.client-id=${GOOGLE_WEB_CLIENT_ID:bench-google-web-client-id} +google.app.client-id=${GOOGLE_APP_CLIENT_ID:bench-google-app-client-id} +apple.client.id=${APPLE_CLIENT_ID} +apple.team.id=${APPLE_TEAM_ID} +apple.login.key=${APPLE_LOGIN_KEY} +apple.client.secret=${APPLE_CLIENT_SECRET:} +apple.private-key.base64=${APPLE_PRIVATE_KEY_BASE64} +apple.keys-url=${APPLE_KEYS_URL:https://appleid.apple.com/auth/keys} +apple.token-url=${APPLE_TOKEN_URL:https://appleid.apple.com/auth/token} + +# Firebase +firebase.credentials.base64=${FIREBASE_CREDENTIALS_BASE64:} + +# Logging +logging.level.root=WARN +logging.level.devkor.ontime_back=INFO + +# Feature flags +feature.apple-login.enabled=true +analytics.preference.default-enabled=${ANALYTICS_PREFERENCE_DEFAULT_ENABLED:false} + +# Actuator +management.endpoint.health.probes.enabled=true +management.endpoints.web.exposure.include=health +management.health.readinessstate.enabled=true +management.health.livenessstate.enabled=true +server.shutdown=graceful +spring.lifecycle.timeout-per-shutdown-phase=30s diff --git a/ontime-back/src/test/java/devkor/ontime_back/global/oauth/OAuthLoginFilterValidationTest.java b/ontime-back/src/test/java/devkor/ontime_back/global/oauth/OAuthLoginFilterValidationTest.java index 6e50b43f..96e7a628 100644 --- a/ontime-back/src/test/java/devkor/ontime_back/global/oauth/OAuthLoginFilterValidationTest.java +++ b/ontime-back/src/test/java/devkor/ontime_back/global/oauth/OAuthLoginFilterValidationTest.java @@ -42,6 +42,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @@ -330,7 +331,7 @@ void appleLoginFilterRejectsInvalidRequest() throws Exception { } @Test - @DisplayName("애플 로그인 필터가 기존 유저를 Apple refresh token으로 로그인 처리한다") + @DisplayName("애플 로그인 필터가 기존 유저는 Apple token 교환 없이 로그인 처리한다") void appleLoginFilterLogsInExistingUser() throws Exception { AppleLoginFilter filter = new AppleLoginFilter( "/oauth2/apple/login", @@ -341,14 +342,12 @@ void appleLoginFilterLogsInExistingUser() throws Exception { MockHttpServletResponse response = new MockHttpServletResponse(); Claims claims = Jwts.claims().setSubject("apple-id"); claims.put("email", "user@example.com"); - AppleTokenResponseDto tokenResponse = appleTokenResponse("apple-refresh-token"); User existingUser = user(1L, "user@example.com", Role.USER); when(appleLoginService.verifyIdentityToken("apple-id-token")).thenReturn(claims); - when(appleLoginService.getAppleAccessTokenAndRefreshToken("auth-code")).thenReturn(tokenResponse); when(userRepository.findBySocialTypeAndSocialId(SocialType.APPLE, "apple-id")) .thenReturn(Optional.of(existingUser)); - when(appleLoginService.handleLogin("apple-refresh-token", existingUser, response)).thenReturn( + when(appleLoginService.handleLogin(null, existingUser, response)).thenReturn( new org.springframework.security.authentication.UsernamePasswordAuthenticationToken(existingUser, null) ); @@ -356,12 +355,13 @@ void appleLoginFilterLogsInExistingUser() throws Exception { request("/oauth2/apple/login", validAppleBody()), response).getPrincipal()).isSameAs(existingUser); - verify(appleLoginService).handleLogin("apple-refresh-token", existingUser, response); + verify(appleLoginService, never()).getAppleAccessTokenAndRefreshToken("auth-code"); + verify(appleLoginService).handleLogin(null, existingUser, response); } @Test - @DisplayName("애플 로그인 필터가 Apple refresh token 교환 실패 시 identity token 검증만으로 기존 유저를 로그인 처리한다") - void appleLoginFilterLogsInExistingUserWhenTokenExchangeFails() throws Exception { + @DisplayName("애플 로그인 필터가 기존 유저 경로에서는 실패 가능한 Apple token 교환을 시도하지 않는다") + void appleLoginFilterSkipsTokenExchangeForExistingUser() throws Exception { AppleLoginFilter filter = new AppleLoginFilter( "/oauth2/apple/login", objectMapper, @@ -373,8 +373,6 @@ void appleLoginFilterLogsInExistingUserWhenTokenExchangeFails() throws Exception User existingUser = user(1L, "user@example.com", Role.USER); when(appleLoginService.verifyIdentityToken("apple-id-token")).thenReturn(claims); - when(appleLoginService.getAppleAccessTokenAndRefreshToken("auth-code")) - .thenThrow(new RuntimeException("invalid_client")); when(userRepository.findBySocialTypeAndSocialId(SocialType.APPLE, "apple-id")) .thenReturn(Optional.of(existingUser)); when(appleLoginService.handleLogin(null, existingUser, response)).thenReturn( @@ -385,6 +383,7 @@ void appleLoginFilterLogsInExistingUserWhenTokenExchangeFails() throws Exception request("/oauth2/apple/login", validAppleBody()), response).getPrincipal()).isSameAs(existingUser); + verify(appleLoginService, never()).getAppleAccessTokenAndRefreshToken("auth-code"); verify(appleLoginService).handleLogin(null, existingUser, response); } diff --git a/ontime-back/src/test/java/devkor/ontime_back/global/oauth/apple/AppleLoginServiceTest.java b/ontime-back/src/test/java/devkor/ontime_back/global/oauth/apple/AppleLoginServiceTest.java index c2b7bbbf..16900f6e 100644 --- a/ontime-back/src/test/java/devkor/ontime_back/global/oauth/apple/AppleLoginServiceTest.java +++ b/ontime-back/src/test/java/devkor/ontime_back/global/oauth/apple/AppleLoginServiceTest.java @@ -163,6 +163,55 @@ void verifyIdentityTokenReturnsClaimsWhenIssuerAudienceAndExpirationAreValid() t assertThat(verifiedClaims).isSameAs(claims); } + @Test + void verifyIdentityTokenReusesCachedApplePublicKeys() throws Exception { + RestTemplate restTemplate = mockRestTemplate(); + ApplePublicKeyResponse appleKeys = new ApplePublicKeyResponse(java.util.List.of()); + PublicKey publicKey = java.security.KeyPairGenerator.getInstance("RSA").generateKeyPair().getPublic(); + Claims claims = validAppleClaims(); + Map headers = Map.of("kid", "key-id", "alg", "RS256"); + ReflectionTestUtils.setField(appleLoginService, "clientId", "com.ontime.service"); + when(jwtUtils.parseHeaders("identity-token")).thenReturn(headers); + when(restTemplate.getForObject("https://appleid.apple.com/auth/keys", ApplePublicKeyResponse.class)) + .thenReturn(appleKeys); + when(applePublicKeyGenerator.generatePublicKey(headers, appleKeys)).thenReturn(publicKey); + when(jwtUtils.getTokenClaims("identity-token", publicKey)).thenReturn(claims); + + appleLoginService.verifyIdentityToken("identity-token"); + appleLoginService.verifyIdentityToken("identity-token"); + + verify(restTemplate, times(1)).getForObject("https://appleid.apple.com/auth/keys", ApplePublicKeyResponse.class); + verify(applePublicKeyGenerator, times(2)).generatePublicKey(headers, appleKeys); + } + + @Test + void verifyIdentityTokenRefreshesApplePublicKeysOnceWhenKidIsUnknown() throws Exception { + RestTemplate restTemplate = mockRestTemplate(); + ApplePublicKeyResponse staleKeys = new ApplePublicKeyResponse(java.util.List.of( + new ApplePublicKey("RSA", "old-key-id", "RS256", "AQAB", "AQAB") + )); + ApplePublicKeyResponse rotatedKeys = new ApplePublicKeyResponse(java.util.List.of( + new ApplePublicKey("RSA", "new-key-id", "RS256", "AQAB", "AQAB") + )); + PublicKey publicKey = java.security.KeyPairGenerator.getInstance("RSA").generateKeyPair().getPublic(); + Claims claims = validAppleClaims(); + Map headers = Map.of("kid", "new-key-id", "alg", "RS256"); + ReflectionTestUtils.setField(appleLoginService, "clientId", "com.ontime.service"); + when(jwtUtils.parseHeaders("identity-token")).thenReturn(headers); + when(restTemplate.getForObject("https://appleid.apple.com/auth/keys", ApplePublicKeyResponse.class)) + .thenReturn(staleKeys) + .thenReturn(rotatedKeys); + when(applePublicKeyGenerator.generatePublicKey(headers, staleKeys)) + .thenThrow(new IllegalArgumentException("Invalid JWT: No matching Apple Public Key found")); + when(applePublicKeyGenerator.generatePublicKey(headers, rotatedKeys)).thenReturn(publicKey); + when(jwtUtils.getTokenClaims("identity-token", publicKey)).thenReturn(claims); + + Claims verifiedClaims = appleLoginService.verifyIdentityToken("identity-token"); + + assertThat(verifiedClaims).isSameAs(claims); + verify(restTemplate, times(2)).getForObject("https://appleid.apple.com/auth/keys", ApplePublicKeyResponse.class); + } + @Test void verifyIdentityTokenRejectsTokenFromUnexpectedIssuer() throws Exception { RestTemplate restTemplate = mockRestTemplate(); diff --git a/scripts/benchmarks/apple-login/README.md b/scripts/benchmarks/apple-login/README.md new file mode 100644 index 00000000..fc7048a1 --- /dev/null +++ b/scripts/benchmarks/apple-login/README.md @@ -0,0 +1,98 @@ +# Apple Login Benchmark + +This benchmark measures issue #323: repeated Apple provider round trips on the returning Apple User login path. + +## Scenario + +- Endpoint: `POST /oauth2/apple/login` +- Primary path: returning Apple User, warm Apple key cache +- Seeded Apple subjects: `bench-apple-user-` up to `bench-apple-user-64` +- Apple provider: local stub, not the real Apple network +- Stub delay: + - `GET /auth/keys`: 80 ms + - `POST /auth/token`: 300 ms + +## Why the Apple provider is stubbed + +The primary comparison should isolate backend request-path behavior. Real Apple calls include DNS, TLS, internet routing, and provider-side variability, so they are useful as smoke checks but not as the main before/after evidence. + +## Run + +Install `k6` first if it is not already available: + +```bash +brew install k6 +``` + +Run from the repository root: + +```bash +scripts/benchmarks/apple-login/run.sh before quick +scripts/benchmarks/apple-login/run.sh after quick +``` + +Full evidence runs: + +```bash +scripts/benchmarks/apple-login/run.sh before full +scripts/benchmarks/apple-login/run.sh after full +``` + +The script starts an isolated MySQL container, starts the Apple stub, starts the backend with the `bench` Spring profile, seeds returning Apple Users, runs k6, and writes results. + +The script does not run `git checkout`. Measure the checkout you have selected, and use `before` or `after` only as the result label. + +## Defaults + +- Database container: `ontime-bench-mysql` +- Database: `ontime_bench` +- MySQL port: `127.0.0.1:3307` +- Backend port: `127.0.0.1:18081` +- Apple stub port: `127.0.0.1:18080` +- Quick mode: 1 run per concurrency +- Full mode: 10 runs per concurrency +- Warmup: 2 minutes +- Measurement: 5 minutes +- Concurrency: 1, 10, 20 + +Each VU uses a distinct returning Apple User. This keeps the benchmark focused on Apple provider round trips instead of same-user active-session token rotation under concurrent login load. + +Override example: + +```bash +WARMUP_DURATION=10s MEASUREMENT_DURATION=30s SCENARIOS="1" RUNS=1 \ + scripts/benchmarks/apple-login/run.sh before quick +``` + +## Results + +Results are written under: + +```text +scripts/benchmarks/apple-login/results/-