feat: add production capability foundations

This commit is contained in:
donghyeon-ka
2026-07-31 23:50:44 +09:00
parent b3add0162d
commit 567422f2e5
757 changed files with 132385 additions and 2146 deletions
+43 -18
View File
@@ -12,9 +12,20 @@ APP_ERROR_DETAIL_EXPOSURE_ENABLED=false
APP_LOG_BODY_CAPTURE_ENABLED=false
APP_MULTI_INSTANCE_ENABLED=false
APP_MIGRATION_ON_STARTUP=true
APP_RATE_LIMIT_ENABLED=true
APP_RATE_LIMIT_ENABLED=false
APP_RATE_LIMIT_CLIENT_IP_MODE=remote-addr-only
APP_RATE_LIMIT_PROVIDER=disabled
APP_RATE_LIMIT_REDIS_KEY_HMAC_SECRET=
APP_IDEMPOTENCY_TTL=24h
APP_IDEMPOTENCY_PROVIDER=jdbc
APP_IDEMPOTENCY_REDIS_KEY_HMAC_SECRET=
APP_IDEMPOTENCY_REDIS_NAMESPACE_ENVIRONMENT=local
APP_IDEMPOTENCY_PROCESSING_LEASE=30s
APP_IDEMPOTENCY_FAILURE_RETENTION=24h
APP_LEASE_PROVIDER=disabled
APP_LEASE_REDIS_KEY_HMAC_SECRET=
APP_LEASE_REDIS_NAMESPACE_ENVIRONMENT=local
APP_LEASE_REDIS_DRIFT_BUDGET=10ms
# ----- Async executor -----
APP_ASYNC_EXECUTOR_CORE_SIZE=10
@@ -22,6 +33,13 @@ APP_ASYNC_EXECUTOR_MAX_SIZE=50
APP_ASYNC_EXECUTOR_QUEUE_CAPACITY=200
# ----- Optional integration adapters (default: all disabled) -----
APP_CACHE_CANONICAL_DEFAULT_PROVIDER=disabled
# Sentinel primary revalidation cadence for canonically active Sentinel roles.
APP_REDIS_SENTINEL_DISCOVERY_REFRESH_PERIOD=30s
# Canonical role semantic readiness: refresh no more often than this interval.
APP_REDIS_SEMANTIC_PROBE_MINIMUM_INTERVAL=5s
# Fail closed when the last completed semantic observation is older than this bound.
APP_REDIS_SEMANTIC_PROBE_MAXIMUM_STALENESS=15s
APP_CACHE_REDIS_ENABLED=false
APP_CACHE_REDIS_CLIENT_MODE=managed
APP_CACHE_REDIS_HOST=localhost
@@ -34,6 +52,13 @@ APP_CACHE_REDIS_MAXIMUM_IN_FLIGHT_BYTES=16777216
APP_CACHE_REDIS_NAMESPACE_ENVIRONMENT=local
APP_CACHE_REDIS_SEMANTIC_REGION=default
APP_CACHE_REDIS_MAXIMUM_VALUE_BYTES=1048576
APP_CACHE_REDIS_L1_ENABLED=false
APP_CACHE_REDIS_L1_MAXIMUM_ENTRIES=10000
APP_CACHE_REDIS_L1_MAXIMUM_WEIGHT_BYTES=67108864
APP_CACHE_REDIS_L1_MAXIMUM_ENTRY_WEIGHT_BYTES=1048576
APP_CACHE_REDIS_L1_TTL=30s
APP_CACHE_REDIS_L1_GENERATION_RECHECK_INTERVAL=5s
APP_CACHE_REDIS_L1_INVALIDATION_QUEUE_CAPACITY=1024
APP_CACHE_DEFAULT_TTL=300s
APP_CACHE_NEGATIVE_TTL=60s
APP_MESSAGING_BROKER=
@@ -41,23 +66,6 @@ APP_MESSAGING_KAFKA_BROKERS=
APP_NOTIFICATION_SLACK_PROVIDER=
APP_NOTIFICATION_EMAIL_PROVIDER=
# ----- Outbound HTTP client -----
APP_OUTBOUND_HTTP_CONNECT_TIMEOUT=2s
APP_OUTBOUND_HTTP_READ_TIMEOUT=5s
APP_OUTBOUND_HTTP_GLOBAL_CALL_TIMEOUT=10s
APP_OUTBOUND_HTTP_MAXIMUM_IN_FLIGHT_CALLS=128
APP_OUTBOUND_HTTP_RETRY_ENABLED=false
APP_OUTBOUND_HTTP_RETRY_MAX_ATTEMPTS=3
APP_OUTBOUND_HTTP_RETRY_INITIAL_BACKOFF=100ms
APP_OUTBOUND_HTTP_RETRY_BACKOFF_MULTIPLIER=2.0
APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_ENABLED=false
APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_FAILURE_RATE_THRESHOLD=50
APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_SLIDING_WINDOW_SIZE=100
APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_MINIMUM_NUMBER_OF_CALLS=100
APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_WAIT_DURATION_IN_OPEN_STATE=60s
APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_PERMITTED_CALLS_IN_HALF_OPEN=10
APP_OUTBOUND_HTTP_RESPONSE_SIZE_LIMIT=10MB
# ----- Logging: root & app levels -----
APP_LOG_LEVEL_ROOT=INFO
APP_LOG_LEVEL_APP=DEBUG
@@ -130,9 +138,26 @@ APP_SERVER_ERROR_INCLUDE_MESSAGE=never
PRESENTATION_API_BASE_PATH=/api
# ----- Auth (OIDC resource server) -----
APP_SECURITY_AUTH_MODE=jwt
APP_SECURITY_JWT_ISSUER=http://localhost:8081/realms/ca-skeleton
APP_SECURITY_JWT_AUDIENCE=ca-skeleton-api
SECURITY_PUBLIC_PATHS=/api/healthcheck
APP_SESSION_COOKIE_NAME=CA_SESSION
APP_SESSION_COOKIE_SECURE=true
APP_SESSION_COOKIE_HTTP_ONLY=true
APP_SESSION_COOKIE_SAME_SITE=Lax
APP_SESSION_COOKIE_PATH=/
APP_SESSION_CSRF_COOKIE_NAME=XSRF-TOKEN
APP_SESSION_CSRF_HEADER_NAME=X-XSRF-TOKEN
APP_SESSION_REDIS_KEY_HMAC_SECRET=
APP_SESSION_REDIS_NAMESPACE_ENVIRONMENT=local
APP_SESSION_IDLE_TIMEOUT=30m
APP_SESSION_ABSOLUTE_LIFETIME=8h
APP_SESSION_TOUCH_INTERVAL=1m
APP_SESSION_TOMBSTONE_TTL=5m
APP_SESSION_MAXIMUM_ENVELOPE_BYTES=32768
APP_SESSION_MAXIMUM_ATTRIBUTES=64
APP_SESSION_MAXIMUM_SCALAR_BYTES=8192
# ----- CORS -----
APP_SECURITY_CORS_ENABLED=true
+29 -28
View File
@@ -204,8 +204,9 @@ vendor/build나 container base image까지 byte-for-byte 같음을 주장하지
**`prod` 에서는 반드시 `false`**, 아니면 기동 실패.
- **`APP_MULTI_INSTANCE_ENABLED`** — `true` 면 인스턴스 협조용 빈 5종(lock / cache-stampede /
leader / rate-limit / migration)이 모두 있어야 하며, 하나라도 없으면 기동이 실패합니다.
- **`APP_RATE_LIMIT_ENABLED`** — fixed-window rate-limit interceptor 활성화
(429 + `Retry-After` + `X-RateLimit-*` 응답).
- **`APP_RATE_LIMIT_ENABLED`** — provider-neutral edge rate-limit interceptor 활성화
(429 + `Retry-After` + `X-RateLimit-*` 응답). 기본값은 `false`이며, `true`로 바꿀 때는
`APP_RATE_LIMIT_PROVIDER=redis`와 canonical coordination role을 함께 구성해야 합니다.
- **`APP_RATE_LIMIT_CLIENT_IP_MODE`** — 클라이언트 IP 판별 방식. `remote-addr-only` |
`forwarded-headers-trusted`. **신뢰된 ingress/LB 가 `X-Forwarded-For` 를 앱 도달 전에 덮어쓸 때만**
`forwarded-headers-trusted` 를 쓰세요. 아니면 IP 위조에 노출됩니다.
@@ -228,6 +229,8 @@ vendor/build나 container base image까지 byte-for-byte 같음을 주장하지
fail-fast sentinel 이 포트를 충족합니다(Layer 3).
- **`APP_CACHE_REDIS_ENABLED`** — Redis 캐시 어댑터 on/off. `true` | `false`.
- **`APP_CACHE_CANONICAL_DEFAULT_PROVIDER`** — canonical default semantic region 선택.
`disabled`(기본) | `redis`. `redis`는 canonical Redis CACHE role binding을 함께 요구합니다.
- **`APP_CACHE_REDIS_CLIENT_MODE`** — `managed`는 내장 Lettuce runtime, `external`은 프로젝트가
제공한 `RedisClient` bean을 사용합니다.
- **`APP_MESSAGING_BROKER`** — 활성 메시지 브로커 id(예: `kafka`). 빈 값 = 메시징 비활성(사용 시
@@ -240,32 +243,30 @@ fail-fast sentinel 이 포트를 충족합니다(Layer 3).
### Outbound HTTP client
- **결정 — timeout 은 필수(D5).** timeout 미설정 또는 무한 timeout 은 금지이며, 기동 시 0 이 아닌 값을
강제합니다. 무한 timeout 은 네트워크 호출이 영원히 매달릴 수 있어 런타임 장애가 아니라 설정 실수로
보고 즉시 기동을 실패시킵니다.
- **`APP_OUTBOUND_HTTP_CONNECT_TIMEOUT`** — TCP connect timeout. duration(예: `2s`), 필수, non-zero.
- **`APP_OUTBOUND_HTTP_READ_TIMEOUT`** — socket read timeout. duration(예: `5s`), 필수, non-zero.
- **`APP_OUTBOUND_HTTP_GLOBAL_CALL_TIMEOUT`** — retry 를 포함한 end-to-end 마감 예산. duration(예:
`10s`), 필수, non-zero.
- **`APP_OUTBOUND_HTTP_MAXIMUM_IN_FLIGHT_CALLS`** — client별 살아 있는 logical-call worker 상한.
기본값 `128`, 허용 범위 `1..10000`.
- **`APP_OUTBOUND_HTTP_RETRY_ENABLED`** — retry 데코레이터 on/off. `true` 로 켜면 `MeterRegistry` 빈이
있어야 하며(D3 가드), 없으면 기동 실패.
- retry 튜닝(아래 3개는 `retry-enabled=true` 일 때 적용, 기본값은 기존 하드코딩 동작 보존):
- **`..._RETRY_MAX_ATTEMPTS`** — 총 시도 횟수(최초 시도 포함). 1 이상 정수.
- **`..._RETRY_INITIAL_BACKOFF`** — exponential backoff 시작 간격. duration, non-zero.
- **`..._RETRY_BACKOFF_MULTIPLIER`** — backoff 배수. 1.0 이상 double.
- **`APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_ENABLED`** — circuit breaker on/off. `true` 로 켜면
`MeterRegistry` 빈 필요(D3), 없으면 기동 실패.
- circuit breaker 튜닝(아래는 `circuit-breaker-enabled=true` 일 때 적용, 기본값은 Resilience4j
`ofDefaults()`):
- **`..._FAILURE_RATE_THRESHOLD`** — open 으로 전환되는 실패율 임계치(%). (0, 100] 범위 float.
- **`..._SLIDING_WINDOW_SIZE`** — COUNT_BASED sliding window 크기. 1 이상 정수.
- **`..._MINIMUM_NUMBER_OF_CALLS`** — 실패율 계산을 시작하는 최소 호출 수. 1 이상 정수.
- **`..._WAIT_DURATION_IN_OPEN_STATE`** — open 상태 유지 시간. duration, non-zero.
- **`..._PERMITTED_CALLS_IN_HALF_OPEN`** — half-open 에서 허용하는 시험 호출 수. 1 이상 정수.
- **`APP_OUTBOUND_HTTP_RESPONSE_SIZE_LIMIT`** — 메모리에 받는 응답 본문 최대 크기(예: `10MB`). 이를
넘는 응답은 streaming API 를 써야 합니다(D7).
현재 canonical activation은 다음 두 설정 트리만 사용합니다.
```yaml
ca-skeleton:
capabilities:
http-client:
expected-state: DISABLED
bindings: {}
providers:
http-client: {}
```
- 기본 `DISABLED`는 binding/provider definition이 모두 비어 있어야 하며
`DISABLED_VERIFIED`만 게시하고 client, executor, pool, retry/CB registry를 만들지 않습니다.
- `ACTIVE`는 exact destination/provider/operation-catalog binding을 요구합니다. 현재 유일한
buffered-classic readiness card가 `NOT_IMPLEMENTED`이므로 provider resource 생성 전에
fail-closed합니다. 아직 운영 HTTP provider를 활성화할 수 있다는 뜻이 아닙니다.
- 기존 `APP_OUTBOUND_HTTP_*``app.outbound.http.*`는 canonical 설정이 아닙니다. `.env`,
application YAML과 env-key registry에서 제거됐으며 canonical composition에 입력하면 상태와
무관하게 기동을 거부합니다.
- legacy JDK facade가 필요한 fork만 canonical composition 밖에서
`OutboundHttpSettings.bindLegacy(Binder)`와 legacy configuration을 명시적으로 import합니다.
timeout/retry/CB/response-size 설정은 그 migration API 내부 계약일 뿐 canonical provider
readiness를 증명하지 않습니다.
### Logging
+39 -25
View File
@@ -47,9 +47,24 @@ production configuration and compare the result with the committed snapshot.
### JwtToAuthenticatedPrincipalConverter
- `principal` 필드를 `transient` 로 두는 근거: principal 은 매 인증마다 converter 가 재구성하며
`ObjectOutputStream` 으로 round-trip 되지 않는다(이 템플릿엔 Java-직렬화 세션 저장소가 없음 — grep 확인).
`ObjectOutputStream` 으로 round-trip 되지 않는다. Redis session mode에서도 아래 primitive snapshot
repository가 `Authentication` 객체 그래프를 저장하지 않는다.
Serializable 이 아닌 Spring Security `Authentication` 토큰 필드의 관례적 해결책이 transient 표시다.
### JWT / Redis session 상호배타 모드
`ca-skeleton.security.auth-mode=jwt|redis-session`은 하나만 선택한다. JWT mode는 stateless이고
CSRF/session repository를 만들지 않는다. Redis session mode는 `Secure`, `HttpOnly`, host-only
session cookie, `SameSite=Lax`, cookie/header CSRF와 `migrateSession` fixation 방어를 함께 켠다.
기본 `HttpSessionSecurityContextRepository`는 Spring Security 객체 전체를 session attribute에 넣어
outbound session codec의 primitive allowlist를 깨므로 사용하지 않는다.
`PrimitiveSessionSecurityContextRepository``AuthenticatedPrincipal`의 bounded
principal/email/roles/authorities만 versioned `byte[]` snapshot으로 저장한다. credential, bearer/JWT,
arbitrary principal graph와 `SPRING_SECURITY_CONTEXT` 객체는 저장하지 않는다. foreign principal이나
손상·초과 snapshot은 인증 없음으로 fail closed한다. 실제 security filter save/restore 테스트가 다음
요청에서 principal과 authorities가 복원되고 session에는 primitive snapshot만 남는 것을 검증한다.
### SecurityErrorClassifier
- AuthN/AuthZ decision matrix 구현. 실행 앱이 coarse 한 3-way 매핑 대신 registry(`docs/registries/error-codes.yaml`)가
선언한 세분화 코드를 방출한다.
@@ -234,22 +249,20 @@ production configuration and compare the result with the committed snapshot.
## ratelimit
### 알고리즘 seam (RateLimiter / RateLimiterFactory / RateLimitAlgorithm / FixedWindowRateLimiter)
- 알고리즘은 프로젝트마다 바뀔 수 있는 운영 선택이라 `RateLimiter` 인터페이스 뒤에 둔다.
- **OCP(개방-폐쇄)**: `RateLimitInterceptor``RateLimiter` 타입에만 의존하고, `RateLimiterFactory` 의 단일
`switch` 가 설정에서 구체 전략을 선택한다. 새 알고리즘 추가 = "새 `RateLimiter` 구현 + `RateLimitAlgorithm`
enum 값 + factory case" 이며 interceptor/web config 변경 불요. 향후 후보: `SLIDING_WINDOW`, `TOKEN_BUCKET`.
- **알고리즘 중립 출력 계약**: 구현마다 카운트 방식이 달라도(fixed-window end vs 연속 sliding vs token refill)
`X-RateLimit-*` 헤더 계약이 안정적이도록 모든 구현이 `RateLimitDecision` 을 아래 의미로 채운다.
- `limit` — 설정 quota
- `remaining` — 해당 키에 지금 아직 허용되는 요청 수, 0 으로 floor
- `resetAt` — 키가 최소 1개 요청 capacity 를 다시 얻는 시각(fixed-window=window 종료, token-bucket=다음
refill, sliding-window=가장 오래된 카운트 요청 만료 시점)
- `allowed` — quota 소진 시 false (→ 429)
- **FixedWindowRateLimiter 트레이드오프**: `X-RateLimit-Reset` 시각은 정확(window 종료)한 대신 window 경계를
가로지르는 burst 를 허용 — 스켈레톤 계약상 허용 가능. **D5**: 분산 limiter 는 core 범위 밖이라 per-instance
전용이며, 다중 인스턴스 배포 시 유효 한도는 설정값의 N배. key→window 맵은 evict 되지 않는다(single-node,
distinct active key 수로 bounded) — 키 cardinality 무제한 배포는 expiry/eviction 추가 필요.
### provider-neutral edge contract
- inbound web은 `shared-contract``EdgeRateLimitPort`만 호출한다. Redis key, Lua, local counter와
provider 설정을 알지 못한다.
- outbound provider activation SSOT는
`ca-skeleton.capabilities.rate-limit.provider=disabled|redis`이고, HTTP enforcement의 별도 축은
`app.rate-limit.enabled`다. transport가 enabled인데 exact provider가 없거나 중복이면 startup을
실패시킨다.
- fixed window, sliding counter, token bucket 선택과 policy revision은 Redis provider가 소유한다.
과거 process-local unbounded fixed-window map/factory/settings는 제거되었다. local emergency가
필요하면 bounded cardinality/TTL/in-flight와 명시적 degraded-provider 계약을 먼저 추가해야 하며,
silent primary fallback은 허용하지 않는다.
- `EdgeRateLimitTransportBridge`는 provider의 typed allow/deny/unavailable/incompatible outcome을
HTTP 2xx/429/503과 `Retry-After`로만 투영한다. timeout은 quota가 소비되지 않았다는 증거가 아니다.
### RateLimitKeyResolver
- 키 형태: service-to-service
@@ -270,11 +283,12 @@ production configuration and compare the result with the committed snapshot.
- servlet filter 가 아니라 interceptor 를 쓰는 이유: 비인증 키에 필요한 route template 이 interceptor 단계에서
resolve 되기 때문(RateLimitKeyResolver 참조).
- `@EnableConfigurationProperties` 근거: 앱 레벨 `@ConfigurationPropertiesScan` 을 돌리지 않는 `@WebMvcTest`
슬라이스에서도 `RateLimitSettings` 를 쓰게 하려고. `Clock` 은 공유 application bean 이 있으면 가져오고
슬라이스에서도 `EdgeRateLimitTransportSettings` 를 쓰게 하려고. `Clock` 은 공유 application bean 이 있으면 가져오고
슬라이스에선 `Clock#systemUTC()` 로 fallback.
### RateLimitInterceptor
- fixed-window rate limit 매핑된 handler 실행 전에 적용. 모든 응답에 `X-RateLimit-*` 헤더 포함(generated_if_missing=true).
- provider가 선택한 rate-limit policy를 매핑된 handler 실행 전에 적용. quota 결과에는
`X-RateLimit-*` 헤더를 포함한다(generated_if_missing=true).
- 한도 초과 거부 응답의 세 보장(RATE_LIMIT category + retryable + `Retry-After`)이 클라이언트가 이를 retryable
의존성 장애로 오분류하는 것을 막는다.
@@ -295,12 +309,12 @@ production configuration and compare the result with the committed snapshot.
`Access-Control-Allow-Credentials: true` 와 함께 보낼 수 없다. Spring 런타임 검사에 의존하지 않고 기동
시점에 fail-fast 거부.
### RateLimitSettings
- `ca-skeleton.rate-limit.*` 에서 바인딩되고, composition root 의 `@ConfigurationPropertiesScan` 으로 자동 등록된다.
- `enabled``APP_RATE_LIMIT_ENABLED`(env-keys.yaml, restart-only, behavior-change)에 매핑.
- `limit`/`window`/`algorithm` 은 env key 없음 — 리미터 튜닝 파라미터(`프로젝트 선택`; 멀티 인스턴스
정확성은 범위 밖, D5)이며 fork 가 레지스트리 변경 없이 `application.yml` 에서 재정의하도록 in-code 기본값.
`algorithm` 기본값 `RateLimitAlgorithm.FIXED_WINDOW`.
### EdgeRateLimitTransportSettings
- `app.rate-limit.*`은 HTTP enforcement, default policy ID, pseudonymization key version,
caller deadline, trusted client-IP mode만 소유한다.
- algorithm/quota/state TTL/HMAC secret는 outbound Redis capability 설정이 소유하며 web settings로
복제하지 않는다.
### SecuritySettings
- OIDC resource-server 설정. `issuerUri` 는 인증이 연결될 때 필수 — 없으면 Spring Boot oauth2 auto-config 가
+2
View File
@@ -6,6 +6,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.session:spring-session-core'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
implementation('org.openapitools:jackson-databind-nullable:0.2.6') {
exclude group: 'com.fasterxml.jackson.core', module: 'jackson-databind'
@@ -15,4 +16,5 @@ dependencies {
// never a hand-maintained stale schema). The release-blocking drift gate is
// owned by feature-contract-verification-test-suite (planned).
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0'
testImplementation 'org.springframework.security:spring-security-test'
}
+2
View File
@@ -164,7 +164,9 @@ org.springframework.security:spring-security-crypto:7.0.0=compileClasspath,runti
org.springframework.security:spring-security-oauth2-core:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-oauth2-jose:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-oauth2-resource-server:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-test:7.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-web:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.session:spring-session-core:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
@@ -4,6 +4,7 @@ import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
@@ -24,6 +25,10 @@ import org.springframework.security.oauth2.jwt.SupplierJwtDecoder;
* README for the design rationale.
*/
@Configuration
@ConditionalOnProperty(
name = "ca-skeleton.security.auth-mode",
havingValue = "jwt",
matchIfMissing = true)
public class JwtDecoderConfig {
@Bean
@@ -0,0 +1,255 @@
package dev.caskeleton.adapter.inbound.web.auth;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.EOFException;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.TreeSet;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.context.HttpRequestResponseHolder;
import org.springframework.security.web.context.SecurityContextRepository;
/**
* Stores only a bounded primitive authentication snapshot in {@link HttpSession}.
*
* <p>Spring Security objects, credentials, tokens and arbitrary principal graphs never cross the
* Spring Session serialization boundary.
*/
final class PrimitiveSessionSecurityContextRepository implements SecurityContextRepository {
static final String SNAPSHOT_ATTRIBUTE = "dev.caskeleton.security.PRIMITIVE_SECURITY_CONTEXT_V1";
private static final int MAGIC = 0x43534543;
private static final int VERSION = 1;
private static final int MAXIMUM_SNAPSHOT_BYTES = 16_384;
private static final int MAXIMUM_PRINCIPAL_BYTES = 256;
private static final int MAXIMUM_EMAIL_BYTES = 320;
private static final int MAXIMUM_TOKEN_BYTES = 128;
private static final int MAXIMUM_ROLES = 64;
private static final int MAXIMUM_AUTHORITIES = 128;
@Override
public SecurityContext loadContext(HttpRequestResponseHolder requestResponseHolder) {
return load(requestResponseHolder.getRequest());
}
@Override
public void saveContext(
SecurityContext context, HttpServletRequest request, HttpServletResponse response) {
Objects.requireNonNull(request, "request");
Authentication authentication = context == null ? null : context.getAuthentication();
if (authentication == null
|| !authentication.isAuthenticated()
|| authentication instanceof AnonymousAuthenticationToken) {
HttpSession existing = request.getSession(false);
if (existing != null) {
existing.removeAttribute(SNAPSHOT_ATTRIBUTE);
}
return;
}
request.getSession(true).setAttribute(SNAPSHOT_ATTRIBUTE, encode(authentication));
}
@Override
public boolean containsContext(HttpServletRequest request) {
HttpSession session = request.getSession(false);
return session != null && session.getAttribute(SNAPSHOT_ATTRIBUTE) instanceof byte[];
}
private static SecurityContext load(HttpServletRequest request) {
SecurityContext empty = SecurityContextHolder.createEmptyContext();
HttpSession session = request.getSession(false);
if (session == null) {
return empty;
}
Object stored = session.getAttribute(SNAPSHOT_ATTRIBUTE);
if (!(stored instanceof byte[] snapshot)) {
return empty;
}
try {
PrimitiveAuthentication decoded = decode(snapshot);
AuthenticatedPrincipal principal =
new AuthenticatedPrincipal(decoded.principalId, decoded.email, decoded.roles);
List<GrantedAuthority> authorities =
decoded.authorities.stream()
.map(SimpleGrantedAuthority::new)
.map(GrantedAuthority.class::cast)
.toList();
empty.setAuthentication(
UsernamePasswordAuthenticationToken.authenticated(principal, null, authorities));
return empty;
} catch (IllegalArgumentException exception) {
session.removeAttribute(SNAPSHOT_ATTRIBUTE);
return empty;
}
}
private static byte[] encode(Authentication authentication) {
if (!(authentication.getPrincipal() instanceof AuthenticatedPrincipal principal)) {
throw new IllegalArgumentException(
"redis-session authentication requires an AuthenticatedPrincipal");
}
Set<String> roles = boundedTokens(principal.roles(), MAXIMUM_ROLES, "roles");
Set<String> authorities =
boundedTokens(
authentication.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList(),
MAXIMUM_AUTHORITIES,
"authorities");
try {
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
try (DataOutputStream output = new DataOutputStream(bytes)) {
output.writeInt(MAGIC);
output.writeByte(VERSION);
writeText(output, principal.idpUserId(), MAXIMUM_PRINCIPAL_BYTES, "principal ID");
writeNullableText(output, principal.email(), MAXIMUM_EMAIL_BYTES, "email");
writeTokens(output, roles);
writeTokens(output, authorities);
}
byte[] snapshot = bytes.toByteArray();
if (snapshot.length > MAXIMUM_SNAPSHOT_BYTES) {
throw new IllegalArgumentException("security context snapshot exceeds the byte bound");
}
return snapshot;
} catch (IOException exception) {
throw new IllegalStateException("in-memory security context encoding failed", exception);
}
}
private static PrimitiveAuthentication decode(byte[] snapshot) {
if (snapshot.length < 1 || snapshot.length > MAXIMUM_SNAPSHOT_BYTES) {
throw invalidSnapshot();
}
try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(snapshot.clone()))) {
if (input.readInt() != MAGIC || input.readUnsignedByte() != VERSION) {
throw invalidSnapshot();
}
String principalId = readText(input, MAXIMUM_PRINCIPAL_BYTES);
String email = readNullableText(input, MAXIMUM_EMAIL_BYTES);
Set<String> roles = readTokens(input, MAXIMUM_ROLES);
Set<String> authorities = readTokens(input, MAXIMUM_AUTHORITIES);
if (input.available() != 0) {
throw invalidSnapshot();
}
return new PrimitiveAuthentication(principalId, email, roles, authorities);
} catch (IOException | IllegalArgumentException exception) {
throw invalidSnapshot();
}
}
private static void writeTokens(DataOutputStream output, Set<String> values) throws IOException {
output.writeInt(values.size());
for (String value : values) {
writeText(output, value, MAXIMUM_TOKEN_BYTES, "security token");
}
}
private static Set<String> readTokens(DataInputStream input, int maximumCount)
throws IOException {
int count = input.readInt();
if (count < 0 || count > maximumCount) {
throw invalidSnapshot();
}
Set<String> values = new LinkedHashSet<>();
for (int index = 0; index < count; index++) {
if (!values.add(readText(input, MAXIMUM_TOKEN_BYTES))) {
throw invalidSnapshot();
}
}
return Set.copyOf(values);
}
private static Set<String> boundedTokens(
Collection<String> values, int maximumCount, String field) {
if (values == null || values.size() > maximumCount) {
throw new IllegalArgumentException(field + " exceed the configured count bound");
}
TreeSet<String> bounded = new TreeSet<>();
for (String value : values) {
requireBoundedText(value, MAXIMUM_TOKEN_BYTES, field);
bounded.add(value);
}
return Set.copyOf(bounded);
}
private static void writeNullableText(
DataOutputStream output, String value, int maximumBytes, String field) throws IOException {
output.writeBoolean(value != null);
if (value != null) {
writeText(output, value, maximumBytes, field);
}
}
private static String readNullableText(DataInputStream input, int maximumBytes)
throws IOException {
return input.readBoolean() ? readText(input, maximumBytes) : null;
}
private static void writeText(
DataOutputStream output, String value, int maximumBytes, String field) throws IOException {
byte[] encoded = requireBoundedText(value, maximumBytes, field);
output.writeInt(encoded.length);
output.write(encoded);
}
private static String readText(DataInputStream input, int maximumBytes) throws IOException {
int length = input.readInt();
if (length < 1 || length > maximumBytes || length > input.available()) {
throw new EOFException("invalid security context text length");
}
byte[] encoded = input.readNBytes(length);
String value = new String(encoded, StandardCharsets.UTF_8);
byte[] canonical = requireBoundedText(value, maximumBytes, "decoded value");
if (!java.util.Arrays.equals(canonical, encoded)) {
throw invalidSnapshot();
}
return value;
}
private static byte[] requireBoundedText(String value, int maximumBytes, String field) {
if (value == null || value.isBlank() || value.chars().anyMatch(Character::isISOControl)) {
throw new IllegalArgumentException(field + " must be non-blank text without controls");
}
byte[] encoded = value.getBytes(StandardCharsets.UTF_8);
if (encoded.length > maximumBytes) {
throw new IllegalArgumentException(field + " exceeds the UTF-8 byte bound");
}
return encoded;
}
private static IllegalArgumentException invalidSnapshot() {
return new IllegalArgumentException("security context snapshot is corrupt or incompatible");
}
private static final class PrimitiveAuthentication {
private final String principalId;
private final String email;
private final Set<String> roles;
private final Set<String> authorities;
private PrimitiveAuthentication(
String principalId, String email, Set<String> roles, Set<String> authorities) {
this.principalId = principalId;
this.email = email;
this.roles = roles;
this.authorities = authorities;
}
}
}
@@ -0,0 +1,34 @@
package dev.caskeleton.adapter.inbound.web.auth;
import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.session.config.annotation.web.http.EnableSpringHttpSession;
import org.springframework.session.web.http.CookieSerializer;
import org.springframework.session.web.http.DefaultCookieSerializer;
/** Provider-neutral servlet session filter and hardened host-only cookie composition. */
@Configuration(proxyBeanMethods = false)
@EnableSpringHttpSession
@ConditionalOnProperty(
name = "ca-skeleton.security.auth-mode",
havingValue = "redis-session",
matchIfMissing = false)
public class RedisSessionWebConfig {
@Bean
CookieSerializer sessionCookieSerializer(SecuritySettings settings) {
SecuritySettings.SessionCookieSettings policy = settings.session();
DefaultCookieSerializer serializer = new DefaultCookieSerializer();
serializer.setCookieName(policy.cookieName());
serializer.setUseSecureCookie(policy.secure());
serializer.setUseHttpOnlyCookie(policy.httpOnly());
serializer.setSameSite(policy.sameSite());
serializer.setCookiePath(policy.path());
serializer.setCookieMaxAge(-1);
serializer.setUseBase64Encoding(true);
// No domain or domain pattern is configured: the session cookie remains host-only.
return serializer;
}
}
@@ -2,6 +2,7 @@ package dev.caskeleton.adapter.inbound.web.auth;
import dev.caskeleton.adapter.inbound.web.settings.CorsSettings;
import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
@@ -10,6 +11,8 @@ import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
@@ -49,19 +52,28 @@ public class SecurityConfig {
return new EnvelopeAccessDeniedHandler(classifier, objectMapper);
}
@Bean
@ConditionalOnProperty(
name = "ca-skeleton.security.auth-mode",
havingValue = "redis-session",
matchIfMissing = false)
PrimitiveSessionSecurityContextRepository primitiveSessionSecurityContextRepository() {
return new PrimitiveSessionSecurityContextRepository();
}
@Bean
public SecurityFilterChain filterChain(
HttpSecurity http,
AuthenticationEntryPoint authenticationEntryPoint,
AccessDeniedHandler accessDeniedHandler)
AccessDeniedHandler accessDeniedHandler,
org.springframework.beans.factory.ObjectProvider<PrimitiveSessionSecurityContextRepository>
sessionSecurityContextRepository)
throws Exception {
String[] publicPaths = securitySettings.publicPaths().toArray(new String[0]);
http.csrf(csrf -> csrf.disable())
.cors(c -> c.configurationSource(corsConfigurationSource()))
http.cors(c -> c.configurationSource(corsConfigurationSource()))
// Disable Spring Security's default Cache-Control writer; CacheControlFilter
// owns the cache header policy. See README for the design rationale.
.headers(headers -> headers.cacheControl(cache -> cache.disable()))
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(
auth -> {
if (publicPaths.length > 0) {
@@ -75,13 +87,45 @@ public class SecurityConfig {
.exceptionHandling(
ex ->
ex.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler))
.oauth2ResourceServer(
oauth ->
oauth
.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler)
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtConverter)));
.accessDeniedHandler(accessDeniedHandler));
if (securitySettings.authMode() == SecuritySettings.AuthenticationMode.JWT) {
http.csrf(csrf -> csrf.disable())
.sessionManagement(
session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.oauth2ResourceServer(
oauth ->
oauth
.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler)
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtConverter)));
} else {
SecuritySettings.SessionCookieSettings sessionSettings = securitySettings.session();
CookieCsrfTokenRepository csrfRepository = new CookieCsrfTokenRepository();
csrfRepository.setCookieName(sessionSettings.csrfCookieName());
csrfRepository.setHeaderName(sessionSettings.csrfHeaderName());
csrfRepository.setCookieCustomizer(
cookie ->
cookie
.secure(true)
.httpOnly(false)
.sameSite(sessionSettings.sameSite())
.path(sessionSettings.path()));
CsrfTokenRequestAttributeHandler csrfRequestHandler = new CsrfTokenRequestAttributeHandler();
http.csrf(
csrf ->
csrf.csrfTokenRepository(csrfRepository)
.csrfTokenRequestHandler(csrfRequestHandler))
.sessionManagement(
session ->
session
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.sessionFixation(fixation -> fixation.migrateSession()))
.securityContext(
securityContext ->
securityContext
.securityContextRepository(sessionSecurityContextRepository.getObject())
.requireExplicitSave(false));
}
return http.build();
}
@@ -0,0 +1,86 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
import dev.caskeleton.shared.ratelimit.EdgeRateLimitPort;
import dev.caskeleton.shared.ratelimit.EdgeRateLimitSubject;
import dev.caskeleton.shared.ratelimit.EdgeSubjectPseudonymizer;
import dev.caskeleton.shared.ratelimit.RateLimitOutcome;
import dev.caskeleton.shared.ratelimit.RateLimitRequest;
import dev.caskeleton.shared.ratelimit.RateLimitSubjectDigest;
import jakarta.servlet.http.HttpServletRequest;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
import java.util.regex.Pattern;
/**
* Provider-neutral bridge from an HTTP request to {@link EdgeRateLimitPort}.
*
* <p>Raw principal, API-key identity, client IP, and route values stop at the pseudonymizer. Only
* the versioned digest and bounded enforcement metadata cross the provider boundary.
*/
public final class EdgeRateLimitTransportBridge {
private static final Pattern POLICY_ID = Pattern.compile("[a-z][a-z0-9-]{0,62}");
private static final Duration MAXIMUM_CALLER_DEADLINE_BUDGET = Duration.ofSeconds(30);
private final EdgeRateLimitPort port;
private final EdgeSubjectPseudonymizer pseudonymizer;
private final RateLimitKeyResolver subjectResolver;
private final Clock clock;
private final String policyId;
private final Duration callerDeadlineBudget;
private final RateLimitEvaluationIdGenerator evaluationIdGenerator;
public EdgeRateLimitTransportBridge(
EdgeRateLimitPort port,
EdgeSubjectPseudonymizer pseudonymizer,
RateLimitKeyResolver subjectResolver,
Clock clock,
String policyId,
Duration callerDeadlineBudget,
RateLimitEvaluationIdGenerator evaluationIdGenerator) {
this.port = Objects.requireNonNull(port, "port must not be null");
this.pseudonymizer = Objects.requireNonNull(pseudonymizer, "pseudonymizer must not be null");
this.subjectResolver =
Objects.requireNonNull(subjectResolver, "subjectResolver must not be null");
this.clock = Objects.requireNonNull(clock, "clock must not be null");
if (policyId == null || !POLICY_ID.matcher(policyId).matches()) {
throw new IllegalArgumentException("policyId must be a bounded policy identifier");
}
this.policyId = policyId;
this.callerDeadlineBudget = positiveBoundedBudget(callerDeadlineBudget, "callerDeadlineBudget");
this.evaluationIdGenerator =
Objects.requireNonNull(evaluationIdGenerator, "evaluationIdGenerator must not be null");
}
public RateLimitOutcome evaluate(HttpServletRequest request) {
Objects.requireNonNull(request, "request must not be null");
EdgeRateLimitSubject rawSubject = subjectResolver.resolve(request);
RateLimitSubjectDigest subjectDigest =
Objects.requireNonNull(
pseudonymizer.pseudonymize(rawSubject), "pseudonymizer must return a subject digest");
Instant callerDeadline = clock.instant().plus(callerDeadlineBudget);
String evaluationId =
Objects.requireNonNull(
evaluationIdGenerator.generate(), "evaluationIdGenerator must return an evaluation ID");
return Objects.requireNonNull(
port.evaluate(
new RateLimitRequest(policyId, subjectDigest, 1, evaluationId, callerDeadline)),
"rate-limit port must return an outcome");
}
static Duration positiveBoundedBudget(Duration value, String field) {
Objects.requireNonNull(value, field + " must not be null");
if (value.isZero()
|| value.isNegative()
|| value.compareTo(MAXIMUM_CALLER_DEADLINE_BUDGET) > 0) {
throw new IllegalArgumentException(field + " must be positive and no more than 30 seconds");
}
long milliseconds = value.toMillis();
if (!Duration.ofMillis(milliseconds).equals(value)) {
throw new IllegalArgumentException(field + " must use whole milliseconds");
}
return value;
}
}
@@ -0,0 +1,40 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
import java.time.Duration;
import java.util.regex.Pattern;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* HTTP bridge settings bound to the transport-only {@code app.rate-limit} axis.
*
* <p>The outbound provider is selected independently by {@code
* ca-skeleton.capabilities.rate-limit.provider}; enabling this bridge never selects a provider or a
* fallback.
*/
@ConfigurationProperties(prefix = "app.rate-limit")
public record EdgeRateLimitTransportSettings(
boolean enabled,
String defaultPolicyId,
Duration callerDeadlineBudget,
int hashKeyVersion,
RateLimitClientIpMode clientIpMode) {
private static final Pattern POLICY_ID = Pattern.compile("[a-z][a-z0-9-]{0,62}");
public EdgeRateLimitTransportSettings {
defaultPolicyId =
defaultPolicyId == null || defaultPolicyId.isBlank() ? "api-default" : defaultPolicyId;
callerDeadlineBudget =
callerDeadlineBudget == null ? Duration.ofSeconds(2) : callerDeadlineBudget;
hashKeyVersion = hashKeyVersion == 0 ? 1 : hashKeyVersion;
clientIpMode = clientIpMode == null ? RateLimitClientIpMode.REMOTE_ADDR_ONLY : clientIpMode;
if (!POLICY_ID.matcher(defaultPolicyId).matches()) {
throw new IllegalArgumentException("defaultPolicyId must be a bounded policy identifier");
}
EdgeRateLimitTransportBridge.positiveBoundedBudget(
callerDeadlineBudget, "callerDeadlineBudget");
if (hashKeyVersion < 1 || hashKeyVersion > 9999) {
throw new IllegalArgumentException("hashKeyVersion must be in 1..9999");
}
}
}
@@ -1,54 +0,0 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Single-node, in-process fixed-window rate limiter. Each key gets a counter for the current window
* {@code floor(epochSecond / window)}; the counter resets when the window rolls. See README for the
* design rationale.
*/
public final class FixedWindowRateLimiter implements RateLimiter {
private final int limit;
private final long windowSeconds;
private final Clock clock;
private final ConcurrentMap<String, Window> windows = new ConcurrentHashMap<>();
public FixedWindowRateLimiter(int limit, Duration window, Clock clock) {
this.limit = Math.max(1, limit);
this.windowSeconds = Math.max(1L, window.toSeconds());
this.clock = clock;
}
@Override
public RateLimitDecision decide(String key) {
long nowSecond = clock.instant().getEpochSecond();
long windowId = nowSecond / windowSeconds;
Instant resetAt = Instant.ofEpochSecond((windowId + 1) * windowSeconds);
Window window =
windows.compute(
key,
(k, current) ->
(current == null || current.id != windowId) ? new Window(windowId) : current);
int count = window.count.incrementAndGet();
boolean allowed = count <= limit;
int remaining = Math.max(0, limit - count);
return new RateLimitDecision(allowed, limit, remaining, resetAt);
}
private static final class Window {
private final long id;
private final AtomicInteger count = new AtomicInteger();
private Window(long id) {
this.id = id;
}
}
}
@@ -1,11 +0,0 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
/**
* Selectable rate-limit algorithm, bound from {@code ca-skeleton.rate-limit.algorithm}. See README
* for the design rationale.
*/
public enum RateLimitAlgorithm {
/** Fixed-window counter — the default single-node implementation. */
FIXED_WINDOW
}
@@ -1,15 +0,0 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
import java.time.Instant;
/**
* Outcome of a single rate-limit check, carrying the values surfaced as the {@code X-RateLimit-*}
* signaling headers. See README for the design rationale.
*
* @param allowed false when the caller has exceeded the limit this window (→ 429)
* @param limit the window quota ({@code X-RateLimit-Limit})
* @param remaining requests left in the current window, floored at 0 ({@code
* X-RateLimit-Remaining})
* @param resetAt instant the current fixed window ends ({@code X-RateLimit-Reset}, rfc3339)
*/
public record RateLimitDecision(boolean allowed, int limit, int remaining, Instant resetAt) {}
@@ -0,0 +1,8 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
/** Server-owned source of per-evaluation replay identifiers. */
@FunctionalInterface
public interface RateLimitEvaluationIdGenerator {
String generate();
}
@@ -2,73 +2,111 @@ package dev.caskeleton.adapter.inbound.web.ratelimit;
import dev.caskeleton.adapter.inbound.web.error.ErrorResponseFactory;
import dev.caskeleton.adapter.inbound.web.http.ApiHeaders;
import dev.caskeleton.shared.error.ApiErrorCode;
import dev.caskeleton.shared.error.OperationalError;
import dev.caskeleton.shared.ratelimit.RateLimitDecision;
import dev.caskeleton.shared.ratelimit.RateLimitOutcome;
import dev.caskeleton.shared.response.Envelope;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.time.Duration;
import java.time.format.DateTimeFormatter;
import java.util.Objects;
import org.springframework.http.MediaType;
import org.springframework.web.servlet.HandlerInterceptor;
import tools.jackson.databind.ObjectMapper;
/**
* Applies the rate limit before a mapped handler runs. Every response carries the {@code
* X-RateLimit-*} signaling headers; when the limit is exceeded the request is rejected with a 429
* {@code RATE_LIMIT_EXCEEDED} envelope, a {@code Retry-After} header, and the signaling headers.
* See README for the design rationale.
* Maps provider-neutral rate-limit outcomes to the stable HTTP signaling contract.
*
* <p>Disabled instances have no bridge and therefore cannot resolve a subject, pseudonymize, or
* invoke a provider.
*/
public final class RateLimitInterceptor implements HandlerInterceptor {
private static final String CLIENT_SAFE_MESSAGE =
private static final String DENIED_MESSAGE =
"Too many requests, please retry after the indicated interval";
private static final String UNAVAILABLE_MESSAGE =
"Rate-limit enforcement is temporarily unavailable";
private static final String INCOMPATIBLE_MESSAGE =
"Rate-limit enforcement is unavailable due to an incompatible provider";
private final boolean enabled;
private final RateLimiter limiter;
private final RateLimitKeyResolver keyResolver;
private final EdgeRateLimitTransportBridge bridge;
private final ObjectMapper objectMapper;
private final int retryAfterSeconds;
public RateLimitInterceptor(
boolean enabled,
RateLimiter limiter,
RateLimitKeyResolver keyResolver,
ObjectMapper objectMapper,
int retryAfterSeconds) {
this.enabled = enabled;
this.limiter = limiter;
this.keyResolver = keyResolver;
this.objectMapper = objectMapper;
this.retryAfterSeconds = retryAfterSeconds;
private RateLimitInterceptor(EdgeRateLimitTransportBridge bridge, ObjectMapper objectMapper) {
this.bridge = bridge;
this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper must not be null");
}
public static RateLimitInterceptor enabled(
EdgeRateLimitTransportBridge bridge, ObjectMapper objectMapper) {
return new RateLimitInterceptor(
Objects.requireNonNull(bridge, "bridge must not be null"), objectMapper);
}
public static RateLimitInterceptor disabled(ObjectMapper objectMapper) {
return new RateLimitInterceptor(null, objectMapper);
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
if (!enabled) {
if (bridge == null) {
return true;
}
RateLimitDecision decision = limiter.decide(keyResolver.resolve(request));
return switch (bridge.evaluate(request)) {
case RateLimitOutcome.Evaluated evaluated -> handleEvaluated(response, evaluated.decision());
case RateLimitOutcome.Unavailable unavailable ->
rejectUnavailable(response, unavailable.retryAfter());
case RateLimitOutcome.Indeterminate indeterminate ->
rejectUnavailable(response, indeterminate.retryAfter());
case RateLimitOutcome.Incompatible incompatible -> rejectIncompatible(response);
};
}
private boolean handleEvaluated(HttpServletResponse response, RateLimitDecision decision)
throws Exception {
applySignalingHeaders(response, decision);
if (decision.allowed()) {
return true;
}
rejectWith429(response);
response.setHeader(ApiHeaders.RETRY_AFTER, retryAfterSeconds(decision.retryAfter()));
reject(response, OperationalError.RATE_LIMIT_EXCEEDED, DENIED_MESSAGE);
return false;
}
private void applySignalingHeaders(HttpServletResponse response, RateLimitDecision decision) {
response.setHeader(ApiHeaders.X_RATELIMIT_LIMIT, Integer.toString(decision.limit()));
response.setHeader(ApiHeaders.X_RATELIMIT_REMAINING, Integer.toString(decision.remaining()));
private boolean rejectUnavailable(HttpServletResponse response, Duration retryAfter)
throws Exception {
response.setHeader(ApiHeaders.RETRY_AFTER, retryAfterSeconds(retryAfter));
reject(response, RateLimitTransportError.RATE_LIMIT_UNAVAILABLE, UNAVAILABLE_MESSAGE);
return false;
}
private boolean rejectIncompatible(HttpServletResponse response) throws Exception {
reject(response, RateLimitTransportError.RATE_LIMIT_INCOMPATIBLE, INCOMPATIBLE_MESSAGE);
return false;
}
private static void applySignalingHeaders(
HttpServletResponse response, RateLimitDecision decision) {
response.setHeader(ApiHeaders.X_RATELIMIT_LIMIT, Long.toString(decision.limit()));
response.setHeader(ApiHeaders.X_RATELIMIT_REMAINING, Long.toString(decision.remaining()));
response.setHeader(
ApiHeaders.X_RATELIMIT_RESET, DateTimeFormatter.ISO_INSTANT.format(decision.resetAt()));
}
private void rejectWith429(HttpServletResponse response) throws Exception {
response.setStatus(OperationalError.RATE_LIMIT_EXCEEDED.httpStatus());
response.setHeader(ApiHeaders.RETRY_AFTER, Integer.toString(retryAfterSeconds));
private void reject(HttpServletResponse response, ApiErrorCode error, String message)
throws Exception {
response.setStatus(error.httpStatus());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
Envelope<Void> body =
ErrorResponseFactory.body(OperationalError.RATE_LIMIT_EXCEEDED, CLIENT_SAFE_MESSAGE, null);
Envelope<Void> body = ErrorResponseFactory.body(error, message, null);
objectMapper.writeValue(response.getWriter(), body);
}
private static String retryAfterSeconds(Duration retryAfter) {
long milliseconds = retryAfter.toMillis();
long seconds = Math.floorDiv(milliseconds + 999, 1000);
return Long.toString(seconds);
}
}
@@ -1,21 +1,24 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
import dev.caskeleton.shared.ratelimit.EdgeRateLimitSubject;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Locale;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.servlet.HandlerMapping;
/**
* Derives the rate-limit key from a request:
* Derives a bounded pre-pseudonymization rate-limit subject from a request:
*
* <ul>
* <li>authenticated user → {@code user:<principal>}
* <li>service-to-service (a {@code service}-role principal) → {@code apikey:<id>}
* <li>unauthenticated → {@code ip:<source-ip>:<METHOD route-template>}
* <li>authenticated user → principal + operation
* <li>service-to-service (a {@code service}-role principal) → API key + operation
* <li>unauthenticated → client IP + operation
* </ul>
*
* <p>See README for the design rationale.
* <p>The returned raw identity exists only until {@link EdgeSubjectPseudonymizer} runs. It must not
* cross the provider port boundary.
*/
public final class RateLimitKeyResolver {
@@ -27,19 +30,37 @@ public final class RateLimitKeyResolver {
this.clientIpResolver = clientIpResolver;
}
public String resolve(HttpServletRequest request) {
public EdgeRateLimitSubject resolve(HttpServletRequest request) {
String operationId = operationId(request);
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null
&& auth.isAuthenticated()
&& auth.getPrincipal() instanceof AuthenticatedPrincipal user) {
return user.hasRole(SERVICE_ROLE) ? "apikey:" + user.idpUserId() : "user:" + user.idpUserId();
EdgeRateLimitSubject.Kind kind =
user.hasRole(SERVICE_ROLE)
? EdgeRateLimitSubject.Kind.API_KEY
: EdgeRateLimitSubject.Kind.PRINCIPAL;
return new EdgeRateLimitSubject(kind, user.idpUserId(), operationId);
}
return "ip:" + clientIpResolver.resolve(request) + ":" + routeTemplate(request);
return new EdgeRateLimitSubject(
EdgeRateLimitSubject.Kind.CLIENT_IP, clientIpResolver.resolve(request), operationId);
}
private static String routeTemplate(HttpServletRequest request) {
private static String operationId(HttpServletRequest request) {
String method = normalizedMethod(request.getMethod());
Object pattern = request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE);
String route = pattern instanceof String s ? s : request.getRequestURI();
return request.getMethod() + " " + route;
String route =
pattern instanceof String value && !value.isBlank() ? value : "<unresolved-route>";
return method + " " + route;
}
private static String normalizedMethod(String method) {
if (method == null
|| method.isBlank()
|| method.length() > 16
|| !method.chars().allMatch(Character::isLetter)) {
return "OTHER";
}
return method.toUpperCase(Locale.ROOT);
}
}
@@ -0,0 +1,38 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
import dev.caskeleton.shared.error.ApiErrorCode;
import dev.caskeleton.shared.error.Category;
/** HTTP-only mapping codes for provider outcomes that do not contain an allow/deny decision. */
enum RateLimitTransportError implements ApiErrorCode {
RATE_LIMIT_UNAVAILABLE(Category.TRANSIENT_DEPENDENCY, true),
RATE_LIMIT_INCOMPATIBLE(Category.INTERNAL, false);
private final Category category;
private final boolean retryable;
RateLimitTransportError(Category category, boolean retryable) {
this.category = category;
this.retryable = retryable;
}
@Override
public String code() {
return name();
}
@Override
public Category category() {
return category;
}
@Override
public int httpStatus() {
return 503;
}
@Override
public boolean retryable() {
return retryable;
}
}
@@ -1,8 +1,7 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
import dev.caskeleton.adapter.inbound.web.observability.RetryAfterAdvisor;
import dev.caskeleton.adapter.inbound.web.settings.RateLimitSettings;
import dev.caskeleton.shared.error.OperationalError;
import dev.caskeleton.application.observability.UserPrincipalPseudonymizerPort;
import dev.caskeleton.shared.ratelimit.EdgeRateLimitPort;
import java.time.Clock;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
@@ -12,39 +11,57 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import tools.jackson.databind.ObjectMapper;
/**
* Wires the {@link RateLimitInterceptor} into the MVC interceptor chain. The {@link Clock} is taken
* from the shared application bean when present and falls back to {@link Clock#systemUTC()}. With
* no rate-limit config bound, {@code enabled} defaults to {@code false} and the interceptor is a
* pass-through. See README for the design rationale.
* Wires provider-neutral edge enforcement into MVC.
*
* <p>Transport activation, trusted client-IP selection, policy selection, and deadlines come from
* {@code app.rate-limit}. Provider activation is a separate composition-root decision; an enabled
* bridge requires exactly one semantic port and never installs a local fallback.
*/
@Configuration
@EnableConfigurationProperties(RateLimitSettings.class)
@EnableConfigurationProperties(EdgeRateLimitTransportSettings.class)
public class RateLimitWebConfig implements WebMvcConfigurer {
private final RateLimitInterceptor rateLimitInterceptor;
public RateLimitWebConfig(
RateLimitSettings properties, ObjectMapper objectMapper, ObjectProvider<Clock> clock) {
RateLimiter limiter =
RateLimiterFactory.create(
properties.algorithm(),
properties.limit(),
properties.window(),
clock.getIfAvailable(Clock::systemUTC));
int retryAfter =
RetryAfterAdvisor.retryAfterSeconds(OperationalError.RATE_LIMIT_EXCEEDED).orElse(1);
ClientIpResolver clientIpResolver = ClientIpResolverFactory.create(properties.clientIpMode());
this.rateLimitInterceptor =
new RateLimitInterceptor(
properties.enabled(),
limiter,
new RateLimitKeyResolver(clientIpResolver),
objectMapper,
retryAfter);
EdgeRateLimitTransportSettings transportSettings,
ObjectMapper objectMapper,
ObjectProvider<Clock> clockProvider,
ObjectProvider<EdgeRateLimitPort> portProvider,
ObjectProvider<UserPrincipalPseudonymizerPort> pseudonymizerProvider) {
if (!transportSettings.enabled()) {
this.rateLimitInterceptor = RateLimitInterceptor.disabled(objectMapper);
return;
}
EdgeRateLimitPort port = requiredUnique(portProvider, "EdgeRateLimitPort");
UserPrincipalPseudonymizerPort secretBackedPseudonymizer =
requiredUnique(pseudonymizerProvider, "UserPrincipalPseudonymizerPort");
EdgeRateLimitTransportBridge bridge =
new EdgeRateLimitTransportBridge(
port,
new VersionedEdgeSubjectPseudonymizer(
secretBackedPseudonymizer, transportSettings.hashKeyVersion()),
new RateLimitKeyResolver(
ClientIpResolverFactory.create(transportSettings.clientIpMode())),
clockProvider.getIfAvailable(Clock::systemUTC),
transportSettings.defaultPolicyId(),
transportSettings.callerDeadlineBudget(),
SecureRandomRateLimitEvaluationIdGenerator.versionOne());
this.rateLimitInterceptor = RateLimitInterceptor.enabled(bridge, objectMapper);
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(rateLimitInterceptor);
}
private static <T> T requiredUnique(ObjectProvider<T> provider, String capability) {
T instance = provider.getIfUnique();
if (instance == null) {
throw new IllegalStateException(
capability + " must have exactly one bean when edge rate limiting is enabled");
}
return instance;
}
}
@@ -1,12 +0,0 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
/**
* Rate-limit strategy. Implementations populate {@link RateLimitDecision} so the {@code
* X-RateLimit-*} header contract stays stable across a strategy swap. See README for the design
* rationale and the algorithm-neutral output contract.
*/
public interface RateLimiter {
/** Register one request for {@code key} and report whether it is within the limit. */
RateLimitDecision decide(String key);
}
@@ -1,17 +0,0 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
import java.time.Clock;
import java.time.Duration;
/** Builds the configured {@link RateLimiter} strategy. See README for the design rationale. */
public final class RateLimiterFactory {
private RateLimiterFactory() {}
public static RateLimiter create(
RateLimitAlgorithm algorithm, int limit, Duration window, Clock clock) {
return switch (algorithm) {
case FIXED_WINDOW -> new FixedWindowRateLimiter(limit, window, clock);
};
}
}
@@ -0,0 +1,38 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.Objects;
/**
* Cryptographically random evaluation ID generator.
*
* <p>IDs are created only by the server. HTTP headers and request bodies are never consulted.
*/
public final class SecureRandomRateLimitEvaluationIdGenerator
implements RateLimitEvaluationIdGenerator {
private static final int RANDOM_BYTES = 16;
private final SecureRandom secureRandom;
private final String prefix;
public SecureRandomRateLimitEvaluationIdGenerator(SecureRandom secureRandom, int version) {
this.secureRandom = Objects.requireNonNull(secureRandom, "secureRandom must not be null");
if (version < 1 || version > 9999) {
throw new IllegalArgumentException("evaluation ID version must be in 1..9999");
}
this.prefix = "ev" + version + ":";
}
public static SecureRandomRateLimitEvaluationIdGenerator versionOne() {
return new SecureRandomRateLimitEvaluationIdGenerator(new SecureRandom(), 1);
}
@Override
public String generate() {
byte[] random = new byte[RANDOM_BYTES];
secureRandom.nextBytes(random);
return prefix + Base64.getUrlEncoder().withoutPadding().encodeToString(random);
}
}
@@ -0,0 +1,45 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
import dev.caskeleton.application.observability.UserPrincipalPseudonymizerPort;
import dev.caskeleton.shared.ratelimit.EdgeRateLimitSubject;
import dev.caskeleton.shared.ratelimit.EdgeSubjectPseudonymizer;
import dev.caskeleton.shared.ratelimit.RateLimitSubjectDigest;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
/**
* Adapts the application-provided secret-backed HMAC capability to the edge subject contract.
*
* <p>The adapter length-frames each dimension before hashing and adds an explicit key-rotation
* version to the resulting digest. It does not resolve or retain the HMAC secret.
*/
final class VersionedEdgeSubjectPseudonymizer implements EdgeSubjectPseudonymizer {
private final UserPrincipalPseudonymizerPort delegate;
private final int version;
VersionedEdgeSubjectPseudonymizer(UserPrincipalPseudonymizerPort delegate, int version) {
this.delegate = Objects.requireNonNull(delegate, "delegate must not be null");
if (version < 1 || version > 9999) {
throw new IllegalArgumentException("subject digest version must be in 1..9999");
}
this.version = version;
}
@Override
public RateLimitSubjectDigest pseudonymize(EdgeRateLimitSubject subject) {
Objects.requireNonNull(subject, "subject must not be null");
String canonical =
frame(subject.kind().name())
+ "|"
+ frame(subject.canonicalIdentity())
+ "|"
+ frame(subject.operationId());
String digest = delegate.pseudonymize(canonical);
return new RateLimitSubjectDigest("v" + version + ":" + digest);
}
private static String frame(String value) {
return value.getBytes(StandardCharsets.UTF_8).length + ":" + value;
}
}
@@ -1,42 +0,0 @@
package dev.caskeleton.adapter.inbound.web.settings;
import dev.caskeleton.adapter.inbound.web.ratelimit.RateLimitAlgorithm;
import dev.caskeleton.adapter.inbound.web.ratelimit.RateLimitClientIpMode;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
/**
* Rate-limit knobs bound from {@code ca-skeleton.rate-limit.*}. See README for the design
* rationale.
*
* @param enabled whether the rate-limit interceptor enforces limits
* @param limit max requests allowed per key within one window
* @param window the fixed time window over which {@code limit} is counted
* @param algorithm the rate-limit strategy to use
* @param clientIpMode client-IP source for unauthenticated rate-limit keys
*/
@Validated
@ConfigurationProperties(prefix = "ca-skeleton.rate-limit")
public record RateLimitSettings(
boolean enabled,
Integer limit,
Duration window,
RateLimitAlgorithm algorithm,
RateLimitClientIpMode clientIpMode) {
public RateLimitSettings {
if (limit == null || limit < 1) {
limit = 100;
}
if (window == null || window.isZero() || window.isNegative()) {
window = Duration.ofSeconds(1);
}
if (algorithm == null) {
algorithm = RateLimitAlgorithm.FIXED_WINDOW;
}
if (clientIpMode == null) {
clientIpMode = RateLimitClientIpMode.REMOTE_ADDR_ONLY;
}
}
}
@@ -4,29 +4,107 @@ import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.ConstructorBinding;
/**
* OIDC resource-server config bound from {@code ca-skeleton.security.*}. See README for the design
* rationale.
* Exclusive JWT or Redis-backed browser-session security policy bound from {@code
* ca-skeleton.security.*}.
*/
@ConfigurationProperties(prefix = "ca-skeleton.security")
public record SecuritySettings(String issuerUri, String audience, List<String> publicPaths) {
public record SecuritySettings(
AuthenticationMode authMode,
String issuerUri,
String audience,
List<String> publicPaths,
SessionCookieSettings session) {
private static final Logger log = LoggerFactory.getLogger(SecuritySettings.class);
public SecuritySettings {
if (issuerUri == null || issuerUri.isBlank()) {
@ConstructorBinding
public SecuritySettings(
AuthenticationMode authMode,
String issuerUri,
String audience,
List<String> publicPaths,
SessionCookieSettings session) {
this.authMode = authMode == null ? AuthenticationMode.JWT : authMode;
if (this.authMode == AuthenticationMode.JWT && (issuerUri == null || issuerUri.isBlank())) {
throw new IllegalArgumentException(
"APP_SECURITY_JWT_ISSUER (ca-skeleton.security.issuer-uri) is required");
}
this.issuerUri = issuerUri == null ? "" : issuerUri.trim();
if (audience == null) {
log.warn("APP_SECURITY_JWT_AUDIENCE is missing; skipping audience validation");
audience = "";
if (this.authMode == AuthenticationMode.JWT) {
log.warn("APP_SECURITY_JWT_AUDIENCE is missing; skipping audience validation");
}
this.audience = "";
} else {
this.audience = audience.trim();
}
if (publicPaths == null) {
publicPaths = List.of();
this.publicPaths = List.of();
} else {
publicPaths = List.copyOf(publicPaths);
this.publicPaths = List.copyOf(publicPaths);
}
this.session = session == null ? SessionCookieSettings.defaults() : session;
}
public SecuritySettings(String issuerUri, String audience, List<String> publicPaths) {
this(AuthenticationMode.JWT, issuerUri, audience, publicPaths, null);
}
public enum AuthenticationMode {
JWT,
REDIS_SESSION
}
public record SessionCookieSettings(
String cookieName,
Boolean secure,
Boolean httpOnly,
String sameSite,
String path,
String csrfCookieName,
String csrfHeaderName) {
public SessionCookieSettings(
String cookieName,
Boolean secure,
Boolean httpOnly,
String sameSite,
String path,
String csrfCookieName,
String csrfHeaderName) {
this.cookieName = safeName(cookieName, "CA_SESSION", "cookieName");
this.secure = secure == null || secure;
this.httpOnly = httpOnly == null || httpOnly;
this.sameSite = sameSite == null || sameSite.isBlank() ? "Lax" : sameSite;
if (!this.sameSite.matches("Lax|Strict|None")) {
throw new IllegalArgumentException("session sameSite must be Lax, Strict, or None");
}
this.path = path == null || path.isBlank() ? "/" : path;
if (!this.path.startsWith("/")
|| this.path.length() > 128
|| this.path.chars().anyMatch(Character::isISOControl)) {
throw new IllegalArgumentException("session cookie path must be a bounded absolute path");
}
this.csrfCookieName = safeName(csrfCookieName, "XSRF-TOKEN", "csrfCookieName");
this.csrfHeaderName = safeName(csrfHeaderName, "X-XSRF-TOKEN", "csrfHeaderName");
if (!this.secure || !this.httpOnly) {
throw new IllegalArgumentException("Redis session cookie must remain Secure and HttpOnly");
}
}
private static SessionCookieSettings defaults() {
return new SessionCookieSettings(null, null, null, null, null, null, null);
}
private static String safeName(String value, String fallback, String field) {
String resolved = value == null || value.isBlank() ? fallback : value;
if (!resolved.matches("[A-Za-z][A-Za-z0-9_-]{1,63}")) {
throw new IllegalArgumentException(field + " must be a bounded cookie/header token");
}
return resolved;
}
}
}
@@ -0,0 +1,103 @@
package dev.caskeleton.adapter.inbound.web.auth;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.Set;
import java.util.stream.IntStream;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.context.HttpRequestResponseHolder;
class PrimitiveSessionSecurityContextRepositoryTest {
private final PrimitiveSessionSecurityContextRepository repository =
new PrimitiveSessionSecurityContextRepository();
@Test
void roundTripsOnlyABoundedPrimitiveSnapshotWithoutCredentialsOrFrameworkObjects() {
MockHttpServletRequest request = new MockHttpServletRequest();
MockHttpServletResponse response = new MockHttpServletResponse();
var context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(
UsernamePasswordAuthenticationToken.authenticated(
new AuthenticatedPrincipal(
"idp-user-42", "user@example.test", Set.of("operator", "auditor")),
"must-never-be-stored",
Set.of(
new SimpleGrantedAuthority("ROLE_OPERATOR"),
new SimpleGrantedAuthority("worklog:read"))));
repository.saveContext(context, request, response);
Object stored =
request
.getSession(false)
.getAttribute(PrimitiveSessionSecurityContextRepository.SNAPSHOT_ATTRIBUTE);
assertThat(stored).isInstanceOf(byte[].class);
assertThat(request.getSession(false).getAttribute("SPRING_SECURITY_CONTEXT")).isNull();
var loaded =
repository
.loadContext(new HttpRequestResponseHolder(request, response))
.getAuthentication();
assertThat(loaded.getCredentials()).isNull();
assertThat(loaded.getPrincipal())
.isEqualTo(
new AuthenticatedPrincipal(
"idp-user-42", "user@example.test", Set.of("operator", "auditor")));
assertThat(loaded.getAuthorities())
.extracting(authority -> authority.getAuthority())
.containsExactlyInAnyOrder("ROLE_OPERATOR", "worklog:read");
}
@Test
void rejectsForeignPrincipalGraphsAndFailsClosedOnCorruptSnapshots() {
MockHttpServletRequest request = new MockHttpServletRequest();
MockHttpServletResponse response = new MockHttpServletResponse();
var foreign = SecurityContextHolder.createEmptyContext();
foreign.setAuthentication(
UsernamePasswordAuthenticationToken.authenticated(new Object(), "credential", Set.of()));
assertThatThrownBy(() -> repository.saveContext(foreign, request, response))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("AuthenticatedPrincipal");
request
.getSession(true)
.setAttribute(
PrimitiveSessionSecurityContextRepository.SNAPSHOT_ATTRIBUTE,
new byte[] {0x01, 0x02, 0x03});
assertThat(
repository
.loadContext(new HttpRequestResponseHolder(request, response))
.getAuthentication())
.isNull();
assertThat(
request
.getSession(false)
.getAttribute(PrimitiveSessionSecurityContextRepository.SNAPSHOT_ATTRIBUTE))
.isNull();
}
@Test
void rejectsAuthorityCountsBeyondThePublishedBound() {
MockHttpServletRequest request = new MockHttpServletRequest();
var authorities =
IntStream.range(0, 129)
.mapToObj(index -> new SimpleGrantedAuthority("authority-" + index))
.toList();
var context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(
UsernamePasswordAuthenticationToken.authenticated(
new AuthenticatedPrincipal("idp-user-42", null, Set.of()), null, authorities));
assertThatThrownBy(
() -> repository.saveContext(context, request, new MockHttpServletResponse()))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("authorities");
}
}
@@ -0,0 +1,72 @@
package dev.caskeleton.adapter.inbound.web.auth;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings;
import java.util.concurrent.ConcurrentHashMap;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.test.context.runner.WebApplicationContextRunner;
import org.springframework.context.annotation.Configuration;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.session.MapSessionRepository;
import org.springframework.session.web.http.CookieSerializer;
class RedisSessionWebConfigTest {
private final WebApplicationContextRunner runner =
new WebApplicationContextRunner()
.withUserConfiguration(PropertiesConfig.class, RedisSessionWebConfig.class);
@Test
void jwtModeCreatesNoSessionFilterOrCookieSerializer() {
runner
.withPropertyValues(
"ca-skeleton.security.auth-mode=jwt",
"ca-skeleton.security.issuer-uri=https://issuer.example")
.run(
context -> {
assertThat(context).hasNotFailed();
assertThat(context).doesNotHaveBean(CookieSerializer.class);
assertThat(context).doesNotHaveBean("springSessionRepositoryFilter");
});
}
@Test
void redisSessionModeWritesSecureHttpOnlySameSiteHostOnlyCookie() {
runner
.withBean(
MapSessionRepository.class, () -> new MapSessionRepository(new ConcurrentHashMap<>()))
.withPropertyValues(
"ca-skeleton.security.auth-mode=redis-session",
"ca-skeleton.security.session.cookie-name=APP_SESSION",
"ca-skeleton.security.session.secure=true",
"ca-skeleton.security.session.http-only=true",
"ca-skeleton.security.session.same-site=Strict",
"ca-skeleton.security.session.path=/")
.run(
context -> {
assertThat(context).hasNotFailed();
CookieSerializer serializer = context.getBean(CookieSerializer.class);
MockHttpServletRequest request = new MockHttpServletRequest();
request.setSecure(true);
MockHttpServletResponse response = new MockHttpServletResponse();
serializer.writeCookieValue(
new CookieSerializer.CookieValue(request, response, "opaque-session-id"));
assertThat(response.getHeader("Set-Cookie"))
.contains("APP_SESSION=")
.contains("Path=/")
.contains("Secure")
.contains("HttpOnly")
.contains("SameSite=Strict")
.doesNotContain("Domain=");
});
}
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(SecuritySettings.class)
static class PropertiesConfig {}
}
@@ -0,0 +1,199 @@
package dev.caskeleton.adapter.inbound.web.auth;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import dev.caskeleton.adapter.inbound.web.settings.CorsSettings;
import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings;
import jakarta.servlet.Filter;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.test.context.runner.WebApplicationContextRunner;
import org.springframework.context.annotation.Configuration;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.web.FilterChainProxy;
import org.springframework.security.web.authentication.session.CompositeSessionAuthenticationStrategy;
import org.springframework.security.web.authentication.session.SessionFixationProtectionStrategy;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.security.web.session.SessionManagementFilter;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import tools.jackson.databind.ObjectMapper;
class SecurityModeWebContractTest {
private final WebApplicationContextRunner runner =
new WebApplicationContextRunner()
.withUserConfiguration(PropertiesConfig.class, SecurityConfig.class)
.withBean(JwtToAuthenticatedPrincipalConverter.class)
.withBean(ObjectMapper.class, ObjectMapper::new)
.withBean(
JwtDecoder.class,
() ->
token -> {
throw new UnsupportedOperationException("decoder must remain unused");
});
@Test
void redisSessionModeEnablesCsrfAndRotatesAnAuthenticatedSessionIdentifier() {
runner
.withPropertyValues(
"ca-skeleton.security.auth-mode=redis-session",
"ca-skeleton.security.public-paths=/probe,/csrf",
"ca-skeleton.cors.enabled=false")
.run(
context -> {
MockMvc mvc = mvc(context.getBean("springSecurityFilterChain", Filter.class));
try {
mvc.perform(post("/probe")).andExpect(status().isForbidden());
var csrfResult = mvc.perform(get("/csrf")).andExpect(status().isOk()).andReturn();
Cookie csrfCookie = csrfResult.getResponse().getCookie("XSRF-TOKEN");
assertThat(csrfCookie).isNotNull();
mvc.perform(
post("/probe")
.cookie(csrfCookie)
.header("X-XSRF-TOKEN", csrfCookie.getValue()))
.andExpect(status().isOk());
FilterChainProxy proxy =
context.getBean("springSecurityFilterChain", FilterChainProxy.class);
SessionManagementFilter sessionManagement =
proxy.getFilterChains().getFirst().getFilters().stream()
.filter(SessionManagementFilter.class::isInstance)
.map(SessionManagementFilter.class::cast)
.findFirst()
.orElseThrow();
Object strategy =
ReflectionTestUtils.getField(
sessionManagement, "sessionAuthenticationStrategy");
assertThat(strategy).isInstanceOf(CompositeSessionAuthenticationStrategy.class);
assertThat(
(java.util.List<?>)
ReflectionTestUtils.getField(strategy, "delegateStrategies"))
.anyMatch(SessionFixationProtectionStrategy.class::isInstance);
} catch (Exception exception) {
throw new AssertionError("session security contract failed", exception);
}
});
}
@Test
void redisSessionSecurityFilterPersistsAndRestoresOnlyThePrimitiveAuthenticationSnapshot() {
runner
.withPropertyValues(
"ca-skeleton.security.auth-mode=redis-session",
"ca-skeleton.security.public-paths=/login-test,/csrf",
"ca-skeleton.cors.enabled=false")
.run(
context -> {
MockMvc mvc = mvc(context.getBean("springSecurityFilterChain", Filter.class));
try {
var csrfResult = mvc.perform(get("/csrf")).andExpect(status().isOk()).andReturn();
Cookie csrfCookie = csrfResult.getResponse().getCookie("XSRF-TOKEN");
var login =
mvc.perform(
post("/login-test")
.cookie(csrfCookie)
.header("X-XSRF-TOKEN", csrfCookie.getValue()))
.andExpect(status().isOk())
.andReturn();
MockHttpSession session = (MockHttpSession) login.getRequest().getSession(false);
assertThat(session).isNotNull();
assertThat(
session.getAttribute(
PrimitiveSessionSecurityContextRepository.SNAPSHOT_ATTRIBUTE))
.isInstanceOf(byte[].class);
assertThat(session.getAttribute("SPRING_SECURITY_CONTEXT")).isNull();
mvc.perform(get("/whoami").session(session))
.andExpect(status().isOk())
.andExpect(content().string("session-user"));
} catch (Exception exception) {
throw new AssertionError(
"primitive session security context round-trip failed", exception);
}
});
}
@Test
void jwtModeRemainsCsrfDisabledAndStateless() {
runner
.withPropertyValues(
"ca-skeleton.security.auth-mode=jwt",
"ca-skeleton.security.issuer-uri=https://issuer.example",
"ca-skeleton.security.public-paths=/probe",
"ca-skeleton.cors.enabled=false")
.run(
context -> {
MockMvc mvc = mvc(context.getBean("springSecurityFilterChain", Filter.class));
try {
var result = mvc.perform(post("/probe")).andExpect(status().isOk()).andReturn();
assertThat(result.getRequest().getSession(false)).isNull();
} catch (Exception exception) {
throw new AssertionError("JWT security contract failed", exception);
}
});
}
private static MockMvc mvc(Filter springSecurityFilterChain) {
return MockMvcBuilders.standaloneSetup(new ProbeController())
.addFilters(springSecurityFilterChain)
.build();
}
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties({SecuritySettings.class, CorsSettings.class})
static class PropertiesConfig {}
@RestController
static class ProbeController {
@GetMapping("/probe")
String getProbe() {
return "ok";
}
@PostMapping("/probe")
String postProbe() {
return "ok";
}
@GetMapping("/csrf")
String csrf(HttpServletRequest request) {
CsrfToken token = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
return token.getToken();
}
@PostMapping("/login-test")
String loginForContract() {
SecurityContextHolder.getContext()
.setAuthentication(
UsernamePasswordAuthenticationToken.authenticated(
new AuthenticatedPrincipal(
"session-user", "session-user@example.test", java.util.Set.of("operator")),
null,
java.util.Set.of(new SimpleGrantedAuthority("ROLE_OPERATOR"))));
return "authenticated";
}
@GetMapping("/whoami")
String whoami() {
return ((AuthenticatedPrincipal)
SecurityContextHolder.getContext().getAuthentication().getPrincipal())
.idpUserId();
}
}
}
@@ -0,0 +1,100 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
import dev.caskeleton.shared.ratelimit.EdgeRateLimitSubject;
import dev.caskeleton.shared.ratelimit.RateLimitDecision;
import dev.caskeleton.shared.ratelimit.RateLimitOutcome;
import dev.caskeleton.shared.ratelimit.RateLimitRequest;
import dev.caskeleton.shared.ratelimit.RateLimitSubjectDigest;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Set;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.servlet.HandlerMapping;
class EdgeRateLimitTransportBridgeTest {
private static final Clock CLOCK =
Clock.fixed(Instant.parse("2026-07-29T01:00:00Z"), ZoneOffset.UTC);
private static final String DIGEST = "v7:" + "b".repeat(64);
private static final String EVALUATION_ID = "ev9:" + "C".repeat(22);
@AfterEach
void clearSecurityContext() {
SecurityContextHolder.clearContext();
}
@Test
void sendsOnlyABoundedPseudonymousSubjectAndTransportBudgetToThePort() {
Capture capture = new Capture();
RateLimitOutcome expected =
new RateLimitOutcome.Evaluated(
new RateLimitDecision(
true,
100,
99,
Duration.ZERO,
CLOCK.instant().plusSeconds(1),
"api-default",
"v3",
RateLimitDecision.DecisionSource.GLOBAL_REDIS,
RateLimitDecision.DecisionCertainty.CERTAIN));
EdgeRateLimitTransportBridge bridge =
new EdgeRateLimitTransportBridge(
request -> {
capture.request = request;
return expected;
},
subject -> {
capture.rawSubject = subject;
return new RateLimitSubjectDigest(DIGEST);
},
new RateLimitKeyResolver(new RemoteAddrClientIpResolver()),
CLOCK,
"api-default",
Duration.ofMillis(750),
() -> EVALUATION_ID);
AuthenticatedPrincipal principal =
new AuthenticatedPrincipal("raw-user-42", "raw@example.com", Set.of("user"));
SecurityContextHolder.getContext()
.setAuthentication(new UsernamePasswordAuthenticationToken(principal, "n/a", Set.of()));
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/v1/worklogs/123");
request.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, "/v1/worklogs/{id}");
request.addHeader("Idempotency-Key", "client-controlled-value");
request.addHeader("X-Rate-Limit-Evaluation-Id", "ev1:" + "Z".repeat(22));
RateLimitOutcome actual = bridge.evaluate(request);
assertThat(actual).isSameAs(expected);
assertThat(capture.rawSubject)
.isEqualTo(
new EdgeRateLimitSubject(
EdgeRateLimitSubject.Kind.PRINCIPAL, "raw-user-42", "GET /v1/worklogs/{id}"));
assertThat(capture.request.policyId()).isEqualTo("api-default");
assertThat(capture.request.subjectDigest()).isEqualTo(DIGEST);
assertThat(capture.request.subjectDigest())
.doesNotContain("raw-user-42")
.doesNotContain("raw@example.com");
assertThat(capture.request.cost()).isEqualTo(1);
assertThat(capture.request.evaluationId()).isEqualTo(EVALUATION_ID);
assertThat(capture.request.evaluationId())
.doesNotContain("client-controlled-value")
.doesNotContain("ZZZZ");
assertThat(capture.request.callerDeadline())
.isEqualTo(Instant.parse("2026-07-29T01:00:00.750Z"));
}
private static final class Capture {
private EdgeRateLimitSubject rawSubject;
private RateLimitRequest request;
}
}
@@ -0,0 +1,44 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.time.Duration;
import org.junit.jupiter.api.Test;
class EdgeRateLimitTransportSettingsTest {
@Test
void defaultsPolicyAndCallerBudgetWithoutSelectingALocalProvider() {
EdgeRateLimitTransportSettings settings =
new EdgeRateLimitTransportSettings(true, null, null, 0, null);
assertThat(settings.enabled()).isTrue();
assertThat(settings.defaultPolicyId()).isEqualTo("api-default");
assertThat(settings.callerDeadlineBudget()).isEqualTo(Duration.ofSeconds(2));
assertThat(settings.hashKeyVersion()).isEqualTo(1);
assertThat(settings.clientIpMode()).isEqualTo(RateLimitClientIpMode.REMOTE_ADDR_ONLY);
}
@Test
void rejectsUnboundedPolicyAndDeadlineValues() {
assertThatThrownBy(
() ->
new EdgeRateLimitTransportSettings(
true, "INVALID POLICY", Duration.ofSeconds(1), 1, null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("defaultPolicyId");
assertThatThrownBy(
() ->
new EdgeRateLimitTransportSettings(
true, "api-default", Duration.ofSeconds(31), 1, null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("callerDeadlineBudget");
assertThatThrownBy(
() ->
new EdgeRateLimitTransportSettings(
true, "api-default", Duration.ofSeconds(1), 10_000, null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("hashKeyVersion");
}
}
@@ -1,84 +0,0 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
import static org.assertj.core.api.Assertions.assertThat;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import org.junit.jupiter.api.Test;
class FixedWindowRateLimiterTest {
private static final Instant T0 = Instant.parse("2026-06-09T12:00:00Z");
@Test
void allowsUpToTheLimitThenRejectsWithinAWindow() {
FixedWindowRateLimiter limiter =
new FixedWindowRateLimiter(2, Duration.ofSeconds(1), Clock.fixed(T0, ZoneOffset.UTC));
assertThat(limiter.decide("k").allowed()).isTrue();
RateLimitDecision second = limiter.decide("k");
assertThat(second.allowed()).isTrue();
assertThat(second.remaining()).isZero();
RateLimitDecision third = limiter.decide("k");
assertThat(third.allowed()).isFalse();
assertThat(third.remaining()).isZero();
}
@Test
void separateKeysHaveIndependentCounters() {
FixedWindowRateLimiter limiter =
new FixedWindowRateLimiter(1, Duration.ofSeconds(1), Clock.fixed(T0, ZoneOffset.UTC));
assertThat(limiter.decide("a").allowed()).isTrue();
assertThat(limiter.decide("b").allowed()).isTrue();
assertThat(limiter.decide("a").allowed()).isFalse();
}
@Test
void counterResetsWhenTheWindowRolls() {
MutableClock clock = new MutableClock(T0);
FixedWindowRateLimiter limiter = new FixedWindowRateLimiter(1, Duration.ofSeconds(1), clock);
assertThat(limiter.decide("k").allowed()).isTrue();
assertThat(limiter.decide("k").allowed()).isFalse();
clock.advance(Duration.ofSeconds(1)); // next fixed window
assertThat(limiter.decide("k").allowed()).isTrue();
}
@Test
void resetInstantIsTheWindowEnd() {
FixedWindowRateLimiter limiter =
new FixedWindowRateLimiter(5, Duration.ofSeconds(60), Clock.fixed(T0, ZoneOffset.UTC));
// T0 = 12:00:00 → 60s window starting at 12:00:00 ends at 12:01:00.
assertThat(limiter.decide("k").resetAt()).isEqualTo(Instant.parse("2026-06-09T12:01:00Z"));
}
static final class MutableClock extends Clock {
private Instant instant;
MutableClock(Instant start) {
this.instant = start;
}
void advance(Duration d) {
instant = instant.plus(d);
}
@Override
public Instant instant() {
return instant;
}
@Override
public ZoneId getZone() {
return ZoneOffset.UTC;
}
@Override
public Clock withZone(ZoneId zone) {
return this;
}
}
}
@@ -3,75 +3,176 @@ package dev.caskeleton.adapter.inbound.web.ratelimit;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.inbound.web.http.ApiHeaders;
import dev.caskeleton.shared.ratelimit.EdgeRateLimitPort;
import dev.caskeleton.shared.ratelimit.EdgeSubjectPseudonymizer;
import dev.caskeleton.shared.ratelimit.RateLimitDecision;
import dev.caskeleton.shared.ratelimit.RateLimitOutcome;
import dev.caskeleton.shared.ratelimit.RateLimitSubjectDigest;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.web.servlet.HandlerMapping;
import tools.jackson.databind.ObjectMapper;
class RateLimitInterceptorTest {
private static final Clock CLOCK =
Clock.fixed(Instant.parse("2026-06-09T12:00:00Z"), ZoneOffset.UTC);
private static final String SUBJECT_DIGEST = "v1:" + "a".repeat(64);
private final ObjectMapper objectMapper = new ObjectMapper();
private RateLimitInterceptor interceptor(boolean enabled, int limit) {
FixedWindowRateLimiter limiter =
new FixedWindowRateLimiter(limit, Duration.ofSeconds(1), CLOCK);
return new RateLimitInterceptor(
enabled,
limiter,
new RateLimitKeyResolver(new RemoteAddrClientIpResolver()),
objectMapper,
1);
}
private MockHttpServletRequest request() {
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/v1/worklogs");
req.setRemoteAddr("203.0.113.7");
return req;
}
@Test
void allowedRequestPassesAndEmitsSignalingHeaders() throws Exception {
MockHttpServletResponse res = new MockHttpServletResponse();
void allowedRequestPassesAndEmitsExistingSignalingHeaders() throws Exception {
RateLimitOutcome outcome =
evaluated(true, 5, 4, Duration.ZERO, Instant.parse("2026-06-09T12:00:01Z"));
MockHttpServletResponse response = new MockHttpServletResponse();
boolean proceed = interceptor(true, 5).preHandle(request(), res, new Object());
boolean proceed = interceptor(outcome).preHandle(request(), response, new Object());
assertThat(proceed).isTrue();
assertThat(res.getHeader(ApiHeaders.X_RATELIMIT_LIMIT)).isEqualTo("5");
assertThat(res.getHeader(ApiHeaders.X_RATELIMIT_REMAINING)).isEqualTo("4");
assertThat(res.getHeader(ApiHeaders.X_RATELIMIT_RESET)).isEqualTo("2026-06-09T12:00:01Z");
assertThat(response.getHeader(ApiHeaders.X_RATELIMIT_LIMIT)).isEqualTo("5");
assertThat(response.getHeader(ApiHeaders.X_RATELIMIT_REMAINING)).isEqualTo("4");
assertThat(response.getHeader(ApiHeaders.X_RATELIMIT_RESET)).isEqualTo("2026-06-09T12:00:01Z");
assertThat(response.getHeader(ApiHeaders.RETRY_AFTER)).isNull();
}
@Test
void exceedingTheLimitRejectsWith429EnvelopeRetryAfterAndRetryableTrue() throws Exception {
RateLimitInterceptor interceptor = interceptor(true, 1);
// first request consumes the only slot
interceptor.preHandle(request(), new MockHttpServletResponse(), new Object());
void deniedDecisionRejectsWith429AndUsesTheProviderRetryHint() throws Exception {
RateLimitOutcome outcome =
evaluated(false, 1, 0, Duration.ofMillis(1500), Instant.parse("2026-06-09T12:00:02Z"));
MockHttpServletResponse response = new MockHttpServletResponse();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean proceed = interceptor.preHandle(request(), res, new Object());
boolean proceed = interceptor(outcome).preHandle(request(), response, new Object());
assertThat(proceed).isFalse();
assertThat(res.getStatus()).isEqualTo(429);
assertThat(res.getHeader(ApiHeaders.RETRY_AFTER)).isEqualTo("1");
assertThat(res.getContentAsString())
assertThat(response.getStatus()).isEqualTo(429);
assertThat(response.getHeader(ApiHeaders.RETRY_AFTER)).isEqualTo("2");
assertThat(response.getHeader(ApiHeaders.X_RATELIMIT_LIMIT)).isEqualTo("1");
assertThat(response.getContentAsString())
.contains("\"RATE_LIMIT_EXCEEDED\"")
.contains("\"RATE_LIMIT\"")
.contains("\"retryable\":true");
}
@Test
void disabledLimiterPassesWithoutTouchingHeaders() throws Exception {
MockHttpServletResponse res = new MockHttpServletResponse();
void unavailableAndIndeterminateOutcomesMapTo503WithTheirOwnRetryHints() throws Exception {
MockHttpServletResponse unavailableResponse = new MockHttpServletResponse();
MockHttpServletResponse indeterminateResponse = new MockHttpServletResponse();
boolean proceed = interceptor(false, 1).preHandle(request(), res, new Object());
boolean unavailableProceed =
interceptor(
new RateLimitOutcome.Unavailable(
"api-default",
Duration.ofMillis(100),
RateLimitOutcome.UnavailableCategory.UNAVAILABLE_BEFORE_SEND))
.preHandle(request(), unavailableResponse, new Object());
boolean indeterminateProceed =
interceptor(new RateLimitOutcome.Indeterminate("api-default", Duration.ofMillis(2500)))
.preHandle(request(), indeterminateResponse, new Object());
assertThat(unavailableProceed).isFalse();
assertThat(unavailableResponse.getStatus()).isEqualTo(503);
assertThat(unavailableResponse.getHeader(ApiHeaders.RETRY_AFTER)).isEqualTo("1");
assertThat(unavailableResponse.getHeader(ApiHeaders.X_RATELIMIT_LIMIT)).isNull();
assertThat(unavailableResponse.getContentAsString())
.contains("\"RATE_LIMIT_UNAVAILABLE\"")
.contains("\"retryable\":true");
assertThat(indeterminateProceed).isFalse();
assertThat(indeterminateResponse.getStatus()).isEqualTo(503);
assertThat(indeterminateResponse.getHeader(ApiHeaders.RETRY_AFTER)).isEqualTo("3");
}
@Test
void incompatibleOutcomeMapsToNonRetryable503WithoutInventingARetryHint() throws Exception {
MockHttpServletResponse response = new MockHttpServletResponse();
boolean proceed =
interceptor(
new RateLimitOutcome.Incompatible(
"api-default", RateLimitOutcome.IncompatibleCategory.PROGRAM_INCOMPATIBLE))
.preHandle(request(), response, new Object());
assertThat(proceed).isFalse();
assertThat(response.getStatus()).isEqualTo(503);
assertThat(response.getHeader(ApiHeaders.RETRY_AFTER)).isNull();
assertThat(response.getContentAsString())
.contains("\"RATE_LIMIT_INCOMPATIBLE\"")
.contains("\"retryable\":false");
}
@Test
void disabledModeHasNoProviderPseudonymizerOrResolverSideEffects() throws Exception {
AtomicInteger calls = new AtomicInteger();
EdgeRateLimitPort port =
request -> {
calls.incrementAndGet();
throw new AssertionError("disabled interceptor must not call the provider");
};
EdgeSubjectPseudonymizer pseudonymizer =
subject -> {
calls.incrementAndGet();
throw new AssertionError("disabled interceptor must not pseudonymize");
};
EdgeRateLimitTransportBridge unusedBridge =
new EdgeRateLimitTransportBridge(
port,
pseudonymizer,
new RateLimitKeyResolver(
request -> {
calls.incrementAndGet();
return request.getRemoteAddr();
}),
CLOCK,
"api-default",
Duration.ofSeconds(1),
() -> "ev1:" + "D".repeat(22));
MockHttpServletResponse response = new MockHttpServletResponse();
boolean proceed =
RateLimitInterceptor.disabled(objectMapper).preHandle(request(), response, unusedBridge);
assertThat(proceed).isTrue();
assertThat(res.getHeader(ApiHeaders.X_RATELIMIT_LIMIT)).isNull();
assertThat(calls).hasValue(0);
assertThat(response.getHeader(ApiHeaders.X_RATELIMIT_LIMIT)).isNull();
}
private RateLimitInterceptor interceptor(RateLimitOutcome outcome) {
EdgeRateLimitTransportBridge bridge =
new EdgeRateLimitTransportBridge(
request -> outcome,
subject -> new RateLimitSubjectDigest(SUBJECT_DIGEST),
new RateLimitKeyResolver(new RemoteAddrClientIpResolver()),
CLOCK,
"api-default",
Duration.ofSeconds(1),
() -> "ev1:" + "D".repeat(22));
return RateLimitInterceptor.enabled(bridge, objectMapper);
}
private MockHttpServletRequest request() {
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/v1/worklogs");
request.setRemoteAddr("203.0.113.7");
request.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, "/v1/worklogs");
return request;
}
private static RateLimitOutcome evaluated(
boolean allowed, long limit, long remaining, Duration retryAfter, Instant resetAt) {
return new RateLimitOutcome.Evaluated(
new RateLimitDecision(
allowed,
limit,
remaining,
retryAfter,
resetAt,
"api-default",
"v1",
RateLimitDecision.DecisionSource.GLOBAL_REDIS,
RateLimitDecision.DecisionCertainty.CERTAIN));
}
}
@@ -3,6 +3,7 @@ package dev.caskeleton.adapter.inbound.web.ratelimit;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
import dev.caskeleton.shared.ratelimit.EdgeRateLimitSubject;
import java.util.Set;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
@@ -27,34 +28,42 @@ class RateLimitKeyResolverTest {
}
@Test
void unauthenticatedKeyIsIpPlusRouteTemplate() {
void unauthenticatedSubjectIsBoundedClientIpPlusRouteTemplate() {
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/v1/worklogs/123");
req.setRemoteAddr("203.0.113.7");
req.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, "/v1/worklogs/{id}");
assertThat(resolver.resolve(req)).isEqualTo("ip:203.0.113.7:GET /v1/worklogs/{id}");
assertThat(resolver.resolve(req))
.isEqualTo(
new EdgeRateLimitSubject(
EdgeRateLimitSubject.Kind.CLIENT_IP, "203.0.113.7", "GET /v1/worklogs/{id}"));
}
@Test
void unauthenticatedKeyFallsBackToUriWhenNoPattern() {
void unauthenticatedSubjectUsesABoundedFallbackWhenNoRouteTemplateExists() {
MockHttpServletRequest req = new MockHttpServletRequest("POST", "/v1/worklogs");
req.setRemoteAddr("198.51.100.4");
assertThat(resolver.resolve(req)).isEqualTo("ip:198.51.100.4:POST /v1/worklogs");
assertThat(resolver.resolve(req).operationId()).isEqualTo("POST <unresolved-route>");
}
@Test
void authenticatedUserKeyIsKeyedByPrincipal() {
void authenticatedUserSubjectIsPrincipalPlusOperation() {
authenticateAs(new AuthenticatedPrincipal("user-42", "u@x.io", Set.of("user")));
assertThat(resolver.resolve(new MockHttpServletRequest("GET", "/v1/worklogs")))
.isEqualTo("user:user-42");
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/v1/worklogs");
request.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, "/v1/worklogs");
assertThat(resolver.resolve(request))
.isEqualTo(
new EdgeRateLimitSubject(
EdgeRateLimitSubject.Kind.PRINCIPAL, "user-42", "GET /v1/worklogs"));
}
@Test
void servicePrincipalKeyIsKeyedByApiKeyId() {
void servicePrincipalSubjectUsesApiKeyKind() {
authenticateAs(new AuthenticatedPrincipal("svc-7", "svc@x.io", Set.of("service")));
assertThat(resolver.resolve(new MockHttpServletRequest("GET", "/v1/worklogs")))
.isEqualTo("apikey:svc-7");
assertThat(resolver.resolve(new MockHttpServletRequest("GET", "/v1/worklogs")).kind())
.isEqualTo(EdgeRateLimitSubject.Kind.API_KEY);
}
@Test
@@ -63,7 +72,7 @@ class RateLimitKeyResolverTest {
req.setRemoteAddr("10.0.0.1");
req.addHeader("X-Forwarded-For", "203.0.113.9, 10.0.0.1");
assertThat(resolver.resolve(req)).isEqualTo("ip:10.0.0.1:GET /v1/ping");
assertThat(resolver.resolve(req).canonicalIdentity()).isEqualTo("10.0.0.1");
}
@Test
@@ -74,7 +83,7 @@ class RateLimitKeyResolverTest {
req.setRemoteAddr("10.0.0.1");
req.addHeader("X-Forwarded-For", "203.0.113.9, 10.0.0.1");
assertThat(forwardedResolver.resolve(req)).isEqualTo("ip:203.0.113.9:GET /v1/ping");
assertThat(forwardedResolver.resolve(req).canonicalIdentity()).isEqualTo("203.0.113.9");
}
@Test
@@ -85,6 +94,6 @@ class RateLimitKeyResolverTest {
req.setRemoteAddr("198.51.100.4");
req.addHeader("X-Forwarded-For", " ");
assertThat(forwardedResolver.resolve(req)).isEqualTo("ip:198.51.100.4:GET /v1/ping");
assertThat(forwardedResolver.resolve(req).canonicalIdentity()).isEqualTo("198.51.100.4");
}
}
@@ -0,0 +1,35 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verifyNoInteractions;
import dev.caskeleton.application.observability.UserPrincipalPseudonymizerPort;
import dev.caskeleton.shared.ratelimit.EdgeRateLimitPort;
import java.time.Clock;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.ObjectProvider;
import tools.jackson.databind.ObjectMapper;
class RateLimitWebConfigTest {
@Test
void disabledCapabilityDoesNotResolveProviderPseudonymizerOrClock() {
ObjectProvider<Clock> clockProvider = provider();
ObjectProvider<EdgeRateLimitPort> rateLimitPortProvider = provider();
ObjectProvider<UserPrincipalPseudonymizerPort> pseudonymizerProvider = provider();
new RateLimitWebConfig(
new EdgeRateLimitTransportSettings(false, null, null, 0, null),
new ObjectMapper(),
clockProvider,
rateLimitPortProvider,
pseudonymizerProvider);
verifyNoInteractions(clockProvider, rateLimitPortProvider, pseudonymizerProvider);
}
@SuppressWarnings("unchecked")
private static <T> ObjectProvider<T> provider() {
return mock(ObjectProvider.class);
}
}
@@ -1,28 +0,0 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
import static org.assertj.core.api.Assertions.assertThat;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import org.junit.jupiter.api.Test;
class RateLimiterFactoryTest {
private static final Clock CLOCK =
Clock.fixed(Instant.parse("2026-06-09T00:00:00Z"), ZoneOffset.UTC);
@Test
void fixedWindowAlgorithmBuildsAFixedWindowLimiter() {
RateLimiter limiter =
RateLimiterFactory.create(
RateLimitAlgorithm.FIXED_WINDOW, 10, Duration.ofSeconds(1), CLOCK);
assertThat(limiter).isInstanceOf(FixedWindowRateLimiter.class);
// returns the interface type so the interceptor never sees the concrete class
RateLimitDecision decision = limiter.decide("k");
assertThat(decision.allowed()).isTrue();
assertThat(decision.limit()).isEqualTo(10);
}
}
@@ -0,0 +1,24 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
import static org.assertj.core.api.Assertions.assertThat;
import java.security.SecureRandom;
import java.util.HashSet;
import java.util.Set;
import org.junit.jupiter.api.Test;
class SecureRandomRateLimitEvaluationIdGeneratorTest {
@Test
void generatesVersionedBoundedServerSideIdsWithCryptographicRandomness() {
RateLimitEvaluationIdGenerator generator =
new SecureRandomRateLimitEvaluationIdGenerator(new SecureRandom(), 1);
Set<String> generated = new HashSet<>();
for (int index = 0; index < 100; index++) {
generated.add(generator.generate());
}
assertThat(generated).hasSize(100).allMatch(value -> value.matches("ev1:[A-Za-z0-9_-]{22}"));
}
}
@@ -0,0 +1,32 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.shared.ratelimit.EdgeRateLimitSubject;
import org.junit.jupiter.api.Test;
class VersionedEdgeSubjectPseudonymizerTest {
@Test
void lengthFramesEveryDimensionBeforeDelegatingAndVersionsTheDigest() {
StringBuilder delegatedInput = new StringBuilder();
VersionedEdgeSubjectPseudonymizer pseudonymizer =
new VersionedEdgeSubjectPseudonymizer(
raw -> {
delegatedInput.append(raw);
return "c".repeat(64);
},
3);
assertThat(
pseudonymizer
.pseudonymize(
new EdgeRateLimitSubject(
EdgeRateLimitSubject.Kind.CLIENT_IP,
"203.0.113.7",
"GET /v1/worklogs/{id}"))
.value())
.isEqualTo("v3:" + "c".repeat(64));
assertThat(delegatedInput).hasToString("9:CLIENT_IP|11:203.0.113.7|21:GET /v1/worklogs/{id}");
}
}
@@ -1,43 +0,0 @@
package dev.caskeleton.adapter.inbound.web.settings;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.inbound.web.ratelimit.RateLimitAlgorithm;
import dev.caskeleton.adapter.inbound.web.ratelimit.RateLimitClientIpMode;
import java.time.Duration;
import org.junit.jupiter.api.Test;
class RateLimitSettingsTest {
@Test
void bindsSuppliedValues() {
RateLimitSettings props =
new RateLimitSettings(
true,
250,
Duration.ofSeconds(5),
RateLimitAlgorithm.FIXED_WINDOW,
RateLimitClientIpMode.FORWARDED_HEADERS_TRUSTED);
assertThat(props.enabled()).isTrue();
assertThat(props.limit()).isEqualTo(250);
assertThat(props.window()).isEqualTo(Duration.ofSeconds(5));
assertThat(props.algorithm()).isEqualTo(RateLimitAlgorithm.FIXED_WINDOW);
assertThat(props.clientIpMode()).isEqualTo(RateLimitClientIpMode.FORWARDED_HEADERS_TRUSTED);
}
@Test
void defaultsAbsentOrInvalidLimitWindowAndAlgorithm() {
RateLimitSettings props = new RateLimitSettings(false, null, null, null, null);
assertThat(props.limit()).isEqualTo(100);
assertThat(props.window()).isEqualTo(Duration.ofSeconds(1));
assertThat(props.algorithm()).isEqualTo(RateLimitAlgorithm.FIXED_WINDOW);
assertThat(props.clientIpMode()).isEqualTo(RateLimitClientIpMode.REMOTE_ADDR_ONLY);
}
@Test
void rejectsNonPositiveLimitAndWindowWithSafeDefaults() {
RateLimitSettings props = new RateLimitSettings(true, 0, Duration.ZERO, null, null);
assertThat(props.limit()).isEqualTo(100);
assertThat(props.window()).isEqualTo(Duration.ofSeconds(1));
}
}
@@ -68,6 +68,27 @@ class SecuritySettingsTest {
assertThat(settings.publicPaths()).isUnmodifiable();
}
@Test
void redisSessionModeDoesNotRequireJwtAndBindsSecureHostOnlyCookiePolicy() {
runner
.withPropertyValues(
"ca-skeleton.security.auth-mode=redis-session",
"ca-skeleton.security.session.cookie-name=APP_SESSION",
"ca-skeleton.security.session.secure=true",
"ca-skeleton.security.session.http-only=true",
"ca-skeleton.security.session.same-site=Strict")
.run(
context -> {
assertThat(context).hasNotFailed();
SecuritySettings settings = context.getBean(SecuritySettings.class);
assertThat(settings.authMode())
.isEqualTo(SecuritySettings.AuthenticationMode.REDIS_SESSION);
assertThat(settings.issuerUri()).isEmpty();
assertThat(settings.session().cookieName()).isEqualTo("APP_SESSION");
assertThat(settings.session().sameSite()).isEqualTo("Strict");
});
}
@Configuration
@EnableConfigurationProperties(SecuritySettings.class)
static class EnableProperties {}
@@ -15,6 +15,10 @@ Package root: `dev.caskeleton.adapter.outbound.cache`.
- Implement semantic cache ports from `application-core` without exposing Redis concepts to core.
- Own canonical physical keys, digesting, codec/envelope, program catalog, typed Redis atomic
facades, runtime client adaptation, and capability-specific failure semantics.
- Implement absolute soft/hard expiry and deterministic bounded jitter behind the semantic cache
port; cache-aside/source protection policy remains framework-free in `application-core`.
- Implement the provider-neutral `EdgeRateLimitPort` with dedicated coordination Redis settings,
connection/admission, private keys and versioned atomic programs.
- Keep the legacy cache router isolated while consumers migrate to semantic ports.
- Reuse `adapter:outbound:support` for shared outbound concerns.
@@ -24,10 +28,14 @@ Package root: `dev.caskeleton.adapter.outbound.cache`.
`src/config/architecture/modules.json` entry.
- No inbound transport, persistence entity/repository, bootstrap, or sample dependency.
- Cache adapters do not decide business freshness, entitlement, or domain fallback rules.
- Physical Redis TTL must equal encoded hard expiry; future/corrupt schema must never collapse into
an ordinary miss.
- Application/domain code must not receive raw Redis keys, commands, Lua/Function names, SDK
objects, topology, or connection types.
- Cache fail-open behavior must not be reused for session, idempotency, strict quota, lease, or
fencing.
- Rate-limit composition must not reuse `app.cache.redis`, its connection, external client mode or
failure-open semantics; v1 is coordination-role and fail-closed only.
- The standalone runtime/cache service lane is R1 evidence only. Sentinel/Cluster, TLS/ACL,
persistence/restart, eviction and fault evidence are required separately for R2.
+259 -20
View File
@@ -10,16 +10,157 @@
## 현재 readiness
현재 standalone runtime과 semantic string cache는 R1이다. 모듈이 Lettuce connection lifecycle,
현재 checked-in readiness registry에는 `selected` card가 없으므로 Redis R2 release claim도
없다.
| Capability card | 현재 상태 | Promotion topology |
| --- | --- | --- |
| cache | `implemented-candidate` | standalone |
| edge rate limit | `implemented-candidate` | standalone |
| request-replay idempotency | `implemented-candidate` | standalone |
| cache refresh soft lease | `implemented-candidate` | standalone |
| session | `implemented-candidate` | standalone |
| fenced coordination | `not-implemented` | 없음 |
`implemented-candidate`는 구현과 standalone/security/fault/compatibility evidence lane이 있다는
뜻일 뿐 release selection이나 R2 qualification이 아니다. 현재 evidence는 Sentinel/Cluster,
k3s multi-node, topology failover, credential/certificate rotation 또는 R3를 증명하지 않는다.
모듈은 Lettuce connection lifecycle,
finite command timeout, reconnect replay 차단, finite request queue/admission, positive/negative
TTL, digest-protected bounded binary envelope, HMAC physical key,
invalidation, Lua `EVALSHA -> NOSCRIPT -> EVAL` 실행기를 제공한다.
TTL, absolute soft/hard expiry, deterministic bounded TTL jitter, digest-protected v2 binary
envelope, HMAC physical key,
invalidation, closed-catalog
`EVALSHA -> NOSCRIPT -> SCRIPT LOAD -> digest verify -> EVALSHA` recovery를 제공한다.
`app.cache.redis.client-mode=external`이면 프로젝트가 제공한 `RedisClient` 호환 경로를 사용하고
managed connection을 생성하지 않는다.
명시적으로 Redis 7.4 image를 띄워 실행하는 standalone lane이 실제 expiry
compare-and-delete Lua 실행을 검증하지만 Sentinel/Cluster,
TLS/ACL/credential rotation, restart/fault/eviction evidence, health/metrics가 없으므로 R2가 아니다.
명시적으로 최소 지원 Redis 7.2 image를 띄워 실행하는 standalone lane이 실제 expiry,
compare-and-delete, cache `NX`, observation-token compare-and-replace, 세 rate-limit 프로그램,
각 프로그램의 exact-boundary/denial-no-consume, clock-regression state 불변,
token refill remainder와 malformed hash 분류를 검증한다. TLS named-user ACL에서 semantic
readiness의 `SCRIPT LOAD`/대표 명령 거부 증거는 있지만 Sentinel/Cluster, credential rotation,
restart/fault/eviction과 capability 전체의 운영 증거가 완성되지 않았으므로 R2가 아니다.
## Role policy와 health 경계
Canonical role binding은 startup에 다음 정책을 fail-closed로 검증한다.
- `CACHE`: `required=false`, `expected-eviction=allkeys-lfu|allkeys-lru`
- `COORDINATION`: `required=true`, `expected-eviction=noeviction`
- `SESSION`: `required=true`, `expected-eviction=noeviction`
Redis 모듈은 바인딩된 role router만 사용해 capability-aware semantic probe를 수행한다. PING만으로
ready를 선언하지 않는다. 모든 plan은 `ca-health:` namespace의 bounded opaque nonce key에 먼저
5초 TTL을 부여하고 SET/GET round trip을 검증한다. 선택 capability별 대표 프로그램은 다음과 같다.
- cache: `SET_IF_ABSENT_WITH_TTL`
- rate limit: `RATE_FIXED_WINDOW_V2`
- request-replay idempotency: `IDEMPOTENCY_CLAIM_V1`
- efficiency lease: `LEASE_ACQUIRE_V1`
- session: `SESSION_CREATE_V1`
대표 프로그램은 catalog digest의 `EVALSHA` 경로와 bounded result schema를 검증한다. 별도의
catalog-owned `semantic-capability-acl-v1` 프로그램은 Redis Lua API의
`redis.acl_check_cmd`로 대표 프로그램의 exact ACL command/key surface와 `SCRIPT LOAD` 권한을
비변경 방식으로 확인하고, `redis.REDIS_VERSION_NUM`으로 명시적인 Redis `>=7.2` policy gate를
먼저 적용한다. 두 Lua API 상수/함수는 Redis 7.0부터 제공되지만 이 템플릿이 지원을 선언하는
minimum은 7.2다. runtime identity에 허용해야 하는 probe key pattern은
`~ca-health:*`다. probe는 성공/실패와 무관하게 best-effort cleanup을 수행하고, cleanup이
거절돼도 모든 생성 key는 최대 5초 안에 만료된다.
각 role은 startup에 full semantic qualification을 완료한 관측을 seed한다. 이후 health scrape는
`APP_REDIS_SEMANTIC_PROBE_MINIMUM_INTERVAL`(기본 5초) 동안 같은 관측을 재사용하고 role별
single-flight로만 refresh한다. refresh follower는 기다리지 않으며 15초 기본
`APP_REDIS_SEMANTIC_PROBE_MAXIMUM_STALENESS` 안에서는 이전 관측과 `semanticObservedAt`,
`semanticAgeMillis`, `semanticStale=true`를 반환한다. 최대 staleness를 넘으면
`SEMANTIC_OBSERVATION_STALE`로 fail closed한다. eligibility와 age는 monotonic ticker를 사용해
wall-clock jump의 영향을 받지 않는다.
연결 가능한 optional/required role의 ACL, Redis 7.2 minimum, program result/schema mismatch는
모두 startup-fatal이다. 명확히 분류된 temporary connect/PING 실패만 optional CACHE를 dormant
route와 `COMMAND_UNAVAILABLE` 관측으로 시작하게 한다. health-triggered single-flight reconnect는
후보에 PING과 full semantic qualification을 모두 수행한 뒤에만 기존 router를 swap하며,
required COORDINATION/SESSION과 auth/TLS/material/unknown failure는 계속 fail closed한다.
Cluster에서 same-slot probe가 증명하는 범위는 해당 hash slot owner 한 노드뿐이다. 이 결과를
cluster 전체 노드나 failover target의 version/ACL/program 호환성 증거로 확대 해석하면 안 되며,
운영 promotion 전 별도의 cluster-wide 외부 conformance가 필요하다.
`shared-contract`의 framework-neutral snapshot은 role, 선택된 capability, availability,
sanitized reason, semantic observation metadata와 expected eviction만 제공한다. semantic success, read/write failure,
program ACL denial, program failure, admission saturation, recent command failure, closed route,
command unavailable, probe-in-progress, stale observation은 서로 다른 bounded reason이다. endpoint, deployment ID, key/value,
username, credential/trust reference와 server exception은 health detail에 노출하지 않는다.
Actuator 타입과 health-group 소유권은 `app-bootstrap`에 있다. CACHE 장애는
`redisOptional``state=DEGRADED` detail로만 나타나고 readiness를 내리지 않는다.
COORDINATION/SESSION 장애는 `redisRequired``DOWN`으로 만들며, 어떤 Redis contributor도
liveness에는 포함되지 않는다. role binding이 없으면 Redis client 생성과 Redis health
contributor 생성은 모두 0이다.
이 runtime은 Redis `CONFIG GET/SET` 권한을 요구하거나 노출하지 않는다. 따라서
`expected-eviction` 검증은 설정 의도에 대한 startup 검증이며 실제 server의
`maxmemory-policy`를 증명하지 않는다. Snapshot/health detail은 이 한계를
`CONFIGURED_EXPECTATION_ONLY`로, 외부 증거 상태를
`externalEvictionAttestation=INCOMPLETE`로 명시한다. 운영 readiness를 더 강하게 만들려면 배포
파이프라인의 외부 conformance job 또는 서명된 operator attestation으로 effective policy를
검증해야 한다. semantic probe는 runtime `CONFIG`/`ACL` 조회나 변경 권한을 요구하지 않는다.
## Distributed edge rate limit
`shared-contract``EdgeRateLimitPort` 뒤에서 fixed window, sliding-window counter, token bucket을
정확히 하나의 versioned Lua 실행으로 평가한다. 세 프로그램은 Redis `TIME`을 한 번만 읽고, server
time, bounded clock-regression clamp, denial-no-consume, finite state TTL과 정확히 7개 필드인 응답
계약을 공유한다. Redis `TYPE`의 status-table/string 차이를 정규화하고 malformed hash field는
typed incompatibility로 닫는다. Token bucket은 refill division remainder를 상태로 보존해 호출
빈도에 따라 quota가 달라지지 않는다. Sliding counter만 algorithm certainty가 approximate이고
나머지는 certain이다.
모든 closed program manifest의 `minimumRedisVersion`은 실제 minimum qualification lane과 같은
7.2다. 더 낮은 Redis 버전은 별도 service lane이 추가되기 전까지 호환을 주장하지 않는다.
## Redis-backed HTTP session
`redis-session` readiness card는 standalone을 선택 topology로 하는 implemented candidate다.
`RedisVersionedSessionRepository`는 Spring Session의 저장소 경계만 구현하고, 쿠키·CSRF·session
fixation 정책은 inbound web이 소유한다. 실제 Redis 상태 변경은 manifest로 닫힌 6개 Lua 프로그램
(create/inspect/save/touch/revoke/rotate)을 통해서만 수행한다.
- raw session ID는 physical key에 들어가지 않고 versioned HMAC digest로 변환된다.
- idle timeout과 absolute lifetime을 동시에 적용하며 touch 쓰기는 설정된 interval로 제한한다.
- logout은 revision `0`의 adapter-private force-revoke를 사용한다. 하나의 Lua 실행에서 tombstone을
먼저 만들고 live hash를 삭제하므로 concurrent stale save가 세션을 부활시킬 수 없다.
- rotation은 old ID tombstone과 new ID 생성을 원자적으로 수행한다. old/new ID가 서로 다른 Cluster
slot이므로 현재 activation은 standalone만 허용하고 Cluster와 Sentinel을 startup에서 거부한다.
- 저장 payload는 N/N-1 version을 읽는 명시적 primitive allowlist envelope다. Java serialization과
default typing을 쓰지 않는다. SHA-256 checksum은 우발적 손상 탐지용이며 authenticity 또는 공격자
변조 방지 보장이 아니다.
- timeout/response loss와 OOM은 성공이나 miss로 바꾸지 않고 unavailable/indeterminate로 닫는다.
별도 요청에서 같은 operation ID를 자동 재사용해 reconcile하지 않으므로 운영자는 timeout 뒤에
mutation 성공을 추정하면 안 된다.
현재 저장소는 의도적으로 unindexed baseline이다. principal lookup, 사용자 전체 logout,
maximum-concurrent-session 제어는 제공하지 않는다. 이 기능이 필요한 프로젝트는 별도 bounded index와
그 index의 원자성·복구 증거를 추가해야 한다. 현재 `card-redis-session` 레인은 같은 JVM 안의 서로
독립적인 두 runtime/repository client가 하나의 standalone Redis를 공유할 때의 logout/stale-save
race, TLS+named ACL, partition+`noeviction` OOM/recovery, Redis 7.2/7.4 compatibility를 검증한다.
이는 multi-process/pod, rolling deployment, pod/network failure qualification이 아니다.
아웃바운드 provider의 기본값은
`ca-skeleton.capabilities.rate-limit.provider=disabled`다. `redis`로 선택하면 canonical
`COORDINATION` role, `failure-policy=fail-closed`, default policy와 secret reference가 모두
필요하다. `app.rate-limit.enabled`는 HTTP transport enforcement만 제어하며 provider를 암묵적으로
선택하거나 fallback을 만들지 않는다. 설정은 `app.cache.redis`를 fallback으로 사용하지 않고,
`distributedRateLimiter`라는 semantic port bean만 외부에 제공한다. Caller deadline이 canonical
Redis command timeout보다 짧으면 command를 보내지 않고 typed no-mutation outcome을 반환한다.
Rate-limit physical key는 raw principal/IP/API key를 포함하지 않고 policy ID/revision/algorithm과
이미 pseudonymized된 subject digest를 다시 HMAC한다. Unknown policy/state/program/reply,
pre-send admission failure, post-dispatch indeterminate failure와 unsafe Redis clock을 서로 다른
outcome으로 보존하며 fail-open하지 않는다. 현재 standalone과 standalone TLS+named ACL의
`implemented-candidate` evidence가 있다. Sentinel/Cluster, topology failover,
credential/certificate rotation, effective eviction/persistence attestation과 R3 증거는 없으며,
checked-in `selected` card가 없으므로 R2 release claim도 없다.
## Application cache contract
@@ -36,6 +177,21 @@ TLS/ACL/credential rotation, restart/fault/eviction evidence, health/metrics가
TTL, jitter, codec, topology와 Redis SDK 타입은 이 port에 들어가지 않는다. 실제 product의
use case는 `CacheRegionPort`를 상속한 semantic subtype을 정의해야 한다.
`application-core``CacheAsideExecutor`는 lookup/source/write 흐름을 공통화하고 다음을
보장한다.
- fresh/negative hit에서 source를 호출하지 않음;
- authoritative absence만 negative cache하고, miss refill은 `ONLY_IF_ABSENT`, stale/quarantine
refill은 `ONLY_IF_OBSERVED`로 기록;
- classified transient source failure에서만 hard expiry 전 stale fallback;
- local single-flight의 in-flight key/waiter bound와 abandoned-flight opportunistic cleanup;
- source bulkhead의 concurrency/admission/load deadline bound;
- unclassified exception과 interrupt/cancellation 보존.
동기 source loader는 cooperative cancellation token을 확인해야 한다. 임의 source 코드를
강제 종료하지 않으며, source가 token/deadline을 무시하면 bulkhead permit은 반환 시점까지
점유된다.
## Physical key
`RedisKeyBuilder`만 다음 canonical shape를 만든다.
@@ -50,24 +206,43 @@ version, 정확히 하나인 hash tag와 전체 UTF-8 byte bound를 검증한다
## Atomic program foundation
`redis/program-set.json`은 세 Lua resource의 exact digest, signature, status, complexity와 timeout
certainty를 기록한다. `RedisAtomicPrimitives`는 compare-delete, compare-expire,
set-if-absent-with-TTL을 typed result로 노출하고 unknown status를 compatibility failure로
처리한다. owner/value/operation/TTL은 Redis 호출 전에 제한된다.
`redis/*-program-set.json``redis/program-set.json`은 cache/rate/idempotency/lease/session 및
primitive Lua resource의 exact digest, signature, status, complexity와 timeout certainty를
기록한다. `RedisAtomicPrimitives`는 compare-delete,
compare-expire, set-if-absent-with-TTL, replace-if-observed-with-TTL을 typed result로 노출하고
unknown status를 compatibility failure로 처리한다. owner/value/observation/operation/TTL은
Redis 호출 전에 제한된다. `redis/rate-program-set.json`은 structured rate-limit 프로그램의
별도 digest/signature/status manifest다.
Generic descriptor/catalog/executor와 typed primitive facade는 package-private collaborator다.
Spring composition에는 raw Redis key/value/TTL을 받는 bean을 노출하지 않으며, 이후 semantic
port adapter가 내부에서만 이 facade를 사용한다.
따라서 이 program set은 현재 internal R0 foundation이며, 실제 도메인 capability가 바로 소비할
수 있는 production bean이나 application port가 아니다.
이 primitive facade 자체는 application에 노출되는 범용 Redis port가 아니다. Cache, rate limit,
idempotency, soft lease, session의 semantic provider만 closed catalog를 내부에서 소비하며, 이
구조 자체가 release selection이나 R2 qualification을 뜻하지 않는다.
`RedisLuaProgramExecutor`가 catalog source로 SHA-1 script identity를 계산하여 `EVALSHA`를 먼저
호출하고 정확히 `NOSCRIPT`일 때만 compiled script를 `EVAL`한다. signature/argument bounds는
호출하고 정확히 `NOSCRIPT`일 때만 catalog script를 `SCRIPT LOAD`한다. 반환 digest가 예상 identity와
같은지 확인한 뒤 `EVALSHA`를 한 번만 재시도한다. signature/argument bounds는
client 호출 전에 다시 검증하고 descriptor catalog membership 및 반환 status membership을
확인한다. unit lane은 강제 `NOSCRIPT` fallback을 검증하고 standalone real-service lane은
compare-and-delete의 실제 atomic execution을 검증한다.
확인한다. unit lane은 강제 `NOSCRIPT` load/retry를 검증하고 standalone real-service lane은
compare-and-delete, NX, bounded trailing-digest observed replace, concurrent-writer 보존을 실제
Redis 7.2에서 검증한다. 같은 lane은 16MiB payload의 record/read/observed-replace와
16MiB+1 사전 거부, mutation interrupt의 `INDETERMINATE` certainty와 interrupt flag 복원도
실행한다.
## Managed runtime과 semantic region
Canonical activation은
`ca-skeleton.capabilities.cache.bindings.default=redis`
`ca-skeleton.providers.redis.roles.cache`를 함께 요구한다. 전자는 semantic policy를, 후자는
topology/TLS/ACL credential을 소유한다. Canonical region은 legacy `app.cache.redis.host`,
`password`, raw HMAC 값을 읽지 않고 CACHE role router와
`RedisCredentialMaterialProvider``secret://` reference만 사용한다. 같은 CACHE router가 L2
command와 invalidation Pub/Sub을 함께 route하므로 topology rotation 때 새 subscription ACK가
확인된 뒤 route가 교체된다. Canonical/legacy 동시 활성은 precedence를 추측하지 않고 startup에서
거절한다. 현재 템플릿이 자동 조합하는 semantic region ID는 `default` 하나이며, 여러 product
region은 region registry/compiler가 추가되기 전까지 자동 생성한다고 주장하지 않는다.
`app.cache.redis.enabled=true`이고 `client-mode=managed`(기본값)이면 `LettuceRedisRuntime`
단일 binary connection을 생성하고 종료 시 connection/client를 닫는다. 프로젝트가
`RedisClient`를 직접 제공하는 경우에는 `client-mode=external`을 명시해야 한다. 이 선택을
@@ -86,13 +261,24 @@ opaque source revision에는 대소 비교 의미가 없으므로
`ONLY_IF_SOURCE_REVISION_NEWER`는 임의 lexical comparison을 하지 않고
`NOT_RECORDED_PROVIDER_POLICY`를 반환한다.
Envelope는 source revision의 application invariant(1..128 characters)를 decode 때도 다시
검사하고 canonical bytes의 SHA-256 digest가 맞지 않으면 corrupt schema result로 격리한다.
Envelope v2는 source revision, soft/hard absolute expiry와 payload를 digest로 보호한다.
`soft <= now < hard`는 stale, `hard <= now`는 expired miss다. Retired v1은 명시적 quarantine
후 reload 대상이고 future/corrupt envelope는 fail-fast다. Integrity digest를 version byte보다
먼저 검사하며, digest가 맞더라도 현재 v2 구조가 잘못되면 corrupt로 분류한다. Stale/retired
lookup은 envelope digest를 opaque observation token으로 전달하고, cache-aside는 Lua에서 현재
digest가 그 token과 같을 때만 새 envelope로 교체한다. 따라서 조회와 refresh 사이의 writer를
삭제하거나 덮어쓰지 않는다. Source revision의 application invariant (1..128 characters)는
decode 때도 다시 검사한다.
`positive-soft-ttl`, 기존 `positive-ttl`(hard), `negative-ttl`, `ttl-jitter`,
`minimum-hard-ttl`은 startup에 immutable policy로 freeze된다. Jitter는 HMAC-derived physical
key와 policy revision으로 결정적이며 positive soft/hard에는 같은 factor를 적용한다. Redis
physical TTL은 envelope에 기록된 hard expiry와 같다.
추가 runtime setting은 `app.cache.redis.maximum-queued-commands=8`(범위 `1..4096`)과
`app.cache.redis.maximum-in-flight-bytes=16777216`이다. command count와 retained
request/response byte budget을 모두 통과해야 Lettuce 호출을 시작하며,
`queue-count × (maximum-value-bytes + overhead)`도 byte bound 이하여야 한다. 이 관계는
`app.cache.redis.maximum-in-flight-bytes=16777216`이다. 최대 readable envelope와 최대 command
byte를 별도로 계산하며, command count와 retained request/response byte budget을 모두 통과해야
Lettuce 호출을 시작한다. `queue-count × maximum-command-bytes`도 byte bound 이하여야 한다. 이 관계는
timeout 완료 뒤 driver가 응답 decode 전까지 command args를 유지하는 경우도 유한하게 제한한다.
timeout 직후에는 runtime admission population과 Lettuce retained population이 겹칠 수 있으므로
최악 상한은 대략 `maximum-in-flight-bytes + queue-count × per-command-bound`이고, 설정 검증은
@@ -104,6 +290,58 @@ Redis가 wire에 내보내는 bulk reply 자체를 `maximum-envelope-bytes + 1`
Netty/codec에 먼저 할당하지 않는다. managed runtime을 활성화할 때 host가 누락되면
`localhost`로 암묵 fallback하지 않고 startup을 실패시킨다.
Generation/revision fence는 mass/per-key invalidation과 source-load race를 막는다. Distributed
refresh soft lease는 정상 시 중복 refresh를 줄이지만 TTL expiry/crash에서는 duplicate owner를
허용하며, cache generation fence를 대체하는 correctness lock이 아니다.
`app.cache.redis.l1.enabled=true`는 semantic string cache 앞에만 optional local L1을 붙인다.
L1은 maximum entries, maximum accounted weight, per-entry accounted weight, local TTL, generation
recheck interval과 invalidation subscriber queue를 모두 finite하게 검증한다. Local expiry는 Redis
envelope hard expiry보다 길어질 수 없다. Weight는 HMAC-derived local identity와 UTF-8 value,
entry/lookup metadata에 대한 고정 conservative allowance를 더한 admission/eviction accounting
proxy이며, JVM heap reservation이나 실제 object layout의 exact byte guarantee가 아니다.
Invalidation Pub/Sub payload는 raw semantic key를 포함하지 않고 HMAC-authenticated bounded
message를 사용한다. Pub/Sub은 durable/exact invalidation 원장이 아니라 eviction hint다. Subscriber
disconnect나 queue overflow는 L1 전체를 flush하고, monotonic local invalidation epoch가 진행 중인
generation probe와 refill admission을 무효화한다. 재연결 뒤 generation을 다시 읽기 전에는 L1
admission을 허용하지 않는다. Hint 유실 시 mass invalidation은 periodic generation recheck,
per-key invalidation은 local TTL 안에서 Redis L2로 복귀한다.
이 local tier는 cache-only internal type을 요구하므로 session, idempotency, strict rate-limit,
coordination provider에 적용할 수 없다. 해당 capability들은 local fail-open cache semantics를
재사용하지 않는다.
Refresh-ahead와 probabilistic early refresh는 아직 구현하지 않았다. 둘 다 correctness baseline이
아니며, refresh-ahead는 명시적인 bounded hot-set registry/scheduler 없이 full keyspace scan으로
대체하지 않는다. Probabilistic early refresh도 versioned probability descriptor와 deterministic
property test가 생기기 전에는 readiness guarantee로 광고하지 않는다. Cache card에는 standalone
TLS+named ACL과 bounded fault evidence가 있지만 Sentinel/Cluster Pub/Sub/failover,
credential/certificate rotation, persistence/restart, effective eviction attestation,
multi-process/pod L1/L2 coherence와 R3 qualification은 아직 없다.
## Efficiency-only lease
`ca-skeleton.capabilities.lease.provider=redis`를 명시한 경우에만
`DistributedLeasePort`가 생성되며, canonical `COORDINATION` role router와 별도 HMAC secret
reference를 사용한다. 미선택 상태에서는 lease bean, secret resolution, native client와 thread
side effect가 모두 0이다.
이 port의 guarantee는 오직 `EFFICIENCY_ONLY`다. acquire/inspect/renew/release는 같은
owner token과 operation ID를 비교하고, response loss를 성공이나 실패로 추측하지 않고
`INDETERMINATE`/`UNKNOWN`으로 유지한다. caller가 최초 send 전에 보관한 같은 attempt로 inspect
또는 acquire replay를 해야 ownership을 복구할 수 있다. Handle validity는 Redis가 보고한 remaining
TTL에서 command 왕복 monotonic elapsed와 drift budget을 차감하며, server expiry wall clock은
telemetry 용도일 뿐이다. Watchdog는 worker와 registration 수, renewal cadence, application
deadline이 모두 유한하고 lease loss/unknown에서 작업 취소 callback을 한 번만 전달한다.
`redisEfficiencyLeaseTest`는 pinned Redis 7.2와 다음/승인 버전에서 standalone concurrency,
TLS/ACL, partition/response uncertainty와 compatibility를 별도 qualification한다. 이 test는
readiness card가 아니며 cache-refresh soft lease나 fenced coordination의 증거로 재사용되지
않는다. Fencing token과 protected-resource stale-token rejection은 구현하지 않았으므로
`redis-fenced-coordination` card는 계속 `not-implemented`다. 이 lease만으로 결제, 재고,
unique ID 또는 외부 장치 command 같은 correctness-sensitive write를 승인하면 안 된다.
## Legacy path
기존 `CacheStoreRouter`, `RedisCacheStore`, `FailOpenCacheStore`는 호환성을 위해 남아 있다. 이
@@ -118,4 +356,5 @@ cd src
./gradlew :application-core:check :adapter:outbound:cache-redis:check --console=plain
./gradlew :adapter:outbound:cache-redis:redisServiceTest \
-Dredis.test.host=127.0.0.1 -Dredis.test.port=6379 --console=plain
./gradlew :adapter:outbound:cache-redis:redisEfficiencyLeaseTest --console=plain
```
@@ -4,12 +4,35 @@ dependencies {
implementation project(':adapter:outbound:support')
implementation 'org.springframework.boot:spring-boot-autoconfigure'
implementation 'org.springframework.session:spring-session-core'
implementation 'org.springframework.session:spring-session-data-redis'
implementation 'org.springframework.data:spring-data-redis'
implementation 'io.lettuce:lettuce-core'
implementation 'io.micrometer:micrometer-core'
implementation 'org.slf4j:slf4j-api'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
}
tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' }
sourceSets {
redisTest {
java.srcDir 'src/redisTest/java'
resources.srcDir 'src/redisTest/resources'
compileClasspath += sourceSets.main.output
runtimeClasspath += sourceSets.main.output
}
}
configurations {
redisTestImplementation.extendsFrom testImplementation
redisTestCompileOnly.extendsFrom testCompileOnly
redisTestRuntimeOnly.extendsFrom testRuntimeOnly
}
dependencies {
redisTestImplementation 'org.testcontainers:testcontainers'
}
tasks.named('test') {
useJUnitPlatform {
excludeTags 'redis-service'
@@ -32,3 +55,562 @@ tasks.register('redisServiceTest', Test) {
}
shouldRunAfter tasks.named('test')
}
def verifyRedisEvidenceSourcesPresent = tasks.register('verifyRedisEvidenceSourcesPresent') {
group = 'redis verification'
description = 'Fails readiness lanes when the redisTest evidence source set is empty.'
inputs.files(sourceSets.redisTest.allSource)
doLast {
Set<File> javaSources = sourceSets.redisTest.java.files.findAll {
it.isFile() && it.name.endsWith('.java')
}
if (javaSources.isEmpty()) {
throw new GradleException(
'Redis evidence source set is empty; readiness tasks must not pass as NO-SOURCE.')
}
File imageRegistry = rootProject.file('gradle/redis-test-images.properties')
if (!imageRegistry.isFile() || imageRegistry.length() == 0) {
throw new GradleException(
"Redis evidence image registry is missing or empty: ${imageRegistry}")
}
}
}
def redisCapabilityMetadata = rootProject.ext.redisCapabilityMetadata
def redisSanitizedEvidenceFileNames = [
'manifest.json',
'capability-card.json',
'topology-fault-timeline.json'
] as Set<String>
def redisSanitizedBundleSha256 = { File directory ->
java.security.MessageDigest digest = java.security.MessageDigest.getInstance('SHA-256')
redisSanitizedEvidenceFileNames.toList().sort().each { String name ->
File file = new File(directory, name)
if (!file.isFile()) {
throw new GradleException(
"Redis sanitized bundle is missing ${name}: ${directory}")
}
byte[] nameBytes = name.getBytes('UTF-8')
byte[] contentBytes = file.bytes
digest.update(java.nio.ByteBuffer.allocate(Long.BYTES).putLong(nameBytes.length).array())
digest.update(nameBytes)
digest.update(java.nio.ByteBuffer.allocate(Long.BYTES).putLong(contentBytes.length).array())
digest.update(contentBytes)
}
digest.digest().encodeHex().toString()
}
def registerRedisEvidenceTask = { String taskName, String tagExpression, String descriptionText ->
def evidenceTask = tasks.register(taskName, Test) {
group = 'redis verification'
description = descriptionText
dependsOn verifyRedisEvidenceSourcesPresent
testClassesDirs = sourceSets.redisTest.output.classesDirs
classpath = sourceSets.redisTest.runtimeClasspath
useJUnitPlatform {
includeTags tagExpression
}
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
jvmArgs '-Duser.timezone=UTC'
systemProperty 'redis.image.registry',
rootProject.file('gradle/redis-test-images.properties').absolutePath
List<Map<String, Object>> sanitizedTimeline = []
List<String> declaredTags = tagExpression.split(/\s*&\s*/).toList()
String cardTag = declaredTags.find { it.startsWith('card-') }
String cardId = cardTag == null ? null : cardTag.substring('card-'.length())
Set<String> evidenceCategories = [
'standalone',
'security',
'sentinel',
'cluster',
'fault',
'compatibility'
] as Set<String>
String evidenceCategory = declaredTags.find {
it.startsWith('redis-') && evidenceCategories.contains(it.substring('redis-'.length()))
}
if (evidenceCategory != null) {
evidenceCategory = evidenceCategory.substring('redis-'.length())
}
File evidenceDirectory = layout.buildDirectory.dir(
"redis-evidence/${taskName}").get().asFile
outputs.dir evidenceDirectory
afterTest { descriptor, result ->
String identity = "${descriptor.className ?: ''}#${descriptor.name ?: ''}"
String identityDigest = java.security.MessageDigest.getInstance('SHA-256')
.digest(identity.getBytes('UTF-8')).encodeHex().toString()
sanitizedTimeline << [
sequence : sanitizedTimeline.size() + 1,
testCaseIdSha256: identityDigest,
outcome : result.resultType.name(),
durationMillis : Math.max(0L, result.endTime - result.startTime)
]
}
afterSuite { descriptor, result ->
if (descriptor.parent != null) {
return
}
evidenceDirectory.mkdirs()
Map<String, Object> card = cardId == null
? null
: rootProject.ext.redisReadinessCards[cardId] as Map<String, Object>
Map<String, String> digests = rootProject.ext.redisEvidenceDigests()
Map<String, Object> metadata = cardId == null
? [
providerIds : [],
roles : [],
programs : [],
keyVersions : [],
codecVersions : [],
guarantees : ['cross-cutting Redis evidence lane'],
nonGuarantees : ['does not qualify a capability card by itself'],
requiredSettings: []
]
: redisCapabilityMetadata[cardId] as Map<String, Object>
Map<String, Object> capabilityCard = [
schemaVersion : 1,
cardId : cardId,
readiness : card?.state,
releaseQualification: 'NOT_CLAIMED',
promotionTopology : card?.selectedTopology,
sourceRevision : rootProject.ext.redisEvidenceSourceRevision,
sourceTreeState : rootProject.ext.redisEvidenceSourceTreeState,
digests : digests,
minimumRedisVersion: '7.2',
providerIds : metadata.providerIds,
roles : metadata.roles,
programIds : metadata.programs,
keyVersions : metadata.keyVersions,
codecVersions : metadata.codecVersions,
guarantees : metadata.guarantees,
nonGuarantees : metadata.nonGuarantees,
requiredSettings : metadata.requiredSettings,
evidenceProfile : card?.requiredEvidence ?: []
]
File capabilityCardFile = new File(evidenceDirectory, 'capability-card.json')
capabilityCardFile.setText(
groovy.json.JsonOutput.prettyPrint(
groovy.json.JsonOutput.toJson(capabilityCard)) + '\n',
'UTF-8')
Map<String, Object> timeline = [
schemaVersion: 1,
taskName : taskName,
cardId : cardId,
topology : card?.selectedTopology,
evidence : evidenceCategory,
timelineKind : 'SANITIZED_TEST_RESULT',
actualEventTimeline: 'NOT_CAPTURED',
sourceRevision: rootProject.ext.redisEvidenceSourceRevision,
sourceTreeState: rootProject.ext.redisEvidenceSourceTreeState,
digests : digests,
events : sanitizedTimeline
]
File timelineFile = new File(evidenceDirectory, 'topology-fault-timeline.json')
timelineFile.setText(
groovy.json.JsonOutput.prettyPrint(
groovy.json.JsonOutput.toJson(timeline)) + '\n',
'UTF-8')
Closure<String> sha256 = { File file ->
java.security.MessageDigest.getInstance('SHA-256')
.digest(file.bytes).encodeHex().toString()
}
String outcome = result.resultType.name() == 'FAILURE'
? 'failed'
: (result.testCount == 0 || result.skippedTestCount > 0
? 'skipped-with-reason'
: 'executed')
Map<String, Object> manifest = [
schemaVersion : 1,
taskPath : path,
tagExpression : tagExpression,
cardId : cardId,
cardState : card?.state,
selectedTopology : card?.selectedTopology,
evidenceCategory : evidenceCategory,
outcome : outcome,
tests : [
discovered: result.testCount,
executed : result.testCount - result.skippedTestCount,
passed : result.successfulTestCount,
failed : result.failedTestCount,
errors : 0,
skipped : result.skippedTestCount
],
runtimeImageAttestation: 'NOT_CAPTURED',
actualEventTimeline: 'NOT_CAPTURED',
releaseQualification: 'NOT_CLAIMED',
sourceRevision : rootProject.ext.redisEvidenceSourceRevision,
sourceTreeState : rootProject.ext.redisEvidenceSourceTreeState,
digests : digests,
companionSha256 : [
capabilityCardSha256: sha256(capabilityCardFile),
timelineSha256 : sha256(timelineFile)
]
]
new File(evidenceDirectory, 'manifest.json').setText(
groovy.json.JsonOutput.prettyPrint(
groovy.json.JsonOutput.toJson(manifest)) + '\n',
'UTF-8')
}
doFirst {
[
'manifest.json',
'capability-card.json',
'topology-fault-timeline.json'
].each { String generatedFile ->
new File(evidenceDirectory, generatedFile).delete()
}
layout.buildDirectory.file(
"redis-evidence-sanitizer/${taskName}.sha256").get().asFile.delete()
Set<File> matchingSources = sourceSets.redisTest.java.files.findAll { File source ->
if (!source.isFile() || !source.name.endsWith('.java')) {
return false
}
String content = source.getText('UTF-8')
declaredTags.every { String tag -> content.contains("@Tag(\"${tag}\")") }
}
if (matchingSources.isEmpty()) {
throw new GradleException(
"${taskName}: no redisTest source declares every required tag " +
"${declaredTags}; zero-evidence readiness must not pass.")
}
}
}
def sanitizerTask = tasks.register("${taskName}SanitizeEvidence") {
group = 'redis verification'
description = "Validates the bounded sanitized artifact for ${taskName} before upload."
mustRunAfter evidenceTask
File sanitizerMarker = layout.buildDirectory.file(
"redis-evidence-sanitizer/${taskName}.sha256").get().asFile
doFirst {
sanitizerMarker.delete()
}
doLast {
File evidenceDirectory = layout.buildDirectory.dir(
"redis-evidence/${taskName}").get().asFile
if (!evidenceDirectory.isDirectory()) {
throw new GradleException(
"${taskName}: sanitized evidence directory was not generated")
}
Set<String> allowedNames = redisSanitizedEvidenceFileNames
List<File> files = evidenceDirectory.listFiles()?.findAll { it.isFile() } ?: []
if (files.collect { it.name } as Set<String> != allowedNames ||
evidenceDirectory.listFiles()?.any { it.isDirectory() }) {
throw new GradleException(
"${taskName}: sanitized evidence must contain exactly ${allowedNames}")
}
files.each { File file ->
if (file.length() > 1_048_576L ||
java.nio.file.Files.isSymbolicLink(file.toPath()) ||
!file.toPath().toRealPath().startsWith(
evidenceDirectory.toPath().toRealPath())) {
throw new GradleException(
"${taskName}: oversized, symlinked, or path-escaping artifact ${file}")
}
String text = file.getText('UTF-8')
Map<String, java.util.regex.Pattern> forbidden = [
pem : java.util.regex.Pattern.compile(
'(?i)-----BEGIN [^-]*(?:PRIVATE KEY|CERTIFICATE)-----'),
aclMaterial : java.util.regex.Pattern.compile(
"(?i)(?:users\\.acl|--pass|[\"']password[\"']\\s*:)"),
uriUserInfo : java.util.regex.Pattern.compile(
'(?i)rediss?://[^\\s/@:]+:[^\\s/@]+@'),
secretReference : java.util.regex.Pattern.compile('(?i)secret://'),
rawMessageFields : java.util.regex.Pattern.compile(
'(?i)"(?:stackTrace|systemOut|systemErr|exception|containerId|host|ip|port|endpoint|rawKey|physicalKey|value|sessionId|csrf|idempotencyToken|ownerToken|operationToken)"\\s*:')
]
forbidden.each { String marker, java.util.regex.Pattern pattern ->
if (pattern.matcher(text).find()) {
throw new GradleException(
"${taskName}: sanitized artifact ${file.name} contains forbidden ${marker} material")
}
}
}
Map<String, Object> manifest = new groovy.json.JsonSlurper().parse(
new File(evidenceDirectory, 'manifest.json')) as Map<String, Object>
Map<String, Object> capability = new groovy.json.JsonSlurper().parse(
new File(evidenceDirectory, 'capability-card.json')) as Map<String, Object>
Map<String, Object> timeline = new groovy.json.JsonSlurper().parse(
new File(evidenceDirectory, 'topology-fault-timeline.json')) as Map<String, Object>
Set<String> manifestFields = [
'schemaVersion',
'taskPath',
'tagExpression',
'cardId',
'cardState',
'selectedTopology',
'evidenceCategory',
'outcome',
'tests',
'runtimeImageAttestation',
'actualEventTimeline',
'releaseQualification',
'sourceRevision',
'sourceTreeState',
'digests',
'companionSha256'
] as Set<String>
Set<String> capabilityFields = [
'schemaVersion',
'cardId',
'readiness',
'releaseQualification',
'promotionTopology',
'sourceRevision',
'sourceTreeState',
'digests',
'minimumRedisVersion',
'providerIds',
'roles',
'programIds',
'keyVersions',
'codecVersions',
'guarantees',
'nonGuarantees',
'requiredSettings',
'evidenceProfile'
] as Set<String>
Set<String> timelineFields = [
'schemaVersion',
'taskName',
'cardId',
'topology',
'evidence',
'timelineKind',
'actualEventTimeline',
'sourceRevision',
'sourceTreeState',
'digests',
'events'
] as Set<String>
if (manifest.keySet() != manifestFields ||
capability.keySet() != capabilityFields ||
timeline.keySet() != timelineFields ||
(manifest.tests as Map).keySet() != [
'discovered',
'executed',
'passed',
'failed',
'errors',
'skipped'
] as Set<String> ||
(manifest.digests as Map).keySet() != [
'registrySha256',
'imageRegistrySha256',
'programSetSha256',
'configurationSha256'
] as Set<String> ||
(manifest.companionSha256 as Map).keySet() != [
'capabilityCardSha256',
'timelineSha256'
] as Set<String>) {
throw new GradleException(
"${taskName}: sanitized evidence contains unknown or missing schema fields")
}
List<Map<String, Object>> events = timeline.events as List<Map<String, Object>>
if (events.size() > 10_000 ||
events.withIndex().any { Map<String, Object> event, int index ->
event.keySet() != [
'sequence',
'testCaseIdSha256',
'outcome',
'durationMillis'
] as Set<String> ||
event.sequence != index + 1 ||
!(event.testCaseIdSha256 ==~ /[0-9a-f]{64}/) ||
!(event.outcome in ['SUCCESS', 'FAILURE', 'SKIPPED']) ||
!(event.durationMillis instanceof Number) ||
(event.durationMillis as Number).longValue() < 0L
}) {
throw new GradleException(
"${taskName}: sanitized test summary contains malformed events")
}
if ((capability.requiredSettings as List).any {
!(it instanceof Map) ||
(it as Map).keySet() != ['name', 'type', 'constraint'] as Set<String>
}) {
throw new GradleException(
"${taskName}: capability card required settings are not a safe name/type/constraint projection")
}
Map<String, Object> tests = manifest.tests as Map<String, Object>
if (!(manifest.outcome in ['executed', 'failed', 'skipped-with-reason']) ||
events.size() != (tests.discovered as Number).intValue() ||
events.count { it.outcome == 'SUCCESS' } !=
(tests.passed as Number).intValue() ||
events.count { it.outcome == 'FAILURE' } !=
(tests.failed as Number).intValue() ||
events.count { it.outcome == 'SKIPPED' } !=
(tests.skipped as Number).intValue()) {
throw new GradleException(
"${taskName}: manifest outcome/counts do not match the sanitized test summary")
}
if (manifest.outcome == 'executed' &&
((tests.discovered as Number).longValue() <= 0L ||
(tests.executed as Number).longValue() <= 0L ||
(tests.passed as Number).longValue() <= 0L ||
(tests.failed as Number).longValue() != 0L ||
(tests.errors as Number).longValue() != 0L ||
(tests.skipped as Number).longValue() != 0L)) {
throw new GradleException(
"${taskName}: executed evidence must be positive with zero failure/error/skip")
}
if (manifest.outcome == 'failed' &&
(tests.failed as Number).longValue() <= 0L) {
throw new GradleException(
"${taskName}: failed evidence must retain a positive bounded failure count")
}
sanitizerMarker.parentFile.mkdirs()
String bundleSha = redisSanitizedBundleSha256(evidenceDirectory)
sanitizerMarker.setText("${bundleSha}\n", 'UTF-8')
if (manifest.outcome == 'skipped-with-reason') {
throw new GradleException(
"${taskName}: skipped or zero-executed evidence is not a passing readiness lane")
}
}
}
evidenceTask.configure {
finalizedBy sanitizerTask
}
evidenceTask
}
tasks.register('verifyRedisEvidenceArtifactsForUpload') {
group = 'redis verification'
description = 'Allows CI upload only when every generated Redis evidence directory was sanitized.'
doLast {
File evidenceRoot = layout.buildDirectory.dir('redis-evidence').get().asFile
File markerRoot = layout.buildDirectory.dir('redis-evidence-sanitizer').get().asFile
List<File> evidenceDirectories = evidenceRoot.isDirectory()
? evidenceRoot.listFiles().findAll { it.isDirectory() }
: []
if (evidenceDirectories.isEmpty()) {
throw new GradleException(
'No sanitized Redis evidence directory exists for upload')
}
Set<String> evidenceTasks = evidenceDirectories.collect { it.name } as Set<String>
Set<String> markerTasks = markerRoot.isDirectory()
? markerRoot.listFiles().findAll {
it.isFile() && it.name.endsWith('.sha256')
}.collect {
it.name.substring(0, it.name.length() - '.sha256'.length())
} as Set<String>
: [] as Set<String>
if (evidenceTasks != markerTasks) {
throw new GradleException(
"Redis evidence upload sanitizer coverage mismatch; evidence=${evidenceTasks}, markers=${markerTasks}")
}
evidenceDirectories.each { File directory ->
Set<String> files = directory.listFiles().findAll { it.isFile() }
.collect { it.name } as Set<String>
if (files != redisSanitizedEvidenceFileNames) {
throw new GradleException(
"Redis upload directory ${directory.name} is outside the sanitized allowlist")
}
String bundleSha = redisSanitizedBundleSha256(directory)
String recordedSha = new File(
markerRoot, "${directory.name}.sha256").getText('UTF-8').trim()
if (recordedSha != bundleSha) {
throw new GradleException(
"Redis upload sanitizer bundle marker is stale for ${directory.name}")
}
}
}
}
registerRedisEvidenceTask(
'redisStandaloneTest',
'redis-standalone',
'Runs real standalone Redis evidence. Docker/service absence and zero tests fail.')
registerRedisEvidenceTask(
'redisSecurityTest',
'redis-security',
'Runs Redis TLS, ACL, secret-redaction, and fail-closed security evidence.')
registerRedisEvidenceTask(
'redisSentinelTest',
'redis-sentinel',
'Runs the explicit Redis Sentinel topology evidence lane.')
registerRedisEvidenceTask(
'redisClusterTest',
'redis-cluster',
'Runs the explicit Redis Cluster topology evidence lane.')
registerRedisEvidenceTask(
'redisFaultTest',
'redis-fault',
'Runs bounded Redis outage, response-loss, memory, and recovery evidence.')
registerRedisEvidenceTask(
'redisCompatibilityTest',
'redis-compatibility',
'Runs pinned minimum/next/approved Redis compatibility evidence.')
registerRedisEvidenceTask(
'redisEfficiencyLeaseTest',
'redis-efficiency-lease',
'Runs non-fenced EFFICIENCY_ONLY lease standalone, security, fault, and compatibility qualification.')
def redisCardTags = [
redisCacheCapabilityTest : 'card-redis-cache',
redisRateLimitCapabilityTest : 'card-redis-edge-rate-limit',
redisIdempotencyCapabilityTest : 'card-redis-request-replay-idempotency',
redisSoftLeaseCapabilityTest : 'card-redis-cache-refresh-soft-lease',
redisFencedCoordinationCapabilityTest: 'card-redis-fenced-coordination',
redisSessionCapabilityTest : 'card-redis-session'
]
redisCardTags.each { String taskName, String cardTag ->
registerRedisEvidenceTask(
taskName,
cardTag,
"Runs all real-service evidence owned by Redis capability card ${cardTag}.")
}
def redisEvidenceTags = [
Standalone : 'redis-standalone',
Security : 'redis-security',
Sentinel : 'redis-sentinel',
Cluster : 'redis-cluster',
Fault : 'redis-fault',
Compatibility: 'redis-compatibility'
]
def redisCardTaskStems = [
Cache : 'card-redis-cache',
RateLimit : 'card-redis-edge-rate-limit',
Idempotency : 'card-redis-request-replay-idempotency',
SoftLease : 'card-redis-cache-refresh-soft-lease',
FencedCoordination: 'card-redis-fenced-coordination',
Session : 'card-redis-session'
]
redisCardTaskStems.each { String cardStem, String cardTag ->
redisEvidenceTags.each { String evidenceStem, String evidenceTag ->
registerRedisEvidenceTask(
"redis${cardStem}${evidenceStem}EvidenceTest",
"${cardTag} & ${evidenceTag}",
"Runs ${evidenceTag} evidence owned only by ${cardTag}.")
}
}
tasks.named('check') {
dependsOn tasks.named('redisStandaloneTest')
}
def redisLabContractDirectory = rootProject.file('../infra/redis-lab')
def redisLabContractTest = tasks.register('redisLabContractTest', Exec) {
group = 'verification'
description = 'Runs the VM-free Redis lab lifecycle and host-isolation contract with fake commands.'
workingDir rootProject.projectDir
executable 'bash'
args new File(redisLabContractDirectory, 'test/redis-lab-contract.sh').absolutePath
inputs.files(
new File(redisLabContractDirectory, 'versions.env'),
new File(redisLabContractDirectory, 'bin/redis-lab'),
new File(redisLabContractDirectory, 'cloud-init/node.yaml'),
new File(redisLabContractDirectory, 'lib/render-kubeconfig.awk'),
fileTree(new File(redisLabContractDirectory, 'test/fixtures')) {
include '**/*'
},
new File(redisLabContractDirectory, 'test/redis-lab-contract.sh'))
outputs.upToDateWhen { false }
}
tasks.named('check') {
dependsOn redisLabContractTest
}
+139 -118
View File
@@ -1,165 +1,186 @@
# This is a Gradle generated file for dependency locking.
# Manual edits can break the build and are not advised.
# This file is expected to be part of source control.
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=testCompileClasspath
ch.qos.logback:logback-classic:1.5.21=testCompileClasspath,testRuntimeClasspath
ch.qos.logback:logback-core:1.5.21=testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=redisTestCompileClasspath,testCompileClasspath
ch.qos.logback:logback-classic:1.5.21=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
ch.qos.logback:logback-core:1.5.21=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-annotations:2.20=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
com.github.docker-java:docker-java-api:3.7.0=redisTestCompileClasspath,redisTestRuntimeClasspath
com.github.docker-java:docker-java-transport-zerodep:3.7.0=redisTestCompileClasspath,redisTestRuntimeClasspath
com.github.docker-java:docker-java-transport:3.7.0=redisTestCompileClasspath,redisTestRuntimeClasspath
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
com.github.spotbugs:spotbugs-annotations:4.8.6=testCompileClasspath
com.github.spotbugs:spotbugs-annotations:4.8.6=redisTestCompileClasspath,testCompileClasspath
com.github.spotbugs:spotbugs:4.10.2=spotbugs
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor
com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs,testCompileClasspath
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
com.google.auto:auto-common:1.2.2=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
com.google.code.findbugs:jsr305:3.0.2=checkstyle,redisTestCompileClasspath,spotbugs,testCompileClasspath
com.google.code.gson:gson:2.13.2=spotbugs
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.38.0=testCompileClasspath
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.38.0=redisTestCompileClasspath,testCompileClasspath
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,redisTestAnnotationProcessor,testAnnotationProcessor
com.google.guava:guava:33.5.0-jre=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
com.google.guava:guava:33.6.0-jre=checkstyle
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,redisTestAnnotationProcessor,testAnnotationProcessor
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,redisTestAnnotationProcessor,testAnnotationProcessor
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath
com.jayway.jsonpath:json-path:2.9.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
commons-beanutils:commons-beanutils:1.11.0=checkstyle
commons-codec:commons-codec:1.19.0=redisTestCompileClasspath,redisTestRuntimeClasspath
commons-collections:commons-collections:3.2.2=checkstyle
commons-io:commons-io:2.20.0=redisTestCompileClasspath,redisTestRuntimeClasspath
commons-io:commons-io:2.21.0=spotbugs
commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
commons-logging:commons-logging:1.3.5=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
info.picocli:picocli:4.7.7=checkstyle
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
io.lettuce:lettuce-core:6.8.1.RELEASE=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-buffer:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-codec-base:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-codec-dns:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-common:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-handler:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-resolver-dns:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-resolver:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-transport-native-unix-common:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-transport:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.projectreactor:reactor-core:3.8.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath
jakarta.annotation:jakarta.annotation-api:3.0.0=testCompileClasspath,testRuntimeClasspath
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
io.lettuce:lettuce-core:6.8.1.RELEASE=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-commons:1.16.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-core:1.16.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-observation:1.16.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-buffer:4.2.7.Final=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-codec-base:4.2.7.Final=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-codec-dns:4.2.7.Final=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-common:4.2.7.Final=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-handler:4.2.7.Final=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-resolver-dns:4.2.7.Final=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-resolver:4.2.7.Final=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-transport-native-unix-common:4.2.7.Final=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-transport:4.2.7.Final=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.projectreactor:reactor-core:3.8.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.activation:jakarta.activation-api:2.1.4=redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.annotation:jakarta.annotation-api:3.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
javax.inject:javax.inject:1=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
jaxen:jaxen:2.0.0=spotbugs
net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
net.bytebuddy:byte-buddy-agent:1.17.8=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.bytebuddy:byte-buddy:1.17.8=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.java.dev.jna:jna:5.18.1=redisTestCompileClasspath,redisTestRuntimeClasspath
net.minidev:accessors-smart:2.6.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.minidev:json-smart:2.6.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
org.antlr:antlr4-runtime:4.13.2=checkstyle
org.apache.bcel:bcel:6.12.0=spotbugs
org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs
org.apache.commons:commons-compress:1.28.0=redisTestCompileClasspath,redisTestRuntimeClasspath
org.apache.commons:commons-lang3:3.20.0=checkstyle,redisTestCompileClasspath,redisTestRuntimeClasspath,spotbugs
org.apache.commons:commons-text:1.15.0=spotbugs
org.apache.commons:commons-text:1.3=checkstyle
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
org.apache.logging.log4j:log4j-api:2.25.2=spotbugs,testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-api:2.25.2=redisTestCompileClasspath,redisTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.xbean:xbean-reflect:3.7=checkstyle
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath
org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath
org.apiguardian:apiguardian-api:1.1.2=redisTestCompileClasspath,testCompileClasspath
org.assertj:assertj-core:3.27.6=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.awaitility:awaitility:4.3.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
org.dom4j:dom4j:2.2.0=spotbugs
org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath
org.hamcrest:hamcrest:3.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.hdrhistogram:HdrHistogram:2.2.2=redisTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
org.javassist:javassist:3.28.0-GA=checkstyle
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath
org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath
org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath
org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath
org.jetbrains:annotations:17.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,redisTestAnnotationProcessor,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-api:6.0.1=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:6.0.1=redisTestRuntimeClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-params:6.0.1=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter:6.0.1=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-commons:6.0.1=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-engine:6.0.1=redisTestRuntimeClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-launcher:6.0.1=redisTestRuntimeClasspath,testRuntimeClasspath
org.junit:junit-bom:6.0.1=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit:junit-bom:6.1.0=spotbugs
org.mockito:mockito-core:5.20.0=testCompileClasspath,testRuntimeClasspath
org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath
org.objenesis:objenesis:3.3=testRuntimeClasspath
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
org.osgi:org.osgi.annotation.bundle:2.0.0=testCompileClasspath
org.osgi:org.osgi.annotation.versioning:1.1.2=testCompileClasspath
org.osgi:org.osgi.resource:1.0.0=testCompileClasspath
org.osgi:org.osgi.service.serviceloader:1.0.0=testCompileClasspath
org.latencyutils:LatencyUtils:2.0.3=redisTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
org.mockito:mockito-core:5.20.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.mockito:mockito-junit-jupiter:5.20.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.objenesis:objenesis:3.3=redisTestRuntimeClasspath,testRuntimeClasspath
org.opentest4j:opentest4j:1.3.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.osgi:org.osgi.annotation.bundle:2.0.0=redisTestCompileClasspath,testCompileClasspath
org.osgi:org.osgi.annotation.versioning:1.1.2=redisTestCompileClasspath,testCompileClasspath
org.osgi:org.osgi.resource:1.0.0=redisTestCompileClasspath,testCompileClasspath
org.osgi:org.osgi.service.serviceloader:1.0.0=redisTestCompileClasspath,testCompileClasspath
org.ow2.asm:asm-analysis:9.10.1=spotbugs
org.ow2.asm:asm-commons:9.10.1=spotbugs
org.ow2.asm:asm-tree:9.10.1=spotbugs
org.ow2.asm:asm-util:9.10.1=spotbugs
org.ow2.asm:asm:9.10.1=spotbugs
org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
org.reactivestreams:reactive-streams:1.0.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.ow2.asm:asm:9.7.1=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.pcollections:pcollections:4.0.1=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
org.reactivestreams:reactive-streams:1.0.4=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.reflections:reflections:0.10.2=checkstyle
org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
org.slf4j:jul-to-slf4j:2.0.17=testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
org.rnorth.duct-tape:duct-tape:1.0.8=redisTestCompileClasspath,redisTestRuntimeClasspath
org.skyscreamer:jsonassert:1.5.3=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.slf4j:jul-to-slf4j:2.0.17=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-api:2.0.17=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor
org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-client:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-converter:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jackson:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-restclient:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-resttestclient:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-servlet:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-test:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-tomcat:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-web-server:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc-test:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot:4.0.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.data:spring-data-commons:4.0.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.data:spring-data-keyvalue:4.0.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.data:spring-data-redis:4.0.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.session:spring-session-core:4.0.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.session:spring-session-data-redis:4.0.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-aop:7.0.1=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-beans:7.0.1=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-context-support:7.0.1=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-context:7.0.1=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-core:7.0.1=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-expression:7.0.1=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-oxm:7.0.1=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-test:7.0.1=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-tx:7.0.1=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-web:7.0.1=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webmvc:7.0.1=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.testcontainers:testcontainers:2.0.2=redisTestCompileClasspath,redisTestRuntimeClasspath
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
org.yaml:snakeyaml:2.5=testCompileClasspath,testRuntimeClasspath
redis.clients.authentication:redis-authx-core:0.1.1-beta2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath
tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath
org.xmlunit:xmlunit-core:2.10.4=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.yaml:snakeyaml:2.5=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
redis.clients.authentication:redis-authx-core:0.1.1-beta2=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-core:3.0.2=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-databind:3.0.2=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson:jackson-bom:3.0.2=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
empty=
@@ -0,0 +1,231 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.time.Duration;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.function.LongSupplier;
/** One daemon worker with storage bounded by the finite active Redis role count. */
final class BoundedRedisSentinelRefreshWorker implements RedisSentinelRefreshWorker {
private final Object monitor = new Object();
private final int capacity;
private final ArrayDeque<Runnable> immediateTasks;
private final List<RecurringTask> recurringTasks;
private final Thread worker;
private final LongSupplier nanoTime;
private boolean closed;
private boolean preferDueRecurring;
BoundedRedisSentinelRefreshWorker(int capacity, String threadName) {
this(capacity, threadName, System::nanoTime);
}
BoundedRedisSentinelRefreshWorker(int capacity, String threadName, LongSupplier nanoTime) {
if (capacity < 1) {
throw new IllegalArgumentException("Redis Sentinel worker capacity must be positive");
}
this.capacity = capacity;
this.immediateTasks = new ArrayDeque<>(capacity);
this.recurringTasks = new ArrayList<>(capacity);
this.nanoTime = Objects.requireNonNull(nanoTime, "nanoTime must be non-null");
this.worker =
Thread.ofPlatform().daemon(true).name(requireText(threadName)).unstarted(this::runWorker);
this.worker.start();
}
@Override
public Cancellable scheduleWithFixedDelay(Runnable task, Duration delay) {
Objects.requireNonNull(task, "task must be non-null");
long delayNanos = positiveNanos(delay);
RecurringTask recurring =
new RecurringTask(task, delayNanos, nanoTime.getAsLong() + delayNanos);
synchronized (monitor) {
ensureOpen();
if (recurringTasks.size() >= capacity) {
throw new IllegalStateException("Redis Sentinel recurring task capacity is exhausted");
}
recurringTasks.add(recurring);
monitor.notifyAll();
}
return () -> cancel(recurring);
}
@Override
public boolean execute(Runnable task) {
Objects.requireNonNull(task, "task must be non-null");
synchronized (monitor) {
if (closed || immediateTasks.size() >= capacity) {
return false;
}
immediateTasks.addLast(task);
monitor.notifyAll();
return true;
}
}
@Override
public void shutdown(Duration timeout) {
long timeoutNanos = positiveNanos(timeout);
synchronized (monitor) {
if (!closed) {
closed = true;
recurringTasks.forEach(task -> task.cancelled = true);
recurringTasks.clear();
immediateTasks.clear();
monitor.notifyAll();
}
}
worker.interrupt();
if (Thread.currentThread() == worker) {
return;
}
try {
long millis = Math.max(1, Math.min(Long.MAX_VALUE, timeoutNanos / 1_000_000L));
worker.join(millis);
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
}
}
private void runWorker() {
while (true) {
Work work;
try {
work = awaitWork();
} catch (InterruptedException interrupted) {
if (isClosed()) {
Thread.currentThread().interrupt();
return;
}
continue;
}
if (work == null) {
return;
}
try {
work.task.run();
} catch (RuntimeException ignored) {
// Refresh failures are deliberately contained and rendered only through sanitized health.
} finally {
if (work.recurring != null) {
reschedule(work.recurring);
}
}
}
}
private Work awaitWork() throws InterruptedException {
synchronized (monitor) {
while (!closed) {
if (!preferDueRecurring) {
Runnable immediate = immediateTasks.pollFirst();
if (immediate != null) {
preferDueRecurring = true;
return new Work(immediate, null);
}
}
long now = nanoTime.getAsLong();
RecurringTask due = null;
long waitNanos = Long.MAX_VALUE;
for (RecurringTask task : recurringTasks) {
if (task.cancelled || task.running) {
continue;
}
long remaining = task.nextRunNanos - now;
if (remaining <= 0) {
due = task;
break;
}
waitNanos = Math.min(waitNanos, remaining);
}
if (due != null) {
due.running = true;
preferDueRecurring = false;
return new Work(due.task, due);
}
Runnable immediate = immediateTasks.pollFirst();
if (immediate != null) {
preferDueRecurring = true;
return new Work(immediate, null);
}
if (waitNanos == Long.MAX_VALUE) {
monitor.wait();
} else {
long millis = waitNanos / 1_000_000L;
int nanos = (int) (waitNanos % 1_000_000L);
monitor.wait(millis, nanos);
}
}
return null;
}
}
private void reschedule(RecurringTask task) {
synchronized (monitor) {
task.running = false;
if (!closed && !task.cancelled) {
task.nextRunNanos = nanoTime.getAsLong() + task.delayNanos;
}
monitor.notifyAll();
}
}
private void cancel(RecurringTask task) {
synchronized (monitor) {
task.cancelled = true;
recurringTasks.remove(task);
monitor.notifyAll();
}
}
private boolean isClosed() {
synchronized (monitor) {
return closed;
}
}
private void ensureOpen() {
if (closed) {
throw new IllegalStateException("Redis Sentinel refresh worker is closed");
}
}
private static long positiveNanos(Duration duration) {
Objects.requireNonNull(duration, "duration must be non-null");
if (duration.isZero() || duration.isNegative()) {
throw new IllegalArgumentException("Redis Sentinel worker duration must be positive");
}
try {
return duration.toNanos();
} catch (ArithmeticException overflow) {
return Long.MAX_VALUE;
}
}
private static String requireText(String value) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("Redis Sentinel worker name must be non-blank");
}
return value.trim();
}
private record Work(Runnable task, RecurringTask recurring) {}
private static final class RecurringTask {
private final Runnable task;
private final long delayNanos;
private long nextRunNanos;
private boolean running;
private boolean cancelled;
private RecurringTask(Runnable task, long delayNanos, long nextRunNanos) {
this.task = task;
this.delayNanos = delayNanos;
this.nextRunNanos = nextRunNanos;
}
}
}
@@ -0,0 +1,83 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import io.lettuce.core.RedisChannelHandler;
import io.lettuce.core.RedisConnectionStateListener;
import io.lettuce.core.pubsub.RedisPubSubAdapter;
import io.lettuce.core.pubsub.StatefulRedisPubSubConnection;
import java.net.SocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
/** Managed standalone Redis Pub/Sub listener for best-effort cache invalidation hints. */
final class LettuceRedisCacheInvalidationSubscription implements AutoCloseable {
private final StatefulRedisPubSubConnection<byte[], byte[]> connection;
private final RedisCacheInvalidationSubscriber subscriber;
private final AtomicBoolean closed = new AtomicBoolean();
private LettuceRedisCacheInvalidationSubscription(
StatefulRedisPubSubConnection<byte[], byte[]> connection,
RedisCacheInvalidationSubscriber subscriber) {
this.connection = connection;
this.subscriber = subscriber;
}
static LettuceRedisCacheInvalidationSubscription subscribe(
LettuceRedisRuntime runtime,
String channel,
RedisCacheInvalidationMessage.Codec codec,
RedisCacheInvalidationSubscriber subscriber) {
Objects.requireNonNull(runtime, "runtime must be non-null");
Objects.requireNonNull(channel, "channel must be non-null");
Objects.requireNonNull(codec, "codec must be non-null");
Objects.requireNonNull(subscriber, "subscriber must be non-null");
byte[] channelBytes = channel.getBytes(StandardCharsets.US_ASCII);
StatefulRedisPubSubConnection<byte[], byte[]> connection =
runtime.openInvalidationSubscription();
connection.addListener(
new RedisPubSubAdapter<>() {
@Override
public void message(byte[] actualChannel, byte[] message) {
if (!Arrays.equals(channelBytes, actualChannel) || message == null) {
return;
}
codec
.decode(new String(message, StandardCharsets.US_ASCII))
.ifPresentOrElse(subscriber::onMessage, subscriber::onMalformedMessage);
}
});
connection.addListener(
new RedisConnectionStateListener() {
@Override
public void onRedisConnected(
RedisChannelHandler<?, ?> connection, SocketAddress remoteAddress) {
// A preceding disconnect already forced L1 flush and generation recheck.
}
@Override
public void onRedisDisconnected(RedisChannelHandler<?, ?> connection) {
subscriber.onDisconnected();
}
});
try {
connection.sync().subscribe(channelBytes);
return new LettuceRedisCacheInvalidationSubscription(connection, subscriber);
} catch (RuntimeException exception) {
connection.close();
throw exception;
}
}
@Override
public void close() {
if (closed.compareAndSet(false, true)) {
try {
connection.close();
} finally {
subscriber.onDisconnected();
}
}
}
}
@@ -0,0 +1,228 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings;
import io.lettuce.core.AbstractRedisClient;
import io.lettuce.core.ClientOptions;
import io.lettuce.core.ConnectionFuture;
import io.lettuce.core.RedisClient;
import io.lettuce.core.RedisURI;
import io.lettuce.core.api.StatefulConnection;
import io.lettuce.core.api.StatefulRedisConnection;
import io.lettuce.core.cluster.ClusterClientOptions;
import io.lettuce.core.cluster.RedisClusterClient;
import io.lettuce.core.cluster.api.StatefulRedisClusterConnection;
import io.lettuce.core.codec.ByteArrayCodec;
import java.time.Duration;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
/** Opens, probes, and owns topology-native Lettuce clients and connections. */
final class LettuceRedisNativeClientFactory implements RedisNativeClientFactory {
interface LifecycleObserver {
LifecycleObserver NOOP = new LifecycleObserver() {};
default void clientCreated() {}
default void connectionClosed() {}
default void clientClosed() {}
}
private final LifecycleObserver observer;
LettuceRedisNativeClientFactory() {
this(LifecycleObserver.NOOP);
}
LettuceRedisNativeClientFactory(LifecycleObserver observer) {
this.observer = Objects.requireNonNull(observer, "observer must be non-null");
}
@Override
public RedisNativeClientHandle openStandalone(
RedisURI uri, ClientOptions options, RedisClientRuntimeSettings settings) {
Objects.requireNonNull(uri, "uri must be non-null");
Objects.requireNonNull(options, "options must be non-null");
Objects.requireNonNull(settings, "settings must be non-null");
RedisClient client = RedisClient.create(uri);
observer.clientCreated();
StatefulRedisConnection<byte[], byte[]> connection = null;
try {
client.setOptions(options);
long deadline = deadline(settings.overallTimeout());
ConnectionFuture<StatefulRedisConnection<byte[], byte[]>> connect =
client.connectAsync(ByteArrayCodec.INSTANCE, uri);
connection =
await(
connect,
boundedByRemaining(settings.acquireTimeout(), deadline),
"Redis standalone connect");
connection.setTimeout(settings.commandTimeout());
await(
connection.async().ping(),
boundedByRemaining(settings.commandTimeout(), deadline),
"Redis standalone probe");
return new LettuceHandle(client, connection, settings.shutdownTimeout(), observer);
} catch (RuntimeException exception) {
closeFailed(client, connection, settings.shutdownTimeout(), observer, List.of(uri));
throw sanitizedConnectFailure(exception);
}
}
@Override
public RedisNativeClientHandle openCluster(
List<RedisURI> seedUris, ClusterClientOptions options, RedisClientRuntimeSettings settings) {
List<RedisURI> uris =
List.copyOf(Objects.requireNonNull(seedUris, "seedUris must be non-null"));
Objects.requireNonNull(options, "options must be non-null");
Objects.requireNonNull(settings, "settings must be non-null");
RedisClusterClient client = RedisClusterClient.create(uris);
observer.clientCreated();
StatefulRedisClusterConnection<byte[], byte[]> connection = null;
try {
client.setOptions(options);
long deadline = deadline(settings.overallTimeout());
java.util.concurrent.CompletableFuture<StatefulRedisClusterConnection<byte[], byte[]>>
connect = client.connectAsync(ByteArrayCodec.INSTANCE);
connection =
await(
connect,
boundedByRemaining(settings.acquireTimeout(), deadline),
"Redis Cluster connect");
connection.setTimeout(settings.commandTimeout());
await(
connection.async().ping(),
boundedByRemaining(settings.commandTimeout(), deadline),
"Redis Cluster probe");
return new LettuceHandle(client, connection, settings.shutdownTimeout(), observer);
} catch (RuntimeException exception) {
closeFailed(client, connection, settings.shutdownTimeout(), observer, uris);
throw sanitizedConnectFailure(exception);
}
}
private static long deadline(Duration overallTimeout) {
long timeoutNanos = overallTimeout.toNanos();
long now = System.nanoTime();
return now > Long.MAX_VALUE - timeoutNanos ? Long.MAX_VALUE : now + timeoutNanos;
}
private static Duration boundedByRemaining(Duration operationTimeout, long deadline) {
long remaining = deadline - System.nanoTime();
if (remaining <= 0) {
throw new IllegalStateException("Redis overall connect deadline expired");
}
Duration remainingDuration = Duration.ofNanos(remaining);
return operationTimeout.compareTo(remainingDuration) < 0 ? operationTimeout : remainingDuration;
}
private static <T> T await(
java.util.concurrent.Future<T> future, Duration timeout, String operation) {
try {
return future.get(timeout.toNanos(), TimeUnit.NANOSECONDS);
} catch (InterruptedException exception) {
future.cancel(true);
Thread.currentThread().interrupt();
throw new IllegalStateException(operation + " was interrupted");
} catch (TimeoutException exception) {
future.cancel(true);
throw new IllegalStateException(operation + " exceeded its bounded timeout");
} catch (ExecutionException exception) {
throw new IllegalStateException(operation + " failed");
}
}
private static IllegalStateException sanitizedConnectFailure(RuntimeException ignored) {
return new IllegalStateException("Redis connect or probe failed within its bounded deadline");
}
private static void closeFailed(
AbstractRedisClient client,
StatefulConnection<?, ?> connection,
Duration shutdownTimeout,
LifecycleObserver observer,
List<RedisURI> uris) {
try {
closeConnection(connection, observer);
} finally {
try {
client.shutdown(Duration.ZERO, shutdownTimeout);
} finally {
observer.clientClosed();
uris.forEach(LettuceRedisNativeClientFactory::destroyCredentials);
}
}
}
private static void closeConnection(
StatefulConnection<?, ?> connection, LifecycleObserver observer) {
if (connection == null) {
return;
}
try {
connection.close();
} finally {
observer.connectionClosed();
}
}
private static void destroyCredentials(RedisURI uri) {
if (uri.getCredentialsProvider() instanceof javax.security.auth.Destroyable destroyable) {
try {
destroyable.destroy();
} catch (javax.security.auth.DestroyFailedException ignored) {
// The adapter-owned providers do not throw; remain fail-safe for alternate implementations.
}
}
}
private static final class LettuceHandle implements RedisNativeClientHandle {
private final AbstractRedisClient client;
private final StatefulConnection<?, ?> connection;
private final Duration configuredShutdownTimeout;
private final LifecycleObserver observer;
private final AtomicBoolean closed = new AtomicBoolean();
private LettuceHandle(
AbstractRedisClient client,
StatefulConnection<?, ?> connection,
Duration configuredShutdownTimeout,
LifecycleObserver observer) {
this.client = client;
this.connection = connection;
this.configuredShutdownTimeout = configuredShutdownTimeout;
this.observer = observer;
}
@Override
public Class<?> nativeClientType() {
return client.getClass();
}
@Override
public void close(Duration timeout) {
Objects.requireNonNull(timeout, "timeout must be non-null");
if (!timeout.equals(configuredShutdownTimeout)) {
throw new IllegalArgumentException("Redis shutdown timeout differs from runtime settings");
}
if (closed.compareAndSet(false, true)) {
try {
closeConnection(connection, observer);
} finally {
try {
client.shutdown(Duration.ZERO, timeout);
} finally {
observer.clientClosed();
}
}
}
}
}
}
@@ -14,10 +14,12 @@ import io.lettuce.core.TimeoutOptions;
import io.lettuce.core.api.StatefulRedisConnection;
import io.lettuce.core.api.sync.RedisCommands;
import io.lettuce.core.codec.ByteArrayCodec;
import io.lettuce.core.pubsub.StatefulRedisPubSubConnection;
import java.net.SocketAddress;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Supplier;
@@ -26,19 +28,7 @@ import java.util.function.Supplier;
final class LettuceRedisRuntime implements RedisClient, RedisBinaryCommands, AutoCloseable {
private static final String VALUE_TOO_LARGE_ERROR = "CA_VALUE_TOO_LARGE";
private static final byte[] BOUNDED_GET_SCRIPT =
"""
local limit = tonumber(ARGV[1])
local value = redis.call('GETRANGE', KEYS[1], 0, limit)
if #value > limit then
return redis.error_reply('CA_VALUE_TOO_LARGE')
end
if #value == 0 and redis.call('EXISTS', KEYS[1]) == 0 then
return false
end
return value
"""
.getBytes(StandardCharsets.UTF_8);
private static final RedisProgramCatalog FOUNDATION_CATALOG = RedisProgramCatalog.foundation();
private final io.lettuce.core.RedisClient client;
private final StatefulRedisConnection<byte[], byte[]> connection;
@@ -54,17 +44,17 @@ final class LettuceRedisRuntime implements RedisClient, RedisBinaryCommands, Aut
private LettuceRedisRuntime(
io.lettuce.core.RedisClient client,
StatefulRedisConnection<byte[], byte[]> connection,
RedisRuntimeSettings settings) {
RedisConnectionProfile settings) {
this.client = client;
this.connection = connection;
this.commands = connection.sync();
this.legacyTtl = settings.positiveTtl();
this.legacyTtl = settings.legacyTtl();
this.shutdownTimeout = settings.commandTimeout();
this.commandAdmission =
new RedisCommandAdmission(
settings.maximumQueuedCommands(), settings.maximumInFlightBytes());
this.maximumReadableValueBytes = settings.maximumValueBytes() + 1024 + 32;
this.maximumCommandBytes = settings.maximumValueBytes() + 2048;
this.maximumReadableValueBytes = settings.maximumReadableValueBytes();
this.maximumCommandBytes = settings.maximumCommandBytes();
connection.addListener(
new RedisConnectionStateListener() {
@Override
@@ -81,6 +71,14 @@ final class LettuceRedisRuntime implements RedisClient, RedisBinaryCommands, Aut
}
static LettuceRedisRuntime connect(RedisRuntimeSettings settings) {
return connect(RedisConnectionProfile.cache(settings));
}
static LettuceRedisRuntime connect(RedisLegacyStandaloneSettings settings) {
return connect(RedisConnectionProfile.rateLimit(settings));
}
private static LettuceRedisRuntime connect(RedisConnectionProfile settings) {
RedisURI uri = redisUri(settings);
io.lettuce.core.RedisClient client = io.lettuce.core.RedisClient.create(uri);
client.setOptions(clientOptions(settings));
@@ -95,6 +93,10 @@ final class LettuceRedisRuntime implements RedisClient, RedisBinaryCommands, Aut
}
static RedisURI redisUri(RedisRuntimeSettings settings) {
return redisUri(RedisConnectionProfile.cache(settings));
}
private static RedisURI redisUri(RedisConnectionProfile settings) {
RedisURI.Builder builder =
RedisURI.Builder.redis(settings.host(), settings.port())
.withTimeout(settings.commandTimeout());
@@ -105,6 +107,10 @@ final class LettuceRedisRuntime implements RedisClient, RedisBinaryCommands, Aut
}
static ClientOptions clientOptions(RedisRuntimeSettings settings) {
return clientOptions(RedisConnectionProfile.cache(settings));
}
private static ClientOptions clientOptions(RedisConnectionProfile settings) {
return ClientOptions.builder()
.autoReconnect(true)
.replayFilter(ignored -> true)
@@ -116,7 +122,7 @@ final class LettuceRedisRuntime implements RedisClient, RedisBinaryCommands, Aut
@Override
public Optional<String> read(String key) {
byte[] value = get(key.getBytes(StandardCharsets.UTF_8));
byte[] value = get(RedisPhysicalKey.owned(new LegacyKeyMaterial(key)));
return value == null
? Optional.empty()
: Optional.of(new String(value, StandardCharsets.UTF_8));
@@ -124,84 +130,109 @@ final class LettuceRedisRuntime implements RedisClient, RedisBinaryCommands, Aut
@Override
public void write(String key, String value) {
set(key.getBytes(StandardCharsets.UTF_8), value.getBytes(StandardCharsets.UTF_8), legacyTtl);
set(
RedisPhysicalKey.owned(new LegacyKeyMaterial(key)),
RedisBinaryValue.utf8(value),
legacyTtl);
}
@Override
public byte[] get(byte[] key) {
byte[] limit = Integer.toString(maximumReadableValueBytes).getBytes(StandardCharsets.US_ASCII);
try {
byte[] value =
execute(
false,
reservationBytes(
maximumReadableValueBytes,
List.of(BOUNDED_GET_SCRIPT),
List.of(key),
List.of(limit)),
() ->
commands.eval(
BOUNDED_GET_SCRIPT,
ScriptOutputType.VALUE,
new byte[][] {key.clone()},
limit));
return value == null ? null : value.clone();
} catch (RedisCommandExecutionException exception) {
if (exception.getMessage() != null
&& exception.getMessage().contains(VALUE_TOO_LARGE_ERROR)) {
throw new RedisValueTooLargeException();
}
throw exception;
}
public byte[] get(RedisPhysicalKey key) {
RedisCatalogProgramInvocation invocation =
FOUNDATION_CATALOG.boundedGetInvocation(key, maximumReadableValueBytes);
byte[] value = RedisScriptRecovery.evalReadOnlyValue(this, invocation);
return value == null ? null : value.clone();
}
@Override
public void set(byte[] key, byte[] value, Duration timeToLive) {
public void set(RedisPhysicalKey key, RedisBinaryValue value, Duration timeToLive) {
byte[] encodedKey = RedisPhysicalKey.WireCodec.copy(key);
byte[] encodedValue = value.copyEncoded();
String result =
execute(
true,
reservationBytes(64, List.of(key, value)),
reservationBytes(64, List.of(encodedKey, encodedValue)),
() ->
commands.set(
key.clone(), value.clone(), SetArgs.Builder.px(timeToLive.toMillis())));
commands.set(encodedKey, encodedValue, SetArgs.Builder.px(timeToLive.toMillis())));
if (!"OK".equals(result)) {
throw new IllegalStateException("Redis SET did not acknowledge the mutation");
}
}
@Override
public long delete(byte[] key) {
return execute(true, reservationBytes(32, List.of(key)), () -> commands.del(key.clone()));
public long delete(RedisPhysicalKey key) {
byte[] encodedKey = RedisPhysicalKey.WireCodec.copy(key);
return execute(true, reservationBytes(32, List.of(encodedKey)), () -> commands.del(encodedKey));
}
@Override
public byte[] evalSha(String sha1, List<byte[]> keys, List<byte[]> arguments) {
public RedisCatalogProgramReply executeCatalogProgram(RedisCatalogProgramInvocation invocation) {
boolean mutation =
invocation.replyShape() != RedisCatalogProgramInvocation.ReplyShape.READ_ONLY_VALUE
&& invocation.replyShape() != RedisCatalogProgramInvocation.ReplyShape.READ_ONLY_MULTI;
ScriptOutputType outputType =
invocation.replyShape() == RedisCatalogProgramInvocation.ReplyShape.MULTI
|| invocation.replyShape()
== RedisCatalogProgramInvocation.ReplyShape.READ_ONLY_MULTI
? ScriptOutputType.MULTI
: ScriptOutputType.VALUE;
try {
return execute(
true,
reservationBytes(256, keys, arguments),
() ->
commands.evalsha(
sha1,
ScriptOutputType.VALUE,
keys.toArray(byte[][]::new),
arguments.toArray(byte[][]::new)));
Object result =
execute(
mutation,
Math.max(256, invocation.encodedBytes()),
() ->
commands.evalsha(
RedisScriptRecovery.sha1(
RedisCatalogProgramInvocation.WireCodec.exactScript(invocation)),
outputType,
RedisCatalogProgramInvocation.WireCodec.keysArray(invocation),
RedisCatalogProgramInvocation.WireCodec.argumentsArray(invocation)));
if (outputType == ScriptOutputType.MULTI) {
@SuppressWarnings("unchecked")
List<byte[]> fields = (List<byte[]>) result;
return RedisCatalogProgramReply.multi(defensiveReply(fields));
}
return RedisCatalogProgramReply.value((byte[]) result);
} catch (io.lettuce.core.RedisNoScriptException exception) {
throw new RedisNoScriptException();
} catch (RedisCommandExecutionException exception) {
if (exception.getMessage() != null
&& exception.getMessage().contains(VALUE_TOO_LARGE_ERROR)) {
throw new RedisValueTooLargeException();
}
throw commandFailure(
mutation,
mutation
? "Redis Lua program execution failed"
: "Redis read-only Lua program execution failed",
exception);
}
}
@Override
public byte[] eval(byte[] script, List<byte[]> keys, List<byte[]> arguments) {
return execute(
public String loadCatalogProgram(RedisCatalogProgramInvocation invocation) {
byte[] script = RedisCatalogProgramInvocation.WireCodec.exactScript(invocation);
try {
return execute(
true, reservationBytes(64, List.of(script)), () -> commands.scriptLoad(script.clone()));
} catch (RedisCommandExecutionException exception) {
throw commandFailure(true, "Redis script load failed", exception);
}
}
void publishInvalidation(String channel, String message) {
byte[] channelBytes = channel.getBytes(StandardCharsets.US_ASCII);
byte[] messageBytes = message.getBytes(StandardCharsets.US_ASCII);
execute(
true,
reservationBytes(256, List.of(script), keys, arguments),
() ->
commands.eval(
script.clone(),
ScriptOutputType.VALUE,
keys.toArray(byte[][]::new),
arguments.toArray(byte[][]::new)));
reservationBytes(64, List.of(channelBytes, messageBytes)),
() -> commands.publish(channelBytes, messageBytes));
}
StatefulRedisPubSubConnection<byte[], byte[]> openInvalidationSubscription() {
ensureOpen();
return client.connectPubSub(ByteArrayCodec.INSTANCE);
}
@Override
@@ -252,7 +283,7 @@ final class LettuceRedisRuntime implements RedisClient, RedisBinaryCommands, Aut
throw exception;
} catch (RedisCommandInterruptedException exception) {
Thread.currentThread().interrupt();
throw exception;
throw commandFailure(mutation, "Redis command was interrupted", exception);
} catch (RedisCommandTimeoutException exception) {
throw commandFailure(mutation, "Redis command timed out", exception);
} catch (RedisConnectionException exception) {
@@ -273,6 +304,13 @@ final class LettuceRedisRuntime implements RedisClient, RedisBinaryCommands, Aut
cause);
}
private static List<byte[]> defensiveReply(List<byte[]> result) {
if (result == null) {
return null;
}
return result.stream().map(value -> value == null ? null : value.clone()).toList();
}
@SafeVarargs
private static int reservationBytes(int responseBytes, List<byte[]>... groups) {
long total = Math.max(1, responseBytes);
@@ -289,4 +327,20 @@ final class LettuceRedisRuntime implements RedisClient, RedisBinaryCommands, Aut
}
return (int) total;
}
static final class LegacyKeyMaterial implements RedisOwnedPhysicalKeyMaterial {
private final byte[] encoded;
private LegacyKeyMaterial(String key) {
this.encoded =
Objects.requireNonNull(key, "legacy key must be non-null")
.getBytes(StandardCharsets.UTF_8);
}
@Override
public byte[] copyEncodedKey() {
return encoded.clone();
}
}
}
@@ -0,0 +1,108 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.application.cache.CacheObservationEvent;
import dev.caskeleton.application.cache.CacheObservationPort;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
/** Micrometer rendering for the framework-free cache observation boundary. */
final class MicrometerCacheObservationPort implements CacheObservationPort {
private final MeterRegistry registry;
private final Set<String> cacheNames;
MicrometerCacheObservationPort(MeterRegistry registry, Set<String> cacheNames) {
this.registry = Objects.requireNonNull(registry, "registry must be non-null");
this.cacheNames = Set.copyOf(Objects.requireNonNull(cacheNames, "cacheNames must be non-null"));
if (this.cacheNames.isEmpty() || this.cacheNames.size() > 50) {
throw new IllegalArgumentException("cacheNames must contain 1..50 startup-registered names");
}
if (this.cacheNames.stream().anyMatch(name -> name == null || name.isBlank())) {
throw new IllegalArgumentException("cacheNames must contain non-blank names");
}
}
@Override
public void observe(CacheObservationEvent event) {
Objects.requireNonNull(event, "event must be non-null");
String cacheName =
event instanceof CacheObservationEvent.Lookup lookup
? lookup.cacheName()
: ((CacheObservationEvent.LocalMaintenance) event).cacheName();
if (!cacheNames.contains(cacheName)) {
throw new IllegalArgumentException("cacheName is not in the startup allowlist");
}
if (event instanceof CacheObservationEvent.Lookup lookup) {
observeLookup(lookup);
return;
}
CacheObservationEvent.LocalMaintenance maintenance =
(CacheObservationEvent.LocalMaintenance) event;
Counter.builder("cache.local.maintenance.total")
.tag("cache_name", maintenance.cacheName())
.tag("event", maintenanceEvent(maintenance))
.register(registry)
.increment();
}
private void observeLookup(CacheObservationEvent.Lookup lookup) {
if (lookup.tier() != CacheObservationEvent.Tier.LOCAL_L1) {
return;
}
Counter.builder("cache.local.requests.total")
.tag("cache_name", lookup.cacheName())
.tag("result", lower(lookup.result()))
.register(registry)
.increment();
if (lookup.result() == CacheObservationEvent.LookupResult.HIT) {
Timer.builder("cache.local.entry.age.seconds")
.tag("cache_name", lookup.cacheName())
.register(registry)
.record(lookup.entryAge());
}
}
private static String lower(Enum<?> value) {
return value.name().toLowerCase(Locale.ROOT);
}
private static String maintenanceEvent(CacheObservationEvent.LocalMaintenance event) {
if (event.action() == CacheObservationEvent.MaintenanceAction.EVICT) {
return "evict_" + lower(event.cause());
}
if (event.cause() == CacheObservationEvent.MaintenanceCause.GENERATION_CHANGED) {
return "reconcile_generation_changed";
}
if (event.action() == CacheObservationEvent.MaintenanceAction.RECONCILE) {
return event.result() == CacheObservationEvent.MaintenanceResult.ERROR
? "reconcile_error"
: "reconcile_unchanged";
}
if (event.cause() == CacheObservationEvent.MaintenanceCause.SUBSCRIBER_DISCONNECTED) {
return "subscriber_disconnected";
}
if (event.cause() == CacheObservationEvent.MaintenanceCause.SUBSCRIBER_OVERFLOW) {
return "subscriber_overflow";
}
if (event.cause() == CacheObservationEvent.MaintenanceCause.MALFORMED_MESSAGE) {
return "subscriber_malformed";
}
if (event.action() == CacheObservationEvent.MaintenanceAction.SUBSCRIBER_EVENT
&& event.result() == CacheObservationEvent.MaintenanceResult.FLUSHED) {
return "flush_invalidation";
}
if (event.action() == CacheObservationEvent.MaintenanceAction.SUBSCRIBER_EVENT) {
return event.result() == CacheObservationEvent.MaintenanceResult.SUCCESS
? "subscriber_publish_success"
: "subscriber_publish_error";
}
if (event.cause() == CacheObservationEvent.MaintenanceCause.INVALIDATION) {
return "flush_invalidation";
}
return "other";
}
}
@@ -0,0 +1,120 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.Gauge;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import java.util.Locale;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
/** Renders the closed Redis capability event model to its six registry-approved meters. */
final class MicrometerRedisCapabilityObservationPort implements RedisCapabilityObservationPort {
private final MeterRegistry registry;
private final ConcurrentMap<
RedisCapabilityObservationEvent.Role, AtomicReference<InFlightSnapshot>>
inFlight = new ConcurrentHashMap<>();
MicrometerRedisCapabilityObservationPort(MeterRegistry registry) {
this.registry = Objects.requireNonNull(registry, "registry must be non-null");
}
@Override
public void observe(RedisCapabilityObservationEvent.Event event) {
Objects.requireNonNull(event, "event must be non-null");
switch (event) {
case RedisCapabilityObservationEvent.OperationCompleted operation ->
observeOperation(operation);
case RedisCapabilityObservationEvent.AdmissionChanged admission ->
observeAdmission(admission);
case RedisCapabilityObservationEvent.ReadinessObserved readiness ->
observeReadiness(readiness);
case RedisCapabilityObservationEvent.LifecycleDrainCompleted lifecycle ->
observeLifecycle(lifecycle);
}
}
private void observeOperation(RedisCapabilityObservationEvent.OperationCompleted event) {
Counter.builder("redis.capability.operations.total")
.tags(
"capability", lower(event.capability()),
"role", lower(event.role()),
"operation", lower(event.operation()),
"redis_outcome", lower(event.outcome()),
"certainty", lower(event.certainty()))
.register(registry)
.increment();
Timer.builder("redis.capability.duration.seconds")
.tags(
"capability", lower(event.capability()),
"role", lower(event.role()),
"operation", lower(event.operation()),
"redis_outcome", lower(event.outcome()))
.register(registry)
.record(event.durationNanos(), TimeUnit.NANOSECONDS);
}
private void observeAdmission(RedisCapabilityObservationEvent.AdmissionChanged event) {
if (event.admission() == RedisCapabilityObservationEvent.AdmissionState.REJECTED_SATURATED
|| event.admission() == RedisCapabilityObservationEvent.AdmissionState.REJECTED_CLOSED) {
Counter.builder("redis.capability.admission.rejected.total")
.tags("role", lower(event.role()), "admission", lower(event.admission()))
.register(registry)
.increment();
}
snapshot(event.role()).set(new InFlightSnapshot(event.state(), event.inFlightCommands()));
}
private void observeReadiness(RedisCapabilityObservationEvent.ReadinessObserved event) {
Counter.builder("redis.capability.readiness.total")
.tags(
"capability", lower(event.capability()),
"role", lower(event.role()),
"state", lower(event.state()),
"reason", lower(event.reason()),
"requirement", lower(event.requirement()))
.register(registry)
.increment();
}
private void observeLifecycle(RedisCapabilityObservationEvent.LifecycleDrainCompleted event) {
Counter.builder("redis.capability.lifecycle.drain.total")
.tags("role", lower(event.role()), "drain_outcome", lower(event.drainOutcome()))
.register(registry)
.increment();
}
private static String lower(Enum<?> value) {
return value.name().toLowerCase(Locale.ROOT);
}
private AtomicReference<InFlightSnapshot> snapshot(RedisCapabilityObservationEvent.Role role) {
return inFlight.computeIfAbsent(
role,
ignored -> {
AtomicReference<InFlightSnapshot> value =
new AtomicReference<>(
new InFlightSnapshot(RedisCapabilityObservationEvent.InFlightState.IDLE, 0));
for (RedisCapabilityObservationEvent.InFlightState state :
RedisCapabilityObservationEvent.InFlightState.values()) {
Gauge.builder(
"redis.capability.inflight.total",
value,
reference -> {
InFlightSnapshot current = reference.get();
return current.state() == state ? current.commands() : 0;
})
.tags("role", lower(role), "state", lower(state))
.register(registry);
}
return value;
});
}
private record InFlightSnapshot(
RedisCapabilityObservationEvent.InFlightState state, int commands) {}
}
@@ -0,0 +1,14 @@
package dev.caskeleton.adapter.outbound.cache.redis;
enum NoOpRedisCapabilityObservationPort implements RedisCapabilityObservationPort {
INSTANCE;
static RedisCapabilityObservationPort instance() {
return INSTANCE;
}
@Override
public void observe(RedisCapabilityObservationEvent.Event event) {
// Intentionally disabled.
}
}
@@ -2,6 +2,7 @@ package dev.caskeleton.adapter.outbound.cache.redis;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Base64;
import java.util.List;
import java.util.Objects;
@@ -10,8 +11,9 @@ final class RedisAtomicPrimitives {
private static final int MAXIMUM_OWNER_BYTES = 128;
private static final int MAXIMUM_OPERATION_ID_BYTES = 128;
private static final int MAXIMUM_VALUE_BYTES = 1_048_576;
private static final int MAXIMUM_VALUE_BYTES = 16_778_272;
private static final long MAXIMUM_TTL_MILLIS = Duration.ofDays(30).toMillis();
private static final long MAXIMUM_CONTROL_TTL_MILLIS = Duration.ofDays(31).toMillis();
private final RedisProgramCatalog catalog;
private final RedisProgramExecutor executor;
@@ -56,12 +58,110 @@ final class RedisAtomicPrimitives {
return parse(RedisProgramId.SET_IF_ABSENT_WITH_TTL, status, SetIfAbsentResult.class);
}
ReplaceIfObservedResult replaceIfObservedWithTtl(
String key, String observationToken, byte[] value, Duration timeToLive, String operationId) {
byte[] keyBytes = key(key);
byte[] expectedDigest = observationDigest(observationToken);
byte[] boundedValue = bounded(value, MAXIMUM_VALUE_BYTES, "value");
byte[] ttl = ttl(timeToLive);
byte[] operation =
bounded(
Objects.requireNonNull(operationId, "operationId must be non-null")
.getBytes(StandardCharsets.UTF_8),
MAXIMUM_OPERATION_ID_BYTES,
"operationId");
String status =
execute(
RedisProgramId.REPLACE_IF_OBSERVED_WITH_TTL,
List.of(keyBytes),
List.of(expectedDigest, boundedValue, ttl, operation));
return parse(
RedisProgramId.REPLACE_IF_OBSERVED_WITH_TTL, status, ReplaceIfObservedResult.class);
}
GenerationInitResult initializeGeneration(String key, String candidateGeneration) {
return initializeGeneration(key, candidateGeneration, Duration.ZERO);
}
GenerationInitResult initializeGeneration(
String key, String candidateGeneration, Duration timeToLive) {
byte[] keyBytes = key(key);
byte[] generation = identifier(candidateGeneration, "candidateGeneration");
byte[] ttl = controlTtl(timeToLive);
String status =
execute(RedisProgramId.REGION_GENERATION_INIT, List.of(keyBytes), List.of(generation, ttl));
return parse(RedisProgramId.REGION_GENERATION_INIT, status, GenerationInitResult.class);
}
GenerationBumpResult bumpGeneration(String key, String candidateGeneration, String operationId) {
return bumpGeneration(key, candidateGeneration, operationId, Duration.ZERO);
}
GenerationBumpResult bumpGeneration(
String key, String candidateGeneration, String operationId, Duration timeToLive) {
byte[] keyBytes = key(key);
byte[] generation = identifier(candidateGeneration, "candidateGeneration");
byte[] operation = identifier(operationId, "operationId");
byte[] ttl = controlTtl(timeToLive);
String status =
execute(
RedisProgramId.REGION_GENERATION_BUMP,
List.of(keyBytes),
List.of(generation, operation, ttl));
return parse(RedisProgramId.REGION_GENERATION_BUMP, status, GenerationBumpResult.class);
}
RefreshClaimResult claimRefreshLease(
String key, String ownerToken, String operationToken, Duration timeToLive) {
byte[] keyBytes = key(key);
byte[] owner = identifier(ownerToken, "ownerToken");
byte[] operation = identifier(operationToken, "operationToken");
byte[] ttl = refreshLeaseTtl(timeToLive);
String status =
execute(
RedisProgramId.CACHE_REFRESH_CLAIM, List.of(keyBytes), List.of(owner, operation, ttl));
return parse(RedisProgramId.CACHE_REFRESH_CLAIM, status, RefreshClaimResult.class);
}
private String execute(RedisProgramId id, List<byte[]> keys, List<byte[]> arguments) {
RedisProgramDescriptor descriptor = catalog.descriptor(id);
if (keys.size() != descriptor.keyCount() || arguments.size() != descriptor.argumentCount()) {
throw new IllegalStateException("typed Redis program signature drift for " + id.externalId());
}
return executor.execute(descriptor, List.copyOf(keys), List.copyOf(arguments));
return executor.execute(catalog.capabilityInvocation(new ProgramMaterial(id, keys, arguments)));
}
static final class ProgramMaterial implements RedisCatalogProgramMaterial {
private final RedisProgramId programId;
private final List<byte[]> keys;
private final List<byte[]> arguments;
private ProgramMaterial(RedisProgramId programId, List<byte[]> keys, List<byte[]> arguments) {
this.programId = Objects.requireNonNull(programId, "programId must be non-null");
this.keys = keys.stream().map(byte[]::clone).toList();
this.arguments = arguments.stream().map(byte[]::clone).toList();
}
@Override
public RedisProgramId programId() {
return programId;
}
@Override
public RedisCatalogProgramInvocation.ReplyShape replyShape() {
return RedisCatalogProgramInvocation.ReplyShape.VALUE;
}
@Override
public List<byte[]> copyKeys() {
return keys.stream().map(byte[]::clone).toList();
}
@Override
public List<byte[]> copyArguments() {
return arguments.stream().map(byte[]::clone).toList();
}
}
private static byte[] key(String key) {
@@ -84,6 +184,66 @@ final class RedisAtomicPrimitives {
return Long.toString(milliseconds).getBytes(StandardCharsets.US_ASCII);
}
private static byte[] controlTtl(Duration timeToLive) {
Objects.requireNonNull(timeToLive, "timeToLive must be non-null");
long milliseconds;
try {
milliseconds = timeToLive.toMillis();
} catch (ArithmeticException exception) {
throw new IllegalArgumentException("control TTL exceeds supported range", exception);
}
if (milliseconds < 0 || milliseconds > MAXIMUM_CONTROL_TTL_MILLIS) {
throw new IllegalArgumentException(
"control TTL must be between 0 and " + MAXIMUM_CONTROL_TTL_MILLIS + " milliseconds");
}
return Long.toString(milliseconds).getBytes(StandardCharsets.US_ASCII);
}
private static byte[] refreshLeaseTtl(Duration timeToLive) {
Objects.requireNonNull(timeToLive, "timeToLive must be non-null");
long milliseconds;
try {
milliseconds = timeToLive.toMillis();
} catch (ArithmeticException exception) {
throw new IllegalArgumentException("refresh lease TTL exceeds supported range", exception);
}
long maximum = Duration.ofMinutes(5).toMillis();
if (milliseconds < 1 || milliseconds > maximum) {
throw new IllegalArgumentException(
"refresh lease TTL must be between 1 and " + maximum + " milliseconds");
}
return Long.toString(milliseconds).getBytes(StandardCharsets.US_ASCII);
}
private static byte[] observationDigest(String observationToken) {
Objects.requireNonNull(observationToken, "observationToken must be non-null");
byte[] digest;
try {
digest = Base64.getUrlDecoder().decode(observationToken);
} catch (IllegalArgumentException exception) {
throw new IllegalArgumentException("observationToken must be unpadded Base64URL", exception);
}
if (digest.length != 32
|| !Base64.getUrlEncoder()
.withoutPadding()
.encodeToString(digest)
.equals(observationToken)) {
throw new IllegalArgumentException(
"observationToken must encode exactly one canonical SHA-256 digest");
}
return digest;
}
private static byte[] identifier(String value, String field) {
Objects.requireNonNull(value, field + " must be non-null");
byte[] bytes = value.getBytes(StandardCharsets.US_ASCII);
if (bytes.length < 16 || bytes.length > 64 || !value.matches("[A-Za-z0-9_-]+")) {
throw new IllegalArgumentException(
field + " must be a Base64URL-safe identifier of 16..64 bytes");
}
return bytes;
}
private static byte[] bounded(byte[] value, int maximumBytes, String field) {
Objects.requireNonNull(value, field + " must be non-null");
if (value.length < 1 || value.length > maximumBytes) {
@@ -123,4 +283,34 @@ final class RedisAtomicPrimitives {
WRONG_TYPE,
INVALID
}
enum ReplaceIfObservedResult {
REPLACED,
ABSENT,
NOT_MATCHED,
WRONG_TYPE,
INVALID
}
enum GenerationInitResult {
INITIALIZED,
EXISTING,
WRONG_TYPE,
INVALID
}
enum GenerationBumpResult {
BUMPED,
ALREADY_APPLIED,
WRONG_TYPE,
INVALID
}
enum RefreshClaimResult {
CLAIMED,
ALREADY_OWNED,
CONTENDED,
WRONG_TYPE,
INVALID
}
}
@@ -1,18 +1,13 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.time.Duration;
import java.util.List;
/** Minimal binary Redis command surface owned entirely by this adapter. */
interface RedisBinaryCommands {
interface RedisBinaryCommands extends RedisStructuredCommands {
byte[] get(byte[] key);
byte[] get(RedisPhysicalKey key);
void set(byte[] key, byte[] value, Duration timeToLive);
void set(RedisPhysicalKey key, RedisBinaryValue value, Duration timeToLive);
long delete(byte[] key);
byte[] evalSha(String sha1, List<byte[]> keys, List<byte[]> arguments);
byte[] eval(byte[] script, List<byte[]> keys, List<byte[]> arguments);
long delete(RedisPhysicalKey key);
}
@@ -0,0 +1,42 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
/** Opaque bounded adapter-private value crossing the command gateway. */
final class RedisBinaryValue {
private static final int MAXIMUM_VALUE_BYTES = 16_777_216;
private final byte[] encoded;
private RedisBinaryValue(byte[] encoded) {
Objects.requireNonNull(encoded, "Redis binary value must be non-null");
if (encoded.length < 1 || encoded.length > MAXIMUM_VALUE_BYTES) {
throw new IllegalArgumentException("Redis binary value is out of bounds");
}
this.encoded = encoded.clone();
}
static RedisBinaryValue encoded(byte[] encoded) {
return new RedisBinaryValue(encoded);
}
static RedisBinaryValue utf8(String encoded) {
Objects.requireNonNull(encoded, "Redis binary value must be non-null");
return new RedisBinaryValue(encoded.getBytes(StandardCharsets.UTF_8));
}
int encodedLength() {
return encoded.length;
}
byte[] copyEncoded() {
return encoded.clone();
}
@Override
public String toString() {
return "RedisBinaryValue[redacted]";
}
}
@@ -0,0 +1,15 @@
package dev.caskeleton.adapter.outbound.cache.redis;
/** Descriptor-owned byte offset for BITCOUNT ranges (Redis BITCOUNT is byte-indexed). */
record RedisBitmapByteOffset(long value) {
RedisBitmapByteOffset {
if (value < 0 || value >= 1_048_576) {
throw new IllegalArgumentException("bitmap byte offset exceeds fixed descriptor domain");
}
}
static RedisBitmapByteOffset of(long value) {
return new RedisBitmapByteOffset(value);
}
}
@@ -0,0 +1,55 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.util.OptionalInt;
/** SETBIT result preserves the previous bit instead of mislabelling it as an affected count. */
record RedisBitmapMutationResult(
Status status, RedisPrimitiveMutationResult.Certainty certainty, OptionalInt previousBit) {
enum Status {
APPLIED,
WRONG_TYPE,
UNKNOWN
}
RedisBitmapMutationResult {
if (status == null || certainty == null || previousBit == null) {
throw new IllegalArgumentException("bitmap mutation result is invalid");
}
previousBit.ifPresent(
bit -> {
if (bit != 0 && bit != 1) {
throw new IllegalArgumentException("previous bitmap bit is invalid");
}
});
}
static RedisBitmapMutationResult from(RedisPrimitiveReply reply) {
return switch (reply.status()) {
case APPLIED ->
new RedisBitmapMutationResult(
Status.APPLIED,
RedisPrimitiveMutationResult.Certainty.APPLIED,
OptionalInt.of(Math.toIntExact(reply.signedNumber().orElseThrow())));
case WRONG_TYPE ->
new RedisBitmapMutationResult(
Status.WRONG_TYPE,
RedisPrimitiveMutationResult.Certainty.NOT_APPLIED,
OptionalInt.empty());
default ->
new RedisBitmapMutationResult(
Status.UNKNOWN,
RedisPrimitiveMutationResult.Certainty.NOT_APPLIED,
OptionalInt.empty());
};
}
static RedisBitmapMutationResult failed(RedisCommandFailureException failure) {
return new RedisBitmapMutationResult(
Status.UNKNOWN,
failure.certainty() == RedisCommandFailureException.Certainty.INDETERMINATE
? RedisPrimitiveMutationResult.Certainty.INDETERMINATE
: RedisPrimitiveMutationResult.Certainty.NOT_APPLIED,
OptionalInt.empty());
}
}
@@ -0,0 +1,19 @@
package dev.caskeleton.adapter.outbound.cache.redis;
/** Offset constrained to a descriptor-owned fixed bitmap domain. */
record RedisBitmapOffset(long value, long maximumExclusive) {
RedisBitmapOffset {
if (maximumExclusive < 1 || value < 0 || value >= maximumExclusive) {
throw new IllegalArgumentException("bitmap offset exceeds the fixed descriptor domain");
}
}
static RedisBitmapOffset of(long value, long maximumExclusive) {
return new RedisBitmapOffset(value, maximumExclusive);
}
long byteIndex() {
return value / Byte.SIZE;
}
}
@@ -0,0 +1,57 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.util.List;
import java.util.Objects;
/** Fixed-domain non-authoritative bitmap helpers. */
final class RedisBitmapPrimitives {
private static final long MAXIMUM_OFFSET_EXCLUSIVE = 8_388_608;
private final RedisPrimitiveCatalog catalog;
private final RedisPrimitiveExecutor executor;
RedisBitmapPrimitives(RedisPrimitiveCatalog catalog, RedisPrimitiveCommands commands) {
this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null");
this.executor = new RedisPrimitiveExecutor(catalog, commands);
}
RedisPrimitiveKey key(String slot, String identity) {
return catalog.keyFactory(RedisPrimitiveId.BITMAP_GET).key(slot, identity);
}
RedisBitmapOffset offset(long value) {
return RedisBitmapOffset.of(value, MAXIMUM_OFFSET_EXCLUSIVE);
}
RedisBitmapByteOffset byteOffset(long value) {
return RedisBitmapByteOffset.of(value);
}
RedisPrimitiveReply get(RedisPrimitiveKey key, RedisBitmapOffset offset) {
return executor.execute(
RedisPrimitiveId.BITMAP_GET,
List.of(key),
new RedisPrimitiveInvocation.BitmapArguments(offset, offset, -1));
}
RedisBitmapMutationResult set(RedisPrimitiveKey key, RedisBitmapOffset offset, boolean bit) {
try {
return RedisBitmapMutationResult.from(
executor.execute(
RedisPrimitiveId.BITMAP_SET,
List.of(key),
new RedisPrimitiveInvocation.BitmapArguments(offset, offset, bit ? 1 : 0)));
} catch (RedisCommandFailureException failure) {
return RedisBitmapMutationResult.failed(failure);
}
}
RedisPrimitiveReply count(
RedisPrimitiveKey key, RedisBitmapByteOffset first, RedisBitmapByteOffset last) {
return executor.execute(
RedisPrimitiveId.BITMAP_COUNT_FIXED_RANGE,
List.of(key),
new RedisPrimitiveInvocation.BitmapCountArguments(first, last));
}
}
@@ -0,0 +1,64 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import io.lettuce.core.codec.RedisCodec;
import java.nio.ByteBuffer;
import java.util.Objects;
/**
* Rejects an oversized Redis bulk value before allocating its destination byte array.
*
* <p>RESP aggregate element count and aggregate reply bytes are additionally checked by the
* semantic router because a codec invocation sees only one bulk element. Lettuce constructs the
* aggregate list before that final check, so multi-value commands remain restricted to the vetted
* program catalog and its bounded reply schemas; this codec is the pre-allocation bound for each
* bulk element, not a claim of a pre-allocation aggregate-list bound.
*/
final class RedisBoundedByteArrayCodec implements RedisCodec<byte[], byte[]> {
private final int maximumBulkBytes;
RedisBoundedByteArrayCodec(int maximumBulkBytes) {
if (maximumBulkBytes < 1024 || maximumBulkBytes > 16_777_216) {
throw new IllegalArgumentException("Redis codec bulk byte bound must be in 1024..16777216");
}
this.maximumBulkBytes = maximumBulkBytes;
}
@Override
public byte[] decodeKey(ByteBuffer bytes) {
return decode(bytes);
}
@Override
public byte[] decodeValue(ByteBuffer bytes) {
return decode(bytes);
}
@Override
public ByteBuffer encodeKey(byte[] key) {
return encode(key);
}
@Override
public ByteBuffer encodeValue(byte[] value) {
return encode(value);
}
private byte[] decode(ByteBuffer bytes) {
Objects.requireNonNull(bytes, "Redis decode buffer must be non-null");
if (bytes.remaining() > maximumBulkBytes) {
throw new IllegalStateException("Redis response bulk value exceeds its configured bound");
}
byte[] value = new byte[bytes.remaining()];
bytes.get(value);
return value;
}
private ByteBuffer encode(byte[] value) {
Objects.requireNonNull(value, "Redis encode value must be non-null");
if (value.length > maximumBulkBytes) {
throw new IllegalArgumentException("Redis command bulk value exceeds its configured bound");
}
return ByteBuffer.wrap(value);
}
}
@@ -2,7 +2,14 @@ package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.core.CacheBackend;
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyNamespace;
import dev.caskeleton.application.cache.CacheRegionPort;
import dev.caskeleton.application.cache.CacheObservationPort;
import dev.caskeleton.application.cache.DisabledCacheObservationPort;
import io.micrometer.core.instrument.MeterRegistry;
import java.time.Clock;
import java.util.Arrays;
import java.util.Set;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
@@ -23,7 +30,11 @@ import org.springframework.context.annotation.Configuration;
* therefore never need to know about each other — a new backend is new files only.
*/
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(RedisRuntimeSettings.class)
@EnableConfigurationProperties({RedisRuntimeSettings.class, RedisLocalCacheSettings.class})
@ConditionalOnProperty(
name = "ca-skeleton.providers.redis.legacy-migration-enabled",
havingValue = "true",
matchIfMissing = false)
public class RedisCacheAdapterConfig {
@Configuration(proxyBeanMethods = false)
@@ -44,14 +55,17 @@ public class RedisCacheAdapterConfig {
}
}
@Bean
@Bean(destroyMethod = "close")
@ConditionalOnBean(LettuceRedisRuntime.class)
@ConditionalOnProperty(
name = "app.cache.redis.enabled",
havingValue = "true",
matchIfMissing = false)
CacheRegionPort<String, String> redisStringCacheRegion(
LettuceRedisRuntime runtime, RedisRuntimeSettings settings) {
RedisCacheRegionRuntime redisStringCacheRegion(
LettuceRedisRuntime runtime,
RedisRuntimeSettings settings,
RedisLocalCacheSettings localSettings,
ObjectProvider<MeterRegistry> meterRegistryProvider) {
RedisKeyNamespace namespace =
new RedisKeyNamespace(
settings.namespaceApplication(),
@@ -62,14 +76,75 @@ public class RedisCacheAdapterConfig {
1,
"entry",
512);
return new RedisStringCacheRegion(
new RedisCacheRegionPolicy(
namespace,
settings.hmacSecret(),
settings.positiveTtl(),
settings.negativeTtl(),
settings.maximumValueBytes()),
runtime);
byte[] policySecret = settings.hmacSecret();
RedisCacheRegionPolicy policy;
try {
policy =
new RedisCacheRegionPolicy(
namespace,
policySecret,
"runtime-settings-v2",
settings.positiveSoftTtl(),
settings.positiveTtl(),
settings.negativeTtl(),
settings.ttlJitter(),
settings.minimumHardTtl(),
settings.maximumValueBytes());
} finally {
Arrays.fill(policySecret, (byte) 0);
}
RedisStringCacheRegion l2 = new RedisStringCacheRegion(policy, runtime);
if (!localSettings.enabled()) {
return RedisCacheRegionRuntime.l2Only(l2);
}
MeterRegistry meterRegistry = meterRegistryProvider.getIfAvailable();
CacheObservationPort observations =
meterRegistry == null
? DisabledCacheObservationPort.instance()
: new MicrometerCacheObservationPort(meterRegistry, Set.of(settings.semanticRegion()));
String channel = l2.invalidationChannel();
byte[] codecSecret = settings.hmacSecret();
RedisCacheInvalidationMessage.Codec codec;
try {
codec = RedisCacheInvalidationMessage.Codec.fromOwnedSecret(codecSecret);
} finally {
Arrays.fill(codecSecret, (byte) 0);
}
return RedisCacheRegionRuntime.local(
l2,
new RedisLocalCacheRegion(
settings.semanticRegion(),
l2,
localSettings.policy(),
Clock.systemUTC(),
observations,
channel,
codec,
message -> runtime.publishInvalidation(channel, message)));
}
@Bean(destroyMethod = "close")
@ConditionalOnBean(LettuceRedisRuntime.class)
@ConditionalOnProperty(
name = {"app.cache.redis.enabled", "app.cache.redis.l1.enabled"},
havingValue = "true",
matchIfMissing = false)
LettuceRedisCacheInvalidationSubscription redisCacheInvalidationSubscription(
LettuceRedisRuntime runtime,
@Qualifier("redisStringCacheRegion") RedisCacheRegionRuntime cacheRegion) {
RedisLocalCacheRegion local =
cacheRegion
.local()
.orElseThrow(
() ->
new IllegalStateException(
"Redis L1 invalidation subscription requires the cache-only local"
+ " decorator"));
return LettuceRedisCacheInvalidationSubscription.subscribe(
runtime,
local.invalidationChannel(),
local.invalidationMessageCodec(),
local.invalidationSubscriber());
}
@Bean
@@ -0,0 +1,219 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.application.cache.CacheWriteCondition;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.time.Duration;
import java.util.Base64;
import java.util.Objects;
import java.util.function.Supplier;
/**
* Owns non-expiring random cache generations and per-key revisions.
*
* <p>If an evictable control key disappears, initialization chooses a new random value. An old
* namespace therefore never becomes visible again by resetting to a constant default.
*/
final class RedisCacheConsistencyStore {
private static final String CONDITION_VERSION = "v1";
private static final SecureRandom RANDOM = new SecureRandom();
private static final Duration DEFAULT_KEY_REVISION_TTL = Duration.ofDays(30);
private final RedisBinaryCommands commands;
private final RedisAtomicPrimitives primitives;
private final Supplier<String> identifiers;
private final Duration keyRevisionTtl;
RedisCacheConsistencyStore(RedisBinaryCommands commands) {
this(commands, DEFAULT_KEY_REVISION_TTL);
}
RedisCacheConsistencyStore(RedisBinaryCommands commands, Duration keyRevisionTtl) {
this(
commands,
productionPrimitives(commands),
RedisCacheConsistencyStore::randomIdentifier,
keyRevisionTtl);
}
RedisCacheConsistencyStore(
RedisBinaryCommands commands,
RedisAtomicPrimitives primitives,
Supplier<String> identifiers) {
this(commands, primitives, identifiers, DEFAULT_KEY_REVISION_TTL);
}
RedisCacheConsistencyStore(
RedisBinaryCommands commands,
RedisAtomicPrimitives primitives,
Supplier<String> identifiers,
Duration keyRevisionTtl) {
this.commands = Objects.requireNonNull(commands, "commands must be non-null");
this.primitives = Objects.requireNonNull(primitives, "primitives must be non-null");
this.identifiers = Objects.requireNonNull(identifiers, "identifiers must be non-null");
this.keyRevisionTtl = boundedKeyRevisionTtl(keyRevisionTtl);
}
Snapshot capture(String regionGenerationKey, String keyRevisionKey) {
return new Snapshot(
currentOrInitialize(regionGenerationKey, Duration.ZERO),
currentOrInitialize(keyRevisionKey, keyRevisionTtl));
}
String currentRegionGeneration(String regionGenerationKey) {
return currentOrInitialize(regionGenerationKey, Duration.ZERO);
}
BumpResult bumpKeyRevision(String keyRevisionKey) {
return bumpKeyRevision(keyRevisionKey, nextIdentifier());
}
BumpResult bumpKeyRevision(String keyRevisionKey, String operationId) {
return bump(keyRevisionKey, operationId, keyRevisionTtl);
}
BumpResult bumpRegionGeneration(String regionGenerationKey) {
return bumpRegionGeneration(regionGenerationKey, nextIdentifier());
}
BumpResult bumpRegionGeneration(String regionGenerationKey, String operationId) {
return bump(regionGenerationKey, operationId, Duration.ZERO);
}
Snapshot decode(CacheWriteCondition condition) {
Objects.requireNonNull(condition, "condition must be non-null");
if (!condition.usable()) {
return null;
}
String[] components = condition.value().split("\\.", -1);
if (components.length != 3 || !CONDITION_VERSION.equals(components[0])) {
throw compatibility("MALFORMED_WRITE_CONDITION");
}
try {
return new Snapshot(components[1], components[2]);
} catch (IllegalArgumentException exception) {
throw compatibility("MALFORMED_WRITE_CONDITION");
}
}
private String currentOrInitialize(String key, Duration timeToLive) {
byte[] current = commands.get(physicalKey(key));
String candidate = current == null ? nextIdentifier() : parseState(current).generation();
RedisAtomicPrimitives.GenerationInitResult initialized =
primitives.initializeGeneration(key, candidate, timeToLive);
if (initialized == RedisAtomicPrimitives.GenerationInitResult.WRONG_TYPE
|| initialized == RedisAtomicPrimitives.GenerationInitResult.INVALID) {
throw compatibility(initialized.name());
}
current = commands.get(physicalKey(key));
if (current == null) {
throw compatibility("MISSING_AFTER_INITIALIZATION");
}
return parseState(current).generation();
}
private BumpResult bump(String key, String operationId, Duration timeToLive) {
RedisAtomicPrimitives.GenerationBumpResult result =
primitives.bumpGeneration(
key, nextIdentifier(), validateIdentifier(operationId, "operationId"), timeToLive);
return switch (result) {
case BUMPED -> BumpResult.BUMPED;
case ALREADY_APPLIED -> BumpResult.ALREADY_APPLIED;
case WRONG_TYPE, INVALID -> throw compatibility(result.name());
};
}
private String nextIdentifier() {
return validateIdentifier(identifiers.get(), "generated identifier");
}
private static State parseState(byte[] value) {
String state = new String(value, StandardCharsets.US_ASCII);
int separator = state.indexOf('|');
if (separator < 0 || separator != state.lastIndexOf('|')) {
throw compatibility("MALFORMED_GENERATION_STATE");
}
try {
String generation = validateIdentifier(state.substring(0, separator), "stored generation");
String operation = state.substring(separator + 1);
if (!"-".equals(operation)) {
validateIdentifier(operation, "stored operation");
}
return new State(generation, operation);
} catch (IllegalArgumentException exception) {
throw compatibility("MALFORMED_GENERATION_STATE");
}
}
private static String validateIdentifier(String value, String field) {
if (value == null
|| value.length() < 16
|| value.length() > 64
|| !value.matches("[A-Za-z0-9_-]+")) {
throw new IllegalArgumentException(field + " must contain 16..64 Base64URL-safe characters");
}
return value;
}
private static Duration boundedKeyRevisionTtl(Duration value) {
Objects.requireNonNull(value, "keyRevisionTtl must be non-null");
if (value.isZero() || value.isNegative() || value.compareTo(Duration.ofDays(31)) > 0) {
throw new IllegalArgumentException("keyRevisionTtl must be positive and at most 31 days");
}
return value;
}
private static RedisPhysicalKey physicalKey(String key) {
return RedisPhysicalKey.owned(new ConsistencyKeyMaterial(key));
}
private static String randomIdentifier() {
byte[] random = new byte[16];
RANDOM.nextBytes(random);
return Base64.getUrlEncoder().withoutPadding().encodeToString(random);
}
private static RedisAtomicPrimitives productionPrimitives(RedisBinaryCommands commands) {
RedisProgramCatalog catalog = RedisProgramCatalog.foundation();
return new RedisAtomicPrimitives(catalog, new RedisLuaProgramExecutor(catalog, commands));
}
private static RedisProgramCompatibilityException compatibility(String status) {
return new RedisProgramCompatibilityException(RedisProgramId.REGION_GENERATION_INIT, status);
}
enum BumpResult {
BUMPED,
ALREADY_APPLIED
}
record Snapshot(String generation, String keyRevision) {
Snapshot {
generation = validateIdentifier(generation, "generation");
keyRevision = validateIdentifier(keyRevision, "keyRevision");
}
CacheWriteCondition toWriteCondition() {
return new CacheWriteCondition(CONDITION_VERSION + "." + generation + "." + keyRevision);
}
}
private record State(String generation, String operation) {}
static final class ConsistencyKeyMaterial implements RedisOwnedPhysicalKeyMaterial {
private final byte[] encodedKey;
private ConsistencyKeyMaterial(String key) {
this.encodedKey =
Objects.requireNonNull(key, "key must be non-null").getBytes(StandardCharsets.UTF_8);
}
@Override
public byte[] copyEncodedKey() {
return encodedKey.clone();
}
}
}
@@ -2,117 +2,186 @@ package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.application.cache.AuthoritativeAbsence;
import dev.caskeleton.application.cache.CacheLookup;
import dev.caskeleton.application.cache.CacheObservationToken;
import java.nio.BufferUnderflowException;
import java.nio.ByteBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.Arrays;
import java.util.Base64;
import java.util.Objects;
/** Strict versioned binary envelope for positive and authoritative-negative cache entries. */
final class RedisCacheEnvelopeCodec {
private static final int MAGIC = 0x43414348;
private static final byte VERSION = 1;
private static final int VERSION = 2;
private static final byte POSITIVE = 1;
private static final byte NEGATIVE = 2;
private static final int CONTENT_HEADER_BYTES =
Integer.BYTES + Byte.BYTES + Byte.BYTES + Short.BYTES + Integer.BYTES;
private static final int COMMON_HEADER_BYTES = Integer.BYTES + Byte.BYTES + Byte.BYTES;
private static final int POSITIVE_HEADER_BYTES =
COMMON_HEADER_BYTES + Short.BYTES + Integer.BYTES + Long.BYTES + Long.BYTES;
private static final int NEGATIVE_HEADER_BYTES = COMMON_HEADER_BYTES + Integer.BYTES + Long.BYTES;
private static final int DIGEST_BYTES = 32;
private RedisCacheEnvelopeCodec() {}
static byte[] positive(String value, String sourceRevision, int maximumValueBytes) {
return encode(
POSITIVE,
utf8(Objects.requireNonNull(value, "value must be non-null")),
sourceRevision,
maximumValueBytes);
}
static byte[] negative(
AuthoritativeAbsence reason, String sourceRevision, int maximumValueBytes) {
Objects.requireNonNull(reason, "reason must be non-null");
return encode(NEGATIVE, utf8(reason.name()), sourceRevision, maximumValueBytes);
}
static Decoded decode(byte[] envelope, int maximumValueBytes) {
if (envelope == null
|| envelope.length < CONTENT_HEADER_BYTES + DIGEST_BYTES
|| envelope.length > maximumValueBytes + 1024 + DIGEST_BYTES) {
return incompatible(CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE);
static byte[] positive(
String value,
String sourceRevision,
Instant softExpiresAt,
Instant hardExpiresAt,
int maximumValueBytes) {
Objects.requireNonNull(softExpiresAt, "softExpiresAt must be non-null");
Objects.requireNonNull(hardExpiresAt, "hardExpiresAt must be non-null");
if (softExpiresAt.isAfter(hardExpiresAt)) {
throw new IllegalArgumentException("softExpiresAt must not be after hardExpiresAt");
}
try {
ByteBuffer buffer = ByteBuffer.wrap(envelope, 0, envelope.length - DIGEST_BYTES);
if (buffer.getInt() != MAGIC) {
return incompatible(CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE);
}
byte version = buffer.get();
if (version > VERSION) {
return incompatible(CacheLookup.SchemaCategory.FUTURE_VERSION);
}
if (version < VERSION) {
return incompatible(CacheLookup.SchemaCategory.RETIRED_VERSION);
}
byte[] expectedDigest = sha256(Arrays.copyOf(envelope, envelope.length - DIGEST_BYTES));
byte[] actualDigest =
Arrays.copyOfRange(envelope, envelope.length - DIGEST_BYTES, envelope.length);
if (!MessageDigest.isEqual(expectedDigest, actualDigest)) {
return incompatible(CacheLookup.SchemaCategory.CORRUPT_ENVELOPE);
}
byte type = buffer.get();
int revisionSize = Short.toUnsignedInt(buffer.getShort());
int payloadSize = buffer.getInt();
if (revisionSize < 1
|| revisionSize > 512
|| payloadSize < 1
|| payloadSize > maximumValueBytes
|| buffer.remaining() != revisionSize + payloadSize) {
return incompatible(CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE);
}
byte[] revision = new byte[revisionSize];
byte[] payload = new byte[payloadSize];
buffer.get(revision);
buffer.get(payload);
String sourceRevision = strictUtf8(revision);
if (!validSourceRevision(sourceRevision)) {
return incompatible(CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE);
}
if (type == POSITIVE) {
return new Positive(strictUtf8(payload), sourceRevision);
}
if (type == NEGATIVE) {
return new Negative(AuthoritativeAbsence.valueOf(strictUtf8(payload)));
}
return incompatible(CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE);
} catch (IllegalArgumentException | CharacterCodingException exception) {
return incompatible(CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE);
}
}
private static byte[] encode(
byte type, byte[] payload, String sourceRevision, int maximumValueBytes) {
byte[] revision =
utf8(Objects.requireNonNull(sourceRevision, "sourceRevision must be non-null"));
if (!validSourceRevision(sourceRevision) || revision.length > 512) {
throw new IllegalArgumentException(
"sourceRevision must contain 1..128 characters and at most 512 UTF-8 bytes");
}
if (payload.length < 1 || payload.length > maximumValueBytes) {
throw new IllegalArgumentException("cache payload exceeds configured maximum bytes");
}
byte[] payload =
checkedPayload(
utf8(Objects.requireNonNull(value, "value must be non-null")), maximumValueBytes);
byte[] content =
ByteBuffer.allocate(CONTENT_HEADER_BYTES + revision.length + payload.length)
ByteBuffer.allocate(POSITIVE_HEADER_BYTES + revision.length + payload.length)
.putInt(MAGIC)
.put(VERSION)
.put(type)
.put((byte) VERSION)
.put(POSITIVE)
.putShort((short) revision.length)
.putInt(payload.length)
.putLong(softExpiresAt.toEpochMilli())
.putLong(hardExpiresAt.toEpochMilli())
.put(revision)
.put(payload)
.array();
return withDigest(content);
}
static byte[] negative(
AuthoritativeAbsence reason, Instant hardExpiresAt, int maximumValueBytes) {
Objects.requireNonNull(reason, "reason must be non-null");
Objects.requireNonNull(hardExpiresAt, "hardExpiresAt must be non-null");
byte[] payload = checkedPayload(utf8(reason.name()), maximumValueBytes);
byte[] content =
ByteBuffer.allocate(NEGATIVE_HEADER_BYTES + payload.length)
.putInt(MAGIC)
.put((byte) VERSION)
.put(NEGATIVE)
.putInt(payload.length)
.putLong(hardExpiresAt.toEpochMilli())
.put(payload)
.array();
return withDigest(content);
}
static Decoded decode(byte[] envelope, int maximumValueBytes) {
validateMaximumValueBytes(maximumValueBytes);
if (envelope == null) {
return incompatible(CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE);
}
if (envelope.length < COMMON_HEADER_BYTES + DIGEST_BYTES
|| envelope.length > maximumValueBytes + 1024 + DIGEST_BYTES) {
return incompatible(CacheLookup.SchemaCategory.CORRUPT_ENVELOPE);
}
CacheObservationToken observationToken = CacheObservationToken.unavailable();
try {
int contentLength = envelope.length - DIGEST_BYTES;
byte[] expectedDigest = sha256(Arrays.copyOf(envelope, contentLength));
byte[] actualDigest = Arrays.copyOfRange(envelope, contentLength, envelope.length);
if (!MessageDigest.isEqual(expectedDigest, actualDigest)) {
return incompatible(CacheLookup.SchemaCategory.CORRUPT_ENVELOPE);
}
observationToken = observationToken(actualDigest);
ByteBuffer buffer = ByteBuffer.wrap(envelope, 0, contentLength);
if (buffer.getInt() != MAGIC) {
return incompatible(CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE, observationToken);
}
int version = Byte.toUnsignedInt(buffer.get());
if (version > VERSION) {
return incompatible(CacheLookup.SchemaCategory.FUTURE_VERSION, observationToken);
}
if (version < VERSION) {
return incompatible(CacheLookup.SchemaCategory.RETIRED_VERSION, observationToken);
}
byte type = buffer.get();
if (type == POSITIVE) {
return decodePositive(buffer, maximumValueBytes, observationToken);
}
if (type == NEGATIVE) {
return decodeNegative(buffer, maximumValueBytes, observationToken);
}
return incompatible(CacheLookup.SchemaCategory.CORRUPT_ENVELOPE, observationToken);
} catch (BufferUnderflowException
| IllegalArgumentException
| CharacterCodingException exception) {
return incompatible(CacheLookup.SchemaCategory.CORRUPT_ENVELOPE, observationToken);
}
}
private static Decoded decodePositive(
ByteBuffer buffer, int maximumValueBytes, CacheObservationToken observationToken)
throws CharacterCodingException {
int revisionSize = Short.toUnsignedInt(buffer.getShort());
int payloadSize = buffer.getInt();
Instant softExpiresAt = Instant.ofEpochMilli(buffer.getLong());
Instant hardExpiresAt = Instant.ofEpochMilli(buffer.getLong());
if (revisionSize < 1
|| revisionSize > 512
|| payloadSize < 1
|| payloadSize > maximumValueBytes
|| buffer.remaining() != revisionSize + payloadSize
|| softExpiresAt.isAfter(hardExpiresAt)) {
return incompatible(CacheLookup.SchemaCategory.CORRUPT_ENVELOPE, observationToken);
}
byte[] revision = new byte[revisionSize];
byte[] payload = new byte[payloadSize];
buffer.get(revision);
buffer.get(payload);
String sourceRevision = strictUtf8(revision);
if (!validSourceRevision(sourceRevision)) {
return incompatible(CacheLookup.SchemaCategory.CORRUPT_ENVELOPE, observationToken);
}
return new Positive(
strictUtf8(payload), sourceRevision, softExpiresAt, hardExpiresAt, observationToken);
}
private static Decoded decodeNegative(
ByteBuffer buffer, int maximumValueBytes, CacheObservationToken observationToken)
throws CharacterCodingException {
int payloadSize = buffer.getInt();
Instant hardExpiresAt = Instant.ofEpochMilli(buffer.getLong());
if (payloadSize < 1 || payloadSize > maximumValueBytes || buffer.remaining() != payloadSize) {
return incompatible(CacheLookup.SchemaCategory.CORRUPT_ENVELOPE, observationToken);
}
byte[] payload = new byte[payloadSize];
buffer.get(payload);
return new Negative(
AuthoritativeAbsence.valueOf(strictUtf8(payload)), hardExpiresAt, observationToken);
}
private static byte[] checkedPayload(byte[] payload, int maximumValueBytes) {
validateMaximumValueBytes(maximumValueBytes);
if (payload.length < 1 || payload.length > maximumValueBytes) {
throw new IllegalArgumentException("cache payload exceeds configured maximum bytes");
}
return payload;
}
private static void validateMaximumValueBytes(int maximumValueBytes) {
if (maximumValueBytes < 1 || maximumValueBytes > 16_777_216) {
throw new IllegalArgumentException("maximumValueBytes must be in 1..16777216");
}
}
private static byte[] withDigest(byte[] content) {
return ByteBuffer.allocate(content.length + DIGEST_BYTES)
.put(content)
.put(sha256(content))
@@ -136,6 +205,11 @@ final class RedisCacheEnvelopeCodec {
return !sourceRevision.isBlank() && sourceRevision.length() <= 128;
}
private static CacheObservationToken observationToken(byte[] digest) {
return new CacheObservationToken(
Base64.getUrlEncoder().withoutPadding().encodeToString(digest));
}
private static byte[] sha256(byte[] content) {
try {
return MessageDigest.getInstance("SHA-256").digest(content);
@@ -145,14 +219,28 @@ final class RedisCacheEnvelopeCodec {
}
private static Incompatible incompatible(CacheLookup.SchemaCategory category) {
return new Incompatible(category);
return new Incompatible(category, CacheObservationToken.unavailable());
}
private static Incompatible incompatible(
CacheLookup.SchemaCategory category, CacheObservationToken observationToken) {
return new Incompatible(category, observationToken);
}
sealed interface Decoded permits Positive, Negative, Incompatible {}
record Positive(String value, String sourceRevision) implements Decoded {}
record Positive(
String value,
String sourceRevision,
Instant softExpiresAt,
Instant hardExpiresAt,
CacheObservationToken observationToken)
implements Decoded {}
record Negative(AuthoritativeAbsence reason) implements Decoded {}
record Negative(
AuthoritativeAbsence reason, Instant hardExpiresAt, CacheObservationToken observationToken)
implements Decoded {}
record Incompatible(CacheLookup.SchemaCategory category) implements Decoded {}
record Incompatible(CacheLookup.SchemaCategory category, CacheObservationToken observationToken)
implements Decoded {}
}
@@ -0,0 +1,164 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.util.Arrays;
import java.util.Base64;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicBoolean;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
/** Authenticated, bounded Pub/Sub hint that contains no raw semantic cache key. */
sealed interface RedisCacheInvalidationMessage {
String value();
static RedisCacheInvalidationMessage key(String localEntryIdentity) {
return new Key(localEntryIdentity);
}
static RedisCacheInvalidationMessage region(String generation) {
return new Region(generation);
}
record Key(String value) implements RedisCacheInvalidationMessage {
public Key {
value = boundedAscii(value, "localEntryIdentity", 1024);
}
}
record Region(String value) implements RedisCacheInvalidationMessage {
public Region {
if (value == null
|| value.length() < 16
|| value.length() > 64
|| !value.matches("[A-Za-z0-9_-]+")) {
throw new IllegalArgumentException(
"generation must contain 16..64 Base64URL-safe characters");
}
}
}
/**
* HMAC protects hints from cross-channel corruption; Redis ACL still owns publisher authority.
*/
final class Codec implements AutoCloseable {
private static final int MAXIMUM_WIRE_CHARACTERS = 4096;
private static final Base64.Encoder ENCODER = Base64.getUrlEncoder().withoutPadding();
private static final Base64.Decoder DECODER = Base64.getUrlDecoder();
private final byte[] secret;
private final AtomicBoolean destroyed = new AtomicBoolean();
Codec(byte[] secret) {
Objects.requireNonNull(secret, "secret must be non-null");
if (secret.length < 32) {
throw new IllegalArgumentException("message HMAC secret must contain at least 32 bytes");
}
this.secret = secret.clone();
}
static Codec fromOwnedSecret(byte[] ownedSecret) {
Objects.requireNonNull(ownedSecret, "ownedSecret must be non-null");
try {
return new Codec(ownedSecret);
} finally {
Arrays.fill(ownedSecret, (byte) 0);
}
}
synchronized String encode(RedisCacheInvalidationMessage message) {
ensureUsable();
Objects.requireNonNull(message, "message must be non-null");
String kind = message instanceof Key ? "K" : "R";
byte[] payload = (kind + "\n" + message.value()).getBytes(StandardCharsets.US_ASCII);
return "v1." + ENCODER.encodeToString(payload) + "." + ENCODER.encodeToString(hmac(payload));
}
synchronized Optional<RedisCacheInvalidationMessage> decode(String wire) {
ensureUsable();
if (wire == null || wire.length() < 8 || wire.length() > MAXIMUM_WIRE_CHARACTERS) {
return Optional.empty();
}
String[] components = wire.split("\\.", -1);
if (components.length != 3 || !"v1".equals(components[0])) {
return Optional.empty();
}
try {
byte[] payload = DECODER.decode(components[1]);
byte[] suppliedMac = DECODER.decode(components[2]);
if (!MessageDigest.isEqual(hmac(payload), suppliedMac)) {
return Optional.empty();
}
String decoded = new String(payload, StandardCharsets.US_ASCII);
int separator = decoded.indexOf('\n');
if (separator != 1 || separator != decoded.lastIndexOf('\n')) {
return Optional.empty();
}
String value = decoded.substring(separator + 1);
return switch (decoded.charAt(0)) {
case 'K' -> Optional.of(key(value));
case 'R' -> Optional.of(region(value));
default -> Optional.empty();
};
} catch (IllegalArgumentException exception) {
return Optional.empty();
}
}
private byte[] hmac(byte[] payload) {
byte[] secretCopy = secret.clone();
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secretCopy, "HmacSHA256"));
return mac.doFinal(payload);
} catch (GeneralSecurityException exception) {
throw new IllegalStateException("HmacSHA256 unavailable for invalidation hints", exception);
} finally {
Arrays.fill(secretCopy, (byte) 0);
}
}
@Override
public synchronized void close() {
if (destroyed.compareAndSet(false, true)) {
Arrays.fill(secret, (byte) 0);
}
}
synchronized boolean destroyed() {
if (!destroyed.get()) {
return false;
}
for (byte value : secret) {
if (value != 0) {
return false;
}
}
return true;
}
private void ensureUsable() {
if (destroyed.get()) {
throw new IllegalStateException("invalidation message codec is destroyed");
}
}
}
private static String boundedAscii(String value, String field, int maximumCharacters) {
if (value == null
|| value.isBlank()
|| value.length() > maximumCharacters
|| value.chars().anyMatch(character -> character < 0x21 || character > 0x7e)) {
throw new IllegalArgumentException(
field + " must contain bounded non-whitespace ASCII characters");
}
return value;
}
}
@@ -0,0 +1,64 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.util.Objects;
import java.util.concurrent.ArrayBlockingQueue;
/**
* Bounded handoff between a Redis Pub/Sub callback and cache request threads.
*
* <p>Pub/Sub has no replay. Disconnect or queue overflow therefore flushes L1 immediately and
* forces a generation read before local entries may be repopulated.
*/
final class RedisCacheInvalidationSubscriber {
interface Target {
void apply(RedisCacheInvalidationMessage message);
void disconnected();
void overflow();
void malformedMessage();
}
private final ArrayBlockingQueue<RedisCacheInvalidationMessage> hints;
private final Target target;
RedisCacheInvalidationSubscriber(int capacity, Target target) {
if (capacity < 1 || capacity > 65_536) {
throw new IllegalArgumentException("subscriber capacity must be in 1..65536");
}
this.hints = new ArrayBlockingQueue<>(capacity);
this.target = Objects.requireNonNull(target, "target must be non-null");
}
void onMessage(RedisCacheInvalidationMessage message) {
Objects.requireNonNull(message, "message must be non-null");
if (hints.offer(message)) {
return;
}
hints.clear();
target.overflow();
}
void onDisconnected() {
hints.clear();
target.disconnected();
}
void onMalformedMessage() {
target.malformedMessage();
}
void drain() {
RedisCacheInvalidationMessage hint;
while ((hint = hints.poll()) != null) {
target.apply(hint);
}
}
int queuedHintCount() {
return hints.size();
}
}
@@ -0,0 +1,63 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
/** Lifecycle wrapper that decodes canonical CACHE-role invalidation traffic. */
final class RedisCacheInvalidationSubscription implements AutoCloseable {
private final RedisInvalidationTransport.Subscription delegate;
private final RedisCacheInvalidationSubscriber subscriber;
private final AtomicBoolean closed = new AtomicBoolean();
private RedisCacheInvalidationSubscription(
RedisInvalidationTransport.Subscription delegate,
RedisCacheInvalidationSubscriber subscriber) {
this.delegate = Objects.requireNonNull(delegate, "delegate must be non-null");
this.subscriber = Objects.requireNonNull(subscriber, "subscriber must be non-null");
}
static RedisCacheInvalidationSubscription subscribe(
RedisInvalidationTransport transport,
String channel,
RedisCacheInvalidationMessage.Codec codec,
RedisCacheInvalidationSubscriber subscriber) {
Objects.requireNonNull(transport, "transport must be non-null");
Objects.requireNonNull(channel, "channel must be non-null");
Objects.requireNonNull(codec, "codec must be non-null");
Objects.requireNonNull(subscriber, "subscriber must be non-null");
RedisInvalidationTransport.Subscription delegate =
transport.subscribe(
channel.getBytes(StandardCharsets.US_ASCII),
new RedisInvalidationTransport.Listener() {
@Override
public void onMessage(byte[] wireMessage) {
if (wireMessage == null) {
subscriber.onMalformedMessage();
return;
}
codec
.decode(new String(wireMessage, StandardCharsets.US_ASCII))
.ifPresentOrElse(subscriber::onMessage, subscriber::onMalformedMessage);
}
@Override
public void onDisconnected() {
subscriber.onDisconnected();
}
});
return new RedisCacheInvalidationSubscription(delegate, subscriber);
}
@Override
public void close() {
if (closed.compareAndSet(false, true)) {
try {
delegate.close();
} finally {
subscriber.onDisconnected();
}
}
}
}
@@ -0,0 +1,18 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.application.cache.CacheRegionPort;
/**
* Internal cache-only L2 surface needed by the local decorator.
*
* <p>Session, idempotency, rate-limit and coordination providers do not implement this type and
* therefore cannot accidentally receive the fail-open local tier.
*/
interface RedisCacheL2Region extends CacheRegionPort<String, String> {
/** Stable HMAC-derived identity; never the raw semantic key. */
String localEntryIdentity(String key);
/** Current region generation used to recover from missed best-effort invalidation hints. */
String currentRegionGeneration();
}
@@ -0,0 +1,295 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyBuilder;
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyDigest;
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyNamespace;
import dev.caskeleton.application.cache.CacheRefreshClaimAttempt;
import dev.caskeleton.application.cache.CacheRefreshClaimOutcome;
import dev.caskeleton.application.cache.CacheRefreshCoordinationPort;
import dev.caskeleton.application.cache.CacheRefreshOperationToken;
import dev.caskeleton.application.cache.CacheRefreshOwnerToken;
import dev.caskeleton.application.cache.CacheRefreshReleaseOutcome;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.time.Duration;
import java.util.Arrays;
import java.util.Base64;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.LongSupplier;
import java.util.function.Supplier;
/**
* Redis-backed cache refresh admission lease.
*
* <p>The lease only suppresses duplicate refresh work. Cache generation/revision fences remain the
* correctness mechanism for invalidation races.
*/
final class RedisCacheRefreshCoordinator
implements CacheRefreshCoordinationPort<String>, AutoCloseable {
private static final SecureRandom RANDOM = new SecureRandom();
private final RedisKeyNamespace namespace;
private final byte[] hmacSecret;
private final RedisAtomicPrimitives primitives;
private final Supplier<String> tokens;
private final RedisCapabilityObserver observer;
private final AtomicBoolean destroyed = new AtomicBoolean();
RedisCacheRefreshCoordinator(
RedisKeyNamespace namespace, byte[] hmacSecret, RedisBinaryCommands commands) {
this(
namespace,
hmacSecret,
productionPrimitives(commands),
RedisCacheRefreshCoordinator::randomToken,
NoOpRedisCapabilityObservationPort.instance(),
System::nanoTime);
}
RedisCacheRefreshCoordinator(
RedisKeyNamespace namespace,
byte[] hmacSecret,
RedisBinaryCommands commands,
RedisCapabilityObservationPort observations,
LongSupplier ticker) {
this(
namespace,
hmacSecret,
productionPrimitives(commands),
RedisCacheRefreshCoordinator::randomToken,
observations,
ticker);
}
RedisCacheRefreshCoordinator(
RedisKeyNamespace namespace,
byte[] hmacSecret,
RedisAtomicPrimitives primitives,
Supplier<String> tokens) {
this(
namespace,
hmacSecret,
primitives,
tokens,
NoOpRedisCapabilityObservationPort.instance(),
System::nanoTime);
}
RedisCacheRefreshCoordinator(
RedisKeyNamespace namespace,
byte[] hmacSecret,
RedisAtomicPrimitives primitives,
Supplier<String> tokens,
RedisCapabilityObservationPort observations,
LongSupplier ticker) {
this.namespace = refreshNamespace(namespace);
Objects.requireNonNull(hmacSecret, "hmacSecret must be non-null");
if (hmacSecret.length < 32) {
throw new IllegalArgumentException("hmacSecret must contain at least 32 bytes");
}
this.hmacSecret = hmacSecret.clone();
this.primitives = Objects.requireNonNull(primitives, "primitives must be non-null");
this.tokens = Objects.requireNonNull(tokens, "tokens must be non-null");
this.observer = new RedisCapabilityObserver(observations, ticker);
}
@Override
public CacheRefreshClaimAttempt newAttempt() {
ensureUsable();
return new CacheRefreshClaimAttempt(
new CacheRefreshOwnerToken(nextToken()), new CacheRefreshOperationToken(nextToken()));
}
@Override
public CacheRefreshClaimOutcome claim(
String key, CacheRefreshClaimAttempt attempt, Duration leaseTimeToLive) {
return observer.observe(
RedisCapabilityObservationEvent.Capability.CACHE,
RedisCapabilityObservationEvent.Role.CACHE,
RedisCapabilityObservationEvent.Operation.REFRESH_CLAIM,
() -> claimOpen(key, attempt, leaseTimeToLive),
RedisCacheRefreshCoordinator::classifyClaim);
}
private CacheRefreshClaimOutcome claimOpen(
String key, CacheRefreshClaimAttempt attempt, Duration leaseTimeToLive) {
ensureUsable();
requireUsable(attempt);
try {
RedisAtomicPrimitives.RefreshClaimResult result =
primitives.claimRefreshLease(
physicalKey(key),
attempt.ownerToken().value(),
attempt.operationToken().value(),
leaseTimeToLive);
return switch (result) {
case CLAIMED -> new CacheRefreshClaimOutcome.Claimed(attempt);
case ALREADY_OWNED -> new CacheRefreshClaimOutcome.AlreadyOwned(attempt);
case CONTENDED -> new CacheRefreshClaimOutcome.Contended();
case WRONG_TYPE, INVALID ->
throw new RedisProgramCompatibilityException(
RedisProgramId.CACHE_REFRESH_CLAIM, result.name());
};
} catch (RedisCommandFailureException exception) {
return exception.certainty() == RedisCommandFailureException.Certainty.NOT_APPLIED
? new CacheRefreshClaimOutcome.Unavailable()
: new CacheRefreshClaimOutcome.Indeterminate();
}
}
@Override
public CacheRefreshReleaseOutcome release(String key, CacheRefreshClaimAttempt attempt) {
return observer.observe(
RedisCapabilityObservationEvent.Capability.CACHE,
RedisCapabilityObservationEvent.Role.CACHE,
RedisCapabilityObservationEvent.Operation.REFRESH_RELEASE,
() -> releaseOpen(key, attempt),
RedisCacheRefreshCoordinator::classifyRelease);
}
private CacheRefreshReleaseOutcome releaseOpen(String key, CacheRefreshClaimAttempt attempt) {
ensureUsable();
requireUsable(attempt);
try {
RedisAtomicPrimitives.CompareDeleteResult result =
primitives.compareAndDelete(physicalKey(key), ownerState(attempt));
return switch (result) {
case DELETED -> new CacheRefreshReleaseOutcome.Released();
case ABSENT -> new CacheRefreshReleaseOutcome.AlreadyReleased();
case NOT_OWNER -> new CacheRefreshReleaseOutcome.NotOwner();
case WRONG_TYPE, INVALID ->
throw new RedisProgramCompatibilityException(
RedisProgramId.COMPARE_AND_DELETE, result.name());
};
} catch (RedisCommandFailureException exception) {
return exception.certainty() == RedisCommandFailureException.Certainty.NOT_APPLIED
? new CacheRefreshReleaseOutcome.Unavailable()
: new CacheRefreshReleaseOutcome.Indeterminate();
}
}
private String physicalKey(String semanticKey) {
if (semanticKey == null || semanticKey.isBlank()) {
throw new IllegalArgumentException("semantic cache key must be non-blank");
}
RedisKeyDigest digest =
RedisKeyDigest.sensitive(
namespace.hashKeyVersion(),
hmacSecret,
List.of(semanticKey.getBytes(StandardCharsets.UTF_8)));
return RedisKeyBuilder.build(namespace, digest);
}
@Override
public void close() {
if (destroyed.compareAndSet(false, true)) {
Arrays.fill(hmacSecret, (byte) 0);
}
}
private void ensureUsable() {
if (destroyed.get()) {
throw new IllegalStateException("Redis cache refresh coordinator is destroyed");
}
}
private String nextToken() {
String token = Objects.requireNonNull(tokens.get(), "generated token must be non-null");
if (!token.matches("[A-Za-z0-9_-]{16,63}")) {
throw new IllegalArgumentException(
"generated token must contain 16..63 Base64URL-safe characters");
}
return token;
}
private static byte[] ownerState(CacheRefreshClaimAttempt attempt) {
return (attempt.ownerToken().value() + "|" + attempt.operationToken().value())
.getBytes(StandardCharsets.US_ASCII);
}
private static void requireUsable(CacheRefreshClaimAttempt attempt) {
Objects.requireNonNull(attempt, "attempt must be non-null");
if (!attempt.usable()) {
throw new IllegalArgumentException("Redis refresh coordination requires a usable attempt");
}
}
private static RedisKeyNamespace refreshNamespace(RedisKeyNamespace namespace) {
Objects.requireNonNull(namespace, "namespace must be non-null");
return new RedisKeyNamespace(
namespace.application(),
namespace.environment(),
namespace.capability(),
namespace.region(),
namespace.hashKeyVersion(),
namespace.keyVersion(),
"refresh-lease",
namespace.maximumKeyBytes());
}
private static RedisAtomicPrimitives productionPrimitives(RedisBinaryCommands commands) {
RedisProgramCatalog catalog = RedisProgramCatalog.foundation();
return new RedisAtomicPrimitives(catalog, new RedisLuaProgramExecutor(catalog, commands));
}
private static String randomToken() {
byte[] random = new byte[16];
RANDOM.nextBytes(random);
return Base64.getUrlEncoder().withoutPadding().encodeToString(random);
}
private static RedisCapabilityObserver.Classification classifyClaim(
CacheRefreshClaimOutcome outcome) {
if (outcome instanceof CacheRefreshClaimOutcome.Claimed
|| outcome instanceof CacheRefreshClaimOutcome.AlreadyOwned) {
return classification(
RedisCapabilityObservationEvent.Outcome.SUCCESS,
RedisCapabilityObservationEvent.Certainty.DEFINITE);
}
if (outcome instanceof CacheRefreshClaimOutcome.Contended) {
return classification(
RedisCapabilityObservationEvent.Outcome.CONTENDED,
RedisCapabilityObservationEvent.Certainty.DEFINITE);
}
if (outcome instanceof CacheRefreshClaimOutcome.Indeterminate) {
return classification(
RedisCapabilityObservationEvent.Outcome.INDETERMINATE,
RedisCapabilityObservationEvent.Certainty.INDETERMINATE);
}
return classification(
RedisCapabilityObservationEvent.Outcome.UNAVAILABLE,
RedisCapabilityObservationEvent.Certainty.NOT_APPLIED);
}
private static RedisCapabilityObserver.Classification classifyRelease(
CacheRefreshReleaseOutcome outcome) {
if (outcome instanceof CacheRefreshReleaseOutcome.Released
|| outcome instanceof CacheRefreshReleaseOutcome.AlreadyReleased) {
return classification(
RedisCapabilityObservationEvent.Outcome.SUCCESS,
RedisCapabilityObservationEvent.Certainty.DEFINITE);
}
if (outcome instanceof CacheRefreshReleaseOutcome.NotOwner) {
return classification(
RedisCapabilityObservationEvent.Outcome.CONFLICT,
RedisCapabilityObservationEvent.Certainty.DEFINITE);
}
if (outcome instanceof CacheRefreshReleaseOutcome.Indeterminate) {
return classification(
RedisCapabilityObservationEvent.Outcome.INDETERMINATE,
RedisCapabilityObservationEvent.Certainty.INDETERMINATE);
}
return classification(
RedisCapabilityObservationEvent.Outcome.UNAVAILABLE,
RedisCapabilityObservationEvent.Certainty.NOT_APPLIED);
}
private static RedisCapabilityObserver.Classification classification(
RedisCapabilityObservationEvent.Outcome outcome,
RedisCapabilityObservationEvent.Certainty certainty) {
return new RedisCapabilityObserver.Classification(outcome, certainty);
}
}
@@ -1,32 +1,94 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyNamespace;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.util.Arrays;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
/** Immutable key, TTL and envelope bounds for one semantic string cache region. */
final class RedisCacheRegionPolicy {
final class RedisCacheRegionPolicy implements AutoCloseable {
private static final Duration MAXIMUM_TTL = Duration.ofDays(30);
private static final Duration COMPATIBILITY_MINIMUM_HARD_TTL = Duration.ofMillis(1);
private final RedisKeyNamespace namespace;
private final byte[] hmacSecret;
private final Duration positiveTtl;
private final String policyRevision;
private final Duration positiveSoftTtl;
private final Duration positiveHardTtl;
private final Duration negativeTtl;
private final double jitterRatio;
private final Duration minimumHardTtl;
private final int maximumValueBytes;
private final AtomicBoolean destroyed = new AtomicBoolean();
/**
* Compatibility constructor for the existing single-positive-TTL settings contract.
*
* <p>It deliberately disables stale serving and jitter. New region bindings should use the full
* constructor so the effective policy revision and soft/hard bounds are explicit.
*/
RedisCacheRegionPolicy(
RedisKeyNamespace namespace,
byte[] hmacSecret,
Duration positiveTtl,
Duration negativeTtl,
int maximumValueBytes) {
this(
namespace,
hmacSecret,
"single-ttl-compatibility-r1",
positiveTtl,
positiveTtl,
negativeTtl,
0.0,
COMPATIBILITY_MINIMUM_HARD_TTL,
maximumValueBytes);
}
RedisCacheRegionPolicy(
RedisKeyNamespace namespace,
byte[] hmacSecret,
String policyRevision,
Duration positiveSoftTtl,
Duration positiveHardTtl,
Duration negativeTtl,
double jitterRatio,
Duration minimumHardTtl,
int maximumValueBytes) {
this.namespace = Objects.requireNonNull(namespace, "namespace must be non-null");
Objects.requireNonNull(hmacSecret, "hmacSecret must be non-null");
if (hmacSecret.length < 32) {
throw new IllegalArgumentException("hmacSecret must contain at least 32 bytes");
}
this.hmacSecret = hmacSecret.clone();
this.positiveTtl = positive(positiveTtl, "positiveTtl");
this.policyRevision = policyRevision(policyRevision);
this.positiveSoftTtl = positive(positiveSoftTtl, "positiveSoftTtl");
this.positiveHardTtl = positive(positiveHardTtl, "positiveHardTtl");
this.negativeTtl = positive(negativeTtl, "negativeTtl");
if (this.positiveSoftTtl.compareTo(this.positiveHardTtl) > 0) {
throw new IllegalArgumentException("positive soft TTL must not exceed positive hard TTL");
}
if (!Double.isFinite(jitterRatio) || jitterRatio < 0.0 || jitterRatio > 0.5) {
throw new IllegalArgumentException("jitter ratio must be finite and in 0.0..0.5");
}
this.jitterRatio = jitterRatio;
if (scale(this.positiveHardTtl, 1.0 + jitterRatio).compareTo(MAXIMUM_TTL) > 0
|| scale(this.negativeTtl, 1.0 + jitterRatio).compareTo(MAXIMUM_TTL) > 0) {
throw new IllegalArgumentException(
"configured hard TTL plus positive jitter must not exceed 30 days");
}
this.minimumHardTtl = positive(minimumHardTtl, "minimumHardTtl");
if (this.minimumHardTtl.compareTo(this.positiveHardTtl) > 0
|| this.minimumHardTtl.compareTo(this.negativeTtl) > 0) {
throw new IllegalArgumentException(
"minimum hard TTL must not exceed positive hard TTL or negative TTL");
}
if (maximumValueBytes < 1 || maximumValueBytes > 16_777_216) {
throw new IllegalArgumentException("maximumValueBytes must be in 1..16777216");
}
@@ -38,26 +100,151 @@ final class RedisCacheRegionPolicy {
}
byte[] hmacSecret() {
ensureUsable();
return hmacSecret.clone();
}
String policyRevision() {
return policyRevision;
}
Duration positiveSoftTtl() {
return positiveSoftTtl;
}
Duration positiveHardTtl() {
return positiveHardTtl;
}
/** Existing accessor retained while single-TTL runtime settings migrate to the full policy. */
Duration positiveTtl() {
return positiveTtl;
return positiveHardTtl;
}
Duration negativeTtl() {
return negativeTtl;
}
Duration maximumEntryTimeToLive() {
Duration maximumConfigured =
positiveHardTtl.compareTo(negativeTtl) >= 0 ? positiveHardTtl : negativeTtl;
return scale(maximumConfigured, 1.0 + jitterRatio);
}
int maximumValueBytes() {
return maximumValueBytes;
}
@Override
public void close() {
if (destroyed.compareAndSet(false, true)) {
Arrays.fill(hmacSecret, (byte) 0);
}
}
PositiveExpiry positiveExpiry(byte[] hmacDerivedPhysicalKey) {
double factor = effectiveFactor(hmacDerivedPhysicalKey, "positive", positiveHardTtl);
Duration soft = scale(positiveSoftTtl, factor);
Duration hard = scale(positiveHardTtl, factor);
if (hard.compareTo(minimumHardTtl) < 0) {
hard = minimumHardTtl;
}
if (soft.compareTo(hard) > 0) {
soft = hard;
}
return new PositiveExpiry(soft, hard);
}
Duration negativeTimeToLive(byte[] hmacDerivedPhysicalKey) {
double factor = effectiveFactor(hmacDerivedPhysicalKey, "negative", negativeTtl);
Duration actual = scale(negativeTtl, factor);
return actual.compareTo(minimumHardTtl) < 0 ? minimumHardTtl : actual;
}
private double effectiveFactor(
byte[] hmacDerivedPhysicalKey, String expiryKind, Duration configuredHardTtl) {
Objects.requireNonNull(hmacDerivedPhysicalKey, "hmacDerivedPhysicalKey must be non-null");
if (hmacDerivedPhysicalKey.length == 0) {
throw new IllegalArgumentException("hmacDerivedPhysicalKey must not be empty");
}
double sampledFactor =
1.0 + (jitterRatio * symmetricSample(hmacDerivedPhysicalKey, expiryKind));
double minimumFactor =
((double) minimumHardTtl.toMillis()) / Math.max(1L, configuredHardTtl.toMillis());
return Math.max(sampledFactor, minimumFactor);
}
private double symmetricSample(byte[] hmacDerivedPhysicalKey, String expiryKind) {
byte[] kind = expiryKind.getBytes(StandardCharsets.UTF_8);
byte[] revision = policyRevision.getBytes(StandardCharsets.UTF_8);
ByteBuffer canonical =
ByteBuffer.allocate(
Integer.BYTES
+ hmacDerivedPhysicalKey.length
+ Integer.BYTES
+ revision.length
+ Integer.BYTES
+ kind.length);
canonical
.putInt(hmacDerivedPhysicalKey.length)
.put(hmacDerivedPhysicalKey)
.putInt(revision.length)
.put(revision)
.putInt(kind.length)
.put(kind);
long sampleBits = ByteBuffer.wrap(sha256(canonical.array())).getLong() >>> 11;
double unitInterval = sampleBits * 0x1.0p-53;
return (unitInterval * 2.0) - 1.0;
}
private static Duration scale(Duration configured, double factor) {
long configuredMillis = Math.max(1L, configured.toMillis());
long actualMillis = Math.max(1L, Math.round(configuredMillis * factor));
return Duration.ofMillis(actualMillis);
}
private static Duration positive(Duration value, String field) {
Objects.requireNonNull(value, field + " must be non-null");
if (value.isZero() || value.isNegative() || value.compareTo(Duration.ofDays(30)) > 0) {
if (value.isZero() || value.isNegative() || value.compareTo(MAXIMUM_TTL) > 0) {
throw new IllegalArgumentException(field + " must be positive and at most 30 days");
}
return value;
}
private static String policyRevision(String value) {
Objects.requireNonNull(value, "policyRevision must be non-null");
if (value.isBlank() || value.length() > 128) {
throw new IllegalArgumentException("policyRevision must contain 1..128 characters");
}
return value;
}
private static byte[] sha256(byte[] content) {
try {
return MessageDigest.getInstance("SHA-256").digest(content);
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 unavailable for cache TTL jitter", exception);
}
}
private void ensureUsable() {
if (destroyed.get()) {
throw new IllegalStateException("Redis cache region policy is destroyed");
}
}
record PositiveExpiry(Duration softTtl, Duration hardTtl) {
PositiveExpiry {
Objects.requireNonNull(softTtl, "softTtl must be non-null");
Objects.requireNonNull(hardTtl, "hardTtl must be non-null");
if (softTtl.isZero()
|| softTtl.isNegative()
|| hardTtl.isZero()
|| hardTtl.isNegative()
|| softTtl.compareTo(hardTtl) > 0) {
throw new IllegalArgumentException("positive expiry requires 0 < softTtl <= hardTtl");
}
}
}
}
@@ -0,0 +1,111 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.application.cache.AuthoritativeAbsence;
import dev.caskeleton.application.cache.CacheInvalidationOutcome;
import dev.caskeleton.application.cache.CacheLookup;
import dev.caskeleton.application.cache.CacheRecordMetadata;
import dev.caskeleton.application.cache.CacheRecordOutcome;
import dev.caskeleton.application.cache.CacheRegionPort;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicBoolean;
/** Lifecycle-owning composition of the Redis L2 and its optional cache-only local decorator. */
final class RedisCacheRegionRuntime implements CacheRegionPort<String, String>, AutoCloseable {
private final CacheRegionPort<String, String> delegate;
private final RedisCacheL2Region l2;
private final RedisLocalCacheRegion local;
private final AtomicBoolean closed = new AtomicBoolean();
private RedisCacheRegionRuntime(
CacheRegionPort<String, String> delegate,
RedisCacheL2Region l2,
RedisLocalCacheRegion local) {
this.delegate = Objects.requireNonNull(delegate, "delegate must be non-null");
this.l2 = Objects.requireNonNull(l2, "l2 must be non-null");
this.local = local;
}
static RedisCacheRegionRuntime l2Only(RedisCacheL2Region l2) {
return new RedisCacheRegionRuntime(l2, l2, null);
}
static RedisCacheRegionRuntime local(RedisCacheL2Region l2, RedisLocalCacheRegion local) {
return new RedisCacheRegionRuntime(
Objects.requireNonNull(local, "local must be non-null"), l2, local);
}
Optional<RedisLocalCacheRegion> local() {
return Optional.ofNullable(local);
}
@Override
public CacheLookup<String> lookup(String key) {
ensureOpen();
return delegate.lookup(key);
}
@Override
public CacheRecordOutcome record(String key, String value, CacheRecordMetadata metadata) {
ensureOpen();
return delegate.record(key, value, metadata);
}
@Override
public CacheRecordOutcome recordAbsent(
String key, AuthoritativeAbsence reason, CacheRecordMetadata metadata) {
ensureOpen();
return delegate.recordAbsent(key, reason, metadata);
}
@Override
public CacheInvalidationOutcome invalidate(String key) {
ensureOpen();
return delegate.invalidate(key);
}
@Override
public CacheInvalidationOutcome invalidateRegion() {
ensureOpen();
return delegate.invalidateRegion();
}
@Override
public void close() {
if (closed.compareAndSet(false, true)) {
RuntimeException failure = null;
try {
if (local != null) {
local.close();
}
} catch (RuntimeException exception) {
failure = exception;
}
if (l2 instanceof AutoCloseable closeable) {
try {
closeable.close();
} catch (Exception exception) {
RuntimeException closeFailure =
exception instanceof RuntimeException runtimeException
? runtimeException
: new IllegalStateException("Redis cache L2 close failed", exception);
if (failure == null) {
failure = closeFailure;
} else {
failure.addSuppressed(closeFailure);
}
}
}
if (failure != null) {
throw failure;
}
}
}
private void ensureOpen() {
if (closed.get()) {
throw new IllegalStateException("Redis cache region runtime is closed");
}
}
}
@@ -0,0 +1,24 @@
package dev.caskeleton.adapter.outbound.cache.redis;
/** Rejects ambiguous canonical/legacy activation and unapproved legacy production primaries. */
final class RedisCanonicalActivationValidator {
private RedisCanonicalActivationValidator() {}
static void validate(
boolean canonicalActive,
boolean legacyMigrationEnabled,
boolean legacyCacheEnabled,
boolean legacyRateLimitEnabled) {
boolean legacyActive = legacyCacheEnabled || legacyRateLimitEnabled;
if (canonicalActive && legacyActive) {
throw new IllegalStateException(
"Canonical and legacy Redis configuration cannot be active simultaneously; no precedence"
+ " is defined");
}
if (legacyActive && !legacyMigrationEnabled) {
throw new IllegalStateException(
"Legacy standalone Redis activation requires explicit migration input mode");
}
}
}
@@ -0,0 +1,159 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.redis.config.RedisProviderSettings;
import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole;
import dev.caskeleton.adapter.outbound.cache.redis.security.RedisCredentialMaterialProvider;
import dev.caskeleton.application.cache.CacheObservationPort;
import dev.caskeleton.application.cache.DisabledCacheObservationPort;
import io.micrometer.core.instrument.MeterRegistry;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Duration;
import java.util.Arrays;
import java.util.Set;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/** Canonical default-region cache composition, isolated to the physical Redis CACHE role. */
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties({RedisCanonicalCacheSettings.class, RedisProviderSettings.class})
@ConditionalOnProperty(
name = "ca-skeleton.capabilities.cache.bindings.default",
havingValue = "redis",
matchIfMissing = false)
public class RedisCanonicalCacheConfig {
private static final int MAXIMUM_COMMAND_OVERHEAD_BYTES = 4096;
@Bean(name = "redisCanonicalDefaultCacheRegion", destroyMethod = "close")
@ConditionalOnProperty(
name = "ca-skeleton.capabilities.cache.bindings.default",
havingValue = "redis",
matchIfMissing = false)
RedisCacheRegionRuntime redisCanonicalDefaultCacheRegion(
RedisCanonicalCacheSettings settings,
RedisProviderSettings providerProperties,
RedisCanonicalRoleRegistry roleRegistry,
RedisCredentialMaterialProvider credentialProvider,
ObjectProvider<Clock> clockProvider,
ObjectProvider<MeterRegistry> meterRegistryProvider,
ObjectProvider<RedisCapabilityObservationPort> capabilityObservationsProvider) {
settings.validateActive();
validateCommandBound(settings, providerProperties.runtime());
Clock clock = clockProvider.getIfAvailable(Clock::systemUTC);
RedisCapabilityObservationPort capabilityObservations =
capabilityObservationsProvider.getIfUnique(NoOpRedisCapabilityObservationPort::instance);
RedisRoleCommandRouter router = roleRegistry.router(RedisRole.CACHE);
byte[] hmacSecret =
RedisHmacMaterialResolver.resolve(
settings.keyHmacSecretReference(), credentialProvider, clock, "cache");
RedisCacheRegionPolicy policy = null;
RedisStringCacheRegion l2 = null;
RedisCacheInvalidationMessage.Codec codec = null;
try {
policy =
new RedisCacheRegionPolicy(
settings.namespace(),
hmacSecret,
settings.policyRevision(),
settings.positiveSoftTtl(),
settings.positiveHardTtl(),
settings.negativeTtl(),
settings.ttlJitter(),
minimumHardTtl(settings),
settings.maximumValueBytes());
l2 =
new RedisStringCacheRegion(
policy, router, clock, capabilityObservations, System::nanoTime);
policy = null;
if (!settings.l1().enabled()) {
RedisCacheRegionRuntime runtime = RedisCacheRegionRuntime.l2Only(l2);
l2 = null;
return runtime;
}
MeterRegistry meterRegistry = meterRegistryProvider.getIfAvailable();
CacheObservationPort observations =
meterRegistry == null
? DisabledCacheObservationPort.instance()
: new MicrometerCacheObservationPort(
meterRegistry, Set.of(settings.semanticRegion()));
String channel = l2.invalidationChannel();
codec = new RedisCacheInvalidationMessage.Codec(hmacSecret);
RedisLocalCacheRegion local =
new RedisLocalCacheRegion(
settings.semanticRegion(),
l2,
settings.l1().policy(),
clock,
observations,
channel,
codec,
message ->
router.publish(
channel.getBytes(StandardCharsets.US_ASCII),
message.getBytes(StandardCharsets.US_ASCII)));
RedisCacheRegionRuntime runtime = RedisCacheRegionRuntime.local(l2, local);
l2 = null;
codec = null;
return runtime;
} finally {
Arrays.fill(hmacSecret, (byte) 0);
if (codec != null) {
codec.close();
}
if (l2 != null) {
l2.close();
}
if (policy != null) {
policy.close();
}
}
}
@Bean(name = "redisCanonicalDefaultCacheInvalidationSubscription", destroyMethod = "close")
@ConditionalOnProperty(
name = "ca-skeleton.capabilities.cache.regions.default.l1.enabled",
havingValue = "true",
matchIfMissing = false)
RedisCacheInvalidationSubscription redisCanonicalDefaultCacheInvalidationSubscription(
RedisCanonicalRoleRegistry roleRegistry,
@Qualifier("redisCanonicalDefaultCacheRegion") RedisCacheRegionRuntime cacheRegion) {
RedisLocalCacheRegion local =
cacheRegion
.local()
.orElseThrow(
() ->
new IllegalStateException(
"Canonical Redis L1 subscription requires the cache-only local decorator"));
return RedisCacheInvalidationSubscription.subscribe(
roleRegistry.router(RedisRole.CACHE),
local.invalidationChannel(),
local.invalidationMessageCodec(),
local.invalidationSubscriber());
}
private static void validateCommandBound(
RedisCanonicalCacheSettings settings, RedisProviderSettings.RuntimeProperties runtime) {
long required = (long) settings.maximumValueBytes() + MAXIMUM_COMMAND_OVERHEAD_BYTES;
if (required > runtime.maximumCommandBytes()) {
throw new IllegalArgumentException(
"Canonical Redis cache maximum value bytes exceed the CACHE router command bound");
}
}
private static Duration minimumHardTtl(RedisCanonicalCacheSettings settings) {
Duration minimum = Duration.ofSeconds(1);
if (minimum.compareTo(settings.positiveHardTtl()) > 0
|| minimum.compareTo(settings.negativeTtl()) > 0) {
return settings.positiveHardTtl().compareTo(settings.negativeTtl()) <= 0
? settings.positiveHardTtl()
: settings.negativeTtl();
}
return minimum;
}
}
@@ -0,0 +1,166 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyNamespace;
import dev.caskeleton.adapter.outbound.cache.redis.security.RedisSecretReference;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.ConstructorBinding;
/**
* Canonical policy for the skeleton's default semantic Redis cache region.
*
* <p>Provider connection and authentication settings intentionally do not exist here. The CACHE
* role binding owns the topology router, while this capability policy owns only semantic cache
* behavior and a reference to HMAC key material.
*/
@ConfigurationProperties(prefix = "ca-skeleton.capabilities.cache.regions.default")
public record RedisCanonicalCacheSettings(
String keyHmacSecretReference,
String namespaceApplication,
String namespaceEnvironment,
String semanticRegion,
int hashKeyVersion,
int keyVersion,
String policyRevision,
Duration positiveSoftTtl,
Duration positiveHardTtl,
Duration negativeTtl,
Double ttlJitter,
int maximumValueBytes,
LocalProperties l1) {
private static final Duration MAXIMUM_TTL = Duration.ofDays(30);
@ConstructorBinding
public RedisCanonicalCacheSettings {
keyHmacSecretReference = keyHmacSecretReference == null ? "" : keyHmacSecretReference.trim();
namespaceApplication = defaultText(namespaceApplication, "ca-skeleton");
namespaceEnvironment = defaultText(namespaceEnvironment, "local");
semanticRegion = defaultText(semanticRegion, "default");
hashKeyVersion = hashKeyVersion == 0 ? 1 : hashKeyVersion;
keyVersion = keyVersion == 0 ? 1 : keyVersion;
policyRevision = defaultText(policyRevision, "canonical-default-r1");
positiveHardTtl =
positive(positiveHardTtl, Duration.ofMinutes(5), MAXIMUM_TTL, "positiveHardTtl");
positiveSoftTtl =
positive(
positiveSoftTtl,
positiveHardTtl.multipliedBy(4).dividedBy(5),
MAXIMUM_TTL,
"positiveSoftTtl");
negativeTtl = positive(negativeTtl, Duration.ofMinutes(1), MAXIMUM_TTL, "negativeTtl");
ttlJitter = ttlJitter == null ? 0.10d : ttlJitter;
maximumValueBytes = maximumValueBytes == 0 ? 61_440 : maximumValueBytes;
l1 = l1 == null ? LocalProperties.defaults() : l1;
if (positiveSoftTtl.compareTo(positiveHardTtl) > 0) {
throw new IllegalArgumentException("positiveSoftTtl must not exceed positiveHardTtl");
}
if (!Double.isFinite(ttlJitter) || ttlJitter < 0.0d || ttlJitter > 0.5d) {
throw new IllegalArgumentException("ttlJitter must be in 0.0..0.5");
}
if (policyRevision.length() > 128 || policyRevision.chars().anyMatch(Character::isISOControl)) {
throw new IllegalArgumentException("policyRevision must contain 1..128 safe characters");
}
if (maximumValueBytes < 1 || maximumValueBytes > 16_777_216) {
throw new IllegalArgumentException("maximumValueBytes must be in 1..16777216");
}
// Centralizes slug and key-version validation without retaining a duplicate rule set.
new RedisKeyNamespace(
namespaceApplication,
namespaceEnvironment,
"cache",
semanticRegion,
hashKeyVersion,
keyVersion,
"entry",
512);
}
void validateActive() {
RedisSecretReference.parse(keyHmacSecretReference);
}
RedisKeyNamespace namespace() {
return new RedisKeyNamespace(
namespaceApplication,
namespaceEnvironment,
"cache",
semanticRegion,
hashKeyVersion,
keyVersion,
"entry",
512);
}
public record LocalProperties(
boolean enabled,
int maximumEntries,
long maximumWeightBytes,
long maximumEntryWeightBytes,
Duration timeToLive,
Duration generationRecheckInterval,
int invalidationQueueCapacity) {
@ConstructorBinding
public LocalProperties {
maximumEntries = maximumEntries == 0 ? 10_000 : maximumEntries;
maximumWeightBytes = maximumWeightBytes == 0 ? 67_108_864L : maximumWeightBytes;
maximumEntryWeightBytes = maximumEntryWeightBytes == 0 ? 1_048_576L : maximumEntryWeightBytes;
timeToLive = timeToLive == null ? Duration.ofSeconds(30) : timeToLive;
generationRecheckInterval =
generationRecheckInterval == null ? Duration.ofSeconds(5) : generationRecheckInterval;
invalidationQueueCapacity = invalidationQueueCapacity == 0 ? 1024 : invalidationQueueCapacity;
policy(
maximumEntries,
maximumWeightBytes,
maximumEntryWeightBytes,
timeToLive,
generationRecheckInterval,
invalidationQueueCapacity);
}
RedisLocalCachePolicy policy() {
return policy(
maximumEntries,
maximumWeightBytes,
maximumEntryWeightBytes,
timeToLive,
generationRecheckInterval,
invalidationQueueCapacity);
}
private static LocalProperties defaults() {
return new LocalProperties(false, 0, 0, 0, null, null, 0);
}
private static RedisLocalCachePolicy policy(
int maximumEntries,
long maximumWeightBytes,
long maximumEntryWeightBytes,
Duration timeToLive,
Duration generationRecheckInterval,
int invalidationQueueCapacity) {
return new RedisLocalCachePolicy(
maximumEntries,
maximumWeightBytes,
maximumEntryWeightBytes,
timeToLive,
generationRecheckInterval,
invalidationQueueCapacity);
}
}
private static Duration positive(
Duration value, Duration fallback, Duration maximum, String field) {
Duration actual = value == null ? fallback : value;
if (actual.isZero() || actual.isNegative() || actual.compareTo(maximum) > 0) {
throw new IllegalArgumentException(field + " must be positive and bounded");
}
return actual;
}
private static String defaultText(String value, String fallback) {
return value == null || value.isBlank() ? fallback : value.trim();
}
}
@@ -0,0 +1,181 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings;
import dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettingsFactory;
import dev.caskeleton.adapter.outbound.cache.redis.config.RedisProviderSettings;
import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole;
import dev.caskeleton.adapter.outbound.cache.redis.security.RedisCredentialMaterialProvider;
import dev.caskeleton.adapter.outbound.cache.redis.security.RedisTrustMaterialProvider;
import dev.caskeleton.shared.health.RedisHealthSnapshotProvider;
import io.micrometer.core.instrument.MeterRegistry;
import java.time.Clock;
import java.util.EnumMap;
import java.util.EnumSet;
import java.util.Map;
import java.util.Set;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
/**
* Canonical Redis composition root.
*
* <p>Provider definitions alone are inert. Only an explicit role binding resolves material and
* opens a topology-native client.
*/
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(RedisProviderSettings.class)
public class RedisCanonicalConfig {
@Bean
RedisCapabilityObservationPort redisCapabilityObservationPort(
ObjectProvider<MeterRegistry> meterRegistryProvider) {
MeterRegistry registry = meterRegistryProvider.getIfAvailable();
RedisCapabilityObservationPort delegate =
registry == null
? NoOpRedisCapabilityObservationPort.instance()
: new MicrometerRedisCapabilityObservationPort(registry);
return new SafeRedisCapabilityObservationPort(delegate);
}
@Bean(name = "redisCanonicalRoleRegistry", destroyMethod = "close")
RedisCanonicalRoleRegistry redisCanonicalRoleRegistry(
RedisProviderSettings properties,
Environment environment,
ObjectProvider<RedisCredentialMaterialProvider> credentialProvider,
ObjectProvider<RedisTrustMaterialProvider> trustProvider,
ObjectProvider<Clock> clockProvider,
ObjectProvider<RedisRuntimeConnector> connectorProvider,
ObjectProvider<RedisSentinelRuntimeConnector> sentinelConnectorProvider,
RedisCapabilityObservationPort observations) {
Map<RedisRole, Set<RedisHealthSnapshotProvider.Capability>> selectedCapabilities =
selectedCapabilities(environment);
Map<dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole, RedisDeploymentSettings>
active =
new RedisDeploymentSettingsFactory()
.compileActive(properties, selectedRoles(selectedCapabilities));
RedisCanonicalActivationValidator.validate(
!active.isEmpty(),
properties.legacyMigrationEnabled(),
environment.getProperty("app.cache.redis.enabled", Boolean.class, false),
environment.getProperty("app.rate-limit.legacy-standalone-enabled", Boolean.class, false));
RedisProviderSettings.RuntimeProperties runtime = properties.runtime();
Clock clock = clockProvider.getIfAvailable(Clock::systemUTC);
RedisRuntimeConnector connector =
connectorProvider.getIfAvailable(
() ->
deployment ->
connect(
deployment,
runtime,
requiredUnique(credentialProvider, "Redis credential material provider"),
requiredUnique(trustProvider, "Redis trust material provider"),
clock));
RedisSentinelRuntimeConnector sentinelConnector =
active.values().stream().anyMatch(RedisDeploymentSettings.Sentinel.class::isInstance)
? sentinelConnectorProvider.getIfAvailable(
() ->
new DefaultRedisSentinelRuntimeConnector(
runtime.clientSettings(),
runtime.maximumCommandBytes(),
requiredUnique(credentialProvider, "Redis credential material provider"),
requiredUnique(trustProvider, "Redis trust material provider"),
clock))
: null;
return new RedisCanonicalRoleRegistry(
active,
runtime.clientSettings(),
runtime.maximumInFlightCommands(),
runtime.maximumCommandBytes(),
runtime.maximumInFlightBytes(),
runtime.routeDrainTimeout(),
runtime.defaultWriteTtl(),
connector::connect,
properties.roles(),
selectedCapabilities,
clock,
runtime.semanticProbeMinimumInterval(),
runtime.semanticProbeMaximumStaleness(),
System::nanoTime,
observations,
sentinelConnector,
runtime.sentinelDiscoveryRefreshPeriod(),
BoundedRedisSentinelRefreshWorker::new);
}
private static RedisRoutableCommandRuntime connect(
RedisDeploymentSettings deployment,
RedisProviderSettings.RuntimeProperties runtime,
RedisCredentialMaterialProvider credentialProvider,
RedisTrustMaterialProvider trustProvider,
Clock clock) {
return RedisTopologyCommandRuntime.connect(
deployment,
runtime.clientSettings(),
runtime.maximumCommandBytes(),
credentialProvider,
trustProvider,
clock);
}
private static <T> T requiredUnique(ObjectProvider<T> provider, String capability) {
T instance = provider.getIfUnique();
if (instance == null) {
throw new IllegalStateException(
capability + " must have exactly one bean for a canonically bound Redis role");
}
return instance;
}
public static Map<RedisRole, Set<RedisHealthSnapshotProvider.Capability>> selectedCapabilities(
Environment environment) {
Map<RedisRole, Set<RedisHealthSnapshotProvider.Capability>> selected =
new EnumMap<>(RedisRole.class);
EnumSet<RedisHealthSnapshotProvider.Capability> cache =
EnumSet.noneOf(RedisHealthSnapshotProvider.Capability.class);
if (selected(environment, "ca-skeleton.capabilities.cache.bindings.default", "redis")) {
cache.add(RedisHealthSnapshotProvider.Capability.CACHE);
}
selected.put(RedisRole.CACHE, Set.copyOf(cache));
EnumSet<RedisHealthSnapshotProvider.Capability> coordination =
EnumSet.noneOf(RedisHealthSnapshotProvider.Capability.class);
if (selected(environment, "ca-skeleton.capabilities.rate-limit.provider", "redis")) {
coordination.add(RedisHealthSnapshotProvider.Capability.RATE_LIMIT);
}
if (selected(environment, "ca-skeleton.capabilities.idempotency.provider", "redis")) {
coordination.add(RedisHealthSnapshotProvider.Capability.IDEMPOTENCY);
}
if (selected(environment, "ca-skeleton.capabilities.lease.provider", "redis")) {
coordination.add(RedisHealthSnapshotProvider.Capability.EFFICIENCY_LEASE);
}
selected.put(RedisRole.COORDINATION, Set.copyOf(coordination));
EnumSet<RedisHealthSnapshotProvider.Capability> session =
EnumSet.noneOf(RedisHealthSnapshotProvider.Capability.class);
if (selected(environment, "ca-skeleton.security.auth-mode", "redis-session")) {
session.add(RedisHealthSnapshotProvider.Capability.SESSION);
}
selected.put(RedisRole.SESSION, Set.copyOf(session));
return Map.copyOf(selected);
}
private static Set<RedisRole> selectedRoles(
Map<RedisRole, Set<RedisHealthSnapshotProvider.Capability>> capabilities) {
EnumSet<RedisRole> roles = EnumSet.noneOf(RedisRole.class);
capabilities.forEach(
(role, selectedCapabilities) -> {
if (!selectedCapabilities.isEmpty()) {
roles.add(role);
}
});
return Set.copyOf(roles);
}
private static boolean selected(Environment environment, String property, String expected) {
return expected.equalsIgnoreCase(environment.getProperty(property, ""));
}
}
@@ -0,0 +1,758 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings;
import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole;
import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRoleBinding;
import dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings;
import dev.caskeleton.shared.health.RedisHealthSnapshotProvider;
import java.time.Clock;
import java.time.Duration;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.LongSupplier;
/** Owns exactly the command routers selected by canonical Redis role bindings. */
final class RedisCanonicalRoleRegistry implements AutoCloseable, RedisHealthSnapshotProvider {
private static final Duration SENTINEL_CLEANUP_COMPLETION_MARGIN = Duration.ofMillis(100);
@FunctionalInterface
interface RuntimeFactory {
RedisRoutableCommandRuntime connect(RedisDeploymentSettings deployment);
}
private final Map<RedisRole, RedisRoleCommandRouter> routers;
private final Map<RedisRole, RedisSemanticProbeObservationCache> observations;
private final Map<RedisRole, RedisRoleBinding> bindings;
private final Map<RedisRole, Set<Capability>> capabilities;
private final Map<RedisRole, RedisSemanticProbePlan> probePlans;
private final Map<RedisRole, RecoveryState> recoveries;
private final RedisSemanticReadinessProbe semanticProbe;
private final RuntimeFactory runtimeFactory;
private final Clock clock;
private final Duration probeTimeout;
private final Duration drainTimeout;
private final int maximumInFlight;
private final int maximumCommandBytes;
private final long maximumInFlightBytes;
private final Duration defaultWriteTtl;
private final LongSupplier ticker;
private final RedisCapabilityObservationPort observationsPort;
private final RedisSentinelFailoverCoordinator failoverCoordinator;
private final AtomicBoolean closed = new AtomicBoolean();
RedisCanonicalRoleRegistry(
Map<RedisRole, RedisDeploymentSettings> activeDeployments,
RedisClientRuntimeSettings clientSettings,
int maximumInFlight,
int maximumCommandBytes,
long maximumInFlightBytes,
Duration drainTimeout,
Duration defaultWriteTtl,
RuntimeFactory runtimeFactory) {
this(
activeDeployments,
clientSettings,
maximumInFlight,
maximumCommandBytes,
maximumInFlightBytes,
drainTimeout,
defaultWriteTtl,
runtimeFactory,
Map.of(),
Map.of(),
Clock.systemUTC());
}
RedisCanonicalRoleRegistry(
Map<RedisRole, RedisDeploymentSettings> activeDeployments,
RedisClientRuntimeSettings clientSettings,
int maximumInFlight,
int maximumCommandBytes,
long maximumInFlightBytes,
Duration drainTimeout,
Duration defaultWriteTtl,
RuntimeFactory runtimeFactory,
Map<RedisRole, RedisRoleBinding> bindings,
Map<RedisRole, Set<Capability>> capabilities,
Clock clock) {
this(
activeDeployments,
clientSettings,
maximumInFlight,
maximumCommandBytes,
maximumInFlightBytes,
drainTimeout,
defaultWriteTtl,
runtimeFactory,
bindings,
capabilities,
clock,
Duration.ofSeconds(5),
Duration.ofSeconds(15),
System::nanoTime,
NoOpRedisCapabilityObservationPort.instance());
}
RedisCanonicalRoleRegistry(
Map<RedisRole, RedisDeploymentSettings> activeDeployments,
RedisClientRuntimeSettings clientSettings,
int maximumInFlight,
int maximumCommandBytes,
long maximumInFlightBytes,
Duration drainTimeout,
Duration defaultWriteTtl,
RuntimeFactory runtimeFactory,
Map<RedisRole, RedisRoleBinding> bindings,
Map<RedisRole, Set<Capability>> capabilities,
Clock clock,
Duration semanticProbeMinimumInterval,
Duration semanticProbeMaximumStaleness,
LongSupplier ticker) {
this(
activeDeployments,
clientSettings,
maximumInFlight,
maximumCommandBytes,
maximumInFlightBytes,
drainTimeout,
defaultWriteTtl,
runtimeFactory,
bindings,
capabilities,
clock,
semanticProbeMinimumInterval,
semanticProbeMaximumStaleness,
ticker,
NoOpRedisCapabilityObservationPort.instance(),
null,
Duration.ofSeconds(30),
BoundedRedisSentinelRefreshWorker::new);
}
RedisCanonicalRoleRegistry(
Map<RedisRole, RedisDeploymentSettings> activeDeployments,
RedisClientRuntimeSettings clientSettings,
int maximumInFlight,
int maximumCommandBytes,
long maximumInFlightBytes,
Duration drainTimeout,
Duration defaultWriteTtl,
RuntimeFactory runtimeFactory,
Map<RedisRole, RedisRoleBinding> bindings,
Map<RedisRole, Set<Capability>> capabilities,
Clock clock,
Duration semanticProbeMinimumInterval,
Duration semanticProbeMaximumStaleness,
LongSupplier ticker,
RedisCapabilityObservationPort observationsPort) {
this(
activeDeployments,
clientSettings,
maximumInFlight,
maximumCommandBytes,
maximumInFlightBytes,
drainTimeout,
defaultWriteTtl,
runtimeFactory,
bindings,
capabilities,
clock,
semanticProbeMinimumInterval,
semanticProbeMaximumStaleness,
ticker,
observationsPort,
null,
Duration.ofSeconds(30),
BoundedRedisSentinelRefreshWorker::new);
}
RedisCanonicalRoleRegistry(
Map<RedisRole, RedisDeploymentSettings> activeDeployments,
RedisClientRuntimeSettings clientSettings,
int maximumInFlight,
int maximumCommandBytes,
long maximumInFlightBytes,
Duration drainTimeout,
Duration defaultWriteTtl,
RuntimeFactory runtimeFactory,
Map<RedisRole, RedisRoleBinding> bindings,
Map<RedisRole, Set<Capability>> capabilities,
Clock clock,
Duration semanticProbeMinimumInterval,
Duration semanticProbeMaximumStaleness,
LongSupplier ticker,
RedisCapabilityObservationPort observationsPort,
RedisSentinelRuntimeConnector sentinelConnector,
Duration sentinelDiscoveryRefreshPeriod,
RedisSentinelFailoverCoordinator.WorkerFactory workerFactory) {
Objects.requireNonNull(ticker, "ticker must be non-null");
Objects.requireNonNull(activeDeployments, "activeDeployments must be non-null");
Objects.requireNonNull(clientSettings, "clientSettings must be non-null");
Objects.requireNonNull(runtimeFactory, "runtimeFactory must be non-null");
Objects.requireNonNull(bindings, "bindings must be non-null");
Map<RedisRole, RedisRoleBinding> activeBindings = new EnumMap<>(RedisRole.class);
activeDeployments.forEach(
(role, ignored) -> {
RedisRoleBinding binding = bindings.get(role);
if (binding != null) {
activeBindings.put(role, binding);
}
});
this.bindings = Map.copyOf(activeBindings);
Map<RedisRole, Set<Capability>> safeCapabilities = new EnumMap<>(RedisRole.class);
Objects.requireNonNull(capabilities, "capabilities must be non-null")
.forEach((role, values) -> safeCapabilities.put(role, Set.copyOf(values)));
this.capabilities = Map.copyOf(safeCapabilities);
this.clock = Objects.requireNonNull(clock, "clock must be non-null");
this.runtimeFactory = runtimeFactory;
this.ticker = ticker;
this.observationsPort =
new SafeRedisCapabilityObservationPort(
Objects.requireNonNull(observationsPort, "observationsPort must be non-null"));
this.semanticProbe = RedisSemanticReadinessProbe.system(this.clock);
Map<RedisRole, RedisSemanticProbePlan> plans = new EnumMap<>(RedisRole.class);
activeBindings.forEach(
(role, ignored) ->
plans.put(
role,
RedisSemanticProbePlan.forRole(
role, this.capabilities.getOrDefault(role, Set.of()))));
this.probePlans = Map.copyOf(plans);
this.probeTimeout = clientSettings.commandTimeout();
this.drainTimeout = Objects.requireNonNull(drainTimeout, "drainTimeout must be non-null");
this.maximumInFlight = maximumInFlight;
this.maximumCommandBytes = maximumCommandBytes;
this.maximumInFlightBytes = maximumInFlightBytes;
this.defaultWriteTtl =
Objects.requireNonNull(defaultWriteTtl, "defaultWriteTtl must be non-null");
if (drainTimeout.compareTo(clientSettings.overallTimeout().plusMillis(100)) < 0) {
throw new IllegalArgumentException(
"Redis route drain timeout must include the runtime overall timeout and a 100ms safety"
+ " margin");
}
activeDeployments.forEach(RedisCanonicalRoleRegistry::rejectUnsupportedTopology);
Map<RedisRole, RedisDeploymentSettings.Sentinel> sentinelDeployments =
sentinelDeployments(activeDeployments);
if (!sentinelDeployments.isEmpty() && sentinelConnector == null) {
throw new IllegalStateException(
"Redis Sentinel refresh connector is required for every active Sentinel role");
}
Map<RedisRole, RedisRoleCommandRouter> created = new EnumMap<>(RedisRole.class);
Map<RedisRole, RedisSemanticProbeObservationCache> createdObservations =
new EnumMap<>(RedisRole.class);
Map<RedisRole, RecoveryState> createdRecoveries = new EnumMap<>(RedisRole.class);
AtomicReference<RedisSentinelFailoverCoordinator> coordinatorReference =
new AtomicReference<>();
RedisSentinelFailoverCoordinator createdCoordinator = null;
try {
activeDeployments.forEach(
(role, deployment) -> {
RedisRoleCommandRouter.TopologyFailureListener topologyFailureListener =
deployment instanceof RedisDeploymentSettings.Sentinel
? (failedRoute, failure) -> {
RedisSentinelFailoverCoordinator coordinator = coordinatorReference.get();
if (coordinator != null) {
coordinator.requestRecovery(role, failedRoute);
}
}
: RedisRoleCommandRouter.TopologyFailureListener.ignore();
RedisRoutableCommandRuntime runtime;
try {
runtime =
deployment instanceof RedisDeploymentSettings.Sentinel sentinel
? connectSentinel(sentinelConnector, sentinel)
: runtimeFactory.connect(deployment);
} catch (RedisTemporaryConnectionException temporary) {
if (!isOptionalCache(role)) {
throw temporary;
}
installDormant(
role,
deployment,
created,
createdObservations,
createdRecoveries,
semanticProbeMinimumInterval,
semanticProbeMaximumStaleness,
ticker,
topologyFailureListener);
return;
}
RedisRoleCommandRouter router = newRouter(role, runtime, topologyFailureListener);
try {
RedisSemanticProbePlan plan = probePlans.get(role);
if (plan == null) {
router.probe(probeTimeout);
} else {
RedisSemanticReadinessProbe.Result qualification =
semanticProbe.probeResult(plan, router);
if (qualification.disposition()
== RedisSemanticReadinessProbe.Disposition.RETRYABLE_TRANSPORT
&& isOptionalCache(role)) {
router.close();
installDormant(
role,
deployment,
created,
createdObservations,
createdRecoveries,
semanticProbeMinimumInterval,
semanticProbeMaximumStaleness,
ticker,
topologyFailureListener);
return;
}
if (qualification.disposition()
!= RedisSemanticReadinessProbe.Disposition.SUCCEEDED) {
throw new IllegalStateException(
"Redis semantic qualification failed: " + qualification.reason().name());
}
RedisSemanticProbeObservationCache observation =
new RedisSemanticProbeObservationCache(
semanticProbeMinimumInterval,
semanticProbeMaximumStaleness,
this.clock,
ticker);
observation.seed(qualification.reason());
createdObservations.put(role, observation);
}
created.put(role, router);
} catch (RuntimeException exception) {
router.close();
throw exception;
}
});
if (!sentinelDeployments.isEmpty()) {
createdCoordinator =
new RedisSentinelFailoverCoordinator(
sentinelDeployments,
created,
sentinelConnector,
this::qualifyCandidate,
this::observeSentinelInstall,
probeTimeout,
drainTimeout,
sentinelDiscoveryRefreshPeriod,
longer(
clientSettings.shutdownTimeout().plus(SENTINEL_CLEANUP_COMPLETION_MARGIN),
drainTimeout),
Objects.requireNonNull(workerFactory, "workerFactory must be non-null"));
coordinatorReference.set(createdCoordinator);
}
} catch (RuntimeException exception) {
if (createdCoordinator != null) {
createdCoordinator.close();
}
created.values().forEach(RedisRoleCommandRouter::close);
throw exception;
}
this.routers = Map.copyOf(created);
this.observations = Map.copyOf(createdObservations);
this.recoveries = Map.copyOf(createdRecoveries);
this.failoverCoordinator = createdCoordinator;
}
Set<RedisRole> boundRoles() {
return routers.keySet();
}
boolean isClosed() {
return closed.get();
}
RedisRoleCommandRouter router(RedisRole role) {
RedisRoleCommandRouter router =
routers.get(Objects.requireNonNull(role, "role must be non-null"));
if (router == null) {
throw new IllegalStateException("Redis role is not canonically bound: " + role);
}
return router;
}
RedisRoleCommandRouter.SwapResult rotate(RedisRole role, RedisRoutableCommandRuntime candidate) {
Objects.requireNonNull(candidate, "candidate must be non-null");
RedisSemanticProbePlan plan = probePlans.get(role);
if (plan == null) {
return router(role).swap(candidate, probeTimeout, drainTimeout);
}
RedisRoleCommandRouter qualificationRouter =
new RedisRoleCommandRouter(
role,
candidate,
maximumInFlight,
maximumCommandBytes,
maximumInFlightBytes,
drainTimeout,
defaultWriteTtl);
Reason qualification = semanticProbe.probe(plan, qualificationRouter);
if (qualification != Reason.SEMANTIC_PROBE_SUCCEEDED) {
qualificationRouter.close();
return RedisRoleCommandRouter.SwapResult.PROBE_FAILED;
}
RedisRoutableCommandRuntime qualified =
qualificationRouter.releaseQualifiedRuntimeForTransfer();
RedisRoleCommandRouter.SwapResult result;
try {
result = router(role).swap(qualified, probeTimeout, drainTimeout);
} catch (RuntimeException failure) {
closeQuietly(qualified);
throw failure;
}
if (result != RedisRoleCommandRouter.SwapResult.PROBE_FAILED) {
observations.get(role).seed(Reason.SEMANTIC_PROBE_SUCCEEDED);
}
return result;
}
void probe(RedisRole role) {
router(role).probe(probeTimeout);
}
@Override
public Snapshot snapshot() {
List<RoleHealth> roles = new ArrayList<>(bindings.size());
for (RedisRole role : RedisRole.values()) {
RedisRoleBinding binding = bindings.get(role);
if (binding != null) {
roles.add(probeHealth(role, binding));
}
}
return new Snapshot(clock.instant(), roles);
}
@Override
public void close() {
if (closed.compareAndSet(false, true)) {
if (failoverCoordinator != null) {
failoverCoordinator.close();
}
recoveries.values().forEach(recovery -> recovery.markTerminal(terminalClosed()));
routers.values().forEach(RedisRoleCommandRouter::close);
}
}
private static void rejectUnsupportedTopology(
RedisRole role, RedisDeploymentSettings deployment) {
if (role == RedisRole.SESSION && deployment instanceof RedisDeploymentSettings.Cluster) {
throw new UnsupportedOperationException(
"Redis SESSION role cannot use Cluster until session rotation preserves one hash slot");
}
}
private RoleHealth probeHealth(RedisRole role, RedisRoleBinding binding) {
RedisRoleCommandRouter router = router(role);
RedisSemanticProbeObservationCache observationCache = observations.get(role);
RedisSemanticProbeObservationCache.Observation observation;
RecoveryState recovery = recoveries.get(role);
if (closed.get()) {
observation = observationCache.seed(Reason.ROUTE_CLOSED);
} else if (recovery != null && !recovery.active()) {
observation =
recovery.deployment() instanceof RedisDeploymentSettings.Sentinel
? observationCache.seed(Reason.COMMAND_UNAVAILABLE)
: observationCache.observe(() -> recover(role, recovery).reason());
} else if (router.isClosed()) {
observation = observationCache.seed(Reason.ROUTE_CLOSED);
} else if (router.hadRecentCommandFailure()) {
observation = observationCache.seed(Reason.RECENT_COMMAND_FAILURE);
} else {
observation =
observationCache.observe(() -> semanticProbe.probe(probePlans.get(role), router));
}
if (closed.get() && observation.reason() != Reason.ROUTE_CLOSED) {
observation = observationCache.seed(Reason.ROUTE_CLOSED);
}
Reason reason = observation.reason();
State state =
switch (reason) {
case SEMANTIC_PROBE_SUCCEEDED -> State.AVAILABLE;
case COMMAND_SATURATED -> State.OVERLOADED;
default -> State.UNAVAILABLE;
};
RoleHealth health =
new RoleHealth(
Role.valueOf(role.name()),
binding.deploymentId(),
binding.required(),
EvictionPolicy.valueOf(
binding.expectedEviction().trim().replace('-', '_').toUpperCase(Locale.ROOT)),
EvictionAttestation.CONFIGURED_EXPECTATION_ONLY,
capabilities.getOrDefault(role, Set.of()),
state,
reason,
observation.observedAt(),
observation.age().toMillis(),
observation.stale());
capabilities
.getOrDefault(role, Set.of())
.forEach(
capability ->
observationsPort.observe(
new RedisCapabilityObservationEvent.ReadinessObserved(
RedisCapabilityObservationEvent.Capability.valueOf(capability.name()),
RedisCapabilityObservationEvent.Role.valueOf(health.role().name()),
health.state(),
health.reason(),
health.required()
? RedisCapabilityObservationEvent.Requirement.REQUIRED
: RedisCapabilityObservationEvent.Requirement.OPTIONAL)));
return health;
}
private RedisSemanticReadinessProbe.Result recover(RedisRole role, RecoveryState recovery) {
if (closed.get()) {
return recovery.markTerminal(terminalClosed());
}
RedisSemanticReadinessProbe.Result terminal = recovery.terminal();
if (terminal != null) {
return terminal;
}
RedisRoutableCommandRuntime candidate;
try {
candidate = runtimeFactory.connect(recovery.deployment());
} catch (RedisTemporaryConnectionException temporary) {
return retryableUnavailable();
} catch (RuntimeException permanent) {
return recovery.markTerminal(terminalUnavailable());
}
if (closed.get()) {
closeQuietly(candidate);
return recovery.markTerminal(terminalClosed());
}
RedisRoleCommandRouter qualificationRouter = newRouter(role, candidate);
RedisSemanticReadinessProbe.Result qualification =
semanticProbe.probeResult(probePlans.get(role), qualificationRouter);
if (closed.get()) {
qualificationRouter.close();
return recovery.markTerminal(terminalClosed());
}
if (qualification.disposition() == RedisSemanticReadinessProbe.Disposition.TERMINAL_CONTRACT) {
qualificationRouter.close();
return recovery.markTerminal(qualification);
}
if (qualification.disposition()
== RedisSemanticReadinessProbe.Disposition.RETRYABLE_TRANSPORT) {
qualificationRouter.close();
return qualification;
}
RedisRoutableCommandRuntime qualified =
qualificationRouter.releaseQualifiedRuntimeForTransfer();
if (closed.get()) {
closeQuietly(qualified);
return recovery.markTerminal(terminalClosed());
}
RedisRoleCommandRouter.SwapResult swap;
try {
swap = router(role).swap(qualified, probeTimeout, drainTimeout);
} catch (RuntimeException failure) {
closeQuietly(qualified);
return closed.get() ? recovery.markTerminal(terminalClosed()) : retryableUnavailable();
}
if (swap == RedisRoleCommandRouter.SwapResult.PROBE_FAILED) {
return retryableUnavailable();
}
if (closed.get() || !recovery.markActive()) {
return recovery.markTerminal(terminalClosed());
}
return qualification;
}
private void installDormant(
RedisRole role,
RedisDeploymentSettings deployment,
Map<RedisRole, RedisRoleCommandRouter> created,
Map<RedisRole, RedisSemanticProbeObservationCache> createdObservations,
Map<RedisRole, RecoveryState> createdRecoveries,
Duration minimumInterval,
Duration maximumStaleness,
LongSupplier ticker,
RedisRoleCommandRouter.TopologyFailureListener topologyFailureListener) {
created.put(
role,
newRouter(
role,
new RedisDormantCommandRuntime(deployment.deploymentId()),
topologyFailureListener));
RedisSemanticProbeObservationCache observation =
new RedisSemanticProbeObservationCache(minimumInterval, maximumStaleness, clock, ticker);
observation.seed(Reason.COMMAND_UNAVAILABLE);
createdObservations.put(role, observation);
createdRecoveries.put(role, new RecoveryState(deployment));
}
private RedisRoleCommandRouter newRouter(RedisRole role, RedisRoutableCommandRuntime runtime) {
return newRouter(role, runtime, RedisRoleCommandRouter.TopologyFailureListener.ignore());
}
private RedisRoleCommandRouter newRouter(
RedisRole role,
RedisRoutableCommandRuntime runtime,
RedisRoleCommandRouter.TopologyFailureListener topologyFailureListener) {
return new RedisRoleCommandRouter(
role,
runtime,
maximumInFlight,
maximumCommandBytes,
maximumInFlightBytes,
drainTimeout,
defaultWriteTtl,
ticker,
observationsPort,
RedisDrainWaiter.system(),
topologyFailureListener);
}
private RedisSentinelFailoverCoordinator.CandidateQualification qualifyCandidate(
RedisRole role, RedisRoutableCommandRuntime candidate) {
Objects.requireNonNull(candidate, "candidate must be non-null");
if (closed.get()) {
return RedisSentinelFailoverCoordinator.CandidateQualification.REJECTED;
}
RedisSemanticProbePlan plan = probePlans.get(role);
if (plan == null) {
return RedisSentinelFailoverCoordinator.CandidateQualification.ACCEPTED;
}
RedisRoleCommandRouter qualificationRouter;
try {
qualificationRouter = newRouter(role, candidate);
} catch (RuntimeException failure) {
return RedisSentinelFailoverCoordinator.CandidateQualification.REJECTED;
}
RedisSemanticReadinessProbe.Result qualification;
try {
qualification = semanticProbe.probeResult(plan, qualificationRouter);
} catch (RuntimeException failure) {
detachQualificationRouter(qualificationRouter);
return RedisSentinelFailoverCoordinator.CandidateQualification.REJECTED;
}
if (!detachQualificationRouter(qualificationRouter)) {
return RedisSentinelFailoverCoordinator.CandidateQualification.REJECTED;
}
return qualification.disposition() == RedisSemanticReadinessProbe.Disposition.SUCCEEDED
&& !closed.get()
? RedisSentinelFailoverCoordinator.CandidateQualification.ACCEPTED
: RedisSentinelFailoverCoordinator.CandidateQualification.REJECTED;
}
private void observeSentinelInstall(RedisRole role, RedisRoleCommandRouter.SwapResult result) {
if (result == RedisRoleCommandRouter.SwapResult.DRAINED
|| result == RedisRoleCommandRouter.SwapResult.FORCED_AFTER_TIMEOUT) {
RedisSemanticProbeObservationCache observation = observations.get(role);
if (observation != null) {
observation.seed(Reason.SEMANTIC_PROBE_SUCCEEDED);
}
RecoveryState recovery = recoveries.get(role);
if (recovery != null) {
recovery.markActive();
}
}
}
private static boolean detachQualificationRouter(RedisRoleCommandRouter qualificationRouter) {
try {
qualificationRouter.releaseQualifiedRuntimeForTransfer();
return true;
} catch (RuntimeException failure) {
return false;
}
}
private static RedisRoutableCommandRuntime connectSentinel(
RedisSentinelRuntimeConnector connector, RedisDeploymentSettings.Sentinel deployment) {
RedisSentinelDiscoveredRoute route = connector.discover(deployment);
return connector.connect(deployment, route);
}
private static Map<RedisRole, RedisDeploymentSettings.Sentinel> sentinelDeployments(
Map<RedisRole, RedisDeploymentSettings> deployments) {
EnumMap<RedisRole, RedisDeploymentSettings.Sentinel> sentinels = new EnumMap<>(RedisRole.class);
deployments.forEach(
(role, deployment) -> {
if (deployment instanceof RedisDeploymentSettings.Sentinel sentinel) {
sentinels.put(role, sentinel);
}
});
return Map.copyOf(sentinels);
}
private static Duration longer(Duration first, Duration second) {
return first.compareTo(second) >= 0 ? first : second;
}
private boolean isOptionalCache(RedisRole role) {
RedisRoleBinding binding = bindings.get(role);
return role == RedisRole.CACHE && binding != null && !binding.required();
}
private static RedisSemanticReadinessProbe.Result retryableUnavailable() {
return new RedisSemanticReadinessProbe.Result(
Reason.COMMAND_UNAVAILABLE, RedisSemanticReadinessProbe.Disposition.RETRYABLE_TRANSPORT);
}
private static RedisSemanticReadinessProbe.Result terminalUnavailable() {
return new RedisSemanticReadinessProbe.Result(
Reason.COMMAND_UNAVAILABLE, RedisSemanticReadinessProbe.Disposition.TERMINAL_CONTRACT);
}
private static RedisSemanticReadinessProbe.Result terminalClosed() {
return new RedisSemanticReadinessProbe.Result(
Reason.ROUTE_CLOSED, RedisSemanticReadinessProbe.Disposition.TERMINAL_CONTRACT);
}
private static void closeQuietly(RedisRoutableCommandRuntime runtime) {
try {
runtime.close();
} catch (RuntimeException ignored) {
// Recovery cleanup cannot expose provider detail through health.
}
}
private static final class RecoveryState {
private final RedisDeploymentSettings deployment;
private volatile RedisSemanticReadinessProbe.Result terminal;
private volatile boolean active;
private RecoveryState(RedisDeploymentSettings deployment) {
this.deployment = deployment;
}
private synchronized RedisDeploymentSettings deployment() {
return deployment;
}
private synchronized RedisSemanticReadinessProbe.Result terminal() {
return terminal;
}
private synchronized boolean active() {
return active;
}
private synchronized RedisSemanticReadinessProbe.Result markTerminal(
RedisSemanticReadinessProbe.Result result) {
if (!active && terminal == null) {
terminal = result;
}
return terminal == null ? result : terminal;
}
private synchronized boolean markActive() {
if (terminal != null) {
return false;
}
active = true;
return true;
}
}
}
@@ -0,0 +1,175 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.shared.health.RedisHealthSnapshotProvider;
import java.time.Duration;
import java.util.Objects;
/** Closed, identity-free operational facts emitted only inside the Redis adapter leaf. */
final class RedisCapabilityObservationEvent {
static final long MAXIMUM_DURATION_NANOS = Duration.ofMinutes(5).toNanos();
static final int MAXIMUM_IN_FLIGHT_COMMANDS = 4096;
static final long MAXIMUM_IN_FLIGHT_BYTES = 268_435_456L;
private RedisCapabilityObservationEvent() {}
sealed interface Event
permits OperationCompleted, AdmissionChanged, ReadinessObserved, LifecycleDrainCompleted {}
record OperationCompleted(
Capability capability,
Role role,
Operation operation,
Outcome outcome,
Certainty certainty,
long durationNanos)
implements Event {
public OperationCompleted {
Objects.requireNonNull(capability, "capability must be non-null");
Objects.requireNonNull(role, "role must be non-null");
Objects.requireNonNull(operation, "operation must be non-null");
Objects.requireNonNull(outcome, "outcome must be non-null");
Objects.requireNonNull(certainty, "certainty must be non-null");
if (durationNanos < 0 || durationNanos > MAXIMUM_DURATION_NANOS) {
throw new IllegalArgumentException("durationNanos must be non-negative and bounded");
}
}
}
record AdmissionChanged(
Role role,
AdmissionState admission,
InFlightState state,
int inFlightCommands,
long inFlightBytes)
implements Event {
public AdmissionChanged {
Objects.requireNonNull(role, "role must be non-null");
Objects.requireNonNull(admission, "admission must be non-null");
Objects.requireNonNull(state, "state must be non-null");
if (inFlightCommands < 0 || inFlightCommands > MAXIMUM_IN_FLIGHT_COMMANDS) {
throw new IllegalArgumentException("inFlightCommands must be non-negative and bounded");
}
if (inFlightBytes < 0 || inFlightBytes > MAXIMUM_IN_FLIGHT_BYTES) {
throw new IllegalArgumentException("inFlightBytes must be non-negative and bounded");
}
}
}
record ReadinessObserved(
Capability capability,
Role role,
RedisHealthSnapshotProvider.State state,
RedisHealthSnapshotProvider.Reason reason,
Requirement requirement)
implements Event {
public ReadinessObserved {
Objects.requireNonNull(capability, "capability must be non-null");
Objects.requireNonNull(role, "role must be non-null");
Objects.requireNonNull(state, "state must be non-null");
Objects.requireNonNull(reason, "reason must be non-null");
Objects.requireNonNull(requirement, "requirement must be non-null");
}
}
record LifecycleDrainCompleted(Role role, DrainOutcome drainOutcome) implements Event {
public LifecycleDrainCompleted {
Objects.requireNonNull(role, "role must be non-null");
Objects.requireNonNull(drainOutcome, "drainOutcome must be non-null");
}
}
enum Capability {
CACHE,
RATE_LIMIT,
IDEMPOTENCY,
EFFICIENCY_LEASE,
SESSION,
RUNTIME
}
enum Role {
CACHE,
COORDINATION,
SESSION
}
enum Operation {
LOOKUP,
RECORD,
INVALIDATE,
REFRESH_CLAIM,
REFRESH_RELEASE,
RATE_EVALUATE,
IDEMPOTENCY_CLAIM,
IDEMPOTENCY_START,
IDEMPOTENCY_RENEW,
IDEMPOTENCY_COMPLETE,
IDEMPOTENCY_FAIL,
IDEMPOTENCY_RELEASE,
IDEMPOTENCY_INSPECT,
LEASE_ACQUIRE,
LEASE_INSPECT,
LEASE_RENEW,
LEASE_RELEASE,
SESSION_CREATE,
SESSION_INSPECT,
SESSION_SAVE,
SESSION_TOUCH,
SESSION_REVOKE,
SESSION_ROTATE,
ROUTE_COMMAND
}
enum Outcome {
SUCCESS,
HIT,
MISS,
DENIED,
CONTENDED,
CONFLICT,
INCOMPATIBLE,
UNAVAILABLE,
OVERLOADED,
CLOSED,
INDETERMINATE,
STALE,
SKIPPED,
TOMBSTONED,
ABSOLUTE_EXPIRED
}
enum Certainty {
DEFINITE,
NOT_APPLIED,
INDETERMINATE
}
enum AdmissionState {
ADMITTED,
REJECTED_SATURATED,
REJECTED_CLOSED,
NOT_APPLICABLE
}
enum InFlightState {
IDLE,
ACTIVE,
SATURATED
}
enum Requirement {
OPTIONAL,
REQUIRED
}
enum DrainOutcome {
DRAINED,
FORCED_AFTER_TIMEOUT,
INTERRUPTED
}
}
@@ -0,0 +1,7 @@
package dev.caskeleton.adapter.outbound.cache.redis;
@FunctionalInterface
interface RedisCapabilityObservationPort {
void observe(RedisCapabilityObservationEvent.Event event);
}
@@ -0,0 +1,125 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.util.Objects;
import java.util.function.Function;
import java.util.function.LongSupplier;
import java.util.function.Supplier;
/** Measures one logical semantic operation without accepting request identity or wire material. */
final class RedisCapabilityObserver {
private static final long UNAVAILABLE_TICK = Long.MIN_VALUE;
private final RedisCapabilityObservationPort observations;
private final LongSupplier ticker;
RedisCapabilityObserver(RedisCapabilityObservationPort observations, LongSupplier ticker) {
this.observations =
new SafeRedisCapabilityObservationPort(
Objects.requireNonNull(observations, "observations must be non-null"));
this.ticker = Objects.requireNonNull(ticker, "ticker must be non-null");
}
static RedisCapabilityObserver disabled() {
return new RedisCapabilityObserver(
NoOpRedisCapabilityObservationPort.instance(), System::nanoTime);
}
<T> T observe(
RedisCapabilityObservationEvent.Capability capability,
RedisCapabilityObservationEvent.Role role,
RedisCapabilityObservationEvent.Operation operation,
Supplier<T> action,
Function<T, Classification> classifier) {
return observe(
capability,
role,
operation,
action,
classifier,
ignored ->
new Classification(
RedisCapabilityObservationEvent.Outcome.UNAVAILABLE,
RedisCapabilityObservationEvent.Certainty.NOT_APPLIED));
}
<T> T observe(
RedisCapabilityObservationEvent.Capability capability,
RedisCapabilityObservationEvent.Role role,
RedisCapabilityObservationEvent.Operation operation,
Supplier<T> action,
Function<T, Classification> classifier,
Function<RuntimeException, Classification> failureClassifier) {
Objects.requireNonNull(action, "action must be non-null");
Objects.requireNonNull(classifier, "classifier must be non-null");
Objects.requireNonNull(failureClassifier, "failureClassifier must be non-null");
long started = safeTick();
T result;
try {
result = action.get();
} catch (RuntimeException failure) {
Classification failureClassification;
try {
failureClassification =
Objects.requireNonNull(
failureClassifier.apply(failure), "failure classification must be non-null");
} catch (RuntimeException diagnosticFailure) {
throw failure;
}
completedSafely(capability, role, operation, failureClassification, started);
throw failure;
}
Classification classification;
try {
classification =
Objects.requireNonNull(classifier.apply(result), "classification must be non-null");
} catch (RuntimeException diagnosticFailure) {
return result;
}
completedSafely(capability, role, operation, classification, started);
return result;
}
private void completedSafely(
RedisCapabilityObservationEvent.Capability capability,
RedisCapabilityObservationEvent.Role role,
RedisCapabilityObservationEvent.Operation operation,
Classification classification,
long started) {
try {
long finished = safeTick();
long elapsed =
started == UNAVAILABLE_TICK || finished == UNAVAILABLE_TICK ? 0L : finished - started;
long bounded =
Math.min(RedisCapabilityObservationEvent.MAXIMUM_DURATION_NANOS, Math.max(0L, elapsed));
observations.observe(
new RedisCapabilityObservationEvent.OperationCompleted(
capability,
role,
operation,
classification.outcome(),
classification.certainty(),
bounded));
} catch (RuntimeException ignored) {
// Diagnostic timing/event construction cannot change the authoritative command result.
}
}
private long safeTick() {
try {
return ticker.getAsLong();
} catch (RuntimeException ignored) {
return UNAVAILABLE_TICK;
}
}
record Classification(
RedisCapabilityObservationEvent.Outcome outcome,
RedisCapabilityObservationEvent.Certainty certainty) {
Classification {
Objects.requireNonNull(outcome, "outcome must be non-null");
Objects.requireNonNull(certainty, "certainty must be non-null");
}
}
}
@@ -0,0 +1,321 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.function.Supplier;
/**
* Validated catalog-owned invocation. It is the only program identity carried through command
* ports; raw SHA, Lua source and raw key collections never cross those ports.
*/
final class RedisCatalogProgramInvocation {
enum ReplyShape {
VALUE,
READ_ONLY_VALUE,
MULTI,
READ_ONLY_MULTI
}
private final RedisProgramDescriptor descriptor;
private final byte[] exactScript;
private final String externalId;
private final List<Key> keys;
private final List<Argument> arguments;
private final ReplyShape replyShape;
private final int encodedBytes;
private final Supplier<Duration> remainingBudget;
private RedisCatalogProgramInvocation(
RedisProgramCatalog owner,
RedisProgramDescriptor descriptor,
List<byte[]> keys,
List<byte[]> arguments,
ReplyShape replyShape,
Supplier<Duration> remainingBudget) {
Objects.requireNonNull(owner, "owner must be non-null");
this.descriptor = Objects.requireNonNull(descriptor, "descriptor must be non-null");
this.exactScript = descriptor.scriptBytes();
this.externalId = descriptor.id().externalId();
if (owner.descriptor(descriptor.id()) != descriptor) {
throw new IllegalArgumentException("Redis program descriptor is not owned by this catalog");
}
Objects.requireNonNull(keys, "keys must be non-null");
Objects.requireNonNull(arguments, "arguments must be non-null");
if (keys.size() != descriptor.keyCount() || arguments.size() != descriptor.argumentCount()) {
throw new IllegalArgumentException("Redis program signature does not match descriptor");
}
List<Key> safeKeys = new ArrayList<>(keys.size());
long bytes = 0;
for (byte[] key : keys) {
if (key == null || key.length < 1 || key.length > descriptor.maximumKeyBytes()) {
throw new IllegalArgumentException("Redis program key is out of bounds");
}
safeKeys.add(new Key(key));
bytes += key.length;
}
List<Argument> safeArguments = new ArrayList<>(arguments.size());
for (byte[] argument : arguments) {
if (argument == null
|| argument.length < 1
|| argument.length > descriptor.maximumArgumentBytes()) {
throw new IllegalArgumentException("Redis program argument is out of bounds");
}
Argument safeArgument = new Argument(argument);
safeArguments.add(safeArgument);
bytes += safeArgument.encodedLength();
}
if (bytes > Integer.MAX_VALUE) {
throw new IllegalArgumentException("Redis program invocation is too large");
}
this.keys = List.copyOf(safeKeys);
this.arguments = List.copyOf(safeArguments);
this.replyShape = Objects.requireNonNull(replyShape, "replyShape must be non-null");
this.encodedBytes = (int) bytes;
this.remainingBudget = remainingBudget;
}
private RedisCatalogProgramInvocation(RedisSemanticReadinessProbe.AclProbeMaterial material) {
this.descriptor = null;
this.externalId = "semantic-capability-acl-v1";
this.exactScript = RedisSemanticAclProbeCatalog.scriptBytes();
List<byte[]> keys = material.copyKeys();
Objects.requireNonNull(keys, "keys must be non-null");
List<Key> safeKeys = new ArrayList<>(keys.size());
long bytes = 0;
for (byte[] key : keys) {
if (key == null || key.length < 1 || key.length > 512) {
throw new IllegalArgumentException("Redis program key is out of bounds");
}
safeKeys.add(new Key(key));
bytes += key.length;
}
byte[] encodedCapability =
Objects.requireNonNull(material.capability(), "capability must be non-null")
.name()
.getBytes(java.nio.charset.StandardCharsets.US_ASCII);
List<Argument> safeArguments = List.of(new Argument(encodedCapability));
bytes += encodedCapability.length;
if (bytes > Integer.MAX_VALUE) {
throw new IllegalArgumentException("Redis program invocation is too large");
}
this.keys = List.copyOf(safeKeys);
this.arguments = List.copyOf(safeArguments);
this.replyShape = ReplyShape.READ_ONLY_VALUE;
this.encodedBytes = (int) bytes;
this.remainingBudget = null;
}
static RedisCatalogProgramInvocation capabilityOwned(
RedisProgramCatalog owner, RedisCatalogProgramMaterial material, ReplyShape replyShape) {
RedisProgramDescriptor descriptor = owner.descriptor(material.programId());
return new RedisCatalogProgramInvocation(
owner, descriptor, material.copyKeys(), material.copyArguments(), replyShape, null);
}
static RedisCatalogProgramInvocation primitiveOwned(
RedisProgramCatalog owner,
RedisProgramDescriptor descriptor,
RedisPrimitiveInvocation primitive,
ReplyShape replyShape) {
if (primitive.descriptor().programId() != descriptor.id()) {
throw new IllegalArgumentException("primitive program identity is inconsistent");
}
return new RedisCatalogProgramInvocation(
owner,
descriptor,
primitiveKeys(primitive),
primitiveArguments(descriptor, primitive),
replyShape,
primitive::remainingDeadline);
}
static RedisCatalogProgramInvocation boundedGetOwned(
RedisProgramCatalog owner,
RedisProgramDescriptor descriptor,
RedisPhysicalKey key,
int maximumValueBytes) {
if (descriptor.id() != RedisProgramId.BOUNDED_GET_V1) {
throw new IllegalArgumentException("bounded GET descriptor is required");
}
return new RedisCatalogProgramInvocation(
owner,
descriptor,
List.of(RedisPhysicalKey.WireCodec.copy(key)),
List.of(
Integer.toString(maximumValueBytes)
.getBytes(java.nio.charset.StandardCharsets.US_ASCII)),
ReplyShape.READ_ONLY_VALUE,
null);
}
static RedisCatalogProgramInvocation semanticAclProbe(
RedisSemanticReadinessProbe.AclProbeMaterial material) {
return new RedisCatalogProgramInvocation(
Objects.requireNonNull(material, "semantic ACL material must be non-null"));
}
private static List<byte[]> primitiveKeys(RedisPrimitiveInvocation primitive) {
return primitive.keys().stream()
.map(RedisPrimitiveKey::physicalKey)
.map(RedisPhysicalKey.WireCodec::copy)
.toList();
}
private static List<byte[]> primitiveArguments(
RedisProgramDescriptor descriptor, RedisPrimitiveInvocation primitive) {
if (descriptor.id() == RedisProgramId.BOUNDED_GET_V1) {
return List.of(
Integer.toString(primitive.descriptor().maximumValueBytes())
.getBytes(java.nio.charset.StandardCharsets.US_ASCII));
}
if (!(primitive.arguments() instanceof RedisPrimitiveInvocation.ProgramArguments arguments)) {
throw new IllegalArgumentException("primitive program arguments are not closed");
}
return arguments.programValues().stream().map(RedisPrimitiveValue::copyEncoded).toList();
}
RedisProgramDescriptor descriptor() {
if (descriptor == null) {
throw new IllegalStateException("Redis exact program has no manifest descriptor");
}
return descriptor;
}
RedisProgramId programIdOrNull() {
return descriptor == null ? null : descriptor.id();
}
String externalId() {
return externalId;
}
private byte[] copyExactScript() {
return exactScript.clone();
}
ReplyShape replyShape() {
return replyShape;
}
int keyCount() {
return keys.size();
}
int argumentCount() {
return arguments.size();
}
private byte[] copyArgument(int index) {
return arguments.get(index).copyEncoded();
}
int encodedBytes() {
return encodedBytes;
}
Duration boundedTimeout(Duration defaultTimeout) {
Objects.requireNonNull(defaultTimeout, "defaultTimeout must be non-null");
if (remainingBudget == null) {
return defaultTimeout;
}
Duration remaining = remainingBudget.get();
return remaining.compareTo(defaultTimeout) < 0 ? remaining : defaultTimeout;
}
private byte[][] copyKeysArray() {
byte[][] result = new byte[keys.size()][];
for (int index = 0; index < keys.size(); index++) {
result[index] = keys.get(index).copyEncoded();
}
return result;
}
private List<byte[]> copyKeys() {
List<byte[]> result = new ArrayList<>(keys.size());
for (Key key : keys) {
result.add(key.copyEncoded());
}
return List.copyOf(result);
}
private List<byte[]> copyArguments() {
List<byte[]> result = new ArrayList<>(arguments.size());
for (Argument argument : arguments) {
result.add(argument.copyEncoded());
}
return List.copyOf(result);
}
String sha1() {
return RedisScriptRecovery.sha1(exactScript);
}
private byte[][] copyArgumentsArray() {
byte[][] result = new byte[arguments.size()][];
for (int index = 0; index < arguments.size(); index++) {
result[index] = arguments.get(index).copyEncoded();
}
return result;
}
private static final class Argument {
private final byte[] encoded;
private Argument(byte[] encoded) {
this.encoded = encoded.clone();
}
private int encodedLength() {
return encoded.length;
}
private byte[] copyEncoded() {
return encoded.clone();
}
}
private static final class Key {
private final byte[] encoded;
private Key(byte[] encoded) {
this.encoded = encoded.clone();
}
private byte[] copyEncoded() {
return encoded.clone();
}
}
/** Sole terminal wire unwrap; every byte is derived from an already validated invocation. */
static final class WireCodec {
private WireCodec() {}
static byte[] exactScript(RedisCatalogProgramInvocation invocation) {
return invocation.copyExactScript();
}
static byte[] argument(RedisCatalogProgramInvocation invocation, int index) {
return invocation.copyArgument(index);
}
static byte[][] keysArray(RedisCatalogProgramInvocation invocation) {
return invocation.copyKeysArray();
}
static byte[][] argumentsArray(RedisCatalogProgramInvocation invocation) {
return invocation.copyArgumentsArray();
}
static List<byte[]> keys(RedisCatalogProgramInvocation invocation) {
return invocation.copyKeys();
}
static List<byte[]> arguments(RedisCatalogProgramInvocation invocation) {
return invocation.copyArguments();
}
}
}
@@ -0,0 +1,27 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.util.List;
/**
* Closed capability-owned material consumed only while constructing a catalog invocation.
*
* <p>Every permitted implementation has a private constructor in its semantic owner. No command
* executor receives this raw material and no arbitrary package peer can implement the contract.
*/
sealed interface RedisCatalogProgramMaterial
permits RedisAtomicPrimitives.ProgramMaterial,
RedisEdgeRateLimitProvider.ProgramInvocation,
RedisEfficiencyLeaseProvider.ProgramInvocation,
RedisEfficiencyLeaseHandle.ProgramInvocation,
RedisIdempotencyStoreProvider.ProgramInvocation,
RedisLuaVersionedSessionStore.ProgramInvocation,
RedisSemanticReadinessProbe.ProgramInvocation {
RedisProgramId programId();
RedisCatalogProgramInvocation.ReplyShape replyShape();
List<byte[]> copyKeys();
List<byte[]> copyArguments();
}
@@ -0,0 +1,43 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.util.ArrayList;
import java.util.List;
/** Bounded defensive reply from one catalog invocation. */
final class RedisCatalogProgramReply {
private final byte[] value;
private final List<byte[]> fields;
private RedisCatalogProgramReply(byte[] value, List<byte[]> fields) {
this.value = value == null ? null : value.clone();
this.fields = defensive(fields);
}
static RedisCatalogProgramReply value(byte[] value) {
return new RedisCatalogProgramReply(value, List.of());
}
static RedisCatalogProgramReply multi(List<byte[]> fields) {
return new RedisCatalogProgramReply(null, fields);
}
byte[] copyValue() {
return value == null ? null : value.clone();
}
List<byte[]> copyFields() {
return defensive(fields);
}
private static List<byte[]> defensive(List<byte[]> fields) {
if (fields == null || fields.isEmpty()) {
return List.of();
}
List<byte[]> safe = new ArrayList<>(fields.size());
for (byte[] field : fields) {
safe.add(field == null ? null : field.clone());
}
return List.copyOf(safe);
}
}
@@ -1,5 +1,7 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.util.Objects;
/** Adapter-internal transport failure with explicit overload and mutation certainty. */
final class RedisCommandFailureException extends RuntimeException {
@@ -7,11 +9,18 @@ final class RedisCommandFailureException extends RuntimeException {
private final Kind kind;
private final Certainty certainty;
private final RecoveryHint recoveryHint;
RedisCommandFailureException(Kind kind, Certainty certainty, String message, Throwable cause) {
this(kind, certainty, RecoveryHint.NONE, message, cause);
}
RedisCommandFailureException(
Kind kind, Certainty certainty, RecoveryHint recoveryHint, String message, Throwable cause) {
super(message, cause);
this.kind = kind;
this.certainty = certainty;
this.kind = Objects.requireNonNull(kind, "kind must be non-null");
this.certainty = Objects.requireNonNull(certainty, "certainty must be non-null");
this.recoveryHint = Objects.requireNonNull(recoveryHint, "recoveryHint must be non-null");
}
Kind kind() {
@@ -22,13 +31,23 @@ final class RedisCommandFailureException extends RuntimeException {
return certainty;
}
RecoveryHint recoveryHint() {
return recoveryHint;
}
enum Kind {
UNAVAILABLE,
OVERLOADED
OVERLOADED,
ACL_DENIED
}
enum Certainty {
NOT_APPLIED,
INDETERMINATE
}
enum RecoveryHint {
NONE,
REDISCOVER_SENTINEL
}
}
@@ -0,0 +1,57 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.time.Duration;
import java.util.Objects;
/** Adapter-internal immutable connection/admission profile for one dedicated Redis role. */
record RedisConnectionProfile(
String host,
int port,
String password,
Duration commandTimeout,
Duration legacyTtl,
int maximumReadableValueBytes,
int maximumCommandBytes,
int maximumQueuedCommands,
int maximumInFlightBytes) {
private static final int LEGACY_COMMAND_OVERHEAD_BYTES = 4_096;
RedisConnectionProfile {
Objects.requireNonNull(host, "host must be non-null");
Objects.requireNonNull(password, "password must be non-null");
Objects.requireNonNull(commandTimeout, "commandTimeout must be non-null");
Objects.requireNonNull(legacyTtl, "legacyTtl must be non-null");
if (maximumReadableValueBytes < 1 || maximumCommandBytes < 1) {
throw new IllegalArgumentException("Redis byte bounds must be positive");
}
}
static RedisConnectionProfile cache(RedisRuntimeSettings settings) {
Objects.requireNonNull(settings, "settings must be non-null");
return new RedisConnectionProfile(
settings.host(),
settings.port(),
settings.password(),
settings.commandTimeout(),
settings.positiveTtl(),
settings.maximumReadableValueBytes(),
settings.maximumCommandBytes(),
settings.maximumQueuedCommands(),
settings.maximumInFlightBytes());
}
static RedisConnectionProfile rateLimit(RedisLegacyStandaloneSettings settings) {
Objects.requireNonNull(settings, "settings must be non-null");
return new RedisConnectionProfile(
settings.host(),
settings.port(),
settings.password(),
settings.commandTimeout(),
Duration.ofSeconds(1),
settings.maximumCommandBytes() - LEGACY_COMMAND_OVERHEAD_BYTES,
settings.maximumCommandBytes(),
settings.maximumQueuedCommands(),
settings.maximumInFlightBytes());
}
}
@@ -0,0 +1,40 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.time.Duration;
import java.util.List;
import java.util.Objects;
/** Signed exact counter helpers; increment is atomic with initial TTL. */
final class RedisCounterPrimitives {
private final RedisPrimitiveCatalog catalog;
private final RedisPrimitiveExecutor executor;
RedisCounterPrimitives(RedisPrimitiveCatalog catalog, RedisPrimitiveCommands commands) {
this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null");
this.executor = new RedisPrimitiveExecutor(catalog, commands);
}
RedisPrimitiveKey key(String slot, String identity) {
return catalog.keyFactory(RedisPrimitiveId.COUNTER_READ).key(slot, identity);
}
RedisPrimitiveReply read(RedisPrimitiveKey key) {
return executor.execute(
RedisPrimitiveId.COUNTER_READ, List.of(key), RedisPrimitiveInvocation.NoArguments.INSTANCE);
}
RedisCounterResult increment(
RedisPrimitiveKey key, long delta, long minimum, long maximum, Duration initialTimeToLive) {
try {
return RedisCounterResult.from(
executor.execute(
RedisPrimitiveId.COUNTER_INCREMENT_INITIAL_TTL,
List.of(key),
new RedisPrimitiveInvocation.CounterArguments(
delta, minimum, maximum, initialTimeToLive)));
} catch (RedisCommandFailureException failure) {
return RedisCounterResult.failed(failure);
}
}
}
@@ -0,0 +1,63 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.util.OptionalLong;
/**
* Exact signed counter outcome; the resulting value is never reinterpreted as an affected count.
*/
record RedisCounterResult(
Status status, Certainty certainty, OptionalLong value, String diagnosticCode) {
enum Status {
UPDATED,
LIMIT_EXCEEDED,
OVERFLOW,
MISSING_TTL,
MALFORMED_VALUE,
WRONG_TYPE,
INVALID,
UNKNOWN
}
enum Certainty {
APPLIED,
NOT_APPLIED,
INDETERMINATE
}
RedisCounterResult {
if (status == null || certainty == null || value == null) {
throw new IllegalArgumentException("counter result is invalid");
}
diagnosticCode = diagnosticCode == null ? "" : diagnosticCode;
}
static RedisCounterResult from(RedisPrimitiveReply reply) {
Status status =
switch (reply.status()) {
case UPDATED -> Status.UPDATED;
case LIMIT_EXCEEDED -> Status.LIMIT_EXCEEDED;
case OVERFLOW -> Status.OVERFLOW;
case MISSING_TTL -> Status.MISSING_TTL;
case MALFORMED_VALUE -> Status.MALFORMED_VALUE;
case WRONG_TYPE -> Status.WRONG_TYPE;
case INVALID, TTL_APPLY_FAILED -> Status.INVALID;
default -> Status.UNKNOWN;
};
return new RedisCounterResult(
status,
status == Status.UPDATED ? Certainty.APPLIED : Certainty.NOT_APPLIED,
reply.signedNumber(),
reply.diagnosticCode());
}
static RedisCounterResult failed(RedisCommandFailureException failure) {
return new RedisCounterResult(
Status.UNKNOWN,
failure.certainty() == RedisCommandFailureException.Certainty.INDETERMINATE
? Certainty.INDETERMINATE
: Certainty.NOT_APPLIED,
OptionalLong.empty(),
"");
}
}
@@ -0,0 +1,85 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings;
import dev.caskeleton.adapter.outbound.cache.redis.security.RedisRotatableRuntime;
import java.time.Duration;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
/** Lifecycle-safe Redis deployment runtime with no public native command surface. */
final class RedisDeploymentRuntime implements RedisRotatableRuntime {
public enum Topology {
STANDALONE,
SENTINEL,
CLUSTER
}
public record Timeouts(
Duration connect, Duration acquire, Duration command, Duration overall, Duration shutdown) {
public Timeouts {
Objects.requireNonNull(connect, "connect must be non-null");
Objects.requireNonNull(acquire, "acquire must be non-null");
Objects.requireNonNull(command, "command must be non-null");
Objects.requireNonNull(overall, "overall must be non-null");
Objects.requireNonNull(shutdown, "shutdown must be non-null");
}
}
private final String deploymentId;
private final Topology topology;
private final Timeouts timeouts;
private final RedisNativeClientHandle nativeClient;
private final RedisLettuceUris credentialOwner;
private final AtomicBoolean closed = new AtomicBoolean();
RedisDeploymentRuntime(
String deploymentId,
Topology topology,
RedisClientRuntimeSettings settings,
RedisNativeClientHandle nativeClient,
RedisLettuceUris credentialOwner) {
this.deploymentId = Objects.requireNonNull(deploymentId, "deploymentId must be non-null");
this.topology = Objects.requireNonNull(topology, "topology must be non-null");
Objects.requireNonNull(settings, "settings must be non-null");
this.timeouts =
new Timeouts(
settings.connectTimeout(),
settings.acquireTimeout(),
settings.commandTimeout(),
settings.overallTimeout(),
settings.shutdownTimeout());
this.nativeClient = Objects.requireNonNull(nativeClient, "nativeClient must be non-null");
this.credentialOwner =
Objects.requireNonNull(credentialOwner, "credentialOwner must be non-null");
}
@Override
public String deploymentId() {
return deploymentId;
}
public Topology topology() {
return topology;
}
public Timeouts timeouts() {
return timeouts;
}
public boolean isClosed() {
return closed.get();
}
@Override
public void close() {
if (closed.compareAndSet(false, true)) {
try {
nativeClient.close(timeouts.shutdown());
} finally {
credentialOwner.close();
}
}
}
}
@@ -0,0 +1,118 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings;
import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole;
import dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings;
import dev.caskeleton.adapter.outbound.cache.redis.security.RedisSslOptionsFactory;
import io.lettuce.core.SslOptions;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
/** Creates one topology-native runtime only for an explicitly bound Redis role. */
final class RedisDeploymentRuntimeFactory {
private final RedisLettuceUriFactory uriFactory;
private final RedisSslOptionsFactory sslOptionsFactory;
private final RedisLettuceClientOptionsFactory optionsFactory;
private final RedisNativeClientFactory nativeClientFactory;
RedisDeploymentRuntimeFactory(
RedisLettuceUriFactory uriFactory, RedisSslOptionsFactory sslOptionsFactory) {
this(
uriFactory,
sslOptionsFactory,
new RedisLettuceClientOptionsFactory(),
new LettuceRedisNativeClientFactory());
}
RedisDeploymentRuntimeFactory(
RedisLettuceUriFactory uriFactory,
RedisSslOptionsFactory sslOptionsFactory,
RedisLettuceClientOptionsFactory optionsFactory,
RedisNativeClientFactory nativeClientFactory) {
this.uriFactory = Objects.requireNonNull(uriFactory, "uriFactory must be non-null");
this.sslOptionsFactory =
Objects.requireNonNull(sslOptionsFactory, "sslOptionsFactory must be non-null");
this.optionsFactory = Objects.requireNonNull(optionsFactory, "optionsFactory must be non-null");
this.nativeClientFactory =
Objects.requireNonNull(nativeClientFactory, "nativeClientFactory must be non-null");
}
Optional<RedisDeploymentRuntime> createIfBound(
RedisRole role,
Map<RedisRole, RedisDeploymentSettings> activeDeployments,
RedisClientRuntimeSettings clientSettings) {
Objects.requireNonNull(role, "role must be non-null");
Objects.requireNonNull(activeDeployments, "activeDeployments must be non-null");
RedisDeploymentSettings deployment = activeDeployments.get(role);
if (deployment == null) {
return Optional.empty();
}
return Optional.of(create(deployment, clientSettings));
}
RedisDeploymentRuntime create(
RedisDeploymentSettings deployment, RedisClientRuntimeSettings clientSettings) {
Objects.requireNonNull(deployment, "deployment must be non-null");
Objects.requireNonNull(clientSettings, "clientSettings must be non-null");
if (deployment instanceof RedisDeploymentSettings.Sentinel) {
throw new UnsupportedOperationException(
"Redis Sentinel separate discovery and data trust is unsupported by one Lettuce SSL"
+ " context");
}
SslOptions sslOptions =
sslOptionsFactory.create(deployment.dataTls(), clientSettings.tlsHandshakeTimeout());
RedisLettuceUris uris = uriFactory.create(deployment, clientSettings);
try {
return switch (uris) {
case RedisLettuceUris.Standalone standalone ->
runtime(
deployment,
RedisDeploymentRuntime.Topology.STANDALONE,
clientSettings,
nativeClientFactory.openStandalone(
standalone.dataUri(),
optionsFactory.clientOptions(clientSettings, sslOptions),
clientSettings),
uris);
case RedisLettuceUris.Cluster cluster ->
runtime(
deployment,
RedisDeploymentRuntime.Topology.CLUSTER,
clientSettings,
nativeClientFactory.openCluster(
cluster.seedUris(),
optionsFactory.clusterClientOptions(clientSettings, sslOptions),
clientSettings),
uris);
case RedisLettuceUris.SentinelDiscovery ignored ->
throw new IllegalStateException("Redis Sentinel fail-closed guard was bypassed");
case RedisLettuceUris.SentinelData ignored ->
throw new IllegalStateException("Redis Sentinel fail-closed guard was bypassed");
};
} catch (RuntimeException exception) {
uris.close();
throw exception;
}
}
private static RedisDeploymentRuntime runtime(
RedisDeploymentSettings deployment,
RedisDeploymentRuntime.Topology topology,
RedisClientRuntimeSettings settings,
RedisNativeClientHandle client,
RedisLettuceUris credentialOwner) {
try {
return new RedisDeploymentRuntime(
deployment.deploymentId(), topology, settings, client, credentialOwner);
} catch (RuntimeException exception) {
try {
client.close(settings.shutdownTimeout());
} finally {
credentialOwner.close();
}
throw exception;
}
}
}
@@ -0,0 +1,76 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.time.Duration;
import java.util.Objects;
/** Non-owning unavailable route used while an optional CACHE deployment awaits recovery. */
final class RedisDormantCommandRuntime implements RedisRoutableCommandRuntime {
private final String deploymentId;
RedisDormantCommandRuntime(String deploymentId) {
this.deploymentId = Objects.requireNonNull(deploymentId, "deploymentId must be non-null");
}
@Override
public void probe(Duration timeout) {
throw unavailable();
}
@Override
public String deploymentId() {
return deploymentId;
}
@Override
public byte[] get(RedisPhysicalKey key) {
throw unavailable();
}
@Override
public void set(RedisPhysicalKey key, RedisBinaryValue value, Duration timeToLive) {
throw unavailable();
}
@Override
public long delete(RedisPhysicalKey key) {
throw unavailable();
}
@Override
public RedisPrimitiveReply execute(RedisPrimitiveInvocation invocation) {
throw unavailable();
}
@Override
public RedisCatalogProgramReply executeCatalogProgram(RedisCatalogProgramInvocation invocation) {
throw unavailable();
}
@Override
public String loadCatalogProgram(RedisCatalogProgramInvocation invocation) {
throw unavailable();
}
@Override
public long publish(byte[] channel, byte[] message) {
throw unavailable();
}
@Override
public Subscription subscribe(byte[] channel, Listener listener) {
Objects.requireNonNull(listener, "listener must be non-null");
return () -> {};
}
@Override
public void close() {}
private static RedisCommandFailureException unavailable() {
return new RedisCommandFailureException(
RedisCommandFailureException.Kind.UNAVAILABLE,
RedisCommandFailureException.Certainty.NOT_APPLIED,
"Redis optional role is temporarily unavailable",
null);
}
}
@@ -0,0 +1,50 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.time.Duration;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import java.util.function.IntSupplier;
@FunctionalInterface
interface RedisDrainWaiter {
Result await(IntSupplier inFlight, Object monitor, Duration timeout);
static RedisDrainWaiter system() {
return (inFlight, monitor, timeout) -> {
Objects.requireNonNull(inFlight, "inFlight must be non-null");
Objects.requireNonNull(monitor, "monitor must be non-null");
Objects.requireNonNull(timeout, "timeout must be non-null");
long deadline = saturatedAdd(System.nanoTime(), timeout.toNanos());
synchronized (monitor) {
while (inFlight.getAsInt() > 0) {
long remaining = deadline - System.nanoTime();
if (remaining <= 0) {
return Result.TIMED_OUT;
}
try {
TimeUnit.NANOSECONDS.timedWait(monitor, remaining);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
return Result.INTERRUPTED;
}
}
return Result.DRAINED;
}
};
}
private static long saturatedAdd(long left, long right) {
try {
return Math.addExact(left, right);
} catch (ArithmeticException ignored) {
return Long.MAX_VALUE;
}
}
enum Result {
DRAINED,
TIMED_OUT,
INTERRUPTED
}
}
@@ -0,0 +1,553 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyBuilder;
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyDigest;
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyNamespace;
import dev.caskeleton.shared.ratelimit.EdgeRateLimitPort;
import dev.caskeleton.shared.ratelimit.RateLimitAlgorithm;
import dev.caskeleton.shared.ratelimit.RateLimitDecision;
import dev.caskeleton.shared.ratelimit.RateLimitOutcome;
import dev.caskeleton.shared.ratelimit.RateLimitPolicy;
import dev.caskeleton.shared.ratelimit.RateLimitRequest;
import dev.caskeleton.shared.ratelimit.RateParameters;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.function.LongSupplier;
/** Provider-neutral edge-rate port backed by one exact Redis Lua program per policy evaluation. */
final class RedisEdgeRateLimitProvider implements EdgeRateLimitPort, AutoCloseable {
private static final int PROGRAM_SCHEMA_VERSION = 2;
private static final long SCALE = 1_000_000L;
private static final long MAXIMUM_LIMIT = 1_000_000_000L;
private static final Duration MAXIMUM_WINDOW = Duration.ofDays(1);
private static final Duration MAXIMUM_GRACE = Duration.ofDays(1);
private static final Duration MAXIMUM_CLOCK_REGRESSION = Duration.ofHours(1);
private static final int MAXIMUM_KEY_BYTES = 512;
private final Map<String, RateLimitPolicy> policies;
private final RedisProgramCatalog catalog;
private final RedisRateProgramExecutor executor;
private final String application;
private final String environment;
private final int hashKeyVersion;
private final int keyVersion;
private final byte[] hmacSecret;
private final Clock clock;
private final Duration failureRetryAfter;
private final Duration minimumCallerBudget;
private final RedisCapabilityObserver observer;
private final ReentrantReadWriteLock lifecycle = new ReentrantReadWriteLock();
private boolean closed;
RedisEdgeRateLimitProvider(
Map<String, RateLimitPolicy> policies,
RedisProgramCatalog catalog,
RedisRateProgramExecutor executor,
String application,
String environment,
int hashKeyVersion,
int keyVersion,
byte[] hmacSecret,
Clock clock,
Duration failureRetryAfter) {
this(
policies,
catalog,
executor,
application,
environment,
hashKeyVersion,
keyVersion,
hmacSecret,
clock,
failureRetryAfter,
Duration.ZERO,
NoOpRedisCapabilityObservationPort.instance(),
System::nanoTime);
}
RedisEdgeRateLimitProvider(
Map<String, RateLimitPolicy> policies,
RedisProgramCatalog catalog,
RedisRateProgramExecutor executor,
String application,
String environment,
int hashKeyVersion,
int keyVersion,
byte[] hmacSecret,
Clock clock,
Duration failureRetryAfter,
Duration minimumCallerBudget) {
this(
policies,
catalog,
executor,
application,
environment,
hashKeyVersion,
keyVersion,
hmacSecret,
clock,
failureRetryAfter,
minimumCallerBudget,
NoOpRedisCapabilityObservationPort.instance(),
System::nanoTime);
}
RedisEdgeRateLimitProvider(
Map<String, RateLimitPolicy> policies,
RedisProgramCatalog catalog,
RedisRateProgramExecutor executor,
String application,
String environment,
int hashKeyVersion,
int keyVersion,
byte[] hmacSecret,
Clock clock,
Duration failureRetryAfter,
Duration minimumCallerBudget,
RedisCapabilityObservationPort observations,
LongSupplier ticker) {
this.policies = Map.copyOf(Objects.requireNonNull(policies, "policies must be non-null"));
if (this.policies.isEmpty()) {
throw new IllegalArgumentException("Redis rate-limit provider requires at least one policy");
}
this.policies.forEach(
(id, policy) -> {
Objects.requireNonNull(policy, "rate-limit policy must be non-null");
if (!id.equals(policy.policyId())) {
throw new IllegalArgumentException("rate-limit policy map key must match policyId");
}
validateProviderBounds(policy);
});
this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null");
this.executor = Objects.requireNonNull(executor, "executor must be non-null");
this.application = Objects.requireNonNull(application, "application must be non-null");
this.environment = Objects.requireNonNull(environment, "environment must be non-null");
this.hashKeyVersion = hashKeyVersion;
this.keyVersion = keyVersion;
this.hmacSecret =
Arrays.copyOf(
Objects.requireNonNull(hmacSecret, "hmacSecret must be non-null"), hmacSecret.length);
if (this.hmacSecret.length < 32) {
throw new IllegalArgumentException(
"rate-limit key HMAC secret must contain at least 32 bytes");
}
this.clock = Objects.requireNonNull(clock, "clock must be non-null");
this.failureRetryAfter =
Objects.requireNonNull(failureRetryAfter, "failureRetryAfter must be non-null");
if (failureRetryAfter.isZero()
|| failureRetryAfter.isNegative()
|| failureRetryAfter.compareTo(Duration.ofDays(30)) > 0) {
throw new IllegalArgumentException("failureRetryAfter must be positive and bounded");
}
this.minimumCallerBudget =
Objects.requireNonNull(minimumCallerBudget, "minimumCallerBudget must be non-null");
if (minimumCallerBudget.isNegative()
|| minimumCallerBudget.compareTo(Duration.ofSeconds(30)) > 0) {
throw new IllegalArgumentException("minimumCallerBudget must be non-negative and bounded");
}
this.observer = new RedisCapabilityObserver(observations, ticker);
RateLimitPolicy first = this.policies.values().iterator().next();
namespace(first, "state");
}
@Override
public RateLimitOutcome evaluate(RateLimitRequest request) {
return observer.observe(
RedisCapabilityObservationEvent.Capability.RATE_LIMIT,
RedisCapabilityObservationEvent.Role.COORDINATION,
RedisCapabilityObservationEvent.Operation.RATE_EVALUATE,
() -> evaluateWithLifecycle(request),
RedisEdgeRateLimitProvider::classify);
}
private RateLimitOutcome evaluateWithLifecycle(RateLimitRequest request) {
lifecycle.readLock().lock();
try {
if (closed) {
throw new IllegalStateException("Redis rate-limit provider is closed");
}
return evaluateOpen(request);
} finally {
lifecycle.readLock().unlock();
}
}
private RateLimitOutcome evaluateOpen(RateLimitRequest request) {
Objects.requireNonNull(request, "request must be non-null");
RateLimitPolicy policy = policies.get(request.policyId());
if (policy == null) {
return incompatible(
request.policyId(), RateLimitOutcome.IncompatibleCategory.STATE_INCOMPATIBLE);
}
if (request.cost() > policy.maximumCost()) {
throw new IllegalArgumentException("rate-limit request cost exceeds policy maximumCost");
}
Instant now = clock.instant();
if (!request.callerDeadline().isAfter(now)
|| Duration.between(now, request.callerDeadline()).compareTo(minimumCallerBudget) < 0) {
return unavailable(
policy.policyId(), RateLimitOutcome.UnavailableCategory.NO_MUTATION_CONFIRMED);
}
ProgramInvocation invocation = invocation(policy, request);
RedisRateProgramReply reply;
try {
reply = execute(invocation);
} catch (RedisProgramCompatibilityException exception) {
return incompatible(
policy.policyId(), RateLimitOutcome.IncompatibleCategory.REPLY_INCOMPATIBLE);
} catch (RedisCommandFailureException exception) {
if (!canReplayIndeterminate(policy, request, exception)) {
return mapCommandFailure(policy.policyId(), exception);
}
try {
reply = execute(invocation);
} catch (RedisProgramCompatibilityException retryException) {
return incompatible(
policy.policyId(), RateLimitOutcome.IncompatibleCategory.REPLY_INCOMPATIBLE);
} catch (RedisCommandFailureException retryException) {
return mapCommandFailure(policy.policyId(), retryException);
}
}
return mapReply(policy, reply);
}
@Override
public void close() {
lifecycle.writeLock().lock();
try {
if (!closed) {
Arrays.fill(hmacSecret, (byte) 0);
closed = true;
}
} finally {
lifecycle.writeLock().unlock();
}
}
boolean destroyed() {
lifecycle.readLock().lock();
try {
if (!closed) {
return false;
}
for (byte value : hmacSecret) {
if (value != 0) {
return false;
}
}
return true;
} finally {
lifecycle.readLock().unlock();
}
}
private RedisRateProgramReply execute(ProgramInvocation invocation) {
return executor.execute(catalog.capabilityInvocation(invocation));
}
private boolean canReplayIndeterminate(
RateLimitPolicy policy, RateLimitRequest request, RedisCommandFailureException exception) {
if (exception.certainty() != RedisCommandFailureException.Certainty.INDETERMINATE
|| !policy.evaluationDedupPolicy().enabled()
|| request.evaluationId().isEmpty()
|| minimumCallerBudget.isZero()) {
return false;
}
Instant now = clock.instant();
return request.callerDeadline().isAfter(now)
&& Duration.between(now, request.callerDeadline()).compareTo(minimumCallerBudget) >= 0;
}
private RateLimitOutcome mapReply(RateLimitPolicy policy, RedisRateProgramReply reply) {
Objects.requireNonNull(reply, "rate program reply must be non-null");
return switch (reply.status()) {
case ALLOWED -> evaluated(policy, reply, RedisRateProgramDecision.ALLOWED);
case DENIED -> evaluated(policy, reply, RedisRateProgramDecision.DENIED);
case DEDUP_REPLAY -> evaluated(policy, reply, reply.decision());
case CLOCK_UNSAFE ->
unavailable(policy.policyId(), RateLimitOutcome.UnavailableCategory.CLOCK_UNSAFE);
case STATE_INCOMPATIBLE ->
incompatible(policy.policyId(), RateLimitOutcome.IncompatibleCategory.STATE_INCOMPATIBLE);
case INVALID ->
incompatible(
policy.policyId(), RateLimitOutcome.IncompatibleCategory.PROGRAM_INCOMPATIBLE);
};
}
private RateLimitOutcome evaluated(
RateLimitPolicy policy, RedisRateProgramReply reply, RedisRateProgramDecision decision) {
if (decision == RedisRateProgramDecision.NONE) {
return incompatible(
policy.policyId(), RateLimitOutcome.IncompatibleCategory.REPLY_INCOMPATIBLE);
}
boolean allowed = decision == RedisRateProgramDecision.ALLOWED;
long expectedLimit = limit(policy);
if (reply.limit() != expectedLimit
|| reply.remaining() > reply.limit()
|| reply.effectiveNowMillis() < reply.serverNowMillis()
|| (allowed && reply.retryAfterMillis() != 0)
|| (!allowed && reply.retryAfterMillis() < 1)
|| reply.resetAtMillis() < reply.effectiveNowMillis()) {
return incompatible(
policy.policyId(), RateLimitOutcome.IncompatibleCategory.REPLY_INCOMPATIBLE);
}
RateLimitDecision.DecisionCertainty certainty =
policy.algorithm() == RateLimitAlgorithm.SLIDING_COUNTER
? RateLimitDecision.DecisionCertainty.APPROXIMATE_ALGORITHM
: RateLimitDecision.DecisionCertainty.CERTAIN;
try {
return new RateLimitOutcome.Evaluated(
new RateLimitDecision(
allowed,
reply.limit(),
reply.remaining(),
Duration.ofMillis(reply.retryAfterMillis()),
Instant.ofEpochMilli(reply.resetAtMillis()),
policy.policyId(),
policy.policyRevision(),
RateLimitDecision.DecisionSource.GLOBAL_REDIS,
certainty));
} catch (RuntimeException exception) {
return incompatible(
policy.policyId(), RateLimitOutcome.IncompatibleCategory.REPLY_INCOMPATIBLE);
}
}
private RateLimitOutcome mapCommandFailure(
String policyId, RedisCommandFailureException exception) {
if (exception.certainty() == RedisCommandFailureException.Certainty.INDETERMINATE) {
return new RateLimitOutcome.Indeterminate(policyId, failureRetryAfter);
}
RateLimitOutcome.UnavailableCategory category =
exception.kind() == RedisCommandFailureException.Kind.OVERLOADED
? RateLimitOutcome.UnavailableCategory.ADMISSION_REJECTED
: RateLimitOutcome.UnavailableCategory.UNAVAILABLE_BEFORE_SEND;
return unavailable(policyId, category);
}
private RateLimitOutcome unavailable(
String policyId, RateLimitOutcome.UnavailableCategory category) {
return new RateLimitOutcome.Unavailable(policyId, failureRetryAfter, category);
}
private static RateLimitOutcome incompatible(
String policyId, RateLimitOutcome.IncompatibleCategory category) {
return new RateLimitOutcome.Incompatible(policyId, category);
}
private static RedisCapabilityObserver.Classification classify(RateLimitOutcome outcome) {
if (outcome instanceof RateLimitOutcome.Evaluated evaluated) {
return classification(
evaluated.decision().allowed()
? RedisCapabilityObservationEvent.Outcome.SUCCESS
: RedisCapabilityObservationEvent.Outcome.DENIED,
RedisCapabilityObservationEvent.Certainty.DEFINITE);
}
if (outcome instanceof RateLimitOutcome.Indeterminate) {
return classification(
RedisCapabilityObservationEvent.Outcome.INDETERMINATE,
RedisCapabilityObservationEvent.Certainty.INDETERMINATE);
}
if (outcome instanceof RateLimitOutcome.Incompatible) {
return classification(
RedisCapabilityObservationEvent.Outcome.INCOMPATIBLE,
RedisCapabilityObservationEvent.Certainty.DEFINITE);
}
RateLimitOutcome.Unavailable unavailable = (RateLimitOutcome.Unavailable) outcome;
return classification(
unavailable.category() == RateLimitOutcome.UnavailableCategory.ADMISSION_REJECTED
? RedisCapabilityObservationEvent.Outcome.OVERLOADED
: RedisCapabilityObservationEvent.Outcome.UNAVAILABLE,
RedisCapabilityObservationEvent.Certainty.NOT_APPLIED);
}
private static RedisCapabilityObserver.Classification classification(
RedisCapabilityObservationEvent.Outcome outcome,
RedisCapabilityObservationEvent.Certainty certainty) {
return new RedisCapabilityObserver.Classification(outcome, certainty);
}
private ProgramInvocation invocation(RateLimitPolicy policy, RateLimitRequest request) {
String algorithm = algorithmId(policy.algorithm());
RedisKeyDigest digest =
RedisKeyDigest.sensitive(
hashKeyVersion,
hmacSecret,
List.of(
utf8(policy.policyId()),
utf8(policy.policyRevision()),
utf8(algorithm),
utf8(request.subjectDigest())));
List<byte[]> keys =
List.of(
physicalKey(policy, digest, "state"),
physicalKey(policy, digest, "dedup"),
physicalKey(policy, digest, "dedup-order"));
String evaluationId =
policy.evaluationDedupPolicy().enabled() && !request.evaluationId().isEmpty()
? request.evaluationId()
: "-";
List<byte[]> arguments =
switch (policy.parameters()) {
case RateParameters.FixedWindow fixed ->
commonArguments(policy, request.cost(), fixed.limit(), fixed.window(), evaluationId);
case RateParameters.SlidingCounter sliding ->
commonArguments(
policy, request.cost(), sliding.limit(), sliding.window(), evaluationId);
case RateParameters.TokenBucket token ->
List.of(
ascii(PROGRAM_SCHEMA_VERSION),
utf8(policy.policyRevision()),
ascii(Math.multiplyExact(token.capacity(), SCALE)),
ascii(Math.multiplyExact(token.refillTokens(), SCALE)),
ascii(token.refillPeriod().toMillis()),
ascii(Math.multiplyExact(request.cost(), SCALE)),
ascii(policy.cleanupGrace().toMillis()),
ascii(policy.maximumClockRegression().toMillis()),
utf8(evaluationId),
ascii(policy.evaluationDedupPolicy().timeToLive().toMillis()),
ascii(policy.evaluationDedupPolicy().maximumEntries()),
ascii(policy.evaluationDedupPolicy().maximumStoredBytes()));
};
RedisProgramId programId =
switch (policy.algorithm()) {
case FIXED_WINDOW -> RedisProgramId.RATE_FIXED_WINDOW_V2;
case SLIDING_COUNTER -> RedisProgramId.RATE_SLIDING_COUNTER_V2;
case TOKEN_BUCKET -> RedisProgramId.RATE_TOKEN_BUCKET_V2;
};
return new ProgramInvocation(programId, keys, arguments);
}
private static List<byte[]> commonArguments(
RateLimitPolicy policy, long cost, long limit, Duration window, String evaluationId) {
return List.of(
ascii(PROGRAM_SCHEMA_VERSION),
utf8(policy.policyRevision()),
ascii(limit),
ascii(cost),
ascii(window.toMillis()),
ascii(policy.cleanupGrace().toMillis()),
ascii(policy.maximumClockRegression().toMillis()),
utf8(evaluationId),
ascii(policy.evaluationDedupPolicy().timeToLive().toMillis()),
ascii(policy.evaluationDedupPolicy().maximumEntries()),
ascii(policy.evaluationDedupPolicy().maximumStoredBytes()));
}
private byte[] physicalKey(RateLimitPolicy policy, RedisKeyDigest digest, String kind) {
return utf8(RedisKeyBuilder.build(namespace(policy, kind), digest));
}
private RedisKeyNamespace namespace(RateLimitPolicy policy, String kind) {
return new RedisKeyNamespace(
application,
environment,
"rate",
policy.policyId(),
hashKeyVersion,
keyVersion,
kind,
MAXIMUM_KEY_BYTES);
}
private static void validateProviderBounds(RateLimitPolicy policy) {
if (policy.cleanupGrace().compareTo(MAXIMUM_GRACE) > 0
|| policy.maximumClockRegression().compareTo(MAXIMUM_CLOCK_REGRESSION) > 0) {
throw new IllegalArgumentException("rate-limit grace or clock regression exceeds v2 bounds");
}
switch (policy.parameters()) {
case RateParameters.FixedWindow fixed -> {
boundedLimitAndWindow(fixed.limit(), fixed.window());
}
case RateParameters.SlidingCounter sliding -> {
boundedLimitAndWindow(sliding.limit(), sliding.window());
}
case RateParameters.TokenBucket token -> {
if (token.capacity() > MAXIMUM_LIMIT
|| token.refillPeriod().compareTo(MAXIMUM_WINDOW) > 0) {
throw new IllegalArgumentException("token-bucket policy exceeds v2 program bounds");
}
}
}
}
private static void boundedLimitAndWindow(long limit, Duration window) {
if (limit > MAXIMUM_LIMIT || window.compareTo(MAXIMUM_WINDOW) > 0) {
throw new IllegalArgumentException("rate-limit policy exceeds v2 program bounds");
}
}
private static long limit(RateLimitPolicy policy) {
return switch (policy.parameters()) {
case RateParameters.FixedWindow fixed -> fixed.limit();
case RateParameters.SlidingCounter sliding -> sliding.limit();
case RateParameters.TokenBucket token -> token.capacity();
};
}
private static String algorithmId(RateLimitAlgorithm algorithm) {
return switch (algorithm) {
case FIXED_WINDOW -> "fixed-window";
case SLIDING_COUNTER -> "sliding-window-counter";
case TOKEN_BUCKET -> "token-bucket";
};
}
private static byte[] ascii(long value) {
return Long.toString(value).getBytes(StandardCharsets.US_ASCII);
}
private static byte[] utf8(String value) {
return value.getBytes(StandardCharsets.UTF_8);
}
static final class ProgramInvocation implements RedisCatalogProgramMaterial {
private final RedisProgramId programId;
private final List<byte[]> keys;
private final List<byte[]> arguments;
private ProgramInvocation(RedisProgramId programId, List<byte[]> keys, List<byte[]> arguments) {
this.programId = Objects.requireNonNull(programId, "programId must be non-null");
this.keys =
Objects.requireNonNull(keys, "keys must be non-null").stream()
.map(byte[]::clone)
.toList();
this.arguments =
Objects.requireNonNull(arguments, "arguments must be non-null").stream()
.map(byte[]::clone)
.toList();
}
@Override
public RedisProgramId programId() {
return programId;
}
@Override
public RedisCatalogProgramInvocation.ReplyShape replyShape() {
return RedisCatalogProgramInvocation.ReplyShape.MULTI;
}
@Override
public List<byte[]> copyKeys() {
return keys.stream().map(byte[]::clone).toList();
}
@Override
public List<byte[]> copyArguments() {
return arguments.stream().map(byte[]::clone).toList();
}
}
}
@@ -0,0 +1,56 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole;
import dev.caskeleton.adapter.outbound.cache.redis.security.RedisCredentialMaterialProvider;
import dev.caskeleton.application.lease.DistributedLeasePort;
import java.time.Clock;
import java.util.Arrays;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/** Canonical COORDINATION-role composition for the owner-safe Redis efficiency lease. */
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(RedisLeaseSettings.class)
@ConditionalOnProperty(
name = "ca-skeleton.capabilities.lease.provider",
havingValue = "redis",
matchIfMissing = false)
public class RedisEfficiencyLeaseConfig {
@Bean(name = "distributedLeasePort", destroyMethod = "close")
@ConditionalOnProperty(
name = "ca-skeleton.capabilities.lease.provider",
havingValue = "redis",
matchIfMissing = false)
DistributedLeasePort distributedLeasePort(
RedisLeaseSettings settings,
RedisCanonicalRoleRegistry roleRegistry,
RedisCredentialMaterialProvider credentialProvider,
ObjectProvider<Clock> clockProvider,
ObjectProvider<RedisCapabilityObservationPort> observationsProvider) {
settings.validateActive();
Clock clock = clockProvider.getIfAvailable(Clock::systemUTC);
RedisCapabilityObservationPort observations =
observationsProvider.getIfUnique(NoOpRedisCapabilityObservationPort::instance);
byte[] hmacSecret =
RedisHmacMaterialResolver.resolve(
settings.keyHmacSecretReference(), credentialProvider, clock, "efficiency-lease");
try {
return RedisEfficiencyLeaseProvider.create(
settings.namespaceApplication(),
settings.namespaceEnvironment(),
settings.hashKeyVersion(),
settings.keyVersion(),
hmacSecret,
roleRegistry.router(RedisRole.COORDINATION),
clock,
settings.driftBudget(),
observations);
} finally {
Arrays.fill(hmacSecret, (byte) 0);
}
}
}
@@ -0,0 +1,394 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.application.lease.LeaseAttempt;
import dev.caskeleton.application.lease.LeaseHandle;
import dev.caskeleton.application.lease.LeaseReleaseOutcome;
import dev.caskeleton.application.lease.LeaseRenewOutcome;
import dev.caskeleton.application.lease.LeaseState;
import dev.caskeleton.application.lease.LeaseUnavailableCategory;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.LongSupplier;
/** Thread-safe local validity handle for one Redis efficiency lease. */
final class RedisEfficiencyLeaseHandle implements LeaseHandle {
private static final int PROGRAM_SCHEMA_VERSION = 1;
private static final Duration MAXIMUM_LEASE = Duration.ofHours(24);
private final byte[] key;
private final LeaseAttempt attempt;
private final RedisLeaseProgramExecutor programs;
private final RedisLeaseLifecycle lifecycle;
private final LongSupplier nanoTime;
private final long driftNanos;
private final Instant acquiredAt;
private final AtomicLong validityDeadlineNanos = new AtomicLong();
private final AtomicReference<Instant> observedServerExpiry;
private final AtomicReference<LeaseState> state = new AtomicReference<>(LeaseState.ACTIVE);
private final Object mutationMonitor = new Object();
private final RedisCapabilityObserver observer;
RedisEfficiencyLeaseHandle(
byte[] key,
LeaseAttempt attempt,
RedisLeaseProgramExecutor programs,
RedisLeaseLifecycle lifecycle,
LongSupplier nanoTime,
Duration driftBudget,
Instant acquiredAt,
RedisLeaseProgramReply reply,
long commandStartedNanos,
long commandFinishedNanos,
RedisCapabilityObserver observer) {
this.key = Objects.requireNonNull(key, "key must be non-null").clone();
this.attempt = Objects.requireNonNull(attempt, "attempt must be non-null");
this.programs = Objects.requireNonNull(programs, "programs must be non-null");
this.lifecycle = Objects.requireNonNull(lifecycle, "lifecycle must be non-null");
this.nanoTime = Objects.requireNonNull(nanoTime, "nanoTime must be non-null");
this.driftNanos = Objects.requireNonNull(driftBudget, "driftBudget must be non-null").toNanos();
this.acquiredAt = Objects.requireNonNull(acquiredAt, "acquiredAt must be non-null");
this.observedServerExpiry =
new AtomicReference<>(Instant.ofEpochMilli(reply.serverExpiryMillis()));
this.observer = Objects.requireNonNull(observer, "observer must be non-null");
updateValidity(reply.remainingMillis(), commandStartedNanos, commandFinishedNanos);
}
@Override
public String ownerToken() {
return attempt.ownerToken();
}
@Override
public String operationId() {
return attempt.operationId();
}
@Override
public Instant acquiredAt() {
return acquiredAt;
}
@Override
public Duration remainingValidity() {
if (state.get() != LeaseState.ACTIVE) {
return Duration.ZERO;
}
long remaining = validityDeadlineNanos.get() - nanoTime.getAsLong();
if (remaining <= 0) {
state.compareAndSet(LeaseState.ACTIVE, LeaseState.LOST);
return Duration.ZERO;
}
return Duration.ofNanos(remaining);
}
@Override
public Instant observedServerExpiry() {
return observedServerExpiry.get();
}
@Override
public LeaseState state() {
remainingValidity();
return state.get();
}
@Override
public LeaseRenewOutcome renew(Duration leaseTtl) {
Objects.requireNonNull(leaseTtl, "leaseTtl must be non-null");
if (leaseTtl.isZero()
|| leaseTtl.isNegative()
|| leaseTtl.compareTo(MAXIMUM_LEASE) > 0
|| !Duration.ofMillis(leaseTtl.toMillis()).equals(leaseTtl)) {
throw new IllegalArgumentException("leaseTtl must be positive, bounded, whole milliseconds");
}
return observer.observe(
RedisCapabilityObservationEvent.Capability.EFFICIENCY_LEASE,
RedisCapabilityObservationEvent.Role.COORDINATION,
RedisCapabilityObservationEvent.Operation.LEASE_RENEW,
() -> renewOpen(leaseTtl),
RedisEfficiencyLeaseHandle::classifyRenew);
}
private LeaseRenewOutcome renewOpen(Duration leaseTtl) {
synchronized (mutationMonitor) {
LeaseState current = state();
if (current == LeaseState.RELEASED || current == LeaseState.LOST) {
return new LeaseRenewOutcome.Absent();
}
if (current == LeaseState.UNKNOWN) {
return new LeaseRenewOutcome.Indeterminate(attempt.operationId());
}
long started = nanoTime.getAsLong();
RedisLeaseProgramReply reply;
try {
reply =
lifecycle.withOpen(
() ->
programs.execute(
new ProgramInvocation(
RedisProgramId.LEASE_RENEW_V1,
key,
List.of(
ascii(PROGRAM_SCHEMA_VERSION),
ascii(attempt.ownerToken()),
ascii(attempt.operationId()),
ascii(leaseTtl.toMillis())))));
} catch (RedisCommandFailureException failure) {
state.set(LeaseState.UNKNOWN);
return failure.certainty() == RedisCommandFailureException.Certainty.INDETERMINATE
? new LeaseRenewOutcome.Indeterminate(attempt.operationId())
: new LeaseRenewOutcome.Unavailable(category(failure));
} catch (RedisProgramCompatibilityException | IllegalArgumentException failure) {
state.set(LeaseState.UNKNOWN);
return new LeaseRenewOutcome.Unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
} catch (IllegalStateException failure) {
state.set(LeaseState.UNKNOWN);
return new LeaseRenewOutcome.Unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
}
long finished = nanoTime.getAsLong();
return mapRenew(reply, started, finished);
}
}
private LeaseRenewOutcome mapRenew(RedisLeaseProgramReply reply, long started, long finished) {
return switch (reply.status()) {
case "RENEWED" -> {
if (!validLiveReply(reply)) {
state.set(LeaseState.UNKNOWN);
yield new LeaseRenewOutcome.Unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
}
observedServerExpiry.set(Instant.ofEpochMilli(reply.serverExpiryMillis()));
if (!updateValidity(reply.remainingMillis(), started, finished)) {
yield new LeaseRenewOutcome.Unavailable(LeaseUnavailableCategory.DEADLINE_EXPIRED);
}
yield new LeaseRenewOutcome.Renewed(remainingValidity());
}
case "ABSENT" -> {
state.set(LeaseState.LOST);
yield new LeaseRenewOutcome.Absent();
}
case "NOT_OWNER", "OWNER_OPERATION_CONFLICT" -> {
state.set(LeaseState.LOST);
yield new LeaseRenewOutcome.NotOwner();
}
case "STATE_INCOMPATIBLE", "INVALID" -> {
state.set(LeaseState.UNKNOWN);
yield new LeaseRenewOutcome.Unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
}
default -> {
state.set(LeaseState.UNKNOWN);
yield new LeaseRenewOutcome.Unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
}
};
}
@Override
public LeaseReleaseOutcome release() {
return observer.observe(
RedisCapabilityObservationEvent.Capability.EFFICIENCY_LEASE,
RedisCapabilityObservationEvent.Role.COORDINATION,
RedisCapabilityObservationEvent.Operation.LEASE_RELEASE,
this::releaseOpen,
RedisEfficiencyLeaseHandle::classifyRelease);
}
private LeaseReleaseOutcome releaseOpen() {
synchronized (mutationMonitor) {
if (state.get() == LeaseState.RELEASED) {
return new LeaseReleaseOutcome.AlreadyAbsent();
}
RedisLeaseProgramReply reply;
try {
reply =
lifecycle.withOpen(
() ->
programs.execute(
new ProgramInvocation(
RedisProgramId.LEASE_RELEASE_V1,
key,
List.of(
ascii(PROGRAM_SCHEMA_VERSION),
ascii(attempt.ownerToken()),
ascii(attempt.operationId())))));
} catch (RedisCommandFailureException failure) {
state.set(LeaseState.UNKNOWN);
return failure.certainty() == RedisCommandFailureException.Certainty.INDETERMINATE
? new LeaseReleaseOutcome.Indeterminate(attempt.operationId())
: new LeaseReleaseOutcome.Unavailable(category(failure));
} catch (RedisProgramCompatibilityException | IllegalArgumentException failure) {
state.set(LeaseState.UNKNOWN);
return new LeaseReleaseOutcome.Unavailable(
LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
} catch (IllegalStateException failure) {
state.set(LeaseState.UNKNOWN);
return new LeaseReleaseOutcome.Unavailable(
LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
}
return mapRelease(reply);
}
}
private LeaseReleaseOutcome mapRelease(RedisLeaseProgramReply reply) {
return switch (reply.status()) {
case "RELEASED" -> {
state.set(LeaseState.RELEASED);
yield new LeaseReleaseOutcome.Released();
}
case "ALREADY_ABSENT" -> {
state.set(LeaseState.RELEASED);
yield new LeaseReleaseOutcome.AlreadyAbsent();
}
case "NOT_OWNER", "OWNER_OPERATION_CONFLICT" -> {
state.set(LeaseState.LOST);
yield new LeaseReleaseOutcome.NotOwner();
}
case "STATE_INCOMPATIBLE", "INVALID" -> {
state.set(LeaseState.UNKNOWN);
yield new LeaseReleaseOutcome.Unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
}
default -> {
state.set(LeaseState.UNKNOWN);
yield new LeaseReleaseOutcome.Unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
}
};
}
private boolean updateValidity(long remainingMillis, long started, long finished) {
long commandElapsed = Math.max(0L, finished - started);
long rawValidity;
try {
rawValidity = Math.multiplyExact(remainingMillis, 1_000_000L);
} catch (ArithmeticException failure) {
state.set(LeaseState.UNKNOWN);
return false;
}
long effective = rawValidity - commandElapsed - driftNanos;
if (effective <= 0) {
validityDeadlineNanos.set(finished);
state.set(LeaseState.LOST);
return false;
}
validityDeadlineNanos.set(saturatedAdd(finished, effective));
state.set(LeaseState.ACTIVE);
return true;
}
private boolean validLiveReply(RedisLeaseProgramReply reply) {
return reply.remainingMillis() > 0
&& reply.remainingMillis() <= MAXIMUM_LEASE.toMillis()
&& reply.stateRevision() > 0
&& reply.serverExpiryMillis() >= reply.serverNowMillis()
&& reply.serverExpiryMillis() - reply.serverNowMillis() == reply.remainingMillis()
&& attempt.operationId().equals(reply.operationId());
}
private static LeaseUnavailableCategory category(RedisCommandFailureException failure) {
return failure.kind() == RedisCommandFailureException.Kind.OVERLOADED
? LeaseUnavailableCategory.ADMISSION_REJECTED
: LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND;
}
private static long saturatedAdd(long left, long right) {
try {
return Math.addExact(left, right);
} catch (ArithmeticException failure) {
return Long.MAX_VALUE;
}
}
private static RedisCapabilityObserver.Classification classifyRenew(LeaseRenewOutcome outcome) {
if (outcome instanceof LeaseRenewOutcome.Renewed) {
return definite(RedisCapabilityObservationEvent.Outcome.SUCCESS);
}
if (outcome instanceof LeaseRenewOutcome.NotOwner) {
return definite(RedisCapabilityObservationEvent.Outcome.CONFLICT);
}
if (outcome instanceof LeaseRenewOutcome.Absent) {
return definite(RedisCapabilityObservationEvent.Outcome.MISS);
}
if (outcome instanceof LeaseRenewOutcome.Indeterminate) {
return indeterminate();
}
return unavailable();
}
private static RedisCapabilityObserver.Classification classifyRelease(
LeaseReleaseOutcome outcome) {
if (outcome instanceof LeaseReleaseOutcome.Released
|| outcome instanceof LeaseReleaseOutcome.AlreadyAbsent) {
return definite(RedisCapabilityObservationEvent.Outcome.SUCCESS);
}
if (outcome instanceof LeaseReleaseOutcome.NotOwner) {
return definite(RedisCapabilityObservationEvent.Outcome.CONFLICT);
}
if (outcome instanceof LeaseReleaseOutcome.Indeterminate) {
return indeterminate();
}
return unavailable();
}
private static RedisCapabilityObserver.Classification definite(
RedisCapabilityObservationEvent.Outcome outcome) {
return new RedisCapabilityObserver.Classification(
outcome, RedisCapabilityObservationEvent.Certainty.DEFINITE);
}
private static RedisCapabilityObserver.Classification indeterminate() {
return new RedisCapabilityObserver.Classification(
RedisCapabilityObservationEvent.Outcome.INDETERMINATE,
RedisCapabilityObservationEvent.Certainty.INDETERMINATE);
}
private static RedisCapabilityObserver.Classification unavailable() {
return new RedisCapabilityObserver.Classification(
RedisCapabilityObservationEvent.Outcome.UNAVAILABLE,
RedisCapabilityObservationEvent.Certainty.NOT_APPLIED);
}
private static byte[] ascii(long value) {
return Long.toString(value).getBytes(StandardCharsets.US_ASCII);
}
private static byte[] ascii(String value) {
return value.getBytes(StandardCharsets.US_ASCII);
}
static final class ProgramInvocation implements RedisCatalogProgramMaterial {
private final RedisProgramId programId;
private final byte[] key;
private final List<byte[]> arguments;
private ProgramInvocation(RedisProgramId programId, byte[] key, List<byte[]> arguments) {
this.programId = Objects.requireNonNull(programId, "programId must be non-null");
this.key = Objects.requireNonNull(key, "key must be non-null").clone();
this.arguments = arguments.stream().map(byte[]::clone).toList();
}
@Override
public RedisProgramId programId() {
return programId;
}
@Override
public RedisCatalogProgramInvocation.ReplyShape replyShape() {
return RedisCatalogProgramInvocation.ReplyShape.MULTI;
}
@Override
public List<byte[]> copyKeys() {
return List.of(key.clone());
}
@Override
public List<byte[]> copyArguments() {
return arguments.stream().map(byte[]::clone).toList();
}
}
}
@@ -0,0 +1,503 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.application.lease.DistributedLeasePort;
import dev.caskeleton.application.lease.LeaseAcquireOutcome;
import dev.caskeleton.application.lease.LeaseAttempt;
import dev.caskeleton.application.lease.LeaseInspectionOutcome;
import dev.caskeleton.application.lease.LeaseInspectionRequest;
import dev.caskeleton.application.lease.LeaseRequest;
import dev.caskeleton.application.lease.LeaseUnavailableCategory;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.time.Clock;
import java.time.Duration;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.ThreadLocalRandom;
import java.util.function.LongSupplier;
/**
* Redis owner-safe efficiency lease provider.
*
* <p>This provider has no fencing token and must not authorize correctness-sensitive writes.
*/
final class RedisEfficiencyLeaseProvider implements DistributedLeasePort, AutoCloseable {
private static final int PROGRAM_SCHEMA_VERSION = 1;
private static final Duration MAXIMUM_RETRY_AFTER = Duration.ofMinutes(5);
private final RedisLeaseKeyFactory keys;
private final RedisLeaseProgramExecutor programs;
private final RedisLeaseTokenGenerator tokens;
private final Clock clock;
private final LongSupplier nanoTime;
private final Duration driftBudget;
private final RedisLeaseWaitStrategy waitStrategy;
private final RedisLeaseLifecycle lifecycle = new RedisLeaseLifecycle();
private final RedisCapabilityObserver observer;
RedisEfficiencyLeaseProvider(
RedisLeaseKeyFactory keys,
RedisLeaseProgramExecutor programs,
RedisLeaseTokenGenerator tokens,
Clock clock,
LongSupplier nanoTime,
Duration driftBudget,
RedisLeaseWaitStrategy waitStrategy) {
this(
keys,
programs,
tokens,
clock,
nanoTime,
driftBudget,
waitStrategy,
NoOpRedisCapabilityObservationPort.instance());
}
RedisEfficiencyLeaseProvider(
RedisLeaseKeyFactory keys,
RedisLeaseProgramExecutor programs,
RedisLeaseTokenGenerator tokens,
Clock clock,
LongSupplier nanoTime,
Duration driftBudget,
RedisLeaseWaitStrategy waitStrategy,
RedisCapabilityObservationPort observations) {
this.keys = Objects.requireNonNull(keys, "keys must be non-null");
this.programs = Objects.requireNonNull(programs, "programs must be non-null");
this.tokens = Objects.requireNonNull(tokens, "tokens must be non-null");
this.clock = Objects.requireNonNull(clock, "clock must be non-null");
this.nanoTime = Objects.requireNonNull(nanoTime, "nanoTime must be non-null");
this.driftBudget = Objects.requireNonNull(driftBudget, "driftBudget must be non-null");
if (driftBudget.isNegative()
|| driftBudget.compareTo(Duration.ofSeconds(5)) > 0
|| !Duration.ofMillis(driftBudget.toMillis()).equals(driftBudget)) {
throw new IllegalArgumentException("driftBudget must be non-negative, bounded milliseconds");
}
this.waitStrategy = Objects.requireNonNull(waitStrategy, "waitStrategy must be non-null");
this.observer = new RedisCapabilityObserver(observations, nanoTime);
}
static RedisEfficiencyLeaseProvider create(
String application,
String environment,
int hashKeyVersion,
int keyVersion,
byte[] hmacSecret,
RedisStructuredCommands commands,
Clock clock,
Duration driftBudget) {
return create(
application,
environment,
hashKeyVersion,
keyVersion,
hmacSecret,
commands,
clock,
driftBudget,
NoOpRedisCapabilityObservationPort.instance());
}
static RedisEfficiencyLeaseProvider create(
String application,
String environment,
int hashKeyVersion,
int keyVersion,
byte[] hmacSecret,
RedisStructuredCommands commands,
Clock clock,
Duration driftBudget,
RedisCapabilityObservationPort observations) {
RedisProgramCatalog catalog = RedisProgramCatalog.efficiencyLease();
return new RedisEfficiencyLeaseProvider(
new RedisLeaseKeyFactory(application, environment, hashKeyVersion, keyVersion, hmacSecret),
new RedisLeaseProgramExecutor(catalog, commands),
new RedisLeaseTokenGenerator(new SecureRandom()),
clock,
System::nanoTime,
driftBudget,
RedisLeaseWaitStrategy.parking(),
observations);
}
@Override
public LeaseAttempt newAttempt(String operationId) {
return lifecycle.withOpen(() -> new LeaseAttempt(tokens.next(), operationId));
}
@Override
public LeaseAcquireOutcome tryAcquire(LeaseRequest request) {
return observer.observe(
RedisCapabilityObservationEvent.Capability.EFFICIENCY_LEASE,
RedisCapabilityObservationEvent.Role.COORDINATION,
RedisCapabilityObservationEvent.Operation.LEASE_ACQUIRE,
() -> tryAcquireOpen(request),
RedisEfficiencyLeaseProvider::classifyAcquire);
}
private LeaseAcquireOutcome tryAcquireOpen(LeaseRequest request) {
Objects.requireNonNull(request, "request must be non-null");
byte[] key;
try {
key = lifecycle.withOpen(() -> keys.physicalKey(request.purpose(), request.resourceDigest()));
} catch (IllegalStateException failure) {
return unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
}
long waitStarted = nanoTime.getAsLong();
int backoffAttempt = 0;
while (true) {
long commandStarted = nanoTime.getAsLong();
RedisLeaseProgramReply reply;
try {
reply =
lifecycle.withOpen(
() ->
programs.execute(
new ProgramInvocation(
RedisProgramId.LEASE_ACQUIRE_V1,
key,
List.of(
ascii(PROGRAM_SCHEMA_VERSION),
ascii(request.attempt().ownerToken()),
ascii(request.attempt().operationId()),
ascii(request.leaseTtl().toMillis())))));
} catch (RedisCommandFailureException failure) {
return mapAcquireFailure(request, failure);
} catch (RedisProgramCompatibilityException | IllegalArgumentException failure) {
return unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
} catch (IllegalStateException failure) {
return unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
}
long commandFinished = nanoTime.getAsLong();
LeaseAcquireOutcome mapped = mapAcquire(request, key, reply, commandStarted, commandFinished);
long elapsedWaitNanos = Math.max(0L, commandFinished - waitStarted);
if (!(mapped instanceof LeaseAcquireOutcome.Contended contended)
|| request.waitTimeout().isZero()
|| elapsedWaitNanos >= request.waitTimeout().toNanos()) {
return mapped;
}
long remainingWaitNanos = request.waitTimeout().toNanos() - elapsedWaitNanos;
Duration pause =
pause(
contended.retryAfter(),
Duration.ofNanos(Math.max(0L, remainingWaitNanos)),
backoffAttempt++);
if (pause.isZero()) {
return mapped;
}
try {
waitStrategy.await(pause);
} catch (RedisLeaseWaitInterruptedException interrupted) {
return unavailable(LeaseUnavailableCategory.DEADLINE_EXPIRED);
}
}
}
private LeaseAcquireOutcome mapAcquire(
LeaseRequest request,
byte[] key,
RedisLeaseProgramReply reply,
long commandStarted,
long commandFinished) {
return switch (reply.status()) {
case "ACQUIRED" -> {
if (!validOwnedReply(request.attempt(), request.leaseTtl(), reply)) {
yield unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
}
RedisEfficiencyLeaseHandle handle =
handle(request.attempt(), key, reply, commandStarted, commandFinished);
if (handle.state() != dev.caskeleton.application.lease.LeaseState.ACTIVE) {
handle.release();
yield unavailable(LeaseUnavailableCategory.DEADLINE_EXPIRED);
}
yield new LeaseAcquireOutcome.Acquired(handle);
}
case "REPLAYED_SAME_OPERATION" -> {
if (!validOwnedReply(request.attempt(), Duration.ofHours(24), reply)) {
yield unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
}
RedisEfficiencyLeaseHandle handle =
handle(request.attempt(), key, reply, commandStarted, commandFinished);
if (handle.state() != dev.caskeleton.application.lease.LeaseState.ACTIVE) {
handle.release();
yield unavailable(LeaseUnavailableCategory.DEADLINE_EXPIRED);
}
yield new LeaseAcquireOutcome.ReplayedSameOperation(handle);
}
case "CONTENDED" -> {
if (!validLiveReply(reply)) {
yield unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
}
yield new LeaseAcquireOutcome.Contended(
Duration.ofMillis(Math.min(reply.remainingMillis(), MAXIMUM_RETRY_AFTER.toMillis())));
}
case "OWNER_OPERATION_CONFLICT" ->
validLiveReply(reply)
? new LeaseAcquireOutcome.OwnerOperationConflict()
: unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
case "STATE_INCOMPATIBLE", "INVALID" ->
unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
default -> unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
};
}
@Override
public LeaseInspectionOutcome inspect(LeaseInspectionRequest request) {
return observer.observe(
RedisCapabilityObservationEvent.Capability.EFFICIENCY_LEASE,
RedisCapabilityObservationEvent.Role.COORDINATION,
RedisCapabilityObservationEvent.Operation.LEASE_INSPECT,
() -> inspectOpen(request),
RedisEfficiencyLeaseProvider::classifyInspection);
}
private LeaseInspectionOutcome inspectOpen(LeaseInspectionRequest request) {
Objects.requireNonNull(request, "request must be non-null");
byte[] key;
try {
key = lifecycle.withOpen(() -> keys.physicalKey(request.purpose(), request.resourceDigest()));
} catch (IllegalStateException failure) {
return new LeaseInspectionOutcome.Unavailable(
LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
}
long started = nanoTime.getAsLong();
RedisLeaseProgramReply reply;
try {
reply =
lifecycle.withOpen(
() ->
programs.execute(
new ProgramInvocation(
RedisProgramId.LEASE_INSPECT_V1,
key,
List.of(
ascii(PROGRAM_SCHEMA_VERSION),
ascii(request.attempt().ownerToken()),
ascii(request.attempt().operationId())))));
} catch (RedisCommandFailureException failure) {
return failure.certainty() == RedisCommandFailureException.Certainty.INDETERMINATE
? new LeaseInspectionOutcome.Indeterminate(request.attempt().operationId())
: new LeaseInspectionOutcome.Unavailable(category(failure));
} catch (RedisProgramCompatibilityException | IllegalArgumentException failure) {
return new LeaseInspectionOutcome.Unavailable(
LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
} catch (IllegalStateException failure) {
return new LeaseInspectionOutcome.Unavailable(
LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
}
long finished = nanoTime.getAsLong();
return mapInspection(request, key, reply, started, finished);
}
private LeaseInspectionOutcome mapInspection(
LeaseInspectionRequest request,
byte[] key,
RedisLeaseProgramReply reply,
long started,
long finished) {
return switch (reply.status()) {
case "OWNED" -> {
if (!validOwnedReply(request.attempt(), Duration.ofHours(24), reply)) {
yield new LeaseInspectionOutcome.Unavailable(
LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
}
RedisEfficiencyLeaseHandle handle =
handle(request.attempt(), key, reply, started, finished);
yield handle.state() == dev.caskeleton.application.lease.LeaseState.ACTIVE
? new LeaseInspectionOutcome.Owned(handle)
: new LeaseInspectionOutcome.Unavailable(LeaseUnavailableCategory.DEADLINE_EXPIRED);
}
case "ABSENT" -> new LeaseInspectionOutcome.Absent();
case "NOT_OWNER" -> new LeaseInspectionOutcome.NotOwner();
case "OWNER_OPERATION_CONFLICT" -> new LeaseInspectionOutcome.OwnerOperationConflict();
case "STATE_INCOMPATIBLE", "INVALID" ->
new LeaseInspectionOutcome.Unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
default ->
new LeaseInspectionOutcome.Unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
};
}
private RedisEfficiencyLeaseHandle handle(
LeaseAttempt attempt,
byte[] key,
RedisLeaseProgramReply reply,
long commandStarted,
long commandFinished) {
return new RedisEfficiencyLeaseHandle(
key,
attempt,
programs,
lifecycle,
nanoTime,
driftBudget,
clock.instant(),
reply,
commandStarted,
commandFinished,
observer);
}
private static boolean validOwnedReply(
LeaseAttempt attempt, Duration maximumTtl, RedisLeaseProgramReply reply) {
return reply.remainingMillis() > 0
&& reply.remainingMillis() <= maximumTtl.toMillis()
&& reply.stateRevision() > 0
&& reply.serverExpiryMillis() >= reply.serverNowMillis()
&& reply.serverExpiryMillis() - reply.serverNowMillis() == reply.remainingMillis()
&& attempt.operationId().equals(reply.operationId());
}
private static boolean validLiveReply(RedisLeaseProgramReply reply) {
return reply.remainingMillis() > 0
&& reply.remainingMillis() <= Duration.ofHours(24).toMillis()
&& reply.stateRevision() > 0
&& reply.serverExpiryMillis() >= reply.serverNowMillis()
&& reply.serverExpiryMillis() - reply.serverNowMillis() == reply.remainingMillis();
}
private static Duration pause(Duration contention, Duration remainingWait, int backoffAttempt) {
if (remainingWait.isZero()) {
return Duration.ZERO;
}
int shift = Math.min(backoffAttempt, 7);
long capMillis = Math.min(250L, 2L << shift);
long jitterMillis = ThreadLocalRandom.current().nextLong(1L, capMillis + 1L);
long millis =
Math.min(
jitterMillis,
Math.min(Math.max(1L, contention.toMillis()), Math.max(0L, remainingWait.toMillis())));
return millis < 1 ? Duration.ZERO : Duration.ofMillis(millis);
}
private static LeaseAcquireOutcome mapAcquireFailure(
LeaseRequest request, RedisCommandFailureException failure) {
if (failure.certainty() == RedisCommandFailureException.Certainty.INDETERMINATE) {
return new LeaseAcquireOutcome.Indeterminate(request.attempt().operationId());
}
if (failure.kind() == RedisCommandFailureException.Kind.OVERLOADED) {
return new LeaseAcquireOutcome.Overloaded();
}
return unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
}
private static LeaseAcquireOutcome unavailable(LeaseUnavailableCategory category) {
return new LeaseAcquireOutcome.Unavailable(category);
}
private static LeaseUnavailableCategory category(RedisCommandFailureException failure) {
return failure.kind() == RedisCommandFailureException.Kind.OVERLOADED
? LeaseUnavailableCategory.ADMISSION_REJECTED
: LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND;
}
private static RedisCapabilityObserver.Classification classifyAcquire(
LeaseAcquireOutcome outcome) {
if (outcome instanceof LeaseAcquireOutcome.Acquired
|| outcome instanceof LeaseAcquireOutcome.ReplayedSameOperation) {
return definite(RedisCapabilityObservationEvent.Outcome.SUCCESS);
}
if (outcome instanceof LeaseAcquireOutcome.Contended) {
return definite(RedisCapabilityObservationEvent.Outcome.CONTENDED);
}
if (outcome instanceof LeaseAcquireOutcome.OwnerOperationConflict) {
return definite(RedisCapabilityObservationEvent.Outcome.CONFLICT);
}
if (outcome instanceof LeaseAcquireOutcome.Overloaded) {
return new RedisCapabilityObserver.Classification(
RedisCapabilityObservationEvent.Outcome.OVERLOADED,
RedisCapabilityObservationEvent.Certainty.NOT_APPLIED);
}
if (outcome instanceof LeaseAcquireOutcome.Indeterminate) {
return indeterminate();
}
return unavailable();
}
private static RedisCapabilityObserver.Classification classifyInspection(
LeaseInspectionOutcome outcome) {
if (outcome instanceof LeaseInspectionOutcome.Owned) {
return definite(RedisCapabilityObservationEvent.Outcome.SUCCESS);
}
if (outcome instanceof LeaseInspectionOutcome.Absent) {
return definite(RedisCapabilityObservationEvent.Outcome.MISS);
}
if (outcome instanceof LeaseInspectionOutcome.NotOwner
|| outcome instanceof LeaseInspectionOutcome.OwnerOperationConflict) {
return definite(RedisCapabilityObservationEvent.Outcome.CONFLICT);
}
if (outcome instanceof LeaseInspectionOutcome.Indeterminate) {
return indeterminate();
}
return unavailable();
}
private static RedisCapabilityObserver.Classification definite(
RedisCapabilityObservationEvent.Outcome outcome) {
return new RedisCapabilityObserver.Classification(
outcome, RedisCapabilityObservationEvent.Certainty.DEFINITE);
}
private static RedisCapabilityObserver.Classification indeterminate() {
return new RedisCapabilityObserver.Classification(
RedisCapabilityObservationEvent.Outcome.INDETERMINATE,
RedisCapabilityObservationEvent.Certainty.INDETERMINATE);
}
private static RedisCapabilityObserver.Classification unavailable() {
return new RedisCapabilityObserver.Classification(
RedisCapabilityObservationEvent.Outcome.UNAVAILABLE,
RedisCapabilityObservationEvent.Certainty.NOT_APPLIED);
}
@Override
public void close() {
lifecycle.close(keys::close);
}
boolean destroyed() {
return lifecycle.closed() && keys.destroyed();
}
private static byte[] ascii(long value) {
return Long.toString(value).getBytes(StandardCharsets.US_ASCII);
}
private static byte[] ascii(String value) {
return value.getBytes(StandardCharsets.US_ASCII);
}
static final class ProgramInvocation implements RedisCatalogProgramMaterial {
private final RedisProgramId programId;
private final byte[] key;
private final List<byte[]> arguments;
private ProgramInvocation(RedisProgramId programId, byte[] key, List<byte[]> arguments) {
this.programId = Objects.requireNonNull(programId, "programId must be non-null");
this.key = Objects.requireNonNull(key, "key must be non-null").clone();
this.arguments = arguments.stream().map(byte[]::clone).toList();
}
@Override
public RedisProgramId programId() {
return programId;
}
@Override
public RedisCatalogProgramInvocation.ReplyShape replyShape() {
return RedisCatalogProgramInvocation.ReplyShape.MULTI;
}
@Override
public List<byte[]> copyKeys() {
return List.of(key.clone());
}
@Override
public List<byte[]> copyArguments() {
return arguments.stream().map(byte[]::clone).toList();
}
}
}
@@ -0,0 +1,39 @@
package dev.caskeleton.adapter.outbound.cache.redis;
/** Bounded WGS84 coordinate whose text form is always redacted. */
record RedisGeoCoordinate(double longitude, double latitude) {
RedisGeoCoordinate {
if (!Double.isFinite(longitude)
|| !Double.isFinite(latitude)
|| longitude < -180
|| longitude > 180
|| latitude < -85.05112878
|| latitude > 85.05112878) {
throw new IllegalArgumentException("geo coordinate exceeds descriptor bounds");
}
if (canonical(longitude).length() > 20 || canonical(latitude).length() > 20) {
throw new IllegalArgumentException("geo coordinate exceeds canonical encoding bounds");
}
}
String canonicalLongitude() {
return canonical(longitude);
}
String canonicalLatitude() {
return canonical(latitude);
}
private static String canonical(double value) {
if (value == 0) {
return "0";
}
return java.math.BigDecimal.valueOf(value).stripTrailingZeros().toPlainString();
}
@Override
public String toString() {
return "RedisGeoCoordinate[redacted]";
}
}
@@ -0,0 +1,81 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.time.Duration;
import java.util.List;
import java.util.Objects;
/** Privacy-sensitive bounded GEO helpers; coordinates are never returned or stringified. */
final class RedisGeoPrimitives {
private final RedisPrimitiveCatalog catalog;
private final RedisPrimitiveExecutor executor;
private final RedisPrimitiveDescriptor admission;
RedisGeoPrimitives(RedisPrimitiveCatalog catalog, RedisPrimitiveCommands commands) {
this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null");
this.executor = new RedisPrimitiveExecutor(catalog, commands);
this.admission = catalog.descriptor(RedisPrimitiveId.GEO_ADD);
}
RedisPrimitiveKey key(String slot, String identity) {
return catalog.keyFactory(RedisPrimitiveId.GEO_ADD).key(slot, identity);
}
RedisPrimitiveValue member(String member) {
return RedisPrimitiveValue.utf8(member, admission.maximumMemberBytes());
}
RedisPrimitiveMutationResult admitOrUpdate(
RedisPrimitiveKey key,
RedisPrimitiveValue member,
RedisGeoCoordinate coordinate,
Duration initialTimeToLive) {
return executor.mutate(
RedisPrimitiveId.GEO_ADD,
List.of(key),
new RedisPrimitiveInvocation.GeoAdmissionArguments(
member,
coordinate,
RedisPrimitiveLimit.of(admission.maximumElements(), admission),
initialTimeToLive));
}
RedisPrimitiveReply search(
RedisPrimitiveKey key,
RedisGeoCoordinate center,
double radiusMeters,
int count,
RedisPrimitiveInvocation.GeoArguments.Sort sort) {
RedisPrimitiveDescriptor descriptor = catalog.descriptor(RedisPrimitiveId.GEO_SEARCH);
return executor.execute(
RedisPrimitiveId.GEO_SEARCH,
List.of(key),
new RedisPrimitiveInvocation.GeoArguments(
center,
RedisPrimitiveInvocation.GeoArguments.Shape.RADIUS,
radiusMeters,
0,
RedisPrimitiveLimit.of(count, descriptor),
sort));
}
RedisPrimitiveReply searchBox(
RedisPrimitiveKey key,
RedisGeoCoordinate center,
double widthMeters,
double heightMeters,
int count,
RedisPrimitiveInvocation.GeoArguments.Sort sort) {
RedisPrimitiveDescriptor descriptor = catalog.descriptor(RedisPrimitiveId.GEO_SEARCH);
return executor.execute(
RedisPrimitiveId.GEO_SEARCH,
List.of(key),
new RedisPrimitiveInvocation.GeoArguments(
center,
RedisPrimitiveInvocation.GeoArguments.Shape.BOX,
widthMeters,
heightMeters,
RedisPrimitiveLimit.of(count, descriptor),
sort));
}
}
@@ -0,0 +1,98 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.time.Duration;
import java.util.List;
import java.util.Objects;
/** Bounded hash helpers. Field growth is admitted atomically against descriptor capacity. */
final class RedisHashPrimitives {
private final RedisPrimitiveCatalog catalog;
private final RedisPrimitiveExecutor executor;
private final RedisPrimitiveDescriptor putDescriptor;
RedisHashPrimitives(RedisPrimitiveCatalog catalog, RedisPrimitiveCommands commands) {
this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null");
this.executor = new RedisPrimitiveExecutor(catalog, commands);
this.putDescriptor = catalog.descriptor(RedisPrimitiveId.HASH_SET_FIELDS);
}
RedisPrimitiveKey key(String slot, String identity) {
return catalog.keyFactory(RedisPrimitiveId.HASH_GET).key(slot, identity);
}
RedisPrimitiveValue field(String field) {
return RedisPrimitiveValue.utf8(field, putDescriptor.maximumFieldBytes());
}
RedisPrimitiveValue value(String value) {
return RedisPrimitiveValue.utf8(value, putDescriptor.maximumValueBytes());
}
RedisPrimitiveMutationResult put(
RedisPrimitiveKey key,
RedisPrimitiveValue field,
RedisPrimitiveValue value,
Duration initialTimeToLive) {
return executor.mutate(
RedisPrimitiveId.HASH_SET_FIELDS,
List.of(key),
new RedisPrimitiveInvocation.HashAdmissionArguments(
field,
value,
RedisPrimitiveLimit.of(putDescriptor.maximumElements(), putDescriptor),
initialTimeToLive));
}
RedisPrimitiveReply get(RedisPrimitiveKey key, RedisPrimitiveValue field) {
return executor.execute(
RedisPrimitiveId.HASH_GET,
List.of(key),
new RedisPrimitiveInvocation.BinaryArguments(List.of(field)));
}
RedisPrimitiveReply multiGet(RedisPrimitiveKey key, List<RedisPrimitiveValue> fields) {
RedisPrimitiveDescriptor descriptor = catalog.descriptor(RedisPrimitiveId.HASH_MGET);
RedisPrimitiveLimit.of(fields.size(), descriptor);
return executor.execute(
RedisPrimitiveId.HASH_MGET,
List.of(key),
new RedisPrimitiveInvocation.BinaryArguments(fields));
}
RedisPrimitiveMutationResult delete(RedisPrimitiveKey key, List<RedisPrimitiveValue> fields) {
RedisPrimitiveDescriptor descriptor = catalog.descriptor(RedisPrimitiveId.HASH_DELETE_FIELDS);
RedisPrimitiveLimit.of(fields.size(), descriptor);
return executor.mutate(
RedisPrimitiveId.HASH_DELETE_FIELDS,
List.of(key),
new RedisPrimitiveInvocation.BinaryArguments(fields));
}
RedisPrimitiveScanOutcome<RedisPrimitiveHashEntry> scan(
RedisPrimitiveKey key, RedisPrimitiveCursor cursor, long routeEpoch) {
RedisPrimitiveDescriptor descriptor = catalog.descriptor(RedisPrimitiveId.HASH_SCAN_PAGE);
cursor.validateFor(catalog, descriptor, key, routeEpoch);
return RedisPrimitiveScanOutcome.from(
executor.execute(
RedisPrimitiveId.HASH_SCAN_PAGE,
List.of(key),
new RedisPrimitiveInvocation.ScanPageArguments(
cursor, descriptor.maximumElements(), descriptor.maximumResultBytes())),
RedisPrimitiveHashEntry.class);
}
RedisPrimitiveMutationResult compareRevision(
RedisPrimitiveKey key,
RedisPrimitiveInvocation.HashRevisionArguments.ExpectedKind expectedKind,
String expectedRevision,
String nextRevision,
RedisPrimitiveValue value,
Duration initialTimeToLive) {
return executor.mutate(
RedisPrimitiveId.HASH_REVISION_CAS,
List.of(key),
new RedisPrimitiveInvocation.HashRevisionArguments(
expectedKind, expectedRevision, nextRevision, value, initialTimeToLive));
}
}
@@ -0,0 +1,54 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.redis.security.RedisCredentialMaterialProvider;
import dev.caskeleton.adapter.outbound.cache.redis.security.RedisSecretReference;
import dev.caskeleton.adapter.outbound.cache.redis.security.VersionedRedisCredentialMaterial;
import java.time.Clock;
import java.util.Arrays;
import java.util.Base64;
/** Resolves bounded Base64 HMAC material without retaining a raw configuration secret. */
final class RedisHmacMaterialResolver {
private RedisHmacMaterialResolver() {}
static byte[] resolve(
String reference,
RedisCredentialMaterialProvider credentialProvider,
Clock clock,
String capability) {
try (VersionedRedisCredentialMaterial material =
credentialProvider.resolve(RedisSecretReference.parse(reference))) {
if (material.isExpiredAt(clock.instant())) {
throw failure(capability);
}
byte[] decoded =
material.useSecret(
chars -> {
byte[] encoded = new byte[chars.length];
try {
for (int index = 0; index < chars.length; index++) {
if (chars[index] > 0x7f) {
throw failure(capability);
}
encoded[index] = (byte) chars[index];
}
return Base64.getDecoder().decode(encoded);
} finally {
Arrays.fill(encoded, (byte) 0);
}
});
if (decoded.length < 32 || decoded.length > 4096) {
Arrays.fill(decoded, (byte) 0);
throw failure(capability);
}
return decoded;
} catch (RuntimeException ignored) {
throw failure(capability);
}
}
private static IllegalStateException failure(String capability) {
return new IllegalStateException("Redis " + capability + " HMAC material resolution failed");
}
}
@@ -0,0 +1,54 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/** Approximate HLL helpers forbidden for billing, authorization, quota, audit and security. */
final class RedisHyperLogLogPrimitives {
private final RedisPrimitiveCatalog catalog;
private final RedisPrimitiveExecutor executor;
private final RedisPrimitiveDescriptor addDescriptor;
RedisHyperLogLogPrimitives(RedisPrimitiveCatalog catalog, RedisPrimitiveCommands commands) {
this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null");
this.executor = new RedisPrimitiveExecutor(catalog, commands);
this.addDescriptor = catalog.descriptor(RedisPrimitiveId.HLL_ADD);
}
RedisPrimitiveKey key(String slot, String identity) {
return catalog.keyFactory(RedisPrimitiveId.HLL_ADD).key(slot, identity);
}
RedisPrimitiveValue element(String value) {
return RedisPrimitiveValue.utf8(value, addDescriptor.maximumValueBytes());
}
RedisPrimitiveMutationResult add(RedisPrimitiveKey key, List<RedisPrimitiveValue> elements) {
RedisPrimitiveLimit.of(elements.size(), addDescriptor);
return executor.mutate(
RedisPrimitiveId.HLL_ADD,
List.of(key),
new RedisPrimitiveInvocation.BinaryArguments(elements));
}
RedisPrimitiveReply count(RedisPrimitiveKey key) {
return executor.execute(
RedisPrimitiveId.HLL_COUNT, List.of(key), RedisPrimitiveInvocation.NoArguments.INSTANCE);
}
RedisPrimitiveMutationResult merge(
RedisPrimitiveKey destination, List<RedisPrimitiveKey> sources) {
RedisPrimitiveDescriptor descriptor = catalog.descriptor(RedisPrimitiveId.HLL_MERGE_SAME_SLOT);
if (sources.isEmpty() || sources.size() > descriptor.maximumKeys() - 1) {
throw new IllegalArgumentException("HLL merge fan-in exceeds descriptor bounds");
}
ArrayList<RedisPrimitiveKey> keys = new ArrayList<>();
keys.add(destination);
keys.addAll(sources);
descriptor.validateKeys(keys);
return executor.mutate(
RedisPrimitiveId.HLL_MERGE_SAME_SLOT, keys, RedisPrimitiveInvocation.NoArguments.INSTANCE);
}
}
@@ -0,0 +1,77 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole;
import dev.caskeleton.adapter.outbound.cache.redis.security.RedisCredentialMaterialProvider;
import dev.caskeleton.application.idempotency.IdempotencyExecutorV2;
import dev.caskeleton.application.idempotency.IdempotencyStorePortV2;
import java.security.SecureRandom;
import java.time.Clock;
import java.util.Arrays;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/** Canonical COORDINATION-role composition for Redis request-replay idempotency V2. */
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(RedisIdempotencySettings.class)
@ConditionalOnProperty(
name = "ca-skeleton.capabilities.idempotency.provider",
havingValue = "redis",
matchIfMissing = false)
public class RedisIdempotencyConfig {
@Bean(name = "redisIdempotencyStoreV2", destroyMethod = "close")
@ConditionalOnProperty(
name = "ca-skeleton.capabilities.idempotency.provider",
havingValue = "redis",
matchIfMissing = false)
IdempotencyStorePortV2 redisIdempotencyStoreV2(
RedisIdempotencySettings settings,
RedisCanonicalRoleRegistry roleRegistry,
RedisCredentialMaterialProvider credentialProvider,
ObjectProvider<Clock> clockProvider,
ObjectProvider<RedisCapabilityObservationPort> observationsProvider) {
settings.validateActive();
Clock clock = clockProvider.getIfAvailable(Clock::systemUTC);
RedisCapabilityObservationPort observations =
observationsProvider.getIfUnique(NoOpRedisCapabilityObservationPort::instance);
byte[] hmacSecret =
RedisHmacMaterialResolver.resolve(
settings.keyHmacSecretReference(), credentialProvider, clock, "idempotency");
RedisProgramCatalog catalog = RedisProgramCatalog.idempotencyV2();
try {
return new RedisIdempotencyStoreProvider(
new RedisIdempotencyKeyFactory(
settings.namespaceApplication(),
settings.namespaceEnvironment(),
settings.hashKeyVersion(),
settings.keyVersion(),
hmacSecret),
new RedisIdempotencyProgramExecutor(catalog, roleRegistry.router(RedisRole.COORDINATION)),
new RedisIdempotencyRecordCodec(),
new RedisIdempotencyTokenGenerator(new SecureRandom()),
observations,
System::nanoTime);
} finally {
Arrays.fill(hmacSecret, (byte) 0);
}
}
@Bean(name = "idempotencyExecutorV2")
@ConditionalOnProperty(
name = "ca-skeleton.capabilities.idempotency.provider",
havingValue = "redis",
matchIfMissing = false)
IdempotencyExecutorV2 idempotencyExecutorV2(
IdempotencyStorePortV2 store, RedisIdempotencySettings settings) {
return new IdempotencyExecutorV2(
store,
settings.processingLease(),
settings.replayTtl(),
settings.failureRetention(),
settings.responseCodecId(),
settings.policyRevision());
}
}
@@ -0,0 +1,78 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyBuilder;
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyDigest;
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyNamespace;
import dev.caskeleton.application.idempotency.IdempotencyScope;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
/** HMAC-pseudonymizes every request-replay scope dimension into one Cluster-safe record key. */
final class RedisIdempotencyKeyFactory implements AutoCloseable {
private final RedisKeyNamespace namespace;
private final byte[] hmacSecret;
private final AtomicBoolean closed = new AtomicBoolean();
RedisIdempotencyKeyFactory(
String application,
String environment,
int hashKeyVersion,
int keyVersion,
byte[] hmacSecret) {
this.namespace =
new RedisKeyNamespace(
application,
environment,
"idempotency",
"request",
hashKeyVersion,
keyVersion,
"record",
512);
this.hmacSecret =
Arrays.copyOf(
Objects.requireNonNull(hmacSecret, "hmacSecret must be non-null"), hmacSecret.length);
if (this.hmacSecret.length < 32) {
throw new IllegalArgumentException(
"idempotency scope HMAC secret requires at least 32 bytes");
}
}
byte[] physicalKey(IdempotencyScope scope) {
if (closed.get()) {
throw new IllegalStateException("idempotency key material is closed");
}
Objects.requireNonNull(scope, "scope must be non-null");
RedisKeyDigest digest =
RedisKeyDigest.sensitive(
namespace.hashKeyVersion(),
hmacSecret,
List.of(
utf8(scope.tenant() == null ? "-" : scope.tenant()),
utf8(scope.principal()),
utf8(scope.idempotencyKey()),
utf8(scope.useCaseName())));
return utf8(RedisKeyBuilder.build(namespace, digest));
}
@Override
public void close() {
if (closed.compareAndSet(false, true)) {
Arrays.fill(hmacSecret, (byte) 0);
}
}
boolean destroyed() {
return closed.get()
&& java.util.stream.IntStream.range(0, hmacSecret.length)
.allMatch(index -> hmacSecret[index] == 0);
}
private static byte[] utf8(String value) {
return value.getBytes(StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,44 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.function.Supplier;
/** Prevents request-replay commands from racing provider close and HMAC destruction. */
final class RedisIdempotencyLifecycle {
private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
private boolean closed;
<T> T withOpen(Supplier<T> operation) {
lock.readLock().lock();
try {
if (closed) {
throw new IllegalStateException("Redis idempotency provider is closed");
}
return operation.get();
} finally {
lock.readLock().unlock();
}
}
void close(Runnable destroy) {
lock.writeLock().lock();
try {
if (!closed) {
destroy.run();
closed = true;
}
} finally {
lock.writeLock().unlock();
}
}
boolean closed() {
lock.readLock().lock();
try {
return closed;
} finally {
lock.readLock().unlock();
}
}
}
@@ -0,0 +1,74 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Objects;
/** Executes and fail-closed parses the fixed six-field request-replay program protocol. */
final class RedisIdempotencyProgramExecutor {
private static final long MAXIMUM_EXACT_LUA_INTEGER = 9_007_199_254_740_991L;
private final RedisProgramCatalog catalog;
private final RedisStructuredCommands commands;
RedisIdempotencyProgramExecutor(RedisProgramCatalog catalog, RedisStructuredCommands commands) {
this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null");
this.commands = Objects.requireNonNull(commands, "commands must be non-null");
}
RedisIdempotencyProgramReply execute(RedisIdempotencyStoreProvider.ProgramInvocation material) {
RedisProgramId id = material.programId();
RedisProgramDescriptor descriptor = catalog.descriptor(id);
List<byte[]> result =
RedisScriptRecovery.evalMulti(commands, catalog.capabilityInvocation(material));
if (result == null || result.size() != 6) {
throw incompatible(id);
}
for (byte[] field : result) {
if (field == null || field.length > descriptor.maximumReplyFieldBytes()) {
throw incompatible(id);
}
}
String status = ascii(result.get(0), id);
if (!descriptor.statuses().contains(status)) {
throw new RedisProgramCompatibilityException(id, status);
}
return new RedisIdempotencyProgramReply(
status,
unsigned(result.get(1), id),
unsigned(result.get(2), id),
ascii(result.get(3), id),
ascii(result.get(4), id),
ascii(result.get(5), id));
}
private static String ascii(byte[] value, RedisProgramId id) {
for (byte character : value) {
if (character < 0x20 || character > 0x7e) {
throw incompatible(id);
}
}
return new String(value, StandardCharsets.US_ASCII);
}
private static long unsigned(byte[] value, RedisProgramId id) {
String encoded = ascii(value, id);
if (!encoded.matches("0|[1-9][0-9]{0,15}")) {
throw incompatible(id);
}
try {
long parsed = Long.parseLong(encoded);
if (parsed > MAXIMUM_EXACT_LUA_INTEGER) {
throw incompatible(id);
}
return parsed;
} catch (NumberFormatException exception) {
throw incompatible(id);
}
}
private static RedisProgramCompatibilityException incompatible(RedisProgramId id) {
return new RedisProgramCompatibilityException(id, "<malformed-idempotency-reply>");
}
}

Some files were not shown because too many files have changed in this diff Show More