feat: add production capability foundations
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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 가
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+5
@@ -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
|
||||
|
||||
+255
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
@@ -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;
|
||||
}
|
||||
}
|
||||
+55
-11
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
+86
@@ -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;
|
||||
}
|
||||
}
|
||||
+40
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
-54
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
-11
@@ -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
|
||||
}
|
||||
-15
@@ -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) {}
|
||||
+8
@@ -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();
|
||||
}
|
||||
+69
-31
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+32
-11
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+38
@@ -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;
|
||||
}
|
||||
}
|
||||
+42
-25
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
-12
@@ -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);
|
||||
}
|
||||
-17
@@ -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);
|
||||
};
|
||||
}
|
||||
}
|
||||
+38
@@ -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);
|
||||
}
|
||||
}
|
||||
+45
@@ -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;
|
||||
}
|
||||
}
|
||||
-42
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+87
-9
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+103
@@ -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");
|
||||
}
|
||||
}
|
||||
+72
@@ -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 {}
|
||||
}
|
||||
+199
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+100
@@ -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;
|
||||
}
|
||||
}
|
||||
+44
@@ -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");
|
||||
}
|
||||
}
|
||||
-84
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+137
-36
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
+22
-13
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+35
@@ -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);
|
||||
}
|
||||
}
|
||||
-28
@@ -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);
|
||||
}
|
||||
}
|
||||
+24
@@ -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}"));
|
||||
}
|
||||
}
|
||||
+32
@@ -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}");
|
||||
}
|
||||
}
|
||||
-43
@@ -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));
|
||||
}
|
||||
}
|
||||
+21
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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=
|
||||
|
||||
+231
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+83
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+228
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+124
-70
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+108
@@ -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";
|
||||
}
|
||||
}
|
||||
+120
@@ -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) {}
|
||||
}
|
||||
+14
@@ -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.
|
||||
}
|
||||
}
|
||||
+192
-2
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+4
-9
@@ -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);
|
||||
}
|
||||
|
||||
+42
@@ -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]";
|
||||
}
|
||||
}
|
||||
+15
@@ -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);
|
||||
}
|
||||
}
|
||||
+55
@@ -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());
|
||||
}
|
||||
}
|
||||
+19
@@ -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;
|
||||
}
|
||||
}
|
||||
+57
@@ -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));
|
||||
}
|
||||
}
|
||||
+64
@@ -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);
|
||||
}
|
||||
}
|
||||
+88
-13
@@ -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
|
||||
|
||||
+219
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+170
-82
@@ -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 {}
|
||||
}
|
||||
|
||||
+164
@@ -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;
|
||||
}
|
||||
}
|
||||
+64
@@ -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();
|
||||
}
|
||||
}
|
||||
+63
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
@@ -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();
|
||||
}
|
||||
+295
@@ -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);
|
||||
}
|
||||
}
|
||||
+192
-5
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+111
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+159
@@ -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;
|
||||
}
|
||||
}
|
||||
+166
@@ -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();
|
||||
}
|
||||
}
|
||||
+181
@@ -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, ""));
|
||||
}
|
||||
}
|
||||
+758
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+175
@@ -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
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
@FunctionalInterface
|
||||
interface RedisCapabilityObservationPort {
|
||||
|
||||
void observe(RedisCapabilityObservationEvent.Event event);
|
||||
}
|
||||
+125
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+321
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
@@ -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();
|
||||
}
|
||||
+43
@@ -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);
|
||||
}
|
||||
}
|
||||
+22
-3
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+57
@@ -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());
|
||||
}
|
||||
}
|
||||
+40
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+63
@@ -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(),
|
||||
"");
|
||||
}
|
||||
}
|
||||
+85
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+118
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+76
@@ -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);
|
||||
}
|
||||
}
|
||||
+50
@@ -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
|
||||
}
|
||||
}
|
||||
+553
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+56
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+394
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+503
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+39
@@ -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]";
|
||||
}
|
||||
}
|
||||
+81
@@ -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));
|
||||
}
|
||||
}
|
||||
+98
@@ -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));
|
||||
}
|
||||
}
|
||||
+54
@@ -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");
|
||||
}
|
||||
}
|
||||
+54
@@ -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);
|
||||
}
|
||||
}
|
||||
+77
@@ -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());
|
||||
}
|
||||
}
|
||||
+78
@@ -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);
|
||||
}
|
||||
}
|
||||
+44
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+74
@@ -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
Reference in New Issue
Block a user