feat: add production capability foundations

This commit is contained in:
donghyeon-ka
2026-07-31 23:50:44 +09:00
parent b3add0162d
commit 567422f2e5
757 changed files with 132385 additions and 2146 deletions
@@ -15,6 +15,10 @@ Package root: `dev.caskeleton.adapter.outbound.cache`.
- Implement semantic cache ports from `application-core` without exposing Redis concepts to core.
- Own canonical physical keys, digesting, codec/envelope, program catalog, typed Redis atomic
facades, runtime client adaptation, and capability-specific failure semantics.
- Implement absolute soft/hard expiry and deterministic bounded jitter behind the semantic cache
port; cache-aside/source protection policy remains framework-free in `application-core`.
- Implement the provider-neutral `EdgeRateLimitPort` with dedicated coordination Redis settings,
connection/admission, private keys and versioned atomic programs.
- Keep the legacy cache router isolated while consumers migrate to semantic ports.
- Reuse `adapter:outbound:support` for shared outbound concerns.
@@ -24,10 +28,14 @@ Package root: `dev.caskeleton.adapter.outbound.cache`.
`src/config/architecture/modules.json` entry.
- No inbound transport, persistence entity/repository, bootstrap, or sample dependency.
- Cache adapters do not decide business freshness, entitlement, or domain fallback rules.
- Physical Redis TTL must equal encoded hard expiry; future/corrupt schema must never collapse into
an ordinary miss.
- Application/domain code must not receive raw Redis keys, commands, Lua/Function names, SDK
objects, topology, or connection types.
- Cache fail-open behavior must not be reused for session, idempotency, strict quota, lease, or
fencing.
- Rate-limit composition must not reuse `app.cache.redis`, its connection, external client mode or
failure-open semantics; v1 is coordination-role and fail-closed only.
- The standalone runtime/cache service lane is R1 evidence only. Sentinel/Cluster, TLS/ACL,
persistence/restart, eviction and fault evidence are required separately for R2.
+259 -20
View File
@@ -10,16 +10,157 @@
## 현재 readiness
현재 standalone runtime과 semantic string cache는 R1이다. 모듈이 Lettuce connection lifecycle,
현재 checked-in readiness registry에는 `selected` card가 없으므로 Redis R2 release claim도
없다.
| Capability card | 현재 상태 | Promotion topology |
| --- | --- | --- |
| cache | `implemented-candidate` | standalone |
| edge rate limit | `implemented-candidate` | standalone |
| request-replay idempotency | `implemented-candidate` | standalone |
| cache refresh soft lease | `implemented-candidate` | standalone |
| session | `implemented-candidate` | standalone |
| fenced coordination | `not-implemented` | 없음 |
`implemented-candidate`는 구현과 standalone/security/fault/compatibility evidence lane이 있다는
뜻일 뿐 release selection이나 R2 qualification이 아니다. 현재 evidence는 Sentinel/Cluster,
k3s multi-node, topology failover, credential/certificate rotation 또는 R3를 증명하지 않는다.
모듈은 Lettuce connection lifecycle,
finite command timeout, reconnect replay 차단, finite request queue/admission, positive/negative
TTL, digest-protected bounded binary envelope, HMAC physical key,
invalidation, Lua `EVALSHA -> NOSCRIPT -> EVAL` 실행기를 제공한다.
TTL, absolute soft/hard expiry, deterministic bounded TTL jitter, digest-protected v2 binary
envelope, HMAC physical key,
invalidation, closed-catalog
`EVALSHA -> NOSCRIPT -> SCRIPT LOAD -> digest verify -> EVALSHA` recovery를 제공한다.
`app.cache.redis.client-mode=external`이면 프로젝트가 제공한 `RedisClient` 호환 경로를 사용하고
managed connection을 생성하지 않는다.
명시적으로 Redis 7.4 image를 띄워 실행하는 standalone lane이 실제 expiry
compare-and-delete Lua 실행을 검증하지만 Sentinel/Cluster,
TLS/ACL/credential rotation, restart/fault/eviction evidence, health/metrics가 없으므로 R2가 아니다.
명시적으로 최소 지원 Redis 7.2 image를 띄워 실행하는 standalone lane이 실제 expiry,
compare-and-delete, cache `NX`, observation-token compare-and-replace, 세 rate-limit 프로그램,
각 프로그램의 exact-boundary/denial-no-consume, clock-regression state 불변,
token refill remainder와 malformed hash 분류를 검증한다. TLS named-user ACL에서 semantic
readiness의 `SCRIPT LOAD`/대표 명령 거부 증거는 있지만 Sentinel/Cluster, credential rotation,
restart/fault/eviction과 capability 전체의 운영 증거가 완성되지 않았으므로 R2가 아니다.
## Role policy와 health 경계
Canonical role binding은 startup에 다음 정책을 fail-closed로 검증한다.
- `CACHE`: `required=false`, `expected-eviction=allkeys-lfu|allkeys-lru`
- `COORDINATION`: `required=true`, `expected-eviction=noeviction`
- `SESSION`: `required=true`, `expected-eviction=noeviction`
Redis 모듈은 바인딩된 role router만 사용해 capability-aware semantic probe를 수행한다. PING만으로
ready를 선언하지 않는다. 모든 plan은 `ca-health:` namespace의 bounded opaque nonce key에 먼저
5초 TTL을 부여하고 SET/GET round trip을 검증한다. 선택 capability별 대표 프로그램은 다음과 같다.
- cache: `SET_IF_ABSENT_WITH_TTL`
- rate limit: `RATE_FIXED_WINDOW_V2`
- request-replay idempotency: `IDEMPOTENCY_CLAIM_V1`
- efficiency lease: `LEASE_ACQUIRE_V1`
- session: `SESSION_CREATE_V1`
대표 프로그램은 catalog digest의 `EVALSHA` 경로와 bounded result schema를 검증한다. 별도의
catalog-owned `semantic-capability-acl-v1` 프로그램은 Redis Lua API의
`redis.acl_check_cmd`로 대표 프로그램의 exact ACL command/key surface와 `SCRIPT LOAD` 권한을
비변경 방식으로 확인하고, `redis.REDIS_VERSION_NUM`으로 명시적인 Redis `>=7.2` policy gate를
먼저 적용한다. 두 Lua API 상수/함수는 Redis 7.0부터 제공되지만 이 템플릿이 지원을 선언하는
minimum은 7.2다. runtime identity에 허용해야 하는 probe key pattern은
`~ca-health:*`다. probe는 성공/실패와 무관하게 best-effort cleanup을 수행하고, cleanup이
거절돼도 모든 생성 key는 최대 5초 안에 만료된다.
각 role은 startup에 full semantic qualification을 완료한 관측을 seed한다. 이후 health scrape는
`APP_REDIS_SEMANTIC_PROBE_MINIMUM_INTERVAL`(기본 5초) 동안 같은 관측을 재사용하고 role별
single-flight로만 refresh한다. refresh follower는 기다리지 않으며 15초 기본
`APP_REDIS_SEMANTIC_PROBE_MAXIMUM_STALENESS` 안에서는 이전 관측과 `semanticObservedAt`,
`semanticAgeMillis`, `semanticStale=true`를 반환한다. 최대 staleness를 넘으면
`SEMANTIC_OBSERVATION_STALE`로 fail closed한다. eligibility와 age는 monotonic ticker를 사용해
wall-clock jump의 영향을 받지 않는다.
연결 가능한 optional/required role의 ACL, Redis 7.2 minimum, program result/schema mismatch는
모두 startup-fatal이다. 명확히 분류된 temporary connect/PING 실패만 optional CACHE를 dormant
route와 `COMMAND_UNAVAILABLE` 관측으로 시작하게 한다. health-triggered single-flight reconnect는
후보에 PING과 full semantic qualification을 모두 수행한 뒤에만 기존 router를 swap하며,
required COORDINATION/SESSION과 auth/TLS/material/unknown failure는 계속 fail closed한다.
Cluster에서 same-slot probe가 증명하는 범위는 해당 hash slot owner 한 노드뿐이다. 이 결과를
cluster 전체 노드나 failover target의 version/ACL/program 호환성 증거로 확대 해석하면 안 되며,
운영 promotion 전 별도의 cluster-wide 외부 conformance가 필요하다.
`shared-contract`의 framework-neutral snapshot은 role, 선택된 capability, availability,
sanitized reason, semantic observation metadata와 expected eviction만 제공한다. semantic success, read/write failure,
program ACL denial, program failure, admission saturation, recent command failure, closed route,
command unavailable, probe-in-progress, stale observation은 서로 다른 bounded reason이다. endpoint, deployment ID, key/value,
username, credential/trust reference와 server exception은 health detail에 노출하지 않는다.
Actuator 타입과 health-group 소유권은 `app-bootstrap`에 있다. CACHE 장애는
`redisOptional``state=DEGRADED` detail로만 나타나고 readiness를 내리지 않는다.
COORDINATION/SESSION 장애는 `redisRequired``DOWN`으로 만들며, 어떤 Redis contributor도
liveness에는 포함되지 않는다. role binding이 없으면 Redis client 생성과 Redis health
contributor 생성은 모두 0이다.
이 runtime은 Redis `CONFIG GET/SET` 권한을 요구하거나 노출하지 않는다. 따라서
`expected-eviction` 검증은 설정 의도에 대한 startup 검증이며 실제 server의
`maxmemory-policy`를 증명하지 않는다. Snapshot/health detail은 이 한계를
`CONFIGURED_EXPECTATION_ONLY`로, 외부 증거 상태를
`externalEvictionAttestation=INCOMPLETE`로 명시한다. 운영 readiness를 더 강하게 만들려면 배포
파이프라인의 외부 conformance job 또는 서명된 operator attestation으로 effective policy를
검증해야 한다. semantic probe는 runtime `CONFIG`/`ACL` 조회나 변경 권한을 요구하지 않는다.
## Distributed edge rate limit
`shared-contract``EdgeRateLimitPort` 뒤에서 fixed window, sliding-window counter, token bucket을
정확히 하나의 versioned Lua 실행으로 평가한다. 세 프로그램은 Redis `TIME`을 한 번만 읽고, server
time, bounded clock-regression clamp, denial-no-consume, finite state TTL과 정확히 7개 필드인 응답
계약을 공유한다. Redis `TYPE`의 status-table/string 차이를 정규화하고 malformed hash field는
typed incompatibility로 닫는다. Token bucket은 refill division remainder를 상태로 보존해 호출
빈도에 따라 quota가 달라지지 않는다. Sliding counter만 algorithm certainty가 approximate이고
나머지는 certain이다.
모든 closed program manifest의 `minimumRedisVersion`은 실제 minimum qualification lane과 같은
7.2다. 더 낮은 Redis 버전은 별도 service lane이 추가되기 전까지 호환을 주장하지 않는다.
## Redis-backed HTTP session
`redis-session` readiness card는 standalone을 선택 topology로 하는 implemented candidate다.
`RedisVersionedSessionRepository`는 Spring Session의 저장소 경계만 구현하고, 쿠키·CSRF·session
fixation 정책은 inbound web이 소유한다. 실제 Redis 상태 변경은 manifest로 닫힌 6개 Lua 프로그램
(create/inspect/save/touch/revoke/rotate)을 통해서만 수행한다.
- raw session ID는 physical key에 들어가지 않고 versioned HMAC digest로 변환된다.
- idle timeout과 absolute lifetime을 동시에 적용하며 touch 쓰기는 설정된 interval로 제한한다.
- logout은 revision `0`의 adapter-private force-revoke를 사용한다. 하나의 Lua 실행에서 tombstone을
먼저 만들고 live hash를 삭제하므로 concurrent stale save가 세션을 부활시킬 수 없다.
- rotation은 old ID tombstone과 new ID 생성을 원자적으로 수행한다. old/new ID가 서로 다른 Cluster
slot이므로 현재 activation은 standalone만 허용하고 Cluster와 Sentinel을 startup에서 거부한다.
- 저장 payload는 N/N-1 version을 읽는 명시적 primitive allowlist envelope다. Java serialization과
default typing을 쓰지 않는다. SHA-256 checksum은 우발적 손상 탐지용이며 authenticity 또는 공격자
변조 방지 보장이 아니다.
- timeout/response loss와 OOM은 성공이나 miss로 바꾸지 않고 unavailable/indeterminate로 닫는다.
별도 요청에서 같은 operation ID를 자동 재사용해 reconcile하지 않으므로 운영자는 timeout 뒤에
mutation 성공을 추정하면 안 된다.
현재 저장소는 의도적으로 unindexed baseline이다. principal lookup, 사용자 전체 logout,
maximum-concurrent-session 제어는 제공하지 않는다. 이 기능이 필요한 프로젝트는 별도 bounded index와
그 index의 원자성·복구 증거를 추가해야 한다. 현재 `card-redis-session` 레인은 같은 JVM 안의 서로
독립적인 두 runtime/repository client가 하나의 standalone Redis를 공유할 때의 logout/stale-save
race, TLS+named ACL, partition+`noeviction` OOM/recovery, Redis 7.2/7.4 compatibility를 검증한다.
이는 multi-process/pod, rolling deployment, pod/network failure qualification이 아니다.
아웃바운드 provider의 기본값은
`ca-skeleton.capabilities.rate-limit.provider=disabled`다. `redis`로 선택하면 canonical
`COORDINATION` role, `failure-policy=fail-closed`, default policy와 secret reference가 모두
필요하다. `app.rate-limit.enabled`는 HTTP transport enforcement만 제어하며 provider를 암묵적으로
선택하거나 fallback을 만들지 않는다. 설정은 `app.cache.redis`를 fallback으로 사용하지 않고,
`distributedRateLimiter`라는 semantic port bean만 외부에 제공한다. Caller deadline이 canonical
Redis command timeout보다 짧으면 command를 보내지 않고 typed no-mutation outcome을 반환한다.
Rate-limit physical key는 raw principal/IP/API key를 포함하지 않고 policy ID/revision/algorithm과
이미 pseudonymized된 subject digest를 다시 HMAC한다. Unknown policy/state/program/reply,
pre-send admission failure, post-dispatch indeterminate failure와 unsafe Redis clock을 서로 다른
outcome으로 보존하며 fail-open하지 않는다. 현재 standalone과 standalone TLS+named ACL의
`implemented-candidate` evidence가 있다. Sentinel/Cluster, topology failover,
credential/certificate rotation, effective eviction/persistence attestation과 R3 증거는 없으며,
checked-in `selected` card가 없으므로 R2 release claim도 없다.
## Application cache contract
@@ -36,6 +177,21 @@ TLS/ACL/credential rotation, restart/fault/eviction evidence, health/metrics가
TTL, jitter, codec, topology와 Redis SDK 타입은 이 port에 들어가지 않는다. 실제 product의
use case는 `CacheRegionPort`를 상속한 semantic subtype을 정의해야 한다.
`application-core``CacheAsideExecutor`는 lookup/source/write 흐름을 공통화하고 다음을
보장한다.
- fresh/negative hit에서 source를 호출하지 않음;
- authoritative absence만 negative cache하고, miss refill은 `ONLY_IF_ABSENT`, stale/quarantine
refill은 `ONLY_IF_OBSERVED`로 기록;
- classified transient source failure에서만 hard expiry 전 stale fallback;
- local single-flight의 in-flight key/waiter bound와 abandoned-flight opportunistic cleanup;
- source bulkhead의 concurrency/admission/load deadline bound;
- unclassified exception과 interrupt/cancellation 보존.
동기 source loader는 cooperative cancellation token을 확인해야 한다. 임의 source 코드를
강제 종료하지 않으며, source가 token/deadline을 무시하면 bulkhead permit은 반환 시점까지
점유된다.
## Physical key
`RedisKeyBuilder`만 다음 canonical shape를 만든다.
@@ -50,24 +206,43 @@ version, 정확히 하나인 hash tag와 전체 UTF-8 byte bound를 검증한다
## Atomic program foundation
`redis/program-set.json`은 세 Lua resource의 exact digest, signature, status, complexity와 timeout
certainty를 기록한다. `RedisAtomicPrimitives`는 compare-delete, compare-expire,
set-if-absent-with-TTL을 typed result로 노출하고 unknown status를 compatibility failure로
처리한다. owner/value/operation/TTL은 Redis 호출 전에 제한된다.
`redis/*-program-set.json``redis/program-set.json`은 cache/rate/idempotency/lease/session 및
primitive Lua resource의 exact digest, signature, status, complexity와 timeout certainty를
기록한다. `RedisAtomicPrimitives`는 compare-delete,
compare-expire, set-if-absent-with-TTL, replace-if-observed-with-TTL을 typed result로 노출하고
unknown status를 compatibility failure로 처리한다. owner/value/observation/operation/TTL은
Redis 호출 전에 제한된다. `redis/rate-program-set.json`은 structured rate-limit 프로그램의
별도 digest/signature/status manifest다.
Generic descriptor/catalog/executor와 typed primitive facade는 package-private collaborator다.
Spring composition에는 raw Redis key/value/TTL을 받는 bean을 노출하지 않으며, 이후 semantic
port adapter가 내부에서만 이 facade를 사용한다.
따라서 이 program set은 현재 internal R0 foundation이며, 실제 도메인 capability가 바로 소비할
수 있는 production bean이나 application port가 아니다.
이 primitive facade 자체는 application에 노출되는 범용 Redis port가 아니다. Cache, rate limit,
idempotency, soft lease, session의 semantic provider만 closed catalog를 내부에서 소비하며, 이
구조 자체가 release selection이나 R2 qualification을 뜻하지 않는다.
`RedisLuaProgramExecutor`가 catalog source로 SHA-1 script identity를 계산하여 `EVALSHA`를 먼저
호출하고 정확히 `NOSCRIPT`일 때만 compiled script를 `EVAL`한다. signature/argument bounds는
호출하고 정확히 `NOSCRIPT`일 때만 catalog script를 `SCRIPT LOAD`한다. 반환 digest가 예상 identity와
같은지 확인한 뒤 `EVALSHA`를 한 번만 재시도한다. signature/argument bounds는
client 호출 전에 다시 검증하고 descriptor catalog membership 및 반환 status membership을
확인한다. unit lane은 강제 `NOSCRIPT` fallback을 검증하고 standalone real-service lane은
compare-and-delete의 실제 atomic execution을 검증한다.
확인한다. unit lane은 강제 `NOSCRIPT` load/retry를 검증하고 standalone real-service lane은
compare-and-delete, NX, bounded trailing-digest observed replace, concurrent-writer 보존을 실제
Redis 7.2에서 검증한다. 같은 lane은 16MiB payload의 record/read/observed-replace와
16MiB+1 사전 거부, mutation interrupt의 `INDETERMINATE` certainty와 interrupt flag 복원도
실행한다.
## Managed runtime과 semantic region
Canonical activation은
`ca-skeleton.capabilities.cache.bindings.default=redis`
`ca-skeleton.providers.redis.roles.cache`를 함께 요구한다. 전자는 semantic policy를, 후자는
topology/TLS/ACL credential을 소유한다. Canonical region은 legacy `app.cache.redis.host`,
`password`, raw HMAC 값을 읽지 않고 CACHE role router와
`RedisCredentialMaterialProvider``secret://` reference만 사용한다. 같은 CACHE router가 L2
command와 invalidation Pub/Sub을 함께 route하므로 topology rotation 때 새 subscription ACK가
확인된 뒤 route가 교체된다. Canonical/legacy 동시 활성은 precedence를 추측하지 않고 startup에서
거절한다. 현재 템플릿이 자동 조합하는 semantic region ID는 `default` 하나이며, 여러 product
region은 region registry/compiler가 추가되기 전까지 자동 생성한다고 주장하지 않는다.
`app.cache.redis.enabled=true`이고 `client-mode=managed`(기본값)이면 `LettuceRedisRuntime`
단일 binary connection을 생성하고 종료 시 connection/client를 닫는다. 프로젝트가
`RedisClient`를 직접 제공하는 경우에는 `client-mode=external`을 명시해야 한다. 이 선택을
@@ -86,13 +261,24 @@ opaque source revision에는 대소 비교 의미가 없으므로
`ONLY_IF_SOURCE_REVISION_NEWER`는 임의 lexical comparison을 하지 않고
`NOT_RECORDED_PROVIDER_POLICY`를 반환한다.
Envelope는 source revision의 application invariant(1..128 characters)를 decode 때도 다시
검사하고 canonical bytes의 SHA-256 digest가 맞지 않으면 corrupt schema result로 격리한다.
Envelope v2는 source revision, soft/hard absolute expiry와 payload를 digest로 보호한다.
`soft <= now < hard`는 stale, `hard <= now`는 expired miss다. Retired v1은 명시적 quarantine
후 reload 대상이고 future/corrupt envelope는 fail-fast다. Integrity digest를 version byte보다
먼저 검사하며, digest가 맞더라도 현재 v2 구조가 잘못되면 corrupt로 분류한다. Stale/retired
lookup은 envelope digest를 opaque observation token으로 전달하고, cache-aside는 Lua에서 현재
digest가 그 token과 같을 때만 새 envelope로 교체한다. 따라서 조회와 refresh 사이의 writer를
삭제하거나 덮어쓰지 않는다. Source revision의 application invariant (1..128 characters)는
decode 때도 다시 검사한다.
`positive-soft-ttl`, 기존 `positive-ttl`(hard), `negative-ttl`, `ttl-jitter`,
`minimum-hard-ttl`은 startup에 immutable policy로 freeze된다. Jitter는 HMAC-derived physical
key와 policy revision으로 결정적이며 positive soft/hard에는 같은 factor를 적용한다. Redis
physical TTL은 envelope에 기록된 hard expiry와 같다.
추가 runtime setting은 `app.cache.redis.maximum-queued-commands=8`(범위 `1..4096`)과
`app.cache.redis.maximum-in-flight-bytes=16777216`이다. command count와 retained
request/response byte budget을 모두 통과해야 Lettuce 호출을 시작하며,
`queue-count × (maximum-value-bytes + overhead)`도 byte bound 이하여야 한다. 이 관계는
`app.cache.redis.maximum-in-flight-bytes=16777216`이다. 최대 readable envelope와 최대 command
byte를 별도로 계산하며, command count와 retained request/response byte budget을 모두 통과해야
Lettuce 호출을 시작한다. `queue-count × maximum-command-bytes`도 byte bound 이하여야 한다. 이 관계는
timeout 완료 뒤 driver가 응답 decode 전까지 command args를 유지하는 경우도 유한하게 제한한다.
timeout 직후에는 runtime admission population과 Lettuce retained population이 겹칠 수 있으므로
최악 상한은 대략 `maximum-in-flight-bytes + queue-count × per-command-bound`이고, 설정 검증은
@@ -104,6 +290,58 @@ Redis가 wire에 내보내는 bulk reply 자체를 `maximum-envelope-bytes + 1`
Netty/codec에 먼저 할당하지 않는다. managed runtime을 활성화할 때 host가 누락되면
`localhost`로 암묵 fallback하지 않고 startup을 실패시킨다.
Generation/revision fence는 mass/per-key invalidation과 source-load race를 막는다. Distributed
refresh soft lease는 정상 시 중복 refresh를 줄이지만 TTL expiry/crash에서는 duplicate owner를
허용하며, cache generation fence를 대체하는 correctness lock이 아니다.
`app.cache.redis.l1.enabled=true`는 semantic string cache 앞에만 optional local L1을 붙인다.
L1은 maximum entries, maximum accounted weight, per-entry accounted weight, local TTL, generation
recheck interval과 invalidation subscriber queue를 모두 finite하게 검증한다. Local expiry는 Redis
envelope hard expiry보다 길어질 수 없다. Weight는 HMAC-derived local identity와 UTF-8 value,
entry/lookup metadata에 대한 고정 conservative allowance를 더한 admission/eviction accounting
proxy이며, JVM heap reservation이나 실제 object layout의 exact byte guarantee가 아니다.
Invalidation Pub/Sub payload는 raw semantic key를 포함하지 않고 HMAC-authenticated bounded
message를 사용한다. Pub/Sub은 durable/exact invalidation 원장이 아니라 eviction hint다. Subscriber
disconnect나 queue overflow는 L1 전체를 flush하고, monotonic local invalidation epoch가 진행 중인
generation probe와 refill admission을 무효화한다. 재연결 뒤 generation을 다시 읽기 전에는 L1
admission을 허용하지 않는다. Hint 유실 시 mass invalidation은 periodic generation recheck,
per-key invalidation은 local TTL 안에서 Redis L2로 복귀한다.
이 local tier는 cache-only internal type을 요구하므로 session, idempotency, strict rate-limit,
coordination provider에 적용할 수 없다. 해당 capability들은 local fail-open cache semantics를
재사용하지 않는다.
Refresh-ahead와 probabilistic early refresh는 아직 구현하지 않았다. 둘 다 correctness baseline이
아니며, refresh-ahead는 명시적인 bounded hot-set registry/scheduler 없이 full keyspace scan으로
대체하지 않는다. Probabilistic early refresh도 versioned probability descriptor와 deterministic
property test가 생기기 전에는 readiness guarantee로 광고하지 않는다. Cache card에는 standalone
TLS+named ACL과 bounded fault evidence가 있지만 Sentinel/Cluster Pub/Sub/failover,
credential/certificate rotation, persistence/restart, effective eviction attestation,
multi-process/pod L1/L2 coherence와 R3 qualification은 아직 없다.
## Efficiency-only lease
`ca-skeleton.capabilities.lease.provider=redis`를 명시한 경우에만
`DistributedLeasePort`가 생성되며, canonical `COORDINATION` role router와 별도 HMAC secret
reference를 사용한다. 미선택 상태에서는 lease bean, secret resolution, native client와 thread
side effect가 모두 0이다.
이 port의 guarantee는 오직 `EFFICIENCY_ONLY`다. acquire/inspect/renew/release는 같은
owner token과 operation ID를 비교하고, response loss를 성공이나 실패로 추측하지 않고
`INDETERMINATE`/`UNKNOWN`으로 유지한다. caller가 최초 send 전에 보관한 같은 attempt로 inspect
또는 acquire replay를 해야 ownership을 복구할 수 있다. Handle validity는 Redis가 보고한 remaining
TTL에서 command 왕복 monotonic elapsed와 drift budget을 차감하며, server expiry wall clock은
telemetry 용도일 뿐이다. Watchdog는 worker와 registration 수, renewal cadence, application
deadline이 모두 유한하고 lease loss/unknown에서 작업 취소 callback을 한 번만 전달한다.
`redisEfficiencyLeaseTest`는 pinned Redis 7.2와 다음/승인 버전에서 standalone concurrency,
TLS/ACL, partition/response uncertainty와 compatibility를 별도 qualification한다. 이 test는
readiness card가 아니며 cache-refresh soft lease나 fenced coordination의 증거로 재사용되지
않는다. Fencing token과 protected-resource stale-token rejection은 구현하지 않았으므로
`redis-fenced-coordination` card는 계속 `not-implemented`다. 이 lease만으로 결제, 재고,
unique ID 또는 외부 장치 command 같은 correctness-sensitive write를 승인하면 안 된다.
## Legacy path
기존 `CacheStoreRouter`, `RedisCacheStore`, `FailOpenCacheStore`는 호환성을 위해 남아 있다. 이
@@ -118,4 +356,5 @@ cd src
./gradlew :application-core:check :adapter:outbound:cache-redis:check --console=plain
./gradlew :adapter:outbound:cache-redis:redisServiceTest \
-Dredis.test.host=127.0.0.1 -Dredis.test.port=6379 --console=plain
./gradlew :adapter:outbound:cache-redis:redisEfficiencyLeaseTest --console=plain
```
@@ -4,12 +4,35 @@ dependencies {
implementation project(':adapter:outbound:support')
implementation 'org.springframework.boot:spring-boot-autoconfigure'
implementation 'org.springframework.session:spring-session-core'
implementation 'org.springframework.session:spring-session-data-redis'
implementation 'org.springframework.data:spring-data-redis'
implementation 'io.lettuce:lettuce-core'
implementation 'io.micrometer:micrometer-core'
implementation 'org.slf4j:slf4j-api'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
}
tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' }
sourceSets {
redisTest {
java.srcDir 'src/redisTest/java'
resources.srcDir 'src/redisTest/resources'
compileClasspath += sourceSets.main.output
runtimeClasspath += sourceSets.main.output
}
}
configurations {
redisTestImplementation.extendsFrom testImplementation
redisTestCompileOnly.extendsFrom testCompileOnly
redisTestRuntimeOnly.extendsFrom testRuntimeOnly
}
dependencies {
redisTestImplementation 'org.testcontainers:testcontainers'
}
tasks.named('test') {
useJUnitPlatform {
excludeTags 'redis-service'
@@ -32,3 +55,562 @@ tasks.register('redisServiceTest', Test) {
}
shouldRunAfter tasks.named('test')
}
def verifyRedisEvidenceSourcesPresent = tasks.register('verifyRedisEvidenceSourcesPresent') {
group = 'redis verification'
description = 'Fails readiness lanes when the redisTest evidence source set is empty.'
inputs.files(sourceSets.redisTest.allSource)
doLast {
Set<File> javaSources = sourceSets.redisTest.java.files.findAll {
it.isFile() && it.name.endsWith('.java')
}
if (javaSources.isEmpty()) {
throw new GradleException(
'Redis evidence source set is empty; readiness tasks must not pass as NO-SOURCE.')
}
File imageRegistry = rootProject.file('gradle/redis-test-images.properties')
if (!imageRegistry.isFile() || imageRegistry.length() == 0) {
throw new GradleException(
"Redis evidence image registry is missing or empty: ${imageRegistry}")
}
}
}
def redisCapabilityMetadata = rootProject.ext.redisCapabilityMetadata
def redisSanitizedEvidenceFileNames = [
'manifest.json',
'capability-card.json',
'topology-fault-timeline.json'
] as Set<String>
def redisSanitizedBundleSha256 = { File directory ->
java.security.MessageDigest digest = java.security.MessageDigest.getInstance('SHA-256')
redisSanitizedEvidenceFileNames.toList().sort().each { String name ->
File file = new File(directory, name)
if (!file.isFile()) {
throw new GradleException(
"Redis sanitized bundle is missing ${name}: ${directory}")
}
byte[] nameBytes = name.getBytes('UTF-8')
byte[] contentBytes = file.bytes
digest.update(java.nio.ByteBuffer.allocate(Long.BYTES).putLong(nameBytes.length).array())
digest.update(nameBytes)
digest.update(java.nio.ByteBuffer.allocate(Long.BYTES).putLong(contentBytes.length).array())
digest.update(contentBytes)
}
digest.digest().encodeHex().toString()
}
def registerRedisEvidenceTask = { String taskName, String tagExpression, String descriptionText ->
def evidenceTask = tasks.register(taskName, Test) {
group = 'redis verification'
description = descriptionText
dependsOn verifyRedisEvidenceSourcesPresent
testClassesDirs = sourceSets.redisTest.output.classesDirs
classpath = sourceSets.redisTest.runtimeClasspath
useJUnitPlatform {
includeTags tagExpression
}
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
jvmArgs '-Duser.timezone=UTC'
systemProperty 'redis.image.registry',
rootProject.file('gradle/redis-test-images.properties').absolutePath
List<Map<String, Object>> sanitizedTimeline = []
List<String> declaredTags = tagExpression.split(/\s*&\s*/).toList()
String cardTag = declaredTags.find { it.startsWith('card-') }
String cardId = cardTag == null ? null : cardTag.substring('card-'.length())
Set<String> evidenceCategories = [
'standalone',
'security',
'sentinel',
'cluster',
'fault',
'compatibility'
] as Set<String>
String evidenceCategory = declaredTags.find {
it.startsWith('redis-') && evidenceCategories.contains(it.substring('redis-'.length()))
}
if (evidenceCategory != null) {
evidenceCategory = evidenceCategory.substring('redis-'.length())
}
File evidenceDirectory = layout.buildDirectory.dir(
"redis-evidence/${taskName}").get().asFile
outputs.dir evidenceDirectory
afterTest { descriptor, result ->
String identity = "${descriptor.className ?: ''}#${descriptor.name ?: ''}"
String identityDigest = java.security.MessageDigest.getInstance('SHA-256')
.digest(identity.getBytes('UTF-8')).encodeHex().toString()
sanitizedTimeline << [
sequence : sanitizedTimeline.size() + 1,
testCaseIdSha256: identityDigest,
outcome : result.resultType.name(),
durationMillis : Math.max(0L, result.endTime - result.startTime)
]
}
afterSuite { descriptor, result ->
if (descriptor.parent != null) {
return
}
evidenceDirectory.mkdirs()
Map<String, Object> card = cardId == null
? null
: rootProject.ext.redisReadinessCards[cardId] as Map<String, Object>
Map<String, String> digests = rootProject.ext.redisEvidenceDigests()
Map<String, Object> metadata = cardId == null
? [
providerIds : [],
roles : [],
programs : [],
keyVersions : [],
codecVersions : [],
guarantees : ['cross-cutting Redis evidence lane'],
nonGuarantees : ['does not qualify a capability card by itself'],
requiredSettings: []
]
: redisCapabilityMetadata[cardId] as Map<String, Object>
Map<String, Object> capabilityCard = [
schemaVersion : 1,
cardId : cardId,
readiness : card?.state,
releaseQualification: 'NOT_CLAIMED',
promotionTopology : card?.selectedTopology,
sourceRevision : rootProject.ext.redisEvidenceSourceRevision,
sourceTreeState : rootProject.ext.redisEvidenceSourceTreeState,
digests : digests,
minimumRedisVersion: '7.2',
providerIds : metadata.providerIds,
roles : metadata.roles,
programIds : metadata.programs,
keyVersions : metadata.keyVersions,
codecVersions : metadata.codecVersions,
guarantees : metadata.guarantees,
nonGuarantees : metadata.nonGuarantees,
requiredSettings : metadata.requiredSettings,
evidenceProfile : card?.requiredEvidence ?: []
]
File capabilityCardFile = new File(evidenceDirectory, 'capability-card.json')
capabilityCardFile.setText(
groovy.json.JsonOutput.prettyPrint(
groovy.json.JsonOutput.toJson(capabilityCard)) + '\n',
'UTF-8')
Map<String, Object> timeline = [
schemaVersion: 1,
taskName : taskName,
cardId : cardId,
topology : card?.selectedTopology,
evidence : evidenceCategory,
timelineKind : 'SANITIZED_TEST_RESULT',
actualEventTimeline: 'NOT_CAPTURED',
sourceRevision: rootProject.ext.redisEvidenceSourceRevision,
sourceTreeState: rootProject.ext.redisEvidenceSourceTreeState,
digests : digests,
events : sanitizedTimeline
]
File timelineFile = new File(evidenceDirectory, 'topology-fault-timeline.json')
timelineFile.setText(
groovy.json.JsonOutput.prettyPrint(
groovy.json.JsonOutput.toJson(timeline)) + '\n',
'UTF-8')
Closure<String> sha256 = { File file ->
java.security.MessageDigest.getInstance('SHA-256')
.digest(file.bytes).encodeHex().toString()
}
String outcome = result.resultType.name() == 'FAILURE'
? 'failed'
: (result.testCount == 0 || result.skippedTestCount > 0
? 'skipped-with-reason'
: 'executed')
Map<String, Object> manifest = [
schemaVersion : 1,
taskPath : path,
tagExpression : tagExpression,
cardId : cardId,
cardState : card?.state,
selectedTopology : card?.selectedTopology,
evidenceCategory : evidenceCategory,
outcome : outcome,
tests : [
discovered: result.testCount,
executed : result.testCount - result.skippedTestCount,
passed : result.successfulTestCount,
failed : result.failedTestCount,
errors : 0,
skipped : result.skippedTestCount
],
runtimeImageAttestation: 'NOT_CAPTURED',
actualEventTimeline: 'NOT_CAPTURED',
releaseQualification: 'NOT_CLAIMED',
sourceRevision : rootProject.ext.redisEvidenceSourceRevision,
sourceTreeState : rootProject.ext.redisEvidenceSourceTreeState,
digests : digests,
companionSha256 : [
capabilityCardSha256: sha256(capabilityCardFile),
timelineSha256 : sha256(timelineFile)
]
]
new File(evidenceDirectory, 'manifest.json').setText(
groovy.json.JsonOutput.prettyPrint(
groovy.json.JsonOutput.toJson(manifest)) + '\n',
'UTF-8')
}
doFirst {
[
'manifest.json',
'capability-card.json',
'topology-fault-timeline.json'
].each { String generatedFile ->
new File(evidenceDirectory, generatedFile).delete()
}
layout.buildDirectory.file(
"redis-evidence-sanitizer/${taskName}.sha256").get().asFile.delete()
Set<File> matchingSources = sourceSets.redisTest.java.files.findAll { File source ->
if (!source.isFile() || !source.name.endsWith('.java')) {
return false
}
String content = source.getText('UTF-8')
declaredTags.every { String tag -> content.contains("@Tag(\"${tag}\")") }
}
if (matchingSources.isEmpty()) {
throw new GradleException(
"${taskName}: no redisTest source declares every required tag " +
"${declaredTags}; zero-evidence readiness must not pass.")
}
}
}
def sanitizerTask = tasks.register("${taskName}SanitizeEvidence") {
group = 'redis verification'
description = "Validates the bounded sanitized artifact for ${taskName} before upload."
mustRunAfter evidenceTask
File sanitizerMarker = layout.buildDirectory.file(
"redis-evidence-sanitizer/${taskName}.sha256").get().asFile
doFirst {
sanitizerMarker.delete()
}
doLast {
File evidenceDirectory = layout.buildDirectory.dir(
"redis-evidence/${taskName}").get().asFile
if (!evidenceDirectory.isDirectory()) {
throw new GradleException(
"${taskName}: sanitized evidence directory was not generated")
}
Set<String> allowedNames = redisSanitizedEvidenceFileNames
List<File> files = evidenceDirectory.listFiles()?.findAll { it.isFile() } ?: []
if (files.collect { it.name } as Set<String> != allowedNames ||
evidenceDirectory.listFiles()?.any { it.isDirectory() }) {
throw new GradleException(
"${taskName}: sanitized evidence must contain exactly ${allowedNames}")
}
files.each { File file ->
if (file.length() > 1_048_576L ||
java.nio.file.Files.isSymbolicLink(file.toPath()) ||
!file.toPath().toRealPath().startsWith(
evidenceDirectory.toPath().toRealPath())) {
throw new GradleException(
"${taskName}: oversized, symlinked, or path-escaping artifact ${file}")
}
String text = file.getText('UTF-8')
Map<String, java.util.regex.Pattern> forbidden = [
pem : java.util.regex.Pattern.compile(
'(?i)-----BEGIN [^-]*(?:PRIVATE KEY|CERTIFICATE)-----'),
aclMaterial : java.util.regex.Pattern.compile(
"(?i)(?:users\\.acl|--pass|[\"']password[\"']\\s*:)"),
uriUserInfo : java.util.regex.Pattern.compile(
'(?i)rediss?://[^\\s/@:]+:[^\\s/@]+@'),
secretReference : java.util.regex.Pattern.compile('(?i)secret://'),
rawMessageFields : java.util.regex.Pattern.compile(
'(?i)"(?:stackTrace|systemOut|systemErr|exception|containerId|host|ip|port|endpoint|rawKey|physicalKey|value|sessionId|csrf|idempotencyToken|ownerToken|operationToken)"\\s*:')
]
forbidden.each { String marker, java.util.regex.Pattern pattern ->
if (pattern.matcher(text).find()) {
throw new GradleException(
"${taskName}: sanitized artifact ${file.name} contains forbidden ${marker} material")
}
}
}
Map<String, Object> manifest = new groovy.json.JsonSlurper().parse(
new File(evidenceDirectory, 'manifest.json')) as Map<String, Object>
Map<String, Object> capability = new groovy.json.JsonSlurper().parse(
new File(evidenceDirectory, 'capability-card.json')) as Map<String, Object>
Map<String, Object> timeline = new groovy.json.JsonSlurper().parse(
new File(evidenceDirectory, 'topology-fault-timeline.json')) as Map<String, Object>
Set<String> manifestFields = [
'schemaVersion',
'taskPath',
'tagExpression',
'cardId',
'cardState',
'selectedTopology',
'evidenceCategory',
'outcome',
'tests',
'runtimeImageAttestation',
'actualEventTimeline',
'releaseQualification',
'sourceRevision',
'sourceTreeState',
'digests',
'companionSha256'
] as Set<String>
Set<String> capabilityFields = [
'schemaVersion',
'cardId',
'readiness',
'releaseQualification',
'promotionTopology',
'sourceRevision',
'sourceTreeState',
'digests',
'minimumRedisVersion',
'providerIds',
'roles',
'programIds',
'keyVersions',
'codecVersions',
'guarantees',
'nonGuarantees',
'requiredSettings',
'evidenceProfile'
] as Set<String>
Set<String> timelineFields = [
'schemaVersion',
'taskName',
'cardId',
'topology',
'evidence',
'timelineKind',
'actualEventTimeline',
'sourceRevision',
'sourceTreeState',
'digests',
'events'
] as Set<String>
if (manifest.keySet() != manifestFields ||
capability.keySet() != capabilityFields ||
timeline.keySet() != timelineFields ||
(manifest.tests as Map).keySet() != [
'discovered',
'executed',
'passed',
'failed',
'errors',
'skipped'
] as Set<String> ||
(manifest.digests as Map).keySet() != [
'registrySha256',
'imageRegistrySha256',
'programSetSha256',
'configurationSha256'
] as Set<String> ||
(manifest.companionSha256 as Map).keySet() != [
'capabilityCardSha256',
'timelineSha256'
] as Set<String>) {
throw new GradleException(
"${taskName}: sanitized evidence contains unknown or missing schema fields")
}
List<Map<String, Object>> events = timeline.events as List<Map<String, Object>>
if (events.size() > 10_000 ||
events.withIndex().any { Map<String, Object> event, int index ->
event.keySet() != [
'sequence',
'testCaseIdSha256',
'outcome',
'durationMillis'
] as Set<String> ||
event.sequence != index + 1 ||
!(event.testCaseIdSha256 ==~ /[0-9a-f]{64}/) ||
!(event.outcome in ['SUCCESS', 'FAILURE', 'SKIPPED']) ||
!(event.durationMillis instanceof Number) ||
(event.durationMillis as Number).longValue() < 0L
}) {
throw new GradleException(
"${taskName}: sanitized test summary contains malformed events")
}
if ((capability.requiredSettings as List).any {
!(it instanceof Map) ||
(it as Map).keySet() != ['name', 'type', 'constraint'] as Set<String>
}) {
throw new GradleException(
"${taskName}: capability card required settings are not a safe name/type/constraint projection")
}
Map<String, Object> tests = manifest.tests as Map<String, Object>
if (!(manifest.outcome in ['executed', 'failed', 'skipped-with-reason']) ||
events.size() != (tests.discovered as Number).intValue() ||
events.count { it.outcome == 'SUCCESS' } !=
(tests.passed as Number).intValue() ||
events.count { it.outcome == 'FAILURE' } !=
(tests.failed as Number).intValue() ||
events.count { it.outcome == 'SKIPPED' } !=
(tests.skipped as Number).intValue()) {
throw new GradleException(
"${taskName}: manifest outcome/counts do not match the sanitized test summary")
}
if (manifest.outcome == 'executed' &&
((tests.discovered as Number).longValue() <= 0L ||
(tests.executed as Number).longValue() <= 0L ||
(tests.passed as Number).longValue() <= 0L ||
(tests.failed as Number).longValue() != 0L ||
(tests.errors as Number).longValue() != 0L ||
(tests.skipped as Number).longValue() != 0L)) {
throw new GradleException(
"${taskName}: executed evidence must be positive with zero failure/error/skip")
}
if (manifest.outcome == 'failed' &&
(tests.failed as Number).longValue() <= 0L) {
throw new GradleException(
"${taskName}: failed evidence must retain a positive bounded failure count")
}
sanitizerMarker.parentFile.mkdirs()
String bundleSha = redisSanitizedBundleSha256(evidenceDirectory)
sanitizerMarker.setText("${bundleSha}\n", 'UTF-8')
if (manifest.outcome == 'skipped-with-reason') {
throw new GradleException(
"${taskName}: skipped or zero-executed evidence is not a passing readiness lane")
}
}
}
evidenceTask.configure {
finalizedBy sanitizerTask
}
evidenceTask
}
tasks.register('verifyRedisEvidenceArtifactsForUpload') {
group = 'redis verification'
description = 'Allows CI upload only when every generated Redis evidence directory was sanitized.'
doLast {
File evidenceRoot = layout.buildDirectory.dir('redis-evidence').get().asFile
File markerRoot = layout.buildDirectory.dir('redis-evidence-sanitizer').get().asFile
List<File> evidenceDirectories = evidenceRoot.isDirectory()
? evidenceRoot.listFiles().findAll { it.isDirectory() }
: []
if (evidenceDirectories.isEmpty()) {
throw new GradleException(
'No sanitized Redis evidence directory exists for upload')
}
Set<String> evidenceTasks = evidenceDirectories.collect { it.name } as Set<String>
Set<String> markerTasks = markerRoot.isDirectory()
? markerRoot.listFiles().findAll {
it.isFile() && it.name.endsWith('.sha256')
}.collect {
it.name.substring(0, it.name.length() - '.sha256'.length())
} as Set<String>
: [] as Set<String>
if (evidenceTasks != markerTasks) {
throw new GradleException(
"Redis evidence upload sanitizer coverage mismatch; evidence=${evidenceTasks}, markers=${markerTasks}")
}
evidenceDirectories.each { File directory ->
Set<String> files = directory.listFiles().findAll { it.isFile() }
.collect { it.name } as Set<String>
if (files != redisSanitizedEvidenceFileNames) {
throw new GradleException(
"Redis upload directory ${directory.name} is outside the sanitized allowlist")
}
String bundleSha = redisSanitizedBundleSha256(directory)
String recordedSha = new File(
markerRoot, "${directory.name}.sha256").getText('UTF-8').trim()
if (recordedSha != bundleSha) {
throw new GradleException(
"Redis upload sanitizer bundle marker is stale for ${directory.name}")
}
}
}
}
registerRedisEvidenceTask(
'redisStandaloneTest',
'redis-standalone',
'Runs real standalone Redis evidence. Docker/service absence and zero tests fail.')
registerRedisEvidenceTask(
'redisSecurityTest',
'redis-security',
'Runs Redis TLS, ACL, secret-redaction, and fail-closed security evidence.')
registerRedisEvidenceTask(
'redisSentinelTest',
'redis-sentinel',
'Runs the explicit Redis Sentinel topology evidence lane.')
registerRedisEvidenceTask(
'redisClusterTest',
'redis-cluster',
'Runs the explicit Redis Cluster topology evidence lane.')
registerRedisEvidenceTask(
'redisFaultTest',
'redis-fault',
'Runs bounded Redis outage, response-loss, memory, and recovery evidence.')
registerRedisEvidenceTask(
'redisCompatibilityTest',
'redis-compatibility',
'Runs pinned minimum/next/approved Redis compatibility evidence.')
registerRedisEvidenceTask(
'redisEfficiencyLeaseTest',
'redis-efficiency-lease',
'Runs non-fenced EFFICIENCY_ONLY lease standalone, security, fault, and compatibility qualification.')
def redisCardTags = [
redisCacheCapabilityTest : 'card-redis-cache',
redisRateLimitCapabilityTest : 'card-redis-edge-rate-limit',
redisIdempotencyCapabilityTest : 'card-redis-request-replay-idempotency',
redisSoftLeaseCapabilityTest : 'card-redis-cache-refresh-soft-lease',
redisFencedCoordinationCapabilityTest: 'card-redis-fenced-coordination',
redisSessionCapabilityTest : 'card-redis-session'
]
redisCardTags.each { String taskName, String cardTag ->
registerRedisEvidenceTask(
taskName,
cardTag,
"Runs all real-service evidence owned by Redis capability card ${cardTag}.")
}
def redisEvidenceTags = [
Standalone : 'redis-standalone',
Security : 'redis-security',
Sentinel : 'redis-sentinel',
Cluster : 'redis-cluster',
Fault : 'redis-fault',
Compatibility: 'redis-compatibility'
]
def redisCardTaskStems = [
Cache : 'card-redis-cache',
RateLimit : 'card-redis-edge-rate-limit',
Idempotency : 'card-redis-request-replay-idempotency',
SoftLease : 'card-redis-cache-refresh-soft-lease',
FencedCoordination: 'card-redis-fenced-coordination',
Session : 'card-redis-session'
]
redisCardTaskStems.each { String cardStem, String cardTag ->
redisEvidenceTags.each { String evidenceStem, String evidenceTag ->
registerRedisEvidenceTask(
"redis${cardStem}${evidenceStem}EvidenceTest",
"${cardTag} & ${evidenceTag}",
"Runs ${evidenceTag} evidence owned only by ${cardTag}.")
}
}
tasks.named('check') {
dependsOn tasks.named('redisStandaloneTest')
}
def redisLabContractDirectory = rootProject.file('../infra/redis-lab')
def redisLabContractTest = tasks.register('redisLabContractTest', Exec) {
group = 'verification'
description = 'Runs the VM-free Redis lab lifecycle and host-isolation contract with fake commands.'
workingDir rootProject.projectDir
executable 'bash'
args new File(redisLabContractDirectory, 'test/redis-lab-contract.sh').absolutePath
inputs.files(
new File(redisLabContractDirectory, 'versions.env'),
new File(redisLabContractDirectory, 'bin/redis-lab'),
new File(redisLabContractDirectory, 'cloud-init/node.yaml'),
new File(redisLabContractDirectory, 'lib/render-kubeconfig.awk'),
fileTree(new File(redisLabContractDirectory, 'test/fixtures')) {
include '**/*'
},
new File(redisLabContractDirectory, 'test/redis-lab-contract.sh'))
outputs.upToDateWhen { false }
}
tasks.named('check') {
dependsOn redisLabContractTest
}
+139 -118
View File
@@ -1,165 +1,186 @@
# This is a Gradle generated file for dependency locking.
# Manual edits can break the build and are not advised.
# This file is expected to be part of source control.
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=testCompileClasspath
ch.qos.logback:logback-classic:1.5.21=testCompileClasspath,testRuntimeClasspath
ch.qos.logback:logback-core:1.5.21=testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=redisTestCompileClasspath,testCompileClasspath
ch.qos.logback:logback-classic:1.5.21=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
ch.qos.logback:logback-core:1.5.21=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-annotations:2.20=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
com.github.docker-java:docker-java-api:3.7.0=redisTestCompileClasspath,redisTestRuntimeClasspath
com.github.docker-java:docker-java-transport-zerodep:3.7.0=redisTestCompileClasspath,redisTestRuntimeClasspath
com.github.docker-java:docker-java-transport:3.7.0=redisTestCompileClasspath,redisTestRuntimeClasspath
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
com.github.spotbugs:spotbugs-annotations:4.8.6=testCompileClasspath
com.github.spotbugs:spotbugs-annotations:4.8.6=redisTestCompileClasspath,testCompileClasspath
com.github.spotbugs:spotbugs:4.10.2=spotbugs
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor
com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs,testCompileClasspath
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
com.google.auto:auto-common:1.2.2=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
com.google.code.findbugs:jsr305:3.0.2=checkstyle,redisTestCompileClasspath,spotbugs,testCompileClasspath
com.google.code.gson:gson:2.13.2=spotbugs
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.38.0=testCompileClasspath
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.38.0=redisTestCompileClasspath,testCompileClasspath
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,redisTestAnnotationProcessor,testAnnotationProcessor
com.google.guava:guava:33.5.0-jre=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
com.google.guava:guava:33.6.0-jre=checkstyle
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,redisTestAnnotationProcessor,testAnnotationProcessor
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,redisTestAnnotationProcessor,testAnnotationProcessor
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath
com.jayway.jsonpath:json-path:2.9.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
commons-beanutils:commons-beanutils:1.11.0=checkstyle
commons-codec:commons-codec:1.19.0=redisTestCompileClasspath,redisTestRuntimeClasspath
commons-collections:commons-collections:3.2.2=checkstyle
commons-io:commons-io:2.20.0=redisTestCompileClasspath,redisTestRuntimeClasspath
commons-io:commons-io:2.21.0=spotbugs
commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
commons-logging:commons-logging:1.3.5=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
info.picocli:picocli:4.7.7=checkstyle
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
io.lettuce:lettuce-core:6.8.1.RELEASE=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-buffer:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-codec-base:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-codec-dns:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-common:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-handler:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-resolver-dns:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-resolver:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-transport-native-unix-common:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-transport:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.projectreactor:reactor-core:3.8.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath
jakarta.annotation:jakarta.annotation-api:3.0.0=testCompileClasspath,testRuntimeClasspath
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
io.lettuce:lettuce-core:6.8.1.RELEASE=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-commons:1.16.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-core:1.16.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-observation:1.16.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-buffer:4.2.7.Final=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-codec-base:4.2.7.Final=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-codec-dns:4.2.7.Final=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-common:4.2.7.Final=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-handler:4.2.7.Final=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-resolver-dns:4.2.7.Final=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-resolver:4.2.7.Final=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-transport-native-unix-common:4.2.7.Final=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-transport:4.2.7.Final=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.projectreactor:reactor-core:3.8.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.activation:jakarta.activation-api:2.1.4=redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.annotation:jakarta.annotation-api:3.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
javax.inject:javax.inject:1=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
jaxen:jaxen:2.0.0=spotbugs
net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
net.bytebuddy:byte-buddy-agent:1.17.8=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.bytebuddy:byte-buddy:1.17.8=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.java.dev.jna:jna:5.18.1=redisTestCompileClasspath,redisTestRuntimeClasspath
net.minidev:accessors-smart:2.6.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.minidev:json-smart:2.6.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
org.antlr:antlr4-runtime:4.13.2=checkstyle
org.apache.bcel:bcel:6.12.0=spotbugs
org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs
org.apache.commons:commons-compress:1.28.0=redisTestCompileClasspath,redisTestRuntimeClasspath
org.apache.commons:commons-lang3:3.20.0=checkstyle,redisTestCompileClasspath,redisTestRuntimeClasspath,spotbugs
org.apache.commons:commons-text:1.15.0=spotbugs
org.apache.commons:commons-text:1.3=checkstyle
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
org.apache.logging.log4j:log4j-api:2.25.2=spotbugs,testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-api:2.25.2=redisTestCompileClasspath,redisTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.xbean:xbean-reflect:3.7=checkstyle
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath
org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath
org.apiguardian:apiguardian-api:1.1.2=redisTestCompileClasspath,testCompileClasspath
org.assertj:assertj-core:3.27.6=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.awaitility:awaitility:4.3.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
org.dom4j:dom4j:2.2.0=spotbugs
org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath
org.hamcrest:hamcrest:3.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.hdrhistogram:HdrHistogram:2.2.2=redisTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
org.javassist:javassist:3.28.0-GA=checkstyle
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath
org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath
org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath
org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath
org.jetbrains:annotations:17.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,redisTestAnnotationProcessor,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-api:6.0.1=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:6.0.1=redisTestRuntimeClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-params:6.0.1=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter:6.0.1=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-commons:6.0.1=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-engine:6.0.1=redisTestRuntimeClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-launcher:6.0.1=redisTestRuntimeClasspath,testRuntimeClasspath
org.junit:junit-bom:6.0.1=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit:junit-bom:6.1.0=spotbugs
org.mockito:mockito-core:5.20.0=testCompileClasspath,testRuntimeClasspath
org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath
org.objenesis:objenesis:3.3=testRuntimeClasspath
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
org.osgi:org.osgi.annotation.bundle:2.0.0=testCompileClasspath
org.osgi:org.osgi.annotation.versioning:1.1.2=testCompileClasspath
org.osgi:org.osgi.resource:1.0.0=testCompileClasspath
org.osgi:org.osgi.service.serviceloader:1.0.0=testCompileClasspath
org.latencyutils:LatencyUtils:2.0.3=redisTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
org.mockito:mockito-core:5.20.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.mockito:mockito-junit-jupiter:5.20.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.objenesis:objenesis:3.3=redisTestRuntimeClasspath,testRuntimeClasspath
org.opentest4j:opentest4j:1.3.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.osgi:org.osgi.annotation.bundle:2.0.0=redisTestCompileClasspath,testCompileClasspath
org.osgi:org.osgi.annotation.versioning:1.1.2=redisTestCompileClasspath,testCompileClasspath
org.osgi:org.osgi.resource:1.0.0=redisTestCompileClasspath,testCompileClasspath
org.osgi:org.osgi.service.serviceloader:1.0.0=redisTestCompileClasspath,testCompileClasspath
org.ow2.asm:asm-analysis:9.10.1=spotbugs
org.ow2.asm:asm-commons:9.10.1=spotbugs
org.ow2.asm:asm-tree:9.10.1=spotbugs
org.ow2.asm:asm-util:9.10.1=spotbugs
org.ow2.asm:asm:9.10.1=spotbugs
org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
org.reactivestreams:reactive-streams:1.0.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.ow2.asm:asm:9.7.1=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.pcollections:pcollections:4.0.1=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
org.reactivestreams:reactive-streams:1.0.4=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.reflections:reflections:0.10.2=checkstyle
org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
org.slf4j:jul-to-slf4j:2.0.17=testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
org.rnorth.duct-tape:duct-tape:1.0.8=redisTestCompileClasspath,redisTestRuntimeClasspath
org.skyscreamer:jsonassert:1.5.3=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.slf4j:jul-to-slf4j:2.0.17=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-api:2.0.17=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor
org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-client:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-converter:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jackson:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-restclient:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-resttestclient:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-servlet:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-test:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-tomcat:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-web-server:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc-test:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot:4.0.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.data:spring-data-commons:4.0.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.data:spring-data-keyvalue:4.0.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.data:spring-data-redis:4.0.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.session:spring-session-core:4.0.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.session:spring-session-data-redis:4.0.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-aop:7.0.1=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-beans:7.0.1=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-context-support:7.0.1=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-context:7.0.1=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-core:7.0.1=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-expression:7.0.1=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-oxm:7.0.1=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-test:7.0.1=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-tx:7.0.1=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-web:7.0.1=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webmvc:7.0.1=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.testcontainers:testcontainers:2.0.2=redisTestCompileClasspath,redisTestRuntimeClasspath
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
org.yaml:snakeyaml:2.5=testCompileClasspath,testRuntimeClasspath
redis.clients.authentication:redis-authx-core:0.1.1-beta2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath
tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath
org.xmlunit:xmlunit-core:2.10.4=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.yaml:snakeyaml:2.5=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
redis.clients.authentication:redis-authx-core:0.1.1-beta2=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-core:3.0.2=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-databind:3.0.2=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson:jackson-bom:3.0.2=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
empty=
@@ -0,0 +1,231 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.time.Duration;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.function.LongSupplier;
/** One daemon worker with storage bounded by the finite active Redis role count. */
final class BoundedRedisSentinelRefreshWorker implements RedisSentinelRefreshWorker {
private final Object monitor = new Object();
private final int capacity;
private final ArrayDeque<Runnable> immediateTasks;
private final List<RecurringTask> recurringTasks;
private final Thread worker;
private final LongSupplier nanoTime;
private boolean closed;
private boolean preferDueRecurring;
BoundedRedisSentinelRefreshWorker(int capacity, String threadName) {
this(capacity, threadName, System::nanoTime);
}
BoundedRedisSentinelRefreshWorker(int capacity, String threadName, LongSupplier nanoTime) {
if (capacity < 1) {
throw new IllegalArgumentException("Redis Sentinel worker capacity must be positive");
}
this.capacity = capacity;
this.immediateTasks = new ArrayDeque<>(capacity);
this.recurringTasks = new ArrayList<>(capacity);
this.nanoTime = Objects.requireNonNull(nanoTime, "nanoTime must be non-null");
this.worker =
Thread.ofPlatform().daemon(true).name(requireText(threadName)).unstarted(this::runWorker);
this.worker.start();
}
@Override
public Cancellable scheduleWithFixedDelay(Runnable task, Duration delay) {
Objects.requireNonNull(task, "task must be non-null");
long delayNanos = positiveNanos(delay);
RecurringTask recurring =
new RecurringTask(task, delayNanos, nanoTime.getAsLong() + delayNanos);
synchronized (monitor) {
ensureOpen();
if (recurringTasks.size() >= capacity) {
throw new IllegalStateException("Redis Sentinel recurring task capacity is exhausted");
}
recurringTasks.add(recurring);
monitor.notifyAll();
}
return () -> cancel(recurring);
}
@Override
public boolean execute(Runnable task) {
Objects.requireNonNull(task, "task must be non-null");
synchronized (monitor) {
if (closed || immediateTasks.size() >= capacity) {
return false;
}
immediateTasks.addLast(task);
monitor.notifyAll();
return true;
}
}
@Override
public void shutdown(Duration timeout) {
long timeoutNanos = positiveNanos(timeout);
synchronized (monitor) {
if (!closed) {
closed = true;
recurringTasks.forEach(task -> task.cancelled = true);
recurringTasks.clear();
immediateTasks.clear();
monitor.notifyAll();
}
}
worker.interrupt();
if (Thread.currentThread() == worker) {
return;
}
try {
long millis = Math.max(1, Math.min(Long.MAX_VALUE, timeoutNanos / 1_000_000L));
worker.join(millis);
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
}
}
private void runWorker() {
while (true) {
Work work;
try {
work = awaitWork();
} catch (InterruptedException interrupted) {
if (isClosed()) {
Thread.currentThread().interrupt();
return;
}
continue;
}
if (work == null) {
return;
}
try {
work.task.run();
} catch (RuntimeException ignored) {
// Refresh failures are deliberately contained and rendered only through sanitized health.
} finally {
if (work.recurring != null) {
reschedule(work.recurring);
}
}
}
}
private Work awaitWork() throws InterruptedException {
synchronized (monitor) {
while (!closed) {
if (!preferDueRecurring) {
Runnable immediate = immediateTasks.pollFirst();
if (immediate != null) {
preferDueRecurring = true;
return new Work(immediate, null);
}
}
long now = nanoTime.getAsLong();
RecurringTask due = null;
long waitNanos = Long.MAX_VALUE;
for (RecurringTask task : recurringTasks) {
if (task.cancelled || task.running) {
continue;
}
long remaining = task.nextRunNanos - now;
if (remaining <= 0) {
due = task;
break;
}
waitNanos = Math.min(waitNanos, remaining);
}
if (due != null) {
due.running = true;
preferDueRecurring = false;
return new Work(due.task, due);
}
Runnable immediate = immediateTasks.pollFirst();
if (immediate != null) {
preferDueRecurring = true;
return new Work(immediate, null);
}
if (waitNanos == Long.MAX_VALUE) {
monitor.wait();
} else {
long millis = waitNanos / 1_000_000L;
int nanos = (int) (waitNanos % 1_000_000L);
monitor.wait(millis, nanos);
}
}
return null;
}
}
private void reschedule(RecurringTask task) {
synchronized (monitor) {
task.running = false;
if (!closed && !task.cancelled) {
task.nextRunNanos = nanoTime.getAsLong() + task.delayNanos;
}
monitor.notifyAll();
}
}
private void cancel(RecurringTask task) {
synchronized (monitor) {
task.cancelled = true;
recurringTasks.remove(task);
monitor.notifyAll();
}
}
private boolean isClosed() {
synchronized (monitor) {
return closed;
}
}
private void ensureOpen() {
if (closed) {
throw new IllegalStateException("Redis Sentinel refresh worker is closed");
}
}
private static long positiveNanos(Duration duration) {
Objects.requireNonNull(duration, "duration must be non-null");
if (duration.isZero() || duration.isNegative()) {
throw new IllegalArgumentException("Redis Sentinel worker duration must be positive");
}
try {
return duration.toNanos();
} catch (ArithmeticException overflow) {
return Long.MAX_VALUE;
}
}
private static String requireText(String value) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("Redis Sentinel worker name must be non-blank");
}
return value.trim();
}
private record Work(Runnable task, RecurringTask recurring) {}
private static final class RecurringTask {
private final Runnable task;
private final long delayNanos;
private long nextRunNanos;
private boolean running;
private boolean cancelled;
private RecurringTask(Runnable task, long delayNanos, long nextRunNanos) {
this.task = task;
this.delayNanos = delayNanos;
this.nextRunNanos = nextRunNanos;
}
}
}
@@ -0,0 +1,83 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import io.lettuce.core.RedisChannelHandler;
import io.lettuce.core.RedisConnectionStateListener;
import io.lettuce.core.pubsub.RedisPubSubAdapter;
import io.lettuce.core.pubsub.StatefulRedisPubSubConnection;
import java.net.SocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
/** Managed standalone Redis Pub/Sub listener for best-effort cache invalidation hints. */
final class LettuceRedisCacheInvalidationSubscription implements AutoCloseable {
private final StatefulRedisPubSubConnection<byte[], byte[]> connection;
private final RedisCacheInvalidationSubscriber subscriber;
private final AtomicBoolean closed = new AtomicBoolean();
private LettuceRedisCacheInvalidationSubscription(
StatefulRedisPubSubConnection<byte[], byte[]> connection,
RedisCacheInvalidationSubscriber subscriber) {
this.connection = connection;
this.subscriber = subscriber;
}
static LettuceRedisCacheInvalidationSubscription subscribe(
LettuceRedisRuntime runtime,
String channel,
RedisCacheInvalidationMessage.Codec codec,
RedisCacheInvalidationSubscriber subscriber) {
Objects.requireNonNull(runtime, "runtime must be non-null");
Objects.requireNonNull(channel, "channel must be non-null");
Objects.requireNonNull(codec, "codec must be non-null");
Objects.requireNonNull(subscriber, "subscriber must be non-null");
byte[] channelBytes = channel.getBytes(StandardCharsets.US_ASCII);
StatefulRedisPubSubConnection<byte[], byte[]> connection =
runtime.openInvalidationSubscription();
connection.addListener(
new RedisPubSubAdapter<>() {
@Override
public void message(byte[] actualChannel, byte[] message) {
if (!Arrays.equals(channelBytes, actualChannel) || message == null) {
return;
}
codec
.decode(new String(message, StandardCharsets.US_ASCII))
.ifPresentOrElse(subscriber::onMessage, subscriber::onMalformedMessage);
}
});
connection.addListener(
new RedisConnectionStateListener() {
@Override
public void onRedisConnected(
RedisChannelHandler<?, ?> connection, SocketAddress remoteAddress) {
// A preceding disconnect already forced L1 flush and generation recheck.
}
@Override
public void onRedisDisconnected(RedisChannelHandler<?, ?> connection) {
subscriber.onDisconnected();
}
});
try {
connection.sync().subscribe(channelBytes);
return new LettuceRedisCacheInvalidationSubscription(connection, subscriber);
} catch (RuntimeException exception) {
connection.close();
throw exception;
}
}
@Override
public void close() {
if (closed.compareAndSet(false, true)) {
try {
connection.close();
} finally {
subscriber.onDisconnected();
}
}
}
}
@@ -0,0 +1,228 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings;
import io.lettuce.core.AbstractRedisClient;
import io.lettuce.core.ClientOptions;
import io.lettuce.core.ConnectionFuture;
import io.lettuce.core.RedisClient;
import io.lettuce.core.RedisURI;
import io.lettuce.core.api.StatefulConnection;
import io.lettuce.core.api.StatefulRedisConnection;
import io.lettuce.core.cluster.ClusterClientOptions;
import io.lettuce.core.cluster.RedisClusterClient;
import io.lettuce.core.cluster.api.StatefulRedisClusterConnection;
import io.lettuce.core.codec.ByteArrayCodec;
import java.time.Duration;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
/** Opens, probes, and owns topology-native Lettuce clients and connections. */
final class LettuceRedisNativeClientFactory implements RedisNativeClientFactory {
interface LifecycleObserver {
LifecycleObserver NOOP = new LifecycleObserver() {};
default void clientCreated() {}
default void connectionClosed() {}
default void clientClosed() {}
}
private final LifecycleObserver observer;
LettuceRedisNativeClientFactory() {
this(LifecycleObserver.NOOP);
}
LettuceRedisNativeClientFactory(LifecycleObserver observer) {
this.observer = Objects.requireNonNull(observer, "observer must be non-null");
}
@Override
public RedisNativeClientHandle openStandalone(
RedisURI uri, ClientOptions options, RedisClientRuntimeSettings settings) {
Objects.requireNonNull(uri, "uri must be non-null");
Objects.requireNonNull(options, "options must be non-null");
Objects.requireNonNull(settings, "settings must be non-null");
RedisClient client = RedisClient.create(uri);
observer.clientCreated();
StatefulRedisConnection<byte[], byte[]> connection = null;
try {
client.setOptions(options);
long deadline = deadline(settings.overallTimeout());
ConnectionFuture<StatefulRedisConnection<byte[], byte[]>> connect =
client.connectAsync(ByteArrayCodec.INSTANCE, uri);
connection =
await(
connect,
boundedByRemaining(settings.acquireTimeout(), deadline),
"Redis standalone connect");
connection.setTimeout(settings.commandTimeout());
await(
connection.async().ping(),
boundedByRemaining(settings.commandTimeout(), deadline),
"Redis standalone probe");
return new LettuceHandle(client, connection, settings.shutdownTimeout(), observer);
} catch (RuntimeException exception) {
closeFailed(client, connection, settings.shutdownTimeout(), observer, List.of(uri));
throw sanitizedConnectFailure(exception);
}
}
@Override
public RedisNativeClientHandle openCluster(
List<RedisURI> seedUris, ClusterClientOptions options, RedisClientRuntimeSettings settings) {
List<RedisURI> uris =
List.copyOf(Objects.requireNonNull(seedUris, "seedUris must be non-null"));
Objects.requireNonNull(options, "options must be non-null");
Objects.requireNonNull(settings, "settings must be non-null");
RedisClusterClient client = RedisClusterClient.create(uris);
observer.clientCreated();
StatefulRedisClusterConnection<byte[], byte[]> connection = null;
try {
client.setOptions(options);
long deadline = deadline(settings.overallTimeout());
java.util.concurrent.CompletableFuture<StatefulRedisClusterConnection<byte[], byte[]>>
connect = client.connectAsync(ByteArrayCodec.INSTANCE);
connection =
await(
connect,
boundedByRemaining(settings.acquireTimeout(), deadline),
"Redis Cluster connect");
connection.setTimeout(settings.commandTimeout());
await(
connection.async().ping(),
boundedByRemaining(settings.commandTimeout(), deadline),
"Redis Cluster probe");
return new LettuceHandle(client, connection, settings.shutdownTimeout(), observer);
} catch (RuntimeException exception) {
closeFailed(client, connection, settings.shutdownTimeout(), observer, uris);
throw sanitizedConnectFailure(exception);
}
}
private static long deadline(Duration overallTimeout) {
long timeoutNanos = overallTimeout.toNanos();
long now = System.nanoTime();
return now > Long.MAX_VALUE - timeoutNanos ? Long.MAX_VALUE : now + timeoutNanos;
}
private static Duration boundedByRemaining(Duration operationTimeout, long deadline) {
long remaining = deadline - System.nanoTime();
if (remaining <= 0) {
throw new IllegalStateException("Redis overall connect deadline expired");
}
Duration remainingDuration = Duration.ofNanos(remaining);
return operationTimeout.compareTo(remainingDuration) < 0 ? operationTimeout : remainingDuration;
}
private static <T> T await(
java.util.concurrent.Future<T> future, Duration timeout, String operation) {
try {
return future.get(timeout.toNanos(), TimeUnit.NANOSECONDS);
} catch (InterruptedException exception) {
future.cancel(true);
Thread.currentThread().interrupt();
throw new IllegalStateException(operation + " was interrupted");
} catch (TimeoutException exception) {
future.cancel(true);
throw new IllegalStateException(operation + " exceeded its bounded timeout");
} catch (ExecutionException exception) {
throw new IllegalStateException(operation + " failed");
}
}
private static IllegalStateException sanitizedConnectFailure(RuntimeException ignored) {
return new IllegalStateException("Redis connect or probe failed within its bounded deadline");
}
private static void closeFailed(
AbstractRedisClient client,
StatefulConnection<?, ?> connection,
Duration shutdownTimeout,
LifecycleObserver observer,
List<RedisURI> uris) {
try {
closeConnection(connection, observer);
} finally {
try {
client.shutdown(Duration.ZERO, shutdownTimeout);
} finally {
observer.clientClosed();
uris.forEach(LettuceRedisNativeClientFactory::destroyCredentials);
}
}
}
private static void closeConnection(
StatefulConnection<?, ?> connection, LifecycleObserver observer) {
if (connection == null) {
return;
}
try {
connection.close();
} finally {
observer.connectionClosed();
}
}
private static void destroyCredentials(RedisURI uri) {
if (uri.getCredentialsProvider() instanceof javax.security.auth.Destroyable destroyable) {
try {
destroyable.destroy();
} catch (javax.security.auth.DestroyFailedException ignored) {
// The adapter-owned providers do not throw; remain fail-safe for alternate implementations.
}
}
}
private static final class LettuceHandle implements RedisNativeClientHandle {
private final AbstractRedisClient client;
private final StatefulConnection<?, ?> connection;
private final Duration configuredShutdownTimeout;
private final LifecycleObserver observer;
private final AtomicBoolean closed = new AtomicBoolean();
private LettuceHandle(
AbstractRedisClient client,
StatefulConnection<?, ?> connection,
Duration configuredShutdownTimeout,
LifecycleObserver observer) {
this.client = client;
this.connection = connection;
this.configuredShutdownTimeout = configuredShutdownTimeout;
this.observer = observer;
}
@Override
public Class<?> nativeClientType() {
return client.getClass();
}
@Override
public void close(Duration timeout) {
Objects.requireNonNull(timeout, "timeout must be non-null");
if (!timeout.equals(configuredShutdownTimeout)) {
throw new IllegalArgumentException("Redis shutdown timeout differs from runtime settings");
}
if (closed.compareAndSet(false, true)) {
try {
closeConnection(connection, observer);
} finally {
try {
client.shutdown(Duration.ZERO, timeout);
} finally {
observer.clientClosed();
}
}
}
}
}
}
@@ -14,10 +14,12 @@ import io.lettuce.core.TimeoutOptions;
import io.lettuce.core.api.StatefulRedisConnection;
import io.lettuce.core.api.sync.RedisCommands;
import io.lettuce.core.codec.ByteArrayCodec;
import io.lettuce.core.pubsub.StatefulRedisPubSubConnection;
import java.net.SocketAddress;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Supplier;
@@ -26,19 +28,7 @@ import java.util.function.Supplier;
final class LettuceRedisRuntime implements RedisClient, RedisBinaryCommands, AutoCloseable {
private static final String VALUE_TOO_LARGE_ERROR = "CA_VALUE_TOO_LARGE";
private static final byte[] BOUNDED_GET_SCRIPT =
"""
local limit = tonumber(ARGV[1])
local value = redis.call('GETRANGE', KEYS[1], 0, limit)
if #value > limit then
return redis.error_reply('CA_VALUE_TOO_LARGE')
end
if #value == 0 and redis.call('EXISTS', KEYS[1]) == 0 then
return false
end
return value
"""
.getBytes(StandardCharsets.UTF_8);
private static final RedisProgramCatalog FOUNDATION_CATALOG = RedisProgramCatalog.foundation();
private final io.lettuce.core.RedisClient client;
private final StatefulRedisConnection<byte[], byte[]> connection;
@@ -54,17 +44,17 @@ final class LettuceRedisRuntime implements RedisClient, RedisBinaryCommands, Aut
private LettuceRedisRuntime(
io.lettuce.core.RedisClient client,
StatefulRedisConnection<byte[], byte[]> connection,
RedisRuntimeSettings settings) {
RedisConnectionProfile settings) {
this.client = client;
this.connection = connection;
this.commands = connection.sync();
this.legacyTtl = settings.positiveTtl();
this.legacyTtl = settings.legacyTtl();
this.shutdownTimeout = settings.commandTimeout();
this.commandAdmission =
new RedisCommandAdmission(
settings.maximumQueuedCommands(), settings.maximumInFlightBytes());
this.maximumReadableValueBytes = settings.maximumValueBytes() + 1024 + 32;
this.maximumCommandBytes = settings.maximumValueBytes() + 2048;
this.maximumReadableValueBytes = settings.maximumReadableValueBytes();
this.maximumCommandBytes = settings.maximumCommandBytes();
connection.addListener(
new RedisConnectionStateListener() {
@Override
@@ -81,6 +71,14 @@ final class LettuceRedisRuntime implements RedisClient, RedisBinaryCommands, Aut
}
static LettuceRedisRuntime connect(RedisRuntimeSettings settings) {
return connect(RedisConnectionProfile.cache(settings));
}
static LettuceRedisRuntime connect(RedisLegacyStandaloneSettings settings) {
return connect(RedisConnectionProfile.rateLimit(settings));
}
private static LettuceRedisRuntime connect(RedisConnectionProfile settings) {
RedisURI uri = redisUri(settings);
io.lettuce.core.RedisClient client = io.lettuce.core.RedisClient.create(uri);
client.setOptions(clientOptions(settings));
@@ -95,6 +93,10 @@ final class LettuceRedisRuntime implements RedisClient, RedisBinaryCommands, Aut
}
static RedisURI redisUri(RedisRuntimeSettings settings) {
return redisUri(RedisConnectionProfile.cache(settings));
}
private static RedisURI redisUri(RedisConnectionProfile settings) {
RedisURI.Builder builder =
RedisURI.Builder.redis(settings.host(), settings.port())
.withTimeout(settings.commandTimeout());
@@ -105,6 +107,10 @@ final class LettuceRedisRuntime implements RedisClient, RedisBinaryCommands, Aut
}
static ClientOptions clientOptions(RedisRuntimeSettings settings) {
return clientOptions(RedisConnectionProfile.cache(settings));
}
private static ClientOptions clientOptions(RedisConnectionProfile settings) {
return ClientOptions.builder()
.autoReconnect(true)
.replayFilter(ignored -> true)
@@ -116,7 +122,7 @@ final class LettuceRedisRuntime implements RedisClient, RedisBinaryCommands, Aut
@Override
public Optional<String> read(String key) {
byte[] value = get(key.getBytes(StandardCharsets.UTF_8));
byte[] value = get(RedisPhysicalKey.owned(new LegacyKeyMaterial(key)));
return value == null
? Optional.empty()
: Optional.of(new String(value, StandardCharsets.UTF_8));
@@ -124,84 +130,109 @@ final class LettuceRedisRuntime implements RedisClient, RedisBinaryCommands, Aut
@Override
public void write(String key, String value) {
set(key.getBytes(StandardCharsets.UTF_8), value.getBytes(StandardCharsets.UTF_8), legacyTtl);
set(
RedisPhysicalKey.owned(new LegacyKeyMaterial(key)),
RedisBinaryValue.utf8(value),
legacyTtl);
}
@Override
public byte[] get(byte[] key) {
byte[] limit = Integer.toString(maximumReadableValueBytes).getBytes(StandardCharsets.US_ASCII);
try {
byte[] value =
execute(
false,
reservationBytes(
maximumReadableValueBytes,
List.of(BOUNDED_GET_SCRIPT),
List.of(key),
List.of(limit)),
() ->
commands.eval(
BOUNDED_GET_SCRIPT,
ScriptOutputType.VALUE,
new byte[][] {key.clone()},
limit));
return value == null ? null : value.clone();
} catch (RedisCommandExecutionException exception) {
if (exception.getMessage() != null
&& exception.getMessage().contains(VALUE_TOO_LARGE_ERROR)) {
throw new RedisValueTooLargeException();
}
throw exception;
}
public byte[] get(RedisPhysicalKey key) {
RedisCatalogProgramInvocation invocation =
FOUNDATION_CATALOG.boundedGetInvocation(key, maximumReadableValueBytes);
byte[] value = RedisScriptRecovery.evalReadOnlyValue(this, invocation);
return value == null ? null : value.clone();
}
@Override
public void set(byte[] key, byte[] value, Duration timeToLive) {
public void set(RedisPhysicalKey key, RedisBinaryValue value, Duration timeToLive) {
byte[] encodedKey = RedisPhysicalKey.WireCodec.copy(key);
byte[] encodedValue = value.copyEncoded();
String result =
execute(
true,
reservationBytes(64, List.of(key, value)),
reservationBytes(64, List.of(encodedKey, encodedValue)),
() ->
commands.set(
key.clone(), value.clone(), SetArgs.Builder.px(timeToLive.toMillis())));
commands.set(encodedKey, encodedValue, SetArgs.Builder.px(timeToLive.toMillis())));
if (!"OK".equals(result)) {
throw new IllegalStateException("Redis SET did not acknowledge the mutation");
}
}
@Override
public long delete(byte[] key) {
return execute(true, reservationBytes(32, List.of(key)), () -> commands.del(key.clone()));
public long delete(RedisPhysicalKey key) {
byte[] encodedKey = RedisPhysicalKey.WireCodec.copy(key);
return execute(true, reservationBytes(32, List.of(encodedKey)), () -> commands.del(encodedKey));
}
@Override
public byte[] evalSha(String sha1, List<byte[]> keys, List<byte[]> arguments) {
public RedisCatalogProgramReply executeCatalogProgram(RedisCatalogProgramInvocation invocation) {
boolean mutation =
invocation.replyShape() != RedisCatalogProgramInvocation.ReplyShape.READ_ONLY_VALUE
&& invocation.replyShape() != RedisCatalogProgramInvocation.ReplyShape.READ_ONLY_MULTI;
ScriptOutputType outputType =
invocation.replyShape() == RedisCatalogProgramInvocation.ReplyShape.MULTI
|| invocation.replyShape()
== RedisCatalogProgramInvocation.ReplyShape.READ_ONLY_MULTI
? ScriptOutputType.MULTI
: ScriptOutputType.VALUE;
try {
return execute(
true,
reservationBytes(256, keys, arguments),
() ->
commands.evalsha(
sha1,
ScriptOutputType.VALUE,
keys.toArray(byte[][]::new),
arguments.toArray(byte[][]::new)));
Object result =
execute(
mutation,
Math.max(256, invocation.encodedBytes()),
() ->
commands.evalsha(
RedisScriptRecovery.sha1(
RedisCatalogProgramInvocation.WireCodec.exactScript(invocation)),
outputType,
RedisCatalogProgramInvocation.WireCodec.keysArray(invocation),
RedisCatalogProgramInvocation.WireCodec.argumentsArray(invocation)));
if (outputType == ScriptOutputType.MULTI) {
@SuppressWarnings("unchecked")
List<byte[]> fields = (List<byte[]>) result;
return RedisCatalogProgramReply.multi(defensiveReply(fields));
}
return RedisCatalogProgramReply.value((byte[]) result);
} catch (io.lettuce.core.RedisNoScriptException exception) {
throw new RedisNoScriptException();
} catch (RedisCommandExecutionException exception) {
if (exception.getMessage() != null
&& exception.getMessage().contains(VALUE_TOO_LARGE_ERROR)) {
throw new RedisValueTooLargeException();
}
throw commandFailure(
mutation,
mutation
? "Redis Lua program execution failed"
: "Redis read-only Lua program execution failed",
exception);
}
}
@Override
public byte[] eval(byte[] script, List<byte[]> keys, List<byte[]> arguments) {
return execute(
public String loadCatalogProgram(RedisCatalogProgramInvocation invocation) {
byte[] script = RedisCatalogProgramInvocation.WireCodec.exactScript(invocation);
try {
return execute(
true, reservationBytes(64, List.of(script)), () -> commands.scriptLoad(script.clone()));
} catch (RedisCommandExecutionException exception) {
throw commandFailure(true, "Redis script load failed", exception);
}
}
void publishInvalidation(String channel, String message) {
byte[] channelBytes = channel.getBytes(StandardCharsets.US_ASCII);
byte[] messageBytes = message.getBytes(StandardCharsets.US_ASCII);
execute(
true,
reservationBytes(256, List.of(script), keys, arguments),
() ->
commands.eval(
script.clone(),
ScriptOutputType.VALUE,
keys.toArray(byte[][]::new),
arguments.toArray(byte[][]::new)));
reservationBytes(64, List.of(channelBytes, messageBytes)),
() -> commands.publish(channelBytes, messageBytes));
}
StatefulRedisPubSubConnection<byte[], byte[]> openInvalidationSubscription() {
ensureOpen();
return client.connectPubSub(ByteArrayCodec.INSTANCE);
}
@Override
@@ -252,7 +283,7 @@ final class LettuceRedisRuntime implements RedisClient, RedisBinaryCommands, Aut
throw exception;
} catch (RedisCommandInterruptedException exception) {
Thread.currentThread().interrupt();
throw exception;
throw commandFailure(mutation, "Redis command was interrupted", exception);
} catch (RedisCommandTimeoutException exception) {
throw commandFailure(mutation, "Redis command timed out", exception);
} catch (RedisConnectionException exception) {
@@ -273,6 +304,13 @@ final class LettuceRedisRuntime implements RedisClient, RedisBinaryCommands, Aut
cause);
}
private static List<byte[]> defensiveReply(List<byte[]> result) {
if (result == null) {
return null;
}
return result.stream().map(value -> value == null ? null : value.clone()).toList();
}
@SafeVarargs
private static int reservationBytes(int responseBytes, List<byte[]>... groups) {
long total = Math.max(1, responseBytes);
@@ -289,4 +327,20 @@ final class LettuceRedisRuntime implements RedisClient, RedisBinaryCommands, Aut
}
return (int) total;
}
static final class LegacyKeyMaterial implements RedisOwnedPhysicalKeyMaterial {
private final byte[] encoded;
private LegacyKeyMaterial(String key) {
this.encoded =
Objects.requireNonNull(key, "legacy key must be non-null")
.getBytes(StandardCharsets.UTF_8);
}
@Override
public byte[] copyEncodedKey() {
return encoded.clone();
}
}
}
@@ -0,0 +1,108 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.application.cache.CacheObservationEvent;
import dev.caskeleton.application.cache.CacheObservationPort;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
/** Micrometer rendering for the framework-free cache observation boundary. */
final class MicrometerCacheObservationPort implements CacheObservationPort {
private final MeterRegistry registry;
private final Set<String> cacheNames;
MicrometerCacheObservationPort(MeterRegistry registry, Set<String> cacheNames) {
this.registry = Objects.requireNonNull(registry, "registry must be non-null");
this.cacheNames = Set.copyOf(Objects.requireNonNull(cacheNames, "cacheNames must be non-null"));
if (this.cacheNames.isEmpty() || this.cacheNames.size() > 50) {
throw new IllegalArgumentException("cacheNames must contain 1..50 startup-registered names");
}
if (this.cacheNames.stream().anyMatch(name -> name == null || name.isBlank())) {
throw new IllegalArgumentException("cacheNames must contain non-blank names");
}
}
@Override
public void observe(CacheObservationEvent event) {
Objects.requireNonNull(event, "event must be non-null");
String cacheName =
event instanceof CacheObservationEvent.Lookup lookup
? lookup.cacheName()
: ((CacheObservationEvent.LocalMaintenance) event).cacheName();
if (!cacheNames.contains(cacheName)) {
throw new IllegalArgumentException("cacheName is not in the startup allowlist");
}
if (event instanceof CacheObservationEvent.Lookup lookup) {
observeLookup(lookup);
return;
}
CacheObservationEvent.LocalMaintenance maintenance =
(CacheObservationEvent.LocalMaintenance) event;
Counter.builder("cache.local.maintenance.total")
.tag("cache_name", maintenance.cacheName())
.tag("event", maintenanceEvent(maintenance))
.register(registry)
.increment();
}
private void observeLookup(CacheObservationEvent.Lookup lookup) {
if (lookup.tier() != CacheObservationEvent.Tier.LOCAL_L1) {
return;
}
Counter.builder("cache.local.requests.total")
.tag("cache_name", lookup.cacheName())
.tag("result", lower(lookup.result()))
.register(registry)
.increment();
if (lookup.result() == CacheObservationEvent.LookupResult.HIT) {
Timer.builder("cache.local.entry.age.seconds")
.tag("cache_name", lookup.cacheName())
.register(registry)
.record(lookup.entryAge());
}
}
private static String lower(Enum<?> value) {
return value.name().toLowerCase(Locale.ROOT);
}
private static String maintenanceEvent(CacheObservationEvent.LocalMaintenance event) {
if (event.action() == CacheObservationEvent.MaintenanceAction.EVICT) {
return "evict_" + lower(event.cause());
}
if (event.cause() == CacheObservationEvent.MaintenanceCause.GENERATION_CHANGED) {
return "reconcile_generation_changed";
}
if (event.action() == CacheObservationEvent.MaintenanceAction.RECONCILE) {
return event.result() == CacheObservationEvent.MaintenanceResult.ERROR
? "reconcile_error"
: "reconcile_unchanged";
}
if (event.cause() == CacheObservationEvent.MaintenanceCause.SUBSCRIBER_DISCONNECTED) {
return "subscriber_disconnected";
}
if (event.cause() == CacheObservationEvent.MaintenanceCause.SUBSCRIBER_OVERFLOW) {
return "subscriber_overflow";
}
if (event.cause() == CacheObservationEvent.MaintenanceCause.MALFORMED_MESSAGE) {
return "subscriber_malformed";
}
if (event.action() == CacheObservationEvent.MaintenanceAction.SUBSCRIBER_EVENT
&& event.result() == CacheObservationEvent.MaintenanceResult.FLUSHED) {
return "flush_invalidation";
}
if (event.action() == CacheObservationEvent.MaintenanceAction.SUBSCRIBER_EVENT) {
return event.result() == CacheObservationEvent.MaintenanceResult.SUCCESS
? "subscriber_publish_success"
: "subscriber_publish_error";
}
if (event.cause() == CacheObservationEvent.MaintenanceCause.INVALIDATION) {
return "flush_invalidation";
}
return "other";
}
}
@@ -0,0 +1,120 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.Gauge;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import java.util.Locale;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
/** Renders the closed Redis capability event model to its six registry-approved meters. */
final class MicrometerRedisCapabilityObservationPort implements RedisCapabilityObservationPort {
private final MeterRegistry registry;
private final ConcurrentMap<
RedisCapabilityObservationEvent.Role, AtomicReference<InFlightSnapshot>>
inFlight = new ConcurrentHashMap<>();
MicrometerRedisCapabilityObservationPort(MeterRegistry registry) {
this.registry = Objects.requireNonNull(registry, "registry must be non-null");
}
@Override
public void observe(RedisCapabilityObservationEvent.Event event) {
Objects.requireNonNull(event, "event must be non-null");
switch (event) {
case RedisCapabilityObservationEvent.OperationCompleted operation ->
observeOperation(operation);
case RedisCapabilityObservationEvent.AdmissionChanged admission ->
observeAdmission(admission);
case RedisCapabilityObservationEvent.ReadinessObserved readiness ->
observeReadiness(readiness);
case RedisCapabilityObservationEvent.LifecycleDrainCompleted lifecycle ->
observeLifecycle(lifecycle);
}
}
private void observeOperation(RedisCapabilityObservationEvent.OperationCompleted event) {
Counter.builder("redis.capability.operations.total")
.tags(
"capability", lower(event.capability()),
"role", lower(event.role()),
"operation", lower(event.operation()),
"redis_outcome", lower(event.outcome()),
"certainty", lower(event.certainty()))
.register(registry)
.increment();
Timer.builder("redis.capability.duration.seconds")
.tags(
"capability", lower(event.capability()),
"role", lower(event.role()),
"operation", lower(event.operation()),
"redis_outcome", lower(event.outcome()))
.register(registry)
.record(event.durationNanos(), TimeUnit.NANOSECONDS);
}
private void observeAdmission(RedisCapabilityObservationEvent.AdmissionChanged event) {
if (event.admission() == RedisCapabilityObservationEvent.AdmissionState.REJECTED_SATURATED
|| event.admission() == RedisCapabilityObservationEvent.AdmissionState.REJECTED_CLOSED) {
Counter.builder("redis.capability.admission.rejected.total")
.tags("role", lower(event.role()), "admission", lower(event.admission()))
.register(registry)
.increment();
}
snapshot(event.role()).set(new InFlightSnapshot(event.state(), event.inFlightCommands()));
}
private void observeReadiness(RedisCapabilityObservationEvent.ReadinessObserved event) {
Counter.builder("redis.capability.readiness.total")
.tags(
"capability", lower(event.capability()),
"role", lower(event.role()),
"state", lower(event.state()),
"reason", lower(event.reason()),
"requirement", lower(event.requirement()))
.register(registry)
.increment();
}
private void observeLifecycle(RedisCapabilityObservationEvent.LifecycleDrainCompleted event) {
Counter.builder("redis.capability.lifecycle.drain.total")
.tags("role", lower(event.role()), "drain_outcome", lower(event.drainOutcome()))
.register(registry)
.increment();
}
private static String lower(Enum<?> value) {
return value.name().toLowerCase(Locale.ROOT);
}
private AtomicReference<InFlightSnapshot> snapshot(RedisCapabilityObservationEvent.Role role) {
return inFlight.computeIfAbsent(
role,
ignored -> {
AtomicReference<InFlightSnapshot> value =
new AtomicReference<>(
new InFlightSnapshot(RedisCapabilityObservationEvent.InFlightState.IDLE, 0));
for (RedisCapabilityObservationEvent.InFlightState state :
RedisCapabilityObservationEvent.InFlightState.values()) {
Gauge.builder(
"redis.capability.inflight.total",
value,
reference -> {
InFlightSnapshot current = reference.get();
return current.state() == state ? current.commands() : 0;
})
.tags("role", lower(role), "state", lower(state))
.register(registry);
}
return value;
});
}
private record InFlightSnapshot(
RedisCapabilityObservationEvent.InFlightState state, int commands) {}
}
@@ -0,0 +1,14 @@
package dev.caskeleton.adapter.outbound.cache.redis;
enum NoOpRedisCapabilityObservationPort implements RedisCapabilityObservationPort {
INSTANCE;
static RedisCapabilityObservationPort instance() {
return INSTANCE;
}
@Override
public void observe(RedisCapabilityObservationEvent.Event event) {
// Intentionally disabled.
}
}
@@ -2,6 +2,7 @@ package dev.caskeleton.adapter.outbound.cache.redis;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Base64;
import java.util.List;
import java.util.Objects;
@@ -10,8 +11,9 @@ final class RedisAtomicPrimitives {
private static final int MAXIMUM_OWNER_BYTES = 128;
private static final int MAXIMUM_OPERATION_ID_BYTES = 128;
private static final int MAXIMUM_VALUE_BYTES = 1_048_576;
private static final int MAXIMUM_VALUE_BYTES = 16_778_272;
private static final long MAXIMUM_TTL_MILLIS = Duration.ofDays(30).toMillis();
private static final long MAXIMUM_CONTROL_TTL_MILLIS = Duration.ofDays(31).toMillis();
private final RedisProgramCatalog catalog;
private final RedisProgramExecutor executor;
@@ -56,12 +58,110 @@ final class RedisAtomicPrimitives {
return parse(RedisProgramId.SET_IF_ABSENT_WITH_TTL, status, SetIfAbsentResult.class);
}
ReplaceIfObservedResult replaceIfObservedWithTtl(
String key, String observationToken, byte[] value, Duration timeToLive, String operationId) {
byte[] keyBytes = key(key);
byte[] expectedDigest = observationDigest(observationToken);
byte[] boundedValue = bounded(value, MAXIMUM_VALUE_BYTES, "value");
byte[] ttl = ttl(timeToLive);
byte[] operation =
bounded(
Objects.requireNonNull(operationId, "operationId must be non-null")
.getBytes(StandardCharsets.UTF_8),
MAXIMUM_OPERATION_ID_BYTES,
"operationId");
String status =
execute(
RedisProgramId.REPLACE_IF_OBSERVED_WITH_TTL,
List.of(keyBytes),
List.of(expectedDigest, boundedValue, ttl, operation));
return parse(
RedisProgramId.REPLACE_IF_OBSERVED_WITH_TTL, status, ReplaceIfObservedResult.class);
}
GenerationInitResult initializeGeneration(String key, String candidateGeneration) {
return initializeGeneration(key, candidateGeneration, Duration.ZERO);
}
GenerationInitResult initializeGeneration(
String key, String candidateGeneration, Duration timeToLive) {
byte[] keyBytes = key(key);
byte[] generation = identifier(candidateGeneration, "candidateGeneration");
byte[] ttl = controlTtl(timeToLive);
String status =
execute(RedisProgramId.REGION_GENERATION_INIT, List.of(keyBytes), List.of(generation, ttl));
return parse(RedisProgramId.REGION_GENERATION_INIT, status, GenerationInitResult.class);
}
GenerationBumpResult bumpGeneration(String key, String candidateGeneration, String operationId) {
return bumpGeneration(key, candidateGeneration, operationId, Duration.ZERO);
}
GenerationBumpResult bumpGeneration(
String key, String candidateGeneration, String operationId, Duration timeToLive) {
byte[] keyBytes = key(key);
byte[] generation = identifier(candidateGeneration, "candidateGeneration");
byte[] operation = identifier(operationId, "operationId");
byte[] ttl = controlTtl(timeToLive);
String status =
execute(
RedisProgramId.REGION_GENERATION_BUMP,
List.of(keyBytes),
List.of(generation, operation, ttl));
return parse(RedisProgramId.REGION_GENERATION_BUMP, status, GenerationBumpResult.class);
}
RefreshClaimResult claimRefreshLease(
String key, String ownerToken, String operationToken, Duration timeToLive) {
byte[] keyBytes = key(key);
byte[] owner = identifier(ownerToken, "ownerToken");
byte[] operation = identifier(operationToken, "operationToken");
byte[] ttl = refreshLeaseTtl(timeToLive);
String status =
execute(
RedisProgramId.CACHE_REFRESH_CLAIM, List.of(keyBytes), List.of(owner, operation, ttl));
return parse(RedisProgramId.CACHE_REFRESH_CLAIM, status, RefreshClaimResult.class);
}
private String execute(RedisProgramId id, List<byte[]> keys, List<byte[]> arguments) {
RedisProgramDescriptor descriptor = catalog.descriptor(id);
if (keys.size() != descriptor.keyCount() || arguments.size() != descriptor.argumentCount()) {
throw new IllegalStateException("typed Redis program signature drift for " + id.externalId());
}
return executor.execute(descriptor, List.copyOf(keys), List.copyOf(arguments));
return executor.execute(catalog.capabilityInvocation(new ProgramMaterial(id, keys, arguments)));
}
static final class ProgramMaterial implements RedisCatalogProgramMaterial {
private final RedisProgramId programId;
private final List<byte[]> keys;
private final List<byte[]> arguments;
private ProgramMaterial(RedisProgramId programId, List<byte[]> keys, List<byte[]> arguments) {
this.programId = Objects.requireNonNull(programId, "programId must be non-null");
this.keys = keys.stream().map(byte[]::clone).toList();
this.arguments = arguments.stream().map(byte[]::clone).toList();
}
@Override
public RedisProgramId programId() {
return programId;
}
@Override
public RedisCatalogProgramInvocation.ReplyShape replyShape() {
return RedisCatalogProgramInvocation.ReplyShape.VALUE;
}
@Override
public List<byte[]> copyKeys() {
return keys.stream().map(byte[]::clone).toList();
}
@Override
public List<byte[]> copyArguments() {
return arguments.stream().map(byte[]::clone).toList();
}
}
private static byte[] key(String key) {
@@ -84,6 +184,66 @@ final class RedisAtomicPrimitives {
return Long.toString(milliseconds).getBytes(StandardCharsets.US_ASCII);
}
private static byte[] controlTtl(Duration timeToLive) {
Objects.requireNonNull(timeToLive, "timeToLive must be non-null");
long milliseconds;
try {
milliseconds = timeToLive.toMillis();
} catch (ArithmeticException exception) {
throw new IllegalArgumentException("control TTL exceeds supported range", exception);
}
if (milliseconds < 0 || milliseconds > MAXIMUM_CONTROL_TTL_MILLIS) {
throw new IllegalArgumentException(
"control TTL must be between 0 and " + MAXIMUM_CONTROL_TTL_MILLIS + " milliseconds");
}
return Long.toString(milliseconds).getBytes(StandardCharsets.US_ASCII);
}
private static byte[] refreshLeaseTtl(Duration timeToLive) {
Objects.requireNonNull(timeToLive, "timeToLive must be non-null");
long milliseconds;
try {
milliseconds = timeToLive.toMillis();
} catch (ArithmeticException exception) {
throw new IllegalArgumentException("refresh lease TTL exceeds supported range", exception);
}
long maximum = Duration.ofMinutes(5).toMillis();
if (milliseconds < 1 || milliseconds > maximum) {
throw new IllegalArgumentException(
"refresh lease TTL must be between 1 and " + maximum + " milliseconds");
}
return Long.toString(milliseconds).getBytes(StandardCharsets.US_ASCII);
}
private static byte[] observationDigest(String observationToken) {
Objects.requireNonNull(observationToken, "observationToken must be non-null");
byte[] digest;
try {
digest = Base64.getUrlDecoder().decode(observationToken);
} catch (IllegalArgumentException exception) {
throw new IllegalArgumentException("observationToken must be unpadded Base64URL", exception);
}
if (digest.length != 32
|| !Base64.getUrlEncoder()
.withoutPadding()
.encodeToString(digest)
.equals(observationToken)) {
throw new IllegalArgumentException(
"observationToken must encode exactly one canonical SHA-256 digest");
}
return digest;
}
private static byte[] identifier(String value, String field) {
Objects.requireNonNull(value, field + " must be non-null");
byte[] bytes = value.getBytes(StandardCharsets.US_ASCII);
if (bytes.length < 16 || bytes.length > 64 || !value.matches("[A-Za-z0-9_-]+")) {
throw new IllegalArgumentException(
field + " must be a Base64URL-safe identifier of 16..64 bytes");
}
return bytes;
}
private static byte[] bounded(byte[] value, int maximumBytes, String field) {
Objects.requireNonNull(value, field + " must be non-null");
if (value.length < 1 || value.length > maximumBytes) {
@@ -123,4 +283,34 @@ final class RedisAtomicPrimitives {
WRONG_TYPE,
INVALID
}
enum ReplaceIfObservedResult {
REPLACED,
ABSENT,
NOT_MATCHED,
WRONG_TYPE,
INVALID
}
enum GenerationInitResult {
INITIALIZED,
EXISTING,
WRONG_TYPE,
INVALID
}
enum GenerationBumpResult {
BUMPED,
ALREADY_APPLIED,
WRONG_TYPE,
INVALID
}
enum RefreshClaimResult {
CLAIMED,
ALREADY_OWNED,
CONTENDED,
WRONG_TYPE,
INVALID
}
}
@@ -1,18 +1,13 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.time.Duration;
import java.util.List;
/** Minimal binary Redis command surface owned entirely by this adapter. */
interface RedisBinaryCommands {
interface RedisBinaryCommands extends RedisStructuredCommands {
byte[] get(byte[] key);
byte[] get(RedisPhysicalKey key);
void set(byte[] key, byte[] value, Duration timeToLive);
void set(RedisPhysicalKey key, RedisBinaryValue value, Duration timeToLive);
long delete(byte[] key);
byte[] evalSha(String sha1, List<byte[]> keys, List<byte[]> arguments);
byte[] eval(byte[] script, List<byte[]> keys, List<byte[]> arguments);
long delete(RedisPhysicalKey key);
}
@@ -0,0 +1,42 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
/** Opaque bounded adapter-private value crossing the command gateway. */
final class RedisBinaryValue {
private static final int MAXIMUM_VALUE_BYTES = 16_777_216;
private final byte[] encoded;
private RedisBinaryValue(byte[] encoded) {
Objects.requireNonNull(encoded, "Redis binary value must be non-null");
if (encoded.length < 1 || encoded.length > MAXIMUM_VALUE_BYTES) {
throw new IllegalArgumentException("Redis binary value is out of bounds");
}
this.encoded = encoded.clone();
}
static RedisBinaryValue encoded(byte[] encoded) {
return new RedisBinaryValue(encoded);
}
static RedisBinaryValue utf8(String encoded) {
Objects.requireNonNull(encoded, "Redis binary value must be non-null");
return new RedisBinaryValue(encoded.getBytes(StandardCharsets.UTF_8));
}
int encodedLength() {
return encoded.length;
}
byte[] copyEncoded() {
return encoded.clone();
}
@Override
public String toString() {
return "RedisBinaryValue[redacted]";
}
}
@@ -0,0 +1,15 @@
package dev.caskeleton.adapter.outbound.cache.redis;
/** Descriptor-owned byte offset for BITCOUNT ranges (Redis BITCOUNT is byte-indexed). */
record RedisBitmapByteOffset(long value) {
RedisBitmapByteOffset {
if (value < 0 || value >= 1_048_576) {
throw new IllegalArgumentException("bitmap byte offset exceeds fixed descriptor domain");
}
}
static RedisBitmapByteOffset of(long value) {
return new RedisBitmapByteOffset(value);
}
}
@@ -0,0 +1,55 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.util.OptionalInt;
/** SETBIT result preserves the previous bit instead of mislabelling it as an affected count. */
record RedisBitmapMutationResult(
Status status, RedisPrimitiveMutationResult.Certainty certainty, OptionalInt previousBit) {
enum Status {
APPLIED,
WRONG_TYPE,
UNKNOWN
}
RedisBitmapMutationResult {
if (status == null || certainty == null || previousBit == null) {
throw new IllegalArgumentException("bitmap mutation result is invalid");
}
previousBit.ifPresent(
bit -> {
if (bit != 0 && bit != 1) {
throw new IllegalArgumentException("previous bitmap bit is invalid");
}
});
}
static RedisBitmapMutationResult from(RedisPrimitiveReply reply) {
return switch (reply.status()) {
case APPLIED ->
new RedisBitmapMutationResult(
Status.APPLIED,
RedisPrimitiveMutationResult.Certainty.APPLIED,
OptionalInt.of(Math.toIntExact(reply.signedNumber().orElseThrow())));
case WRONG_TYPE ->
new RedisBitmapMutationResult(
Status.WRONG_TYPE,
RedisPrimitiveMutationResult.Certainty.NOT_APPLIED,
OptionalInt.empty());
default ->
new RedisBitmapMutationResult(
Status.UNKNOWN,
RedisPrimitiveMutationResult.Certainty.NOT_APPLIED,
OptionalInt.empty());
};
}
static RedisBitmapMutationResult failed(RedisCommandFailureException failure) {
return new RedisBitmapMutationResult(
Status.UNKNOWN,
failure.certainty() == RedisCommandFailureException.Certainty.INDETERMINATE
? RedisPrimitiveMutationResult.Certainty.INDETERMINATE
: RedisPrimitiveMutationResult.Certainty.NOT_APPLIED,
OptionalInt.empty());
}
}
@@ -0,0 +1,19 @@
package dev.caskeleton.adapter.outbound.cache.redis;
/** Offset constrained to a descriptor-owned fixed bitmap domain. */
record RedisBitmapOffset(long value, long maximumExclusive) {
RedisBitmapOffset {
if (maximumExclusive < 1 || value < 0 || value >= maximumExclusive) {
throw new IllegalArgumentException("bitmap offset exceeds the fixed descriptor domain");
}
}
static RedisBitmapOffset of(long value, long maximumExclusive) {
return new RedisBitmapOffset(value, maximumExclusive);
}
long byteIndex() {
return value / Byte.SIZE;
}
}
@@ -0,0 +1,57 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.util.List;
import java.util.Objects;
/** Fixed-domain non-authoritative bitmap helpers. */
final class RedisBitmapPrimitives {
private static final long MAXIMUM_OFFSET_EXCLUSIVE = 8_388_608;
private final RedisPrimitiveCatalog catalog;
private final RedisPrimitiveExecutor executor;
RedisBitmapPrimitives(RedisPrimitiveCatalog catalog, RedisPrimitiveCommands commands) {
this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null");
this.executor = new RedisPrimitiveExecutor(catalog, commands);
}
RedisPrimitiveKey key(String slot, String identity) {
return catalog.keyFactory(RedisPrimitiveId.BITMAP_GET).key(slot, identity);
}
RedisBitmapOffset offset(long value) {
return RedisBitmapOffset.of(value, MAXIMUM_OFFSET_EXCLUSIVE);
}
RedisBitmapByteOffset byteOffset(long value) {
return RedisBitmapByteOffset.of(value);
}
RedisPrimitiveReply get(RedisPrimitiveKey key, RedisBitmapOffset offset) {
return executor.execute(
RedisPrimitiveId.BITMAP_GET,
List.of(key),
new RedisPrimitiveInvocation.BitmapArguments(offset, offset, -1));
}
RedisBitmapMutationResult set(RedisPrimitiveKey key, RedisBitmapOffset offset, boolean bit) {
try {
return RedisBitmapMutationResult.from(
executor.execute(
RedisPrimitiveId.BITMAP_SET,
List.of(key),
new RedisPrimitiveInvocation.BitmapArguments(offset, offset, bit ? 1 : 0)));
} catch (RedisCommandFailureException failure) {
return RedisBitmapMutationResult.failed(failure);
}
}
RedisPrimitiveReply count(
RedisPrimitiveKey key, RedisBitmapByteOffset first, RedisBitmapByteOffset last) {
return executor.execute(
RedisPrimitiveId.BITMAP_COUNT_FIXED_RANGE,
List.of(key),
new RedisPrimitiveInvocation.BitmapCountArguments(first, last));
}
}
@@ -0,0 +1,64 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import io.lettuce.core.codec.RedisCodec;
import java.nio.ByteBuffer;
import java.util.Objects;
/**
* Rejects an oversized Redis bulk value before allocating its destination byte array.
*
* <p>RESP aggregate element count and aggregate reply bytes are additionally checked by the
* semantic router because a codec invocation sees only one bulk element. Lettuce constructs the
* aggregate list before that final check, so multi-value commands remain restricted to the vetted
* program catalog and its bounded reply schemas; this codec is the pre-allocation bound for each
* bulk element, not a claim of a pre-allocation aggregate-list bound.
*/
final class RedisBoundedByteArrayCodec implements RedisCodec<byte[], byte[]> {
private final int maximumBulkBytes;
RedisBoundedByteArrayCodec(int maximumBulkBytes) {
if (maximumBulkBytes < 1024 || maximumBulkBytes > 16_777_216) {
throw new IllegalArgumentException("Redis codec bulk byte bound must be in 1024..16777216");
}
this.maximumBulkBytes = maximumBulkBytes;
}
@Override
public byte[] decodeKey(ByteBuffer bytes) {
return decode(bytes);
}
@Override
public byte[] decodeValue(ByteBuffer bytes) {
return decode(bytes);
}
@Override
public ByteBuffer encodeKey(byte[] key) {
return encode(key);
}
@Override
public ByteBuffer encodeValue(byte[] value) {
return encode(value);
}
private byte[] decode(ByteBuffer bytes) {
Objects.requireNonNull(bytes, "Redis decode buffer must be non-null");
if (bytes.remaining() > maximumBulkBytes) {
throw new IllegalStateException("Redis response bulk value exceeds its configured bound");
}
byte[] value = new byte[bytes.remaining()];
bytes.get(value);
return value;
}
private ByteBuffer encode(byte[] value) {
Objects.requireNonNull(value, "Redis encode value must be non-null");
if (value.length > maximumBulkBytes) {
throw new IllegalArgumentException("Redis command bulk value exceeds its configured bound");
}
return ByteBuffer.wrap(value);
}
}
@@ -2,7 +2,14 @@ package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.core.CacheBackend;
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyNamespace;
import dev.caskeleton.application.cache.CacheRegionPort;
import dev.caskeleton.application.cache.CacheObservationPort;
import dev.caskeleton.application.cache.DisabledCacheObservationPort;
import io.micrometer.core.instrument.MeterRegistry;
import java.time.Clock;
import java.util.Arrays;
import java.util.Set;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
@@ -23,7 +30,11 @@ import org.springframework.context.annotation.Configuration;
* therefore never need to know about each other — a new backend is new files only.
*/
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(RedisRuntimeSettings.class)
@EnableConfigurationProperties({RedisRuntimeSettings.class, RedisLocalCacheSettings.class})
@ConditionalOnProperty(
name = "ca-skeleton.providers.redis.legacy-migration-enabled",
havingValue = "true",
matchIfMissing = false)
public class RedisCacheAdapterConfig {
@Configuration(proxyBeanMethods = false)
@@ -44,14 +55,17 @@ public class RedisCacheAdapterConfig {
}
}
@Bean
@Bean(destroyMethod = "close")
@ConditionalOnBean(LettuceRedisRuntime.class)
@ConditionalOnProperty(
name = "app.cache.redis.enabled",
havingValue = "true",
matchIfMissing = false)
CacheRegionPort<String, String> redisStringCacheRegion(
LettuceRedisRuntime runtime, RedisRuntimeSettings settings) {
RedisCacheRegionRuntime redisStringCacheRegion(
LettuceRedisRuntime runtime,
RedisRuntimeSettings settings,
RedisLocalCacheSettings localSettings,
ObjectProvider<MeterRegistry> meterRegistryProvider) {
RedisKeyNamespace namespace =
new RedisKeyNamespace(
settings.namespaceApplication(),
@@ -62,14 +76,75 @@ public class RedisCacheAdapterConfig {
1,
"entry",
512);
return new RedisStringCacheRegion(
new RedisCacheRegionPolicy(
namespace,
settings.hmacSecret(),
settings.positiveTtl(),
settings.negativeTtl(),
settings.maximumValueBytes()),
runtime);
byte[] policySecret = settings.hmacSecret();
RedisCacheRegionPolicy policy;
try {
policy =
new RedisCacheRegionPolicy(
namespace,
policySecret,
"runtime-settings-v2",
settings.positiveSoftTtl(),
settings.positiveTtl(),
settings.negativeTtl(),
settings.ttlJitter(),
settings.minimumHardTtl(),
settings.maximumValueBytes());
} finally {
Arrays.fill(policySecret, (byte) 0);
}
RedisStringCacheRegion l2 = new RedisStringCacheRegion(policy, runtime);
if (!localSettings.enabled()) {
return RedisCacheRegionRuntime.l2Only(l2);
}
MeterRegistry meterRegistry = meterRegistryProvider.getIfAvailable();
CacheObservationPort observations =
meterRegistry == null
? DisabledCacheObservationPort.instance()
: new MicrometerCacheObservationPort(meterRegistry, Set.of(settings.semanticRegion()));
String channel = l2.invalidationChannel();
byte[] codecSecret = settings.hmacSecret();
RedisCacheInvalidationMessage.Codec codec;
try {
codec = RedisCacheInvalidationMessage.Codec.fromOwnedSecret(codecSecret);
} finally {
Arrays.fill(codecSecret, (byte) 0);
}
return RedisCacheRegionRuntime.local(
l2,
new RedisLocalCacheRegion(
settings.semanticRegion(),
l2,
localSettings.policy(),
Clock.systemUTC(),
observations,
channel,
codec,
message -> runtime.publishInvalidation(channel, message)));
}
@Bean(destroyMethod = "close")
@ConditionalOnBean(LettuceRedisRuntime.class)
@ConditionalOnProperty(
name = {"app.cache.redis.enabled", "app.cache.redis.l1.enabled"},
havingValue = "true",
matchIfMissing = false)
LettuceRedisCacheInvalidationSubscription redisCacheInvalidationSubscription(
LettuceRedisRuntime runtime,
@Qualifier("redisStringCacheRegion") RedisCacheRegionRuntime cacheRegion) {
RedisLocalCacheRegion local =
cacheRegion
.local()
.orElseThrow(
() ->
new IllegalStateException(
"Redis L1 invalidation subscription requires the cache-only local"
+ " decorator"));
return LettuceRedisCacheInvalidationSubscription.subscribe(
runtime,
local.invalidationChannel(),
local.invalidationMessageCodec(),
local.invalidationSubscriber());
}
@Bean
@@ -0,0 +1,219 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.application.cache.CacheWriteCondition;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.time.Duration;
import java.util.Base64;
import java.util.Objects;
import java.util.function.Supplier;
/**
* Owns non-expiring random cache generations and per-key revisions.
*
* <p>If an evictable control key disappears, initialization chooses a new random value. An old
* namespace therefore never becomes visible again by resetting to a constant default.
*/
final class RedisCacheConsistencyStore {
private static final String CONDITION_VERSION = "v1";
private static final SecureRandom RANDOM = new SecureRandom();
private static final Duration DEFAULT_KEY_REVISION_TTL = Duration.ofDays(30);
private final RedisBinaryCommands commands;
private final RedisAtomicPrimitives primitives;
private final Supplier<String> identifiers;
private final Duration keyRevisionTtl;
RedisCacheConsistencyStore(RedisBinaryCommands commands) {
this(commands, DEFAULT_KEY_REVISION_TTL);
}
RedisCacheConsistencyStore(RedisBinaryCommands commands, Duration keyRevisionTtl) {
this(
commands,
productionPrimitives(commands),
RedisCacheConsistencyStore::randomIdentifier,
keyRevisionTtl);
}
RedisCacheConsistencyStore(
RedisBinaryCommands commands,
RedisAtomicPrimitives primitives,
Supplier<String> identifiers) {
this(commands, primitives, identifiers, DEFAULT_KEY_REVISION_TTL);
}
RedisCacheConsistencyStore(
RedisBinaryCommands commands,
RedisAtomicPrimitives primitives,
Supplier<String> identifiers,
Duration keyRevisionTtl) {
this.commands = Objects.requireNonNull(commands, "commands must be non-null");
this.primitives = Objects.requireNonNull(primitives, "primitives must be non-null");
this.identifiers = Objects.requireNonNull(identifiers, "identifiers must be non-null");
this.keyRevisionTtl = boundedKeyRevisionTtl(keyRevisionTtl);
}
Snapshot capture(String regionGenerationKey, String keyRevisionKey) {
return new Snapshot(
currentOrInitialize(regionGenerationKey, Duration.ZERO),
currentOrInitialize(keyRevisionKey, keyRevisionTtl));
}
String currentRegionGeneration(String regionGenerationKey) {
return currentOrInitialize(regionGenerationKey, Duration.ZERO);
}
BumpResult bumpKeyRevision(String keyRevisionKey) {
return bumpKeyRevision(keyRevisionKey, nextIdentifier());
}
BumpResult bumpKeyRevision(String keyRevisionKey, String operationId) {
return bump(keyRevisionKey, operationId, keyRevisionTtl);
}
BumpResult bumpRegionGeneration(String regionGenerationKey) {
return bumpRegionGeneration(regionGenerationKey, nextIdentifier());
}
BumpResult bumpRegionGeneration(String regionGenerationKey, String operationId) {
return bump(regionGenerationKey, operationId, Duration.ZERO);
}
Snapshot decode(CacheWriteCondition condition) {
Objects.requireNonNull(condition, "condition must be non-null");
if (!condition.usable()) {
return null;
}
String[] components = condition.value().split("\\.", -1);
if (components.length != 3 || !CONDITION_VERSION.equals(components[0])) {
throw compatibility("MALFORMED_WRITE_CONDITION");
}
try {
return new Snapshot(components[1], components[2]);
} catch (IllegalArgumentException exception) {
throw compatibility("MALFORMED_WRITE_CONDITION");
}
}
private String currentOrInitialize(String key, Duration timeToLive) {
byte[] current = commands.get(physicalKey(key));
String candidate = current == null ? nextIdentifier() : parseState(current).generation();
RedisAtomicPrimitives.GenerationInitResult initialized =
primitives.initializeGeneration(key, candidate, timeToLive);
if (initialized == RedisAtomicPrimitives.GenerationInitResult.WRONG_TYPE
|| initialized == RedisAtomicPrimitives.GenerationInitResult.INVALID) {
throw compatibility(initialized.name());
}
current = commands.get(physicalKey(key));
if (current == null) {
throw compatibility("MISSING_AFTER_INITIALIZATION");
}
return parseState(current).generation();
}
private BumpResult bump(String key, String operationId, Duration timeToLive) {
RedisAtomicPrimitives.GenerationBumpResult result =
primitives.bumpGeneration(
key, nextIdentifier(), validateIdentifier(operationId, "operationId"), timeToLive);
return switch (result) {
case BUMPED -> BumpResult.BUMPED;
case ALREADY_APPLIED -> BumpResult.ALREADY_APPLIED;
case WRONG_TYPE, INVALID -> throw compatibility(result.name());
};
}
private String nextIdentifier() {
return validateIdentifier(identifiers.get(), "generated identifier");
}
private static State parseState(byte[] value) {
String state = new String(value, StandardCharsets.US_ASCII);
int separator = state.indexOf('|');
if (separator < 0 || separator != state.lastIndexOf('|')) {
throw compatibility("MALFORMED_GENERATION_STATE");
}
try {
String generation = validateIdentifier(state.substring(0, separator), "stored generation");
String operation = state.substring(separator + 1);
if (!"-".equals(operation)) {
validateIdentifier(operation, "stored operation");
}
return new State(generation, operation);
} catch (IllegalArgumentException exception) {
throw compatibility("MALFORMED_GENERATION_STATE");
}
}
private static String validateIdentifier(String value, String field) {
if (value == null
|| value.length() < 16
|| value.length() > 64
|| !value.matches("[A-Za-z0-9_-]+")) {
throw new IllegalArgumentException(field + " must contain 16..64 Base64URL-safe characters");
}
return value;
}
private static Duration boundedKeyRevisionTtl(Duration value) {
Objects.requireNonNull(value, "keyRevisionTtl must be non-null");
if (value.isZero() || value.isNegative() || value.compareTo(Duration.ofDays(31)) > 0) {
throw new IllegalArgumentException("keyRevisionTtl must be positive and at most 31 days");
}
return value;
}
private static RedisPhysicalKey physicalKey(String key) {
return RedisPhysicalKey.owned(new ConsistencyKeyMaterial(key));
}
private static String randomIdentifier() {
byte[] random = new byte[16];
RANDOM.nextBytes(random);
return Base64.getUrlEncoder().withoutPadding().encodeToString(random);
}
private static RedisAtomicPrimitives productionPrimitives(RedisBinaryCommands commands) {
RedisProgramCatalog catalog = RedisProgramCatalog.foundation();
return new RedisAtomicPrimitives(catalog, new RedisLuaProgramExecutor(catalog, commands));
}
private static RedisProgramCompatibilityException compatibility(String status) {
return new RedisProgramCompatibilityException(RedisProgramId.REGION_GENERATION_INIT, status);
}
enum BumpResult {
BUMPED,
ALREADY_APPLIED
}
record Snapshot(String generation, String keyRevision) {
Snapshot {
generation = validateIdentifier(generation, "generation");
keyRevision = validateIdentifier(keyRevision, "keyRevision");
}
CacheWriteCondition toWriteCondition() {
return new CacheWriteCondition(CONDITION_VERSION + "." + generation + "." + keyRevision);
}
}
private record State(String generation, String operation) {}
static final class ConsistencyKeyMaterial implements RedisOwnedPhysicalKeyMaterial {
private final byte[] encodedKey;
private ConsistencyKeyMaterial(String key) {
this.encodedKey =
Objects.requireNonNull(key, "key must be non-null").getBytes(StandardCharsets.UTF_8);
}
@Override
public byte[] copyEncodedKey() {
return encodedKey.clone();
}
}
}
@@ -2,117 +2,186 @@ package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.application.cache.AuthoritativeAbsence;
import dev.caskeleton.application.cache.CacheLookup;
import dev.caskeleton.application.cache.CacheObservationToken;
import java.nio.BufferUnderflowException;
import java.nio.ByteBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.Arrays;
import java.util.Base64;
import java.util.Objects;
/** Strict versioned binary envelope for positive and authoritative-negative cache entries. */
final class RedisCacheEnvelopeCodec {
private static final int MAGIC = 0x43414348;
private static final byte VERSION = 1;
private static final int VERSION = 2;
private static final byte POSITIVE = 1;
private static final byte NEGATIVE = 2;
private static final int CONTENT_HEADER_BYTES =
Integer.BYTES + Byte.BYTES + Byte.BYTES + Short.BYTES + Integer.BYTES;
private static final int COMMON_HEADER_BYTES = Integer.BYTES + Byte.BYTES + Byte.BYTES;
private static final int POSITIVE_HEADER_BYTES =
COMMON_HEADER_BYTES + Short.BYTES + Integer.BYTES + Long.BYTES + Long.BYTES;
private static final int NEGATIVE_HEADER_BYTES = COMMON_HEADER_BYTES + Integer.BYTES + Long.BYTES;
private static final int DIGEST_BYTES = 32;
private RedisCacheEnvelopeCodec() {}
static byte[] positive(String value, String sourceRevision, int maximumValueBytes) {
return encode(
POSITIVE,
utf8(Objects.requireNonNull(value, "value must be non-null")),
sourceRevision,
maximumValueBytes);
}
static byte[] negative(
AuthoritativeAbsence reason, String sourceRevision, int maximumValueBytes) {
Objects.requireNonNull(reason, "reason must be non-null");
return encode(NEGATIVE, utf8(reason.name()), sourceRevision, maximumValueBytes);
}
static Decoded decode(byte[] envelope, int maximumValueBytes) {
if (envelope == null
|| envelope.length < CONTENT_HEADER_BYTES + DIGEST_BYTES
|| envelope.length > maximumValueBytes + 1024 + DIGEST_BYTES) {
return incompatible(CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE);
static byte[] positive(
String value,
String sourceRevision,
Instant softExpiresAt,
Instant hardExpiresAt,
int maximumValueBytes) {
Objects.requireNonNull(softExpiresAt, "softExpiresAt must be non-null");
Objects.requireNonNull(hardExpiresAt, "hardExpiresAt must be non-null");
if (softExpiresAt.isAfter(hardExpiresAt)) {
throw new IllegalArgumentException("softExpiresAt must not be after hardExpiresAt");
}
try {
ByteBuffer buffer = ByteBuffer.wrap(envelope, 0, envelope.length - DIGEST_BYTES);
if (buffer.getInt() != MAGIC) {
return incompatible(CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE);
}
byte version = buffer.get();
if (version > VERSION) {
return incompatible(CacheLookup.SchemaCategory.FUTURE_VERSION);
}
if (version < VERSION) {
return incompatible(CacheLookup.SchemaCategory.RETIRED_VERSION);
}
byte[] expectedDigest = sha256(Arrays.copyOf(envelope, envelope.length - DIGEST_BYTES));
byte[] actualDigest =
Arrays.copyOfRange(envelope, envelope.length - DIGEST_BYTES, envelope.length);
if (!MessageDigest.isEqual(expectedDigest, actualDigest)) {
return incompatible(CacheLookup.SchemaCategory.CORRUPT_ENVELOPE);
}
byte type = buffer.get();
int revisionSize = Short.toUnsignedInt(buffer.getShort());
int payloadSize = buffer.getInt();
if (revisionSize < 1
|| revisionSize > 512
|| payloadSize < 1
|| payloadSize > maximumValueBytes
|| buffer.remaining() != revisionSize + payloadSize) {
return incompatible(CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE);
}
byte[] revision = new byte[revisionSize];
byte[] payload = new byte[payloadSize];
buffer.get(revision);
buffer.get(payload);
String sourceRevision = strictUtf8(revision);
if (!validSourceRevision(sourceRevision)) {
return incompatible(CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE);
}
if (type == POSITIVE) {
return new Positive(strictUtf8(payload), sourceRevision);
}
if (type == NEGATIVE) {
return new Negative(AuthoritativeAbsence.valueOf(strictUtf8(payload)));
}
return incompatible(CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE);
} catch (IllegalArgumentException | CharacterCodingException exception) {
return incompatible(CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE);
}
}
private static byte[] encode(
byte type, byte[] payload, String sourceRevision, int maximumValueBytes) {
byte[] revision =
utf8(Objects.requireNonNull(sourceRevision, "sourceRevision must be non-null"));
if (!validSourceRevision(sourceRevision) || revision.length > 512) {
throw new IllegalArgumentException(
"sourceRevision must contain 1..128 characters and at most 512 UTF-8 bytes");
}
if (payload.length < 1 || payload.length > maximumValueBytes) {
throw new IllegalArgumentException("cache payload exceeds configured maximum bytes");
}
byte[] payload =
checkedPayload(
utf8(Objects.requireNonNull(value, "value must be non-null")), maximumValueBytes);
byte[] content =
ByteBuffer.allocate(CONTENT_HEADER_BYTES + revision.length + payload.length)
ByteBuffer.allocate(POSITIVE_HEADER_BYTES + revision.length + payload.length)
.putInt(MAGIC)
.put(VERSION)
.put(type)
.put((byte) VERSION)
.put(POSITIVE)
.putShort((short) revision.length)
.putInt(payload.length)
.putLong(softExpiresAt.toEpochMilli())
.putLong(hardExpiresAt.toEpochMilli())
.put(revision)
.put(payload)
.array();
return withDigest(content);
}
static byte[] negative(
AuthoritativeAbsence reason, Instant hardExpiresAt, int maximumValueBytes) {
Objects.requireNonNull(reason, "reason must be non-null");
Objects.requireNonNull(hardExpiresAt, "hardExpiresAt must be non-null");
byte[] payload = checkedPayload(utf8(reason.name()), maximumValueBytes);
byte[] content =
ByteBuffer.allocate(NEGATIVE_HEADER_BYTES + payload.length)
.putInt(MAGIC)
.put((byte) VERSION)
.put(NEGATIVE)
.putInt(payload.length)
.putLong(hardExpiresAt.toEpochMilli())
.put(payload)
.array();
return withDigest(content);
}
static Decoded decode(byte[] envelope, int maximumValueBytes) {
validateMaximumValueBytes(maximumValueBytes);
if (envelope == null) {
return incompatible(CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE);
}
if (envelope.length < COMMON_HEADER_BYTES + DIGEST_BYTES
|| envelope.length > maximumValueBytes + 1024 + DIGEST_BYTES) {
return incompatible(CacheLookup.SchemaCategory.CORRUPT_ENVELOPE);
}
CacheObservationToken observationToken = CacheObservationToken.unavailable();
try {
int contentLength = envelope.length - DIGEST_BYTES;
byte[] expectedDigest = sha256(Arrays.copyOf(envelope, contentLength));
byte[] actualDigest = Arrays.copyOfRange(envelope, contentLength, envelope.length);
if (!MessageDigest.isEqual(expectedDigest, actualDigest)) {
return incompatible(CacheLookup.SchemaCategory.CORRUPT_ENVELOPE);
}
observationToken = observationToken(actualDigest);
ByteBuffer buffer = ByteBuffer.wrap(envelope, 0, contentLength);
if (buffer.getInt() != MAGIC) {
return incompatible(CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE, observationToken);
}
int version = Byte.toUnsignedInt(buffer.get());
if (version > VERSION) {
return incompatible(CacheLookup.SchemaCategory.FUTURE_VERSION, observationToken);
}
if (version < VERSION) {
return incompatible(CacheLookup.SchemaCategory.RETIRED_VERSION, observationToken);
}
byte type = buffer.get();
if (type == POSITIVE) {
return decodePositive(buffer, maximumValueBytes, observationToken);
}
if (type == NEGATIVE) {
return decodeNegative(buffer, maximumValueBytes, observationToken);
}
return incompatible(CacheLookup.SchemaCategory.CORRUPT_ENVELOPE, observationToken);
} catch (BufferUnderflowException
| IllegalArgumentException
| CharacterCodingException exception) {
return incompatible(CacheLookup.SchemaCategory.CORRUPT_ENVELOPE, observationToken);
}
}
private static Decoded decodePositive(
ByteBuffer buffer, int maximumValueBytes, CacheObservationToken observationToken)
throws CharacterCodingException {
int revisionSize = Short.toUnsignedInt(buffer.getShort());
int payloadSize = buffer.getInt();
Instant softExpiresAt = Instant.ofEpochMilli(buffer.getLong());
Instant hardExpiresAt = Instant.ofEpochMilli(buffer.getLong());
if (revisionSize < 1
|| revisionSize > 512
|| payloadSize < 1
|| payloadSize > maximumValueBytes
|| buffer.remaining() != revisionSize + payloadSize
|| softExpiresAt.isAfter(hardExpiresAt)) {
return incompatible(CacheLookup.SchemaCategory.CORRUPT_ENVELOPE, observationToken);
}
byte[] revision = new byte[revisionSize];
byte[] payload = new byte[payloadSize];
buffer.get(revision);
buffer.get(payload);
String sourceRevision = strictUtf8(revision);
if (!validSourceRevision(sourceRevision)) {
return incompatible(CacheLookup.SchemaCategory.CORRUPT_ENVELOPE, observationToken);
}
return new Positive(
strictUtf8(payload), sourceRevision, softExpiresAt, hardExpiresAt, observationToken);
}
private static Decoded decodeNegative(
ByteBuffer buffer, int maximumValueBytes, CacheObservationToken observationToken)
throws CharacterCodingException {
int payloadSize = buffer.getInt();
Instant hardExpiresAt = Instant.ofEpochMilli(buffer.getLong());
if (payloadSize < 1 || payloadSize > maximumValueBytes || buffer.remaining() != payloadSize) {
return incompatible(CacheLookup.SchemaCategory.CORRUPT_ENVELOPE, observationToken);
}
byte[] payload = new byte[payloadSize];
buffer.get(payload);
return new Negative(
AuthoritativeAbsence.valueOf(strictUtf8(payload)), hardExpiresAt, observationToken);
}
private static byte[] checkedPayload(byte[] payload, int maximumValueBytes) {
validateMaximumValueBytes(maximumValueBytes);
if (payload.length < 1 || payload.length > maximumValueBytes) {
throw new IllegalArgumentException("cache payload exceeds configured maximum bytes");
}
return payload;
}
private static void validateMaximumValueBytes(int maximumValueBytes) {
if (maximumValueBytes < 1 || maximumValueBytes > 16_777_216) {
throw new IllegalArgumentException("maximumValueBytes must be in 1..16777216");
}
}
private static byte[] withDigest(byte[] content) {
return ByteBuffer.allocate(content.length + DIGEST_BYTES)
.put(content)
.put(sha256(content))
@@ -136,6 +205,11 @@ final class RedisCacheEnvelopeCodec {
return !sourceRevision.isBlank() && sourceRevision.length() <= 128;
}
private static CacheObservationToken observationToken(byte[] digest) {
return new CacheObservationToken(
Base64.getUrlEncoder().withoutPadding().encodeToString(digest));
}
private static byte[] sha256(byte[] content) {
try {
return MessageDigest.getInstance("SHA-256").digest(content);
@@ -145,14 +219,28 @@ final class RedisCacheEnvelopeCodec {
}
private static Incompatible incompatible(CacheLookup.SchemaCategory category) {
return new Incompatible(category);
return new Incompatible(category, CacheObservationToken.unavailable());
}
private static Incompatible incompatible(
CacheLookup.SchemaCategory category, CacheObservationToken observationToken) {
return new Incompatible(category, observationToken);
}
sealed interface Decoded permits Positive, Negative, Incompatible {}
record Positive(String value, String sourceRevision) implements Decoded {}
record Positive(
String value,
String sourceRevision,
Instant softExpiresAt,
Instant hardExpiresAt,
CacheObservationToken observationToken)
implements Decoded {}
record Negative(AuthoritativeAbsence reason) implements Decoded {}
record Negative(
AuthoritativeAbsence reason, Instant hardExpiresAt, CacheObservationToken observationToken)
implements Decoded {}
record Incompatible(CacheLookup.SchemaCategory category) implements Decoded {}
record Incompatible(CacheLookup.SchemaCategory category, CacheObservationToken observationToken)
implements Decoded {}
}
@@ -0,0 +1,164 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.util.Arrays;
import java.util.Base64;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicBoolean;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
/** Authenticated, bounded Pub/Sub hint that contains no raw semantic cache key. */
sealed interface RedisCacheInvalidationMessage {
String value();
static RedisCacheInvalidationMessage key(String localEntryIdentity) {
return new Key(localEntryIdentity);
}
static RedisCacheInvalidationMessage region(String generation) {
return new Region(generation);
}
record Key(String value) implements RedisCacheInvalidationMessage {
public Key {
value = boundedAscii(value, "localEntryIdentity", 1024);
}
}
record Region(String value) implements RedisCacheInvalidationMessage {
public Region {
if (value == null
|| value.length() < 16
|| value.length() > 64
|| !value.matches("[A-Za-z0-9_-]+")) {
throw new IllegalArgumentException(
"generation must contain 16..64 Base64URL-safe characters");
}
}
}
/**
* HMAC protects hints from cross-channel corruption; Redis ACL still owns publisher authority.
*/
final class Codec implements AutoCloseable {
private static final int MAXIMUM_WIRE_CHARACTERS = 4096;
private static final Base64.Encoder ENCODER = Base64.getUrlEncoder().withoutPadding();
private static final Base64.Decoder DECODER = Base64.getUrlDecoder();
private final byte[] secret;
private final AtomicBoolean destroyed = new AtomicBoolean();
Codec(byte[] secret) {
Objects.requireNonNull(secret, "secret must be non-null");
if (secret.length < 32) {
throw new IllegalArgumentException("message HMAC secret must contain at least 32 bytes");
}
this.secret = secret.clone();
}
static Codec fromOwnedSecret(byte[] ownedSecret) {
Objects.requireNonNull(ownedSecret, "ownedSecret must be non-null");
try {
return new Codec(ownedSecret);
} finally {
Arrays.fill(ownedSecret, (byte) 0);
}
}
synchronized String encode(RedisCacheInvalidationMessage message) {
ensureUsable();
Objects.requireNonNull(message, "message must be non-null");
String kind = message instanceof Key ? "K" : "R";
byte[] payload = (kind + "\n" + message.value()).getBytes(StandardCharsets.US_ASCII);
return "v1." + ENCODER.encodeToString(payload) + "." + ENCODER.encodeToString(hmac(payload));
}
synchronized Optional<RedisCacheInvalidationMessage> decode(String wire) {
ensureUsable();
if (wire == null || wire.length() < 8 || wire.length() > MAXIMUM_WIRE_CHARACTERS) {
return Optional.empty();
}
String[] components = wire.split("\\.", -1);
if (components.length != 3 || !"v1".equals(components[0])) {
return Optional.empty();
}
try {
byte[] payload = DECODER.decode(components[1]);
byte[] suppliedMac = DECODER.decode(components[2]);
if (!MessageDigest.isEqual(hmac(payload), suppliedMac)) {
return Optional.empty();
}
String decoded = new String(payload, StandardCharsets.US_ASCII);
int separator = decoded.indexOf('\n');
if (separator != 1 || separator != decoded.lastIndexOf('\n')) {
return Optional.empty();
}
String value = decoded.substring(separator + 1);
return switch (decoded.charAt(0)) {
case 'K' -> Optional.of(key(value));
case 'R' -> Optional.of(region(value));
default -> Optional.empty();
};
} catch (IllegalArgumentException exception) {
return Optional.empty();
}
}
private byte[] hmac(byte[] payload) {
byte[] secretCopy = secret.clone();
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secretCopy, "HmacSHA256"));
return mac.doFinal(payload);
} catch (GeneralSecurityException exception) {
throw new IllegalStateException("HmacSHA256 unavailable for invalidation hints", exception);
} finally {
Arrays.fill(secretCopy, (byte) 0);
}
}
@Override
public synchronized void close() {
if (destroyed.compareAndSet(false, true)) {
Arrays.fill(secret, (byte) 0);
}
}
synchronized boolean destroyed() {
if (!destroyed.get()) {
return false;
}
for (byte value : secret) {
if (value != 0) {
return false;
}
}
return true;
}
private void ensureUsable() {
if (destroyed.get()) {
throw new IllegalStateException("invalidation message codec is destroyed");
}
}
}
private static String boundedAscii(String value, String field, int maximumCharacters) {
if (value == null
|| value.isBlank()
|| value.length() > maximumCharacters
|| value.chars().anyMatch(character -> character < 0x21 || character > 0x7e)) {
throw new IllegalArgumentException(
field + " must contain bounded non-whitespace ASCII characters");
}
return value;
}
}
@@ -0,0 +1,64 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.util.Objects;
import java.util.concurrent.ArrayBlockingQueue;
/**
* Bounded handoff between a Redis Pub/Sub callback and cache request threads.
*
* <p>Pub/Sub has no replay. Disconnect or queue overflow therefore flushes L1 immediately and
* forces a generation read before local entries may be repopulated.
*/
final class RedisCacheInvalidationSubscriber {
interface Target {
void apply(RedisCacheInvalidationMessage message);
void disconnected();
void overflow();
void malformedMessage();
}
private final ArrayBlockingQueue<RedisCacheInvalidationMessage> hints;
private final Target target;
RedisCacheInvalidationSubscriber(int capacity, Target target) {
if (capacity < 1 || capacity > 65_536) {
throw new IllegalArgumentException("subscriber capacity must be in 1..65536");
}
this.hints = new ArrayBlockingQueue<>(capacity);
this.target = Objects.requireNonNull(target, "target must be non-null");
}
void onMessage(RedisCacheInvalidationMessage message) {
Objects.requireNonNull(message, "message must be non-null");
if (hints.offer(message)) {
return;
}
hints.clear();
target.overflow();
}
void onDisconnected() {
hints.clear();
target.disconnected();
}
void onMalformedMessage() {
target.malformedMessage();
}
void drain() {
RedisCacheInvalidationMessage hint;
while ((hint = hints.poll()) != null) {
target.apply(hint);
}
}
int queuedHintCount() {
return hints.size();
}
}
@@ -0,0 +1,63 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
/** Lifecycle wrapper that decodes canonical CACHE-role invalidation traffic. */
final class RedisCacheInvalidationSubscription implements AutoCloseable {
private final RedisInvalidationTransport.Subscription delegate;
private final RedisCacheInvalidationSubscriber subscriber;
private final AtomicBoolean closed = new AtomicBoolean();
private RedisCacheInvalidationSubscription(
RedisInvalidationTransport.Subscription delegate,
RedisCacheInvalidationSubscriber subscriber) {
this.delegate = Objects.requireNonNull(delegate, "delegate must be non-null");
this.subscriber = Objects.requireNonNull(subscriber, "subscriber must be non-null");
}
static RedisCacheInvalidationSubscription subscribe(
RedisInvalidationTransport transport,
String channel,
RedisCacheInvalidationMessage.Codec codec,
RedisCacheInvalidationSubscriber subscriber) {
Objects.requireNonNull(transport, "transport must be non-null");
Objects.requireNonNull(channel, "channel must be non-null");
Objects.requireNonNull(codec, "codec must be non-null");
Objects.requireNonNull(subscriber, "subscriber must be non-null");
RedisInvalidationTransport.Subscription delegate =
transport.subscribe(
channel.getBytes(StandardCharsets.US_ASCII),
new RedisInvalidationTransport.Listener() {
@Override
public void onMessage(byte[] wireMessage) {
if (wireMessage == null) {
subscriber.onMalformedMessage();
return;
}
codec
.decode(new String(wireMessage, StandardCharsets.US_ASCII))
.ifPresentOrElse(subscriber::onMessage, subscriber::onMalformedMessage);
}
@Override
public void onDisconnected() {
subscriber.onDisconnected();
}
});
return new RedisCacheInvalidationSubscription(delegate, subscriber);
}
@Override
public void close() {
if (closed.compareAndSet(false, true)) {
try {
delegate.close();
} finally {
subscriber.onDisconnected();
}
}
}
}
@@ -0,0 +1,18 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.application.cache.CacheRegionPort;
/**
* Internal cache-only L2 surface needed by the local decorator.
*
* <p>Session, idempotency, rate-limit and coordination providers do not implement this type and
* therefore cannot accidentally receive the fail-open local tier.
*/
interface RedisCacheL2Region extends CacheRegionPort<String, String> {
/** Stable HMAC-derived identity; never the raw semantic key. */
String localEntryIdentity(String key);
/** Current region generation used to recover from missed best-effort invalidation hints. */
String currentRegionGeneration();
}
@@ -0,0 +1,295 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyBuilder;
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyDigest;
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyNamespace;
import dev.caskeleton.application.cache.CacheRefreshClaimAttempt;
import dev.caskeleton.application.cache.CacheRefreshClaimOutcome;
import dev.caskeleton.application.cache.CacheRefreshCoordinationPort;
import dev.caskeleton.application.cache.CacheRefreshOperationToken;
import dev.caskeleton.application.cache.CacheRefreshOwnerToken;
import dev.caskeleton.application.cache.CacheRefreshReleaseOutcome;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.time.Duration;
import java.util.Arrays;
import java.util.Base64;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.LongSupplier;
import java.util.function.Supplier;
/**
* Redis-backed cache refresh admission lease.
*
* <p>The lease only suppresses duplicate refresh work. Cache generation/revision fences remain the
* correctness mechanism for invalidation races.
*/
final class RedisCacheRefreshCoordinator
implements CacheRefreshCoordinationPort<String>, AutoCloseable {
private static final SecureRandom RANDOM = new SecureRandom();
private final RedisKeyNamespace namespace;
private final byte[] hmacSecret;
private final RedisAtomicPrimitives primitives;
private final Supplier<String> tokens;
private final RedisCapabilityObserver observer;
private final AtomicBoolean destroyed = new AtomicBoolean();
RedisCacheRefreshCoordinator(
RedisKeyNamespace namespace, byte[] hmacSecret, RedisBinaryCommands commands) {
this(
namespace,
hmacSecret,
productionPrimitives(commands),
RedisCacheRefreshCoordinator::randomToken,
NoOpRedisCapabilityObservationPort.instance(),
System::nanoTime);
}
RedisCacheRefreshCoordinator(
RedisKeyNamespace namespace,
byte[] hmacSecret,
RedisBinaryCommands commands,
RedisCapabilityObservationPort observations,
LongSupplier ticker) {
this(
namespace,
hmacSecret,
productionPrimitives(commands),
RedisCacheRefreshCoordinator::randomToken,
observations,
ticker);
}
RedisCacheRefreshCoordinator(
RedisKeyNamespace namespace,
byte[] hmacSecret,
RedisAtomicPrimitives primitives,
Supplier<String> tokens) {
this(
namespace,
hmacSecret,
primitives,
tokens,
NoOpRedisCapabilityObservationPort.instance(),
System::nanoTime);
}
RedisCacheRefreshCoordinator(
RedisKeyNamespace namespace,
byte[] hmacSecret,
RedisAtomicPrimitives primitives,
Supplier<String> tokens,
RedisCapabilityObservationPort observations,
LongSupplier ticker) {
this.namespace = refreshNamespace(namespace);
Objects.requireNonNull(hmacSecret, "hmacSecret must be non-null");
if (hmacSecret.length < 32) {
throw new IllegalArgumentException("hmacSecret must contain at least 32 bytes");
}
this.hmacSecret = hmacSecret.clone();
this.primitives = Objects.requireNonNull(primitives, "primitives must be non-null");
this.tokens = Objects.requireNonNull(tokens, "tokens must be non-null");
this.observer = new RedisCapabilityObserver(observations, ticker);
}
@Override
public CacheRefreshClaimAttempt newAttempt() {
ensureUsable();
return new CacheRefreshClaimAttempt(
new CacheRefreshOwnerToken(nextToken()), new CacheRefreshOperationToken(nextToken()));
}
@Override
public CacheRefreshClaimOutcome claim(
String key, CacheRefreshClaimAttempt attempt, Duration leaseTimeToLive) {
return observer.observe(
RedisCapabilityObservationEvent.Capability.CACHE,
RedisCapabilityObservationEvent.Role.CACHE,
RedisCapabilityObservationEvent.Operation.REFRESH_CLAIM,
() -> claimOpen(key, attempt, leaseTimeToLive),
RedisCacheRefreshCoordinator::classifyClaim);
}
private CacheRefreshClaimOutcome claimOpen(
String key, CacheRefreshClaimAttempt attempt, Duration leaseTimeToLive) {
ensureUsable();
requireUsable(attempt);
try {
RedisAtomicPrimitives.RefreshClaimResult result =
primitives.claimRefreshLease(
physicalKey(key),
attempt.ownerToken().value(),
attempt.operationToken().value(),
leaseTimeToLive);
return switch (result) {
case CLAIMED -> new CacheRefreshClaimOutcome.Claimed(attempt);
case ALREADY_OWNED -> new CacheRefreshClaimOutcome.AlreadyOwned(attempt);
case CONTENDED -> new CacheRefreshClaimOutcome.Contended();
case WRONG_TYPE, INVALID ->
throw new RedisProgramCompatibilityException(
RedisProgramId.CACHE_REFRESH_CLAIM, result.name());
};
} catch (RedisCommandFailureException exception) {
return exception.certainty() == RedisCommandFailureException.Certainty.NOT_APPLIED
? new CacheRefreshClaimOutcome.Unavailable()
: new CacheRefreshClaimOutcome.Indeterminate();
}
}
@Override
public CacheRefreshReleaseOutcome release(String key, CacheRefreshClaimAttempt attempt) {
return observer.observe(
RedisCapabilityObservationEvent.Capability.CACHE,
RedisCapabilityObservationEvent.Role.CACHE,
RedisCapabilityObservationEvent.Operation.REFRESH_RELEASE,
() -> releaseOpen(key, attempt),
RedisCacheRefreshCoordinator::classifyRelease);
}
private CacheRefreshReleaseOutcome releaseOpen(String key, CacheRefreshClaimAttempt attempt) {
ensureUsable();
requireUsable(attempt);
try {
RedisAtomicPrimitives.CompareDeleteResult result =
primitives.compareAndDelete(physicalKey(key), ownerState(attempt));
return switch (result) {
case DELETED -> new CacheRefreshReleaseOutcome.Released();
case ABSENT -> new CacheRefreshReleaseOutcome.AlreadyReleased();
case NOT_OWNER -> new CacheRefreshReleaseOutcome.NotOwner();
case WRONG_TYPE, INVALID ->
throw new RedisProgramCompatibilityException(
RedisProgramId.COMPARE_AND_DELETE, result.name());
};
} catch (RedisCommandFailureException exception) {
return exception.certainty() == RedisCommandFailureException.Certainty.NOT_APPLIED
? new CacheRefreshReleaseOutcome.Unavailable()
: new CacheRefreshReleaseOutcome.Indeterminate();
}
}
private String physicalKey(String semanticKey) {
if (semanticKey == null || semanticKey.isBlank()) {
throw new IllegalArgumentException("semantic cache key must be non-blank");
}
RedisKeyDigest digest =
RedisKeyDigest.sensitive(
namespace.hashKeyVersion(),
hmacSecret,
List.of(semanticKey.getBytes(StandardCharsets.UTF_8)));
return RedisKeyBuilder.build(namespace, digest);
}
@Override
public void close() {
if (destroyed.compareAndSet(false, true)) {
Arrays.fill(hmacSecret, (byte) 0);
}
}
private void ensureUsable() {
if (destroyed.get()) {
throw new IllegalStateException("Redis cache refresh coordinator is destroyed");
}
}
private String nextToken() {
String token = Objects.requireNonNull(tokens.get(), "generated token must be non-null");
if (!token.matches("[A-Za-z0-9_-]{16,63}")) {
throw new IllegalArgumentException(
"generated token must contain 16..63 Base64URL-safe characters");
}
return token;
}
private static byte[] ownerState(CacheRefreshClaimAttempt attempt) {
return (attempt.ownerToken().value() + "|" + attempt.operationToken().value())
.getBytes(StandardCharsets.US_ASCII);
}
private static void requireUsable(CacheRefreshClaimAttempt attempt) {
Objects.requireNonNull(attempt, "attempt must be non-null");
if (!attempt.usable()) {
throw new IllegalArgumentException("Redis refresh coordination requires a usable attempt");
}
}
private static RedisKeyNamespace refreshNamespace(RedisKeyNamespace namespace) {
Objects.requireNonNull(namespace, "namespace must be non-null");
return new RedisKeyNamespace(
namespace.application(),
namespace.environment(),
namespace.capability(),
namespace.region(),
namespace.hashKeyVersion(),
namespace.keyVersion(),
"refresh-lease",
namespace.maximumKeyBytes());
}
private static RedisAtomicPrimitives productionPrimitives(RedisBinaryCommands commands) {
RedisProgramCatalog catalog = RedisProgramCatalog.foundation();
return new RedisAtomicPrimitives(catalog, new RedisLuaProgramExecutor(catalog, commands));
}
private static String randomToken() {
byte[] random = new byte[16];
RANDOM.nextBytes(random);
return Base64.getUrlEncoder().withoutPadding().encodeToString(random);
}
private static RedisCapabilityObserver.Classification classifyClaim(
CacheRefreshClaimOutcome outcome) {
if (outcome instanceof CacheRefreshClaimOutcome.Claimed
|| outcome instanceof CacheRefreshClaimOutcome.AlreadyOwned) {
return classification(
RedisCapabilityObservationEvent.Outcome.SUCCESS,
RedisCapabilityObservationEvent.Certainty.DEFINITE);
}
if (outcome instanceof CacheRefreshClaimOutcome.Contended) {
return classification(
RedisCapabilityObservationEvent.Outcome.CONTENDED,
RedisCapabilityObservationEvent.Certainty.DEFINITE);
}
if (outcome instanceof CacheRefreshClaimOutcome.Indeterminate) {
return classification(
RedisCapabilityObservationEvent.Outcome.INDETERMINATE,
RedisCapabilityObservationEvent.Certainty.INDETERMINATE);
}
return classification(
RedisCapabilityObservationEvent.Outcome.UNAVAILABLE,
RedisCapabilityObservationEvent.Certainty.NOT_APPLIED);
}
private static RedisCapabilityObserver.Classification classifyRelease(
CacheRefreshReleaseOutcome outcome) {
if (outcome instanceof CacheRefreshReleaseOutcome.Released
|| outcome instanceof CacheRefreshReleaseOutcome.AlreadyReleased) {
return classification(
RedisCapabilityObservationEvent.Outcome.SUCCESS,
RedisCapabilityObservationEvent.Certainty.DEFINITE);
}
if (outcome instanceof CacheRefreshReleaseOutcome.NotOwner) {
return classification(
RedisCapabilityObservationEvent.Outcome.CONFLICT,
RedisCapabilityObservationEvent.Certainty.DEFINITE);
}
if (outcome instanceof CacheRefreshReleaseOutcome.Indeterminate) {
return classification(
RedisCapabilityObservationEvent.Outcome.INDETERMINATE,
RedisCapabilityObservationEvent.Certainty.INDETERMINATE);
}
return classification(
RedisCapabilityObservationEvent.Outcome.UNAVAILABLE,
RedisCapabilityObservationEvent.Certainty.NOT_APPLIED);
}
private static RedisCapabilityObserver.Classification classification(
RedisCapabilityObservationEvent.Outcome outcome,
RedisCapabilityObservationEvent.Certainty certainty) {
return new RedisCapabilityObserver.Classification(outcome, certainty);
}
}
@@ -1,32 +1,94 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyNamespace;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.util.Arrays;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
/** Immutable key, TTL and envelope bounds for one semantic string cache region. */
final class RedisCacheRegionPolicy {
final class RedisCacheRegionPolicy implements AutoCloseable {
private static final Duration MAXIMUM_TTL = Duration.ofDays(30);
private static final Duration COMPATIBILITY_MINIMUM_HARD_TTL = Duration.ofMillis(1);
private final RedisKeyNamespace namespace;
private final byte[] hmacSecret;
private final Duration positiveTtl;
private final String policyRevision;
private final Duration positiveSoftTtl;
private final Duration positiveHardTtl;
private final Duration negativeTtl;
private final double jitterRatio;
private final Duration minimumHardTtl;
private final int maximumValueBytes;
private final AtomicBoolean destroyed = new AtomicBoolean();
/**
* Compatibility constructor for the existing single-positive-TTL settings contract.
*
* <p>It deliberately disables stale serving and jitter. New region bindings should use the full
* constructor so the effective policy revision and soft/hard bounds are explicit.
*/
RedisCacheRegionPolicy(
RedisKeyNamespace namespace,
byte[] hmacSecret,
Duration positiveTtl,
Duration negativeTtl,
int maximumValueBytes) {
this(
namespace,
hmacSecret,
"single-ttl-compatibility-r1",
positiveTtl,
positiveTtl,
negativeTtl,
0.0,
COMPATIBILITY_MINIMUM_HARD_TTL,
maximumValueBytes);
}
RedisCacheRegionPolicy(
RedisKeyNamespace namespace,
byte[] hmacSecret,
String policyRevision,
Duration positiveSoftTtl,
Duration positiveHardTtl,
Duration negativeTtl,
double jitterRatio,
Duration minimumHardTtl,
int maximumValueBytes) {
this.namespace = Objects.requireNonNull(namespace, "namespace must be non-null");
Objects.requireNonNull(hmacSecret, "hmacSecret must be non-null");
if (hmacSecret.length < 32) {
throw new IllegalArgumentException("hmacSecret must contain at least 32 bytes");
}
this.hmacSecret = hmacSecret.clone();
this.positiveTtl = positive(positiveTtl, "positiveTtl");
this.policyRevision = policyRevision(policyRevision);
this.positiveSoftTtl = positive(positiveSoftTtl, "positiveSoftTtl");
this.positiveHardTtl = positive(positiveHardTtl, "positiveHardTtl");
this.negativeTtl = positive(negativeTtl, "negativeTtl");
if (this.positiveSoftTtl.compareTo(this.positiveHardTtl) > 0) {
throw new IllegalArgumentException("positive soft TTL must not exceed positive hard TTL");
}
if (!Double.isFinite(jitterRatio) || jitterRatio < 0.0 || jitterRatio > 0.5) {
throw new IllegalArgumentException("jitter ratio must be finite and in 0.0..0.5");
}
this.jitterRatio = jitterRatio;
if (scale(this.positiveHardTtl, 1.0 + jitterRatio).compareTo(MAXIMUM_TTL) > 0
|| scale(this.negativeTtl, 1.0 + jitterRatio).compareTo(MAXIMUM_TTL) > 0) {
throw new IllegalArgumentException(
"configured hard TTL plus positive jitter must not exceed 30 days");
}
this.minimumHardTtl = positive(minimumHardTtl, "minimumHardTtl");
if (this.minimumHardTtl.compareTo(this.positiveHardTtl) > 0
|| this.minimumHardTtl.compareTo(this.negativeTtl) > 0) {
throw new IllegalArgumentException(
"minimum hard TTL must not exceed positive hard TTL or negative TTL");
}
if (maximumValueBytes < 1 || maximumValueBytes > 16_777_216) {
throw new IllegalArgumentException("maximumValueBytes must be in 1..16777216");
}
@@ -38,26 +100,151 @@ final class RedisCacheRegionPolicy {
}
byte[] hmacSecret() {
ensureUsable();
return hmacSecret.clone();
}
String policyRevision() {
return policyRevision;
}
Duration positiveSoftTtl() {
return positiveSoftTtl;
}
Duration positiveHardTtl() {
return positiveHardTtl;
}
/** Existing accessor retained while single-TTL runtime settings migrate to the full policy. */
Duration positiveTtl() {
return positiveTtl;
return positiveHardTtl;
}
Duration negativeTtl() {
return negativeTtl;
}
Duration maximumEntryTimeToLive() {
Duration maximumConfigured =
positiveHardTtl.compareTo(negativeTtl) >= 0 ? positiveHardTtl : negativeTtl;
return scale(maximumConfigured, 1.0 + jitterRatio);
}
int maximumValueBytes() {
return maximumValueBytes;
}
@Override
public void close() {
if (destroyed.compareAndSet(false, true)) {
Arrays.fill(hmacSecret, (byte) 0);
}
}
PositiveExpiry positiveExpiry(byte[] hmacDerivedPhysicalKey) {
double factor = effectiveFactor(hmacDerivedPhysicalKey, "positive", positiveHardTtl);
Duration soft = scale(positiveSoftTtl, factor);
Duration hard = scale(positiveHardTtl, factor);
if (hard.compareTo(minimumHardTtl) < 0) {
hard = minimumHardTtl;
}
if (soft.compareTo(hard) > 0) {
soft = hard;
}
return new PositiveExpiry(soft, hard);
}
Duration negativeTimeToLive(byte[] hmacDerivedPhysicalKey) {
double factor = effectiveFactor(hmacDerivedPhysicalKey, "negative", negativeTtl);
Duration actual = scale(negativeTtl, factor);
return actual.compareTo(minimumHardTtl) < 0 ? minimumHardTtl : actual;
}
private double effectiveFactor(
byte[] hmacDerivedPhysicalKey, String expiryKind, Duration configuredHardTtl) {
Objects.requireNonNull(hmacDerivedPhysicalKey, "hmacDerivedPhysicalKey must be non-null");
if (hmacDerivedPhysicalKey.length == 0) {
throw new IllegalArgumentException("hmacDerivedPhysicalKey must not be empty");
}
double sampledFactor =
1.0 + (jitterRatio * symmetricSample(hmacDerivedPhysicalKey, expiryKind));
double minimumFactor =
((double) minimumHardTtl.toMillis()) / Math.max(1L, configuredHardTtl.toMillis());
return Math.max(sampledFactor, minimumFactor);
}
private double symmetricSample(byte[] hmacDerivedPhysicalKey, String expiryKind) {
byte[] kind = expiryKind.getBytes(StandardCharsets.UTF_8);
byte[] revision = policyRevision.getBytes(StandardCharsets.UTF_8);
ByteBuffer canonical =
ByteBuffer.allocate(
Integer.BYTES
+ hmacDerivedPhysicalKey.length
+ Integer.BYTES
+ revision.length
+ Integer.BYTES
+ kind.length);
canonical
.putInt(hmacDerivedPhysicalKey.length)
.put(hmacDerivedPhysicalKey)
.putInt(revision.length)
.put(revision)
.putInt(kind.length)
.put(kind);
long sampleBits = ByteBuffer.wrap(sha256(canonical.array())).getLong() >>> 11;
double unitInterval = sampleBits * 0x1.0p-53;
return (unitInterval * 2.0) - 1.0;
}
private static Duration scale(Duration configured, double factor) {
long configuredMillis = Math.max(1L, configured.toMillis());
long actualMillis = Math.max(1L, Math.round(configuredMillis * factor));
return Duration.ofMillis(actualMillis);
}
private static Duration positive(Duration value, String field) {
Objects.requireNonNull(value, field + " must be non-null");
if (value.isZero() || value.isNegative() || value.compareTo(Duration.ofDays(30)) > 0) {
if (value.isZero() || value.isNegative() || value.compareTo(MAXIMUM_TTL) > 0) {
throw new IllegalArgumentException(field + " must be positive and at most 30 days");
}
return value;
}
private static String policyRevision(String value) {
Objects.requireNonNull(value, "policyRevision must be non-null");
if (value.isBlank() || value.length() > 128) {
throw new IllegalArgumentException("policyRevision must contain 1..128 characters");
}
return value;
}
private static byte[] sha256(byte[] content) {
try {
return MessageDigest.getInstance("SHA-256").digest(content);
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 unavailable for cache TTL jitter", exception);
}
}
private void ensureUsable() {
if (destroyed.get()) {
throw new IllegalStateException("Redis cache region policy is destroyed");
}
}
record PositiveExpiry(Duration softTtl, Duration hardTtl) {
PositiveExpiry {
Objects.requireNonNull(softTtl, "softTtl must be non-null");
Objects.requireNonNull(hardTtl, "hardTtl must be non-null");
if (softTtl.isZero()
|| softTtl.isNegative()
|| hardTtl.isZero()
|| hardTtl.isNegative()
|| softTtl.compareTo(hardTtl) > 0) {
throw new IllegalArgumentException("positive expiry requires 0 < softTtl <= hardTtl");
}
}
}
}
@@ -0,0 +1,111 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.application.cache.AuthoritativeAbsence;
import dev.caskeleton.application.cache.CacheInvalidationOutcome;
import dev.caskeleton.application.cache.CacheLookup;
import dev.caskeleton.application.cache.CacheRecordMetadata;
import dev.caskeleton.application.cache.CacheRecordOutcome;
import dev.caskeleton.application.cache.CacheRegionPort;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicBoolean;
/** Lifecycle-owning composition of the Redis L2 and its optional cache-only local decorator. */
final class RedisCacheRegionRuntime implements CacheRegionPort<String, String>, AutoCloseable {
private final CacheRegionPort<String, String> delegate;
private final RedisCacheL2Region l2;
private final RedisLocalCacheRegion local;
private final AtomicBoolean closed = new AtomicBoolean();
private RedisCacheRegionRuntime(
CacheRegionPort<String, String> delegate,
RedisCacheL2Region l2,
RedisLocalCacheRegion local) {
this.delegate = Objects.requireNonNull(delegate, "delegate must be non-null");
this.l2 = Objects.requireNonNull(l2, "l2 must be non-null");
this.local = local;
}
static RedisCacheRegionRuntime l2Only(RedisCacheL2Region l2) {
return new RedisCacheRegionRuntime(l2, l2, null);
}
static RedisCacheRegionRuntime local(RedisCacheL2Region l2, RedisLocalCacheRegion local) {
return new RedisCacheRegionRuntime(
Objects.requireNonNull(local, "local must be non-null"), l2, local);
}
Optional<RedisLocalCacheRegion> local() {
return Optional.ofNullable(local);
}
@Override
public CacheLookup<String> lookup(String key) {
ensureOpen();
return delegate.lookup(key);
}
@Override
public CacheRecordOutcome record(String key, String value, CacheRecordMetadata metadata) {
ensureOpen();
return delegate.record(key, value, metadata);
}
@Override
public CacheRecordOutcome recordAbsent(
String key, AuthoritativeAbsence reason, CacheRecordMetadata metadata) {
ensureOpen();
return delegate.recordAbsent(key, reason, metadata);
}
@Override
public CacheInvalidationOutcome invalidate(String key) {
ensureOpen();
return delegate.invalidate(key);
}
@Override
public CacheInvalidationOutcome invalidateRegion() {
ensureOpen();
return delegate.invalidateRegion();
}
@Override
public void close() {
if (closed.compareAndSet(false, true)) {
RuntimeException failure = null;
try {
if (local != null) {
local.close();
}
} catch (RuntimeException exception) {
failure = exception;
}
if (l2 instanceof AutoCloseable closeable) {
try {
closeable.close();
} catch (Exception exception) {
RuntimeException closeFailure =
exception instanceof RuntimeException runtimeException
? runtimeException
: new IllegalStateException("Redis cache L2 close failed", exception);
if (failure == null) {
failure = closeFailure;
} else {
failure.addSuppressed(closeFailure);
}
}
}
if (failure != null) {
throw failure;
}
}
}
private void ensureOpen() {
if (closed.get()) {
throw new IllegalStateException("Redis cache region runtime is closed");
}
}
}
@@ -0,0 +1,24 @@
package dev.caskeleton.adapter.outbound.cache.redis;
/** Rejects ambiguous canonical/legacy activation and unapproved legacy production primaries. */
final class RedisCanonicalActivationValidator {
private RedisCanonicalActivationValidator() {}
static void validate(
boolean canonicalActive,
boolean legacyMigrationEnabled,
boolean legacyCacheEnabled,
boolean legacyRateLimitEnabled) {
boolean legacyActive = legacyCacheEnabled || legacyRateLimitEnabled;
if (canonicalActive && legacyActive) {
throw new IllegalStateException(
"Canonical and legacy Redis configuration cannot be active simultaneously; no precedence"
+ " is defined");
}
if (legacyActive && !legacyMigrationEnabled) {
throw new IllegalStateException(
"Legacy standalone Redis activation requires explicit migration input mode");
}
}
}
@@ -0,0 +1,159 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.redis.config.RedisProviderSettings;
import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole;
import dev.caskeleton.adapter.outbound.cache.redis.security.RedisCredentialMaterialProvider;
import dev.caskeleton.application.cache.CacheObservationPort;
import dev.caskeleton.application.cache.DisabledCacheObservationPort;
import io.micrometer.core.instrument.MeterRegistry;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Duration;
import java.util.Arrays;
import java.util.Set;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/** Canonical default-region cache composition, isolated to the physical Redis CACHE role. */
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties({RedisCanonicalCacheSettings.class, RedisProviderSettings.class})
@ConditionalOnProperty(
name = "ca-skeleton.capabilities.cache.bindings.default",
havingValue = "redis",
matchIfMissing = false)
public class RedisCanonicalCacheConfig {
private static final int MAXIMUM_COMMAND_OVERHEAD_BYTES = 4096;
@Bean(name = "redisCanonicalDefaultCacheRegion", destroyMethod = "close")
@ConditionalOnProperty(
name = "ca-skeleton.capabilities.cache.bindings.default",
havingValue = "redis",
matchIfMissing = false)
RedisCacheRegionRuntime redisCanonicalDefaultCacheRegion(
RedisCanonicalCacheSettings settings,
RedisProviderSettings providerProperties,
RedisCanonicalRoleRegistry roleRegistry,
RedisCredentialMaterialProvider credentialProvider,
ObjectProvider<Clock> clockProvider,
ObjectProvider<MeterRegistry> meterRegistryProvider,
ObjectProvider<RedisCapabilityObservationPort> capabilityObservationsProvider) {
settings.validateActive();
validateCommandBound(settings, providerProperties.runtime());
Clock clock = clockProvider.getIfAvailable(Clock::systemUTC);
RedisCapabilityObservationPort capabilityObservations =
capabilityObservationsProvider.getIfUnique(NoOpRedisCapabilityObservationPort::instance);
RedisRoleCommandRouter router = roleRegistry.router(RedisRole.CACHE);
byte[] hmacSecret =
RedisHmacMaterialResolver.resolve(
settings.keyHmacSecretReference(), credentialProvider, clock, "cache");
RedisCacheRegionPolicy policy = null;
RedisStringCacheRegion l2 = null;
RedisCacheInvalidationMessage.Codec codec = null;
try {
policy =
new RedisCacheRegionPolicy(
settings.namespace(),
hmacSecret,
settings.policyRevision(),
settings.positiveSoftTtl(),
settings.positiveHardTtl(),
settings.negativeTtl(),
settings.ttlJitter(),
minimumHardTtl(settings),
settings.maximumValueBytes());
l2 =
new RedisStringCacheRegion(
policy, router, clock, capabilityObservations, System::nanoTime);
policy = null;
if (!settings.l1().enabled()) {
RedisCacheRegionRuntime runtime = RedisCacheRegionRuntime.l2Only(l2);
l2 = null;
return runtime;
}
MeterRegistry meterRegistry = meterRegistryProvider.getIfAvailable();
CacheObservationPort observations =
meterRegistry == null
? DisabledCacheObservationPort.instance()
: new MicrometerCacheObservationPort(
meterRegistry, Set.of(settings.semanticRegion()));
String channel = l2.invalidationChannel();
codec = new RedisCacheInvalidationMessage.Codec(hmacSecret);
RedisLocalCacheRegion local =
new RedisLocalCacheRegion(
settings.semanticRegion(),
l2,
settings.l1().policy(),
clock,
observations,
channel,
codec,
message ->
router.publish(
channel.getBytes(StandardCharsets.US_ASCII),
message.getBytes(StandardCharsets.US_ASCII)));
RedisCacheRegionRuntime runtime = RedisCacheRegionRuntime.local(l2, local);
l2 = null;
codec = null;
return runtime;
} finally {
Arrays.fill(hmacSecret, (byte) 0);
if (codec != null) {
codec.close();
}
if (l2 != null) {
l2.close();
}
if (policy != null) {
policy.close();
}
}
}
@Bean(name = "redisCanonicalDefaultCacheInvalidationSubscription", destroyMethod = "close")
@ConditionalOnProperty(
name = "ca-skeleton.capabilities.cache.regions.default.l1.enabled",
havingValue = "true",
matchIfMissing = false)
RedisCacheInvalidationSubscription redisCanonicalDefaultCacheInvalidationSubscription(
RedisCanonicalRoleRegistry roleRegistry,
@Qualifier("redisCanonicalDefaultCacheRegion") RedisCacheRegionRuntime cacheRegion) {
RedisLocalCacheRegion local =
cacheRegion
.local()
.orElseThrow(
() ->
new IllegalStateException(
"Canonical Redis L1 subscription requires the cache-only local decorator"));
return RedisCacheInvalidationSubscription.subscribe(
roleRegistry.router(RedisRole.CACHE),
local.invalidationChannel(),
local.invalidationMessageCodec(),
local.invalidationSubscriber());
}
private static void validateCommandBound(
RedisCanonicalCacheSettings settings, RedisProviderSettings.RuntimeProperties runtime) {
long required = (long) settings.maximumValueBytes() + MAXIMUM_COMMAND_OVERHEAD_BYTES;
if (required > runtime.maximumCommandBytes()) {
throw new IllegalArgumentException(
"Canonical Redis cache maximum value bytes exceed the CACHE router command bound");
}
}
private static Duration minimumHardTtl(RedisCanonicalCacheSettings settings) {
Duration minimum = Duration.ofSeconds(1);
if (minimum.compareTo(settings.positiveHardTtl()) > 0
|| minimum.compareTo(settings.negativeTtl()) > 0) {
return settings.positiveHardTtl().compareTo(settings.negativeTtl()) <= 0
? settings.positiveHardTtl()
: settings.negativeTtl();
}
return minimum;
}
}
@@ -0,0 +1,166 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyNamespace;
import dev.caskeleton.adapter.outbound.cache.redis.security.RedisSecretReference;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.ConstructorBinding;
/**
* Canonical policy for the skeleton's default semantic Redis cache region.
*
* <p>Provider connection and authentication settings intentionally do not exist here. The CACHE
* role binding owns the topology router, while this capability policy owns only semantic cache
* behavior and a reference to HMAC key material.
*/
@ConfigurationProperties(prefix = "ca-skeleton.capabilities.cache.regions.default")
public record RedisCanonicalCacheSettings(
String keyHmacSecretReference,
String namespaceApplication,
String namespaceEnvironment,
String semanticRegion,
int hashKeyVersion,
int keyVersion,
String policyRevision,
Duration positiveSoftTtl,
Duration positiveHardTtl,
Duration negativeTtl,
Double ttlJitter,
int maximumValueBytes,
LocalProperties l1) {
private static final Duration MAXIMUM_TTL = Duration.ofDays(30);
@ConstructorBinding
public RedisCanonicalCacheSettings {
keyHmacSecretReference = keyHmacSecretReference == null ? "" : keyHmacSecretReference.trim();
namespaceApplication = defaultText(namespaceApplication, "ca-skeleton");
namespaceEnvironment = defaultText(namespaceEnvironment, "local");
semanticRegion = defaultText(semanticRegion, "default");
hashKeyVersion = hashKeyVersion == 0 ? 1 : hashKeyVersion;
keyVersion = keyVersion == 0 ? 1 : keyVersion;
policyRevision = defaultText(policyRevision, "canonical-default-r1");
positiveHardTtl =
positive(positiveHardTtl, Duration.ofMinutes(5), MAXIMUM_TTL, "positiveHardTtl");
positiveSoftTtl =
positive(
positiveSoftTtl,
positiveHardTtl.multipliedBy(4).dividedBy(5),
MAXIMUM_TTL,
"positiveSoftTtl");
negativeTtl = positive(negativeTtl, Duration.ofMinutes(1), MAXIMUM_TTL, "negativeTtl");
ttlJitter = ttlJitter == null ? 0.10d : ttlJitter;
maximumValueBytes = maximumValueBytes == 0 ? 61_440 : maximumValueBytes;
l1 = l1 == null ? LocalProperties.defaults() : l1;
if (positiveSoftTtl.compareTo(positiveHardTtl) > 0) {
throw new IllegalArgumentException("positiveSoftTtl must not exceed positiveHardTtl");
}
if (!Double.isFinite(ttlJitter) || ttlJitter < 0.0d || ttlJitter > 0.5d) {
throw new IllegalArgumentException("ttlJitter must be in 0.0..0.5");
}
if (policyRevision.length() > 128 || policyRevision.chars().anyMatch(Character::isISOControl)) {
throw new IllegalArgumentException("policyRevision must contain 1..128 safe characters");
}
if (maximumValueBytes < 1 || maximumValueBytes > 16_777_216) {
throw new IllegalArgumentException("maximumValueBytes must be in 1..16777216");
}
// Centralizes slug and key-version validation without retaining a duplicate rule set.
new RedisKeyNamespace(
namespaceApplication,
namespaceEnvironment,
"cache",
semanticRegion,
hashKeyVersion,
keyVersion,
"entry",
512);
}
void validateActive() {
RedisSecretReference.parse(keyHmacSecretReference);
}
RedisKeyNamespace namespace() {
return new RedisKeyNamespace(
namespaceApplication,
namespaceEnvironment,
"cache",
semanticRegion,
hashKeyVersion,
keyVersion,
"entry",
512);
}
public record LocalProperties(
boolean enabled,
int maximumEntries,
long maximumWeightBytes,
long maximumEntryWeightBytes,
Duration timeToLive,
Duration generationRecheckInterval,
int invalidationQueueCapacity) {
@ConstructorBinding
public LocalProperties {
maximumEntries = maximumEntries == 0 ? 10_000 : maximumEntries;
maximumWeightBytes = maximumWeightBytes == 0 ? 67_108_864L : maximumWeightBytes;
maximumEntryWeightBytes = maximumEntryWeightBytes == 0 ? 1_048_576L : maximumEntryWeightBytes;
timeToLive = timeToLive == null ? Duration.ofSeconds(30) : timeToLive;
generationRecheckInterval =
generationRecheckInterval == null ? Duration.ofSeconds(5) : generationRecheckInterval;
invalidationQueueCapacity = invalidationQueueCapacity == 0 ? 1024 : invalidationQueueCapacity;
policy(
maximumEntries,
maximumWeightBytes,
maximumEntryWeightBytes,
timeToLive,
generationRecheckInterval,
invalidationQueueCapacity);
}
RedisLocalCachePolicy policy() {
return policy(
maximumEntries,
maximumWeightBytes,
maximumEntryWeightBytes,
timeToLive,
generationRecheckInterval,
invalidationQueueCapacity);
}
private static LocalProperties defaults() {
return new LocalProperties(false, 0, 0, 0, null, null, 0);
}
private static RedisLocalCachePolicy policy(
int maximumEntries,
long maximumWeightBytes,
long maximumEntryWeightBytes,
Duration timeToLive,
Duration generationRecheckInterval,
int invalidationQueueCapacity) {
return new RedisLocalCachePolicy(
maximumEntries,
maximumWeightBytes,
maximumEntryWeightBytes,
timeToLive,
generationRecheckInterval,
invalidationQueueCapacity);
}
}
private static Duration positive(
Duration value, Duration fallback, Duration maximum, String field) {
Duration actual = value == null ? fallback : value;
if (actual.isZero() || actual.isNegative() || actual.compareTo(maximum) > 0) {
throw new IllegalArgumentException(field + " must be positive and bounded");
}
return actual;
}
private static String defaultText(String value, String fallback) {
return value == null || value.isBlank() ? fallback : value.trim();
}
}
@@ -0,0 +1,181 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings;
import dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettingsFactory;
import dev.caskeleton.adapter.outbound.cache.redis.config.RedisProviderSettings;
import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole;
import dev.caskeleton.adapter.outbound.cache.redis.security.RedisCredentialMaterialProvider;
import dev.caskeleton.adapter.outbound.cache.redis.security.RedisTrustMaterialProvider;
import dev.caskeleton.shared.health.RedisHealthSnapshotProvider;
import io.micrometer.core.instrument.MeterRegistry;
import java.time.Clock;
import java.util.EnumMap;
import java.util.EnumSet;
import java.util.Map;
import java.util.Set;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
/**
* Canonical Redis composition root.
*
* <p>Provider definitions alone are inert. Only an explicit role binding resolves material and
* opens a topology-native client.
*/
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(RedisProviderSettings.class)
public class RedisCanonicalConfig {
@Bean
RedisCapabilityObservationPort redisCapabilityObservationPort(
ObjectProvider<MeterRegistry> meterRegistryProvider) {
MeterRegistry registry = meterRegistryProvider.getIfAvailable();
RedisCapabilityObservationPort delegate =
registry == null
? NoOpRedisCapabilityObservationPort.instance()
: new MicrometerRedisCapabilityObservationPort(registry);
return new SafeRedisCapabilityObservationPort(delegate);
}
@Bean(name = "redisCanonicalRoleRegistry", destroyMethod = "close")
RedisCanonicalRoleRegistry redisCanonicalRoleRegistry(
RedisProviderSettings properties,
Environment environment,
ObjectProvider<RedisCredentialMaterialProvider> credentialProvider,
ObjectProvider<RedisTrustMaterialProvider> trustProvider,
ObjectProvider<Clock> clockProvider,
ObjectProvider<RedisRuntimeConnector> connectorProvider,
ObjectProvider<RedisSentinelRuntimeConnector> sentinelConnectorProvider,
RedisCapabilityObservationPort observations) {
Map<RedisRole, Set<RedisHealthSnapshotProvider.Capability>> selectedCapabilities =
selectedCapabilities(environment);
Map<dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole, RedisDeploymentSettings>
active =
new RedisDeploymentSettingsFactory()
.compileActive(properties, selectedRoles(selectedCapabilities));
RedisCanonicalActivationValidator.validate(
!active.isEmpty(),
properties.legacyMigrationEnabled(),
environment.getProperty("app.cache.redis.enabled", Boolean.class, false),
environment.getProperty("app.rate-limit.legacy-standalone-enabled", Boolean.class, false));
RedisProviderSettings.RuntimeProperties runtime = properties.runtime();
Clock clock = clockProvider.getIfAvailable(Clock::systemUTC);
RedisRuntimeConnector connector =
connectorProvider.getIfAvailable(
() ->
deployment ->
connect(
deployment,
runtime,
requiredUnique(credentialProvider, "Redis credential material provider"),
requiredUnique(trustProvider, "Redis trust material provider"),
clock));
RedisSentinelRuntimeConnector sentinelConnector =
active.values().stream().anyMatch(RedisDeploymentSettings.Sentinel.class::isInstance)
? sentinelConnectorProvider.getIfAvailable(
() ->
new DefaultRedisSentinelRuntimeConnector(
runtime.clientSettings(),
runtime.maximumCommandBytes(),
requiredUnique(credentialProvider, "Redis credential material provider"),
requiredUnique(trustProvider, "Redis trust material provider"),
clock))
: null;
return new RedisCanonicalRoleRegistry(
active,
runtime.clientSettings(),
runtime.maximumInFlightCommands(),
runtime.maximumCommandBytes(),
runtime.maximumInFlightBytes(),
runtime.routeDrainTimeout(),
runtime.defaultWriteTtl(),
connector::connect,
properties.roles(),
selectedCapabilities,
clock,
runtime.semanticProbeMinimumInterval(),
runtime.semanticProbeMaximumStaleness(),
System::nanoTime,
observations,
sentinelConnector,
runtime.sentinelDiscoveryRefreshPeriod(),
BoundedRedisSentinelRefreshWorker::new);
}
private static RedisRoutableCommandRuntime connect(
RedisDeploymentSettings deployment,
RedisProviderSettings.RuntimeProperties runtime,
RedisCredentialMaterialProvider credentialProvider,
RedisTrustMaterialProvider trustProvider,
Clock clock) {
return RedisTopologyCommandRuntime.connect(
deployment,
runtime.clientSettings(),
runtime.maximumCommandBytes(),
credentialProvider,
trustProvider,
clock);
}
private static <T> T requiredUnique(ObjectProvider<T> provider, String capability) {
T instance = provider.getIfUnique();
if (instance == null) {
throw new IllegalStateException(
capability + " must have exactly one bean for a canonically bound Redis role");
}
return instance;
}
public static Map<RedisRole, Set<RedisHealthSnapshotProvider.Capability>> selectedCapabilities(
Environment environment) {
Map<RedisRole, Set<RedisHealthSnapshotProvider.Capability>> selected =
new EnumMap<>(RedisRole.class);
EnumSet<RedisHealthSnapshotProvider.Capability> cache =
EnumSet.noneOf(RedisHealthSnapshotProvider.Capability.class);
if (selected(environment, "ca-skeleton.capabilities.cache.bindings.default", "redis")) {
cache.add(RedisHealthSnapshotProvider.Capability.CACHE);
}
selected.put(RedisRole.CACHE, Set.copyOf(cache));
EnumSet<RedisHealthSnapshotProvider.Capability> coordination =
EnumSet.noneOf(RedisHealthSnapshotProvider.Capability.class);
if (selected(environment, "ca-skeleton.capabilities.rate-limit.provider", "redis")) {
coordination.add(RedisHealthSnapshotProvider.Capability.RATE_LIMIT);
}
if (selected(environment, "ca-skeleton.capabilities.idempotency.provider", "redis")) {
coordination.add(RedisHealthSnapshotProvider.Capability.IDEMPOTENCY);
}
if (selected(environment, "ca-skeleton.capabilities.lease.provider", "redis")) {
coordination.add(RedisHealthSnapshotProvider.Capability.EFFICIENCY_LEASE);
}
selected.put(RedisRole.COORDINATION, Set.copyOf(coordination));
EnumSet<RedisHealthSnapshotProvider.Capability> session =
EnumSet.noneOf(RedisHealthSnapshotProvider.Capability.class);
if (selected(environment, "ca-skeleton.security.auth-mode", "redis-session")) {
session.add(RedisHealthSnapshotProvider.Capability.SESSION);
}
selected.put(RedisRole.SESSION, Set.copyOf(session));
return Map.copyOf(selected);
}
private static Set<RedisRole> selectedRoles(
Map<RedisRole, Set<RedisHealthSnapshotProvider.Capability>> capabilities) {
EnumSet<RedisRole> roles = EnumSet.noneOf(RedisRole.class);
capabilities.forEach(
(role, selectedCapabilities) -> {
if (!selectedCapabilities.isEmpty()) {
roles.add(role);
}
});
return Set.copyOf(roles);
}
private static boolean selected(Environment environment, String property, String expected) {
return expected.equalsIgnoreCase(environment.getProperty(property, ""));
}
}
@@ -0,0 +1,758 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings;
import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole;
import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRoleBinding;
import dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings;
import dev.caskeleton.shared.health.RedisHealthSnapshotProvider;
import java.time.Clock;
import java.time.Duration;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.LongSupplier;
/** Owns exactly the command routers selected by canonical Redis role bindings. */
final class RedisCanonicalRoleRegistry implements AutoCloseable, RedisHealthSnapshotProvider {
private static final Duration SENTINEL_CLEANUP_COMPLETION_MARGIN = Duration.ofMillis(100);
@FunctionalInterface
interface RuntimeFactory {
RedisRoutableCommandRuntime connect(RedisDeploymentSettings deployment);
}
private final Map<RedisRole, RedisRoleCommandRouter> routers;
private final Map<RedisRole, RedisSemanticProbeObservationCache> observations;
private final Map<RedisRole, RedisRoleBinding> bindings;
private final Map<RedisRole, Set<Capability>> capabilities;
private final Map<RedisRole, RedisSemanticProbePlan> probePlans;
private final Map<RedisRole, RecoveryState> recoveries;
private final RedisSemanticReadinessProbe semanticProbe;
private final RuntimeFactory runtimeFactory;
private final Clock clock;
private final Duration probeTimeout;
private final Duration drainTimeout;
private final int maximumInFlight;
private final int maximumCommandBytes;
private final long maximumInFlightBytes;
private final Duration defaultWriteTtl;
private final LongSupplier ticker;
private final RedisCapabilityObservationPort observationsPort;
private final RedisSentinelFailoverCoordinator failoverCoordinator;
private final AtomicBoolean closed = new AtomicBoolean();
RedisCanonicalRoleRegistry(
Map<RedisRole, RedisDeploymentSettings> activeDeployments,
RedisClientRuntimeSettings clientSettings,
int maximumInFlight,
int maximumCommandBytes,
long maximumInFlightBytes,
Duration drainTimeout,
Duration defaultWriteTtl,
RuntimeFactory runtimeFactory) {
this(
activeDeployments,
clientSettings,
maximumInFlight,
maximumCommandBytes,
maximumInFlightBytes,
drainTimeout,
defaultWriteTtl,
runtimeFactory,
Map.of(),
Map.of(),
Clock.systemUTC());
}
RedisCanonicalRoleRegistry(
Map<RedisRole, RedisDeploymentSettings> activeDeployments,
RedisClientRuntimeSettings clientSettings,
int maximumInFlight,
int maximumCommandBytes,
long maximumInFlightBytes,
Duration drainTimeout,
Duration defaultWriteTtl,
RuntimeFactory runtimeFactory,
Map<RedisRole, RedisRoleBinding> bindings,
Map<RedisRole, Set<Capability>> capabilities,
Clock clock) {
this(
activeDeployments,
clientSettings,
maximumInFlight,
maximumCommandBytes,
maximumInFlightBytes,
drainTimeout,
defaultWriteTtl,
runtimeFactory,
bindings,
capabilities,
clock,
Duration.ofSeconds(5),
Duration.ofSeconds(15),
System::nanoTime,
NoOpRedisCapabilityObservationPort.instance());
}
RedisCanonicalRoleRegistry(
Map<RedisRole, RedisDeploymentSettings> activeDeployments,
RedisClientRuntimeSettings clientSettings,
int maximumInFlight,
int maximumCommandBytes,
long maximumInFlightBytes,
Duration drainTimeout,
Duration defaultWriteTtl,
RuntimeFactory runtimeFactory,
Map<RedisRole, RedisRoleBinding> bindings,
Map<RedisRole, Set<Capability>> capabilities,
Clock clock,
Duration semanticProbeMinimumInterval,
Duration semanticProbeMaximumStaleness,
LongSupplier ticker) {
this(
activeDeployments,
clientSettings,
maximumInFlight,
maximumCommandBytes,
maximumInFlightBytes,
drainTimeout,
defaultWriteTtl,
runtimeFactory,
bindings,
capabilities,
clock,
semanticProbeMinimumInterval,
semanticProbeMaximumStaleness,
ticker,
NoOpRedisCapabilityObservationPort.instance(),
null,
Duration.ofSeconds(30),
BoundedRedisSentinelRefreshWorker::new);
}
RedisCanonicalRoleRegistry(
Map<RedisRole, RedisDeploymentSettings> activeDeployments,
RedisClientRuntimeSettings clientSettings,
int maximumInFlight,
int maximumCommandBytes,
long maximumInFlightBytes,
Duration drainTimeout,
Duration defaultWriteTtl,
RuntimeFactory runtimeFactory,
Map<RedisRole, RedisRoleBinding> bindings,
Map<RedisRole, Set<Capability>> capabilities,
Clock clock,
Duration semanticProbeMinimumInterval,
Duration semanticProbeMaximumStaleness,
LongSupplier ticker,
RedisCapabilityObservationPort observationsPort) {
this(
activeDeployments,
clientSettings,
maximumInFlight,
maximumCommandBytes,
maximumInFlightBytes,
drainTimeout,
defaultWriteTtl,
runtimeFactory,
bindings,
capabilities,
clock,
semanticProbeMinimumInterval,
semanticProbeMaximumStaleness,
ticker,
observationsPort,
null,
Duration.ofSeconds(30),
BoundedRedisSentinelRefreshWorker::new);
}
RedisCanonicalRoleRegistry(
Map<RedisRole, RedisDeploymentSettings> activeDeployments,
RedisClientRuntimeSettings clientSettings,
int maximumInFlight,
int maximumCommandBytes,
long maximumInFlightBytes,
Duration drainTimeout,
Duration defaultWriteTtl,
RuntimeFactory runtimeFactory,
Map<RedisRole, RedisRoleBinding> bindings,
Map<RedisRole, Set<Capability>> capabilities,
Clock clock,
Duration semanticProbeMinimumInterval,
Duration semanticProbeMaximumStaleness,
LongSupplier ticker,
RedisCapabilityObservationPort observationsPort,
RedisSentinelRuntimeConnector sentinelConnector,
Duration sentinelDiscoveryRefreshPeriod,
RedisSentinelFailoverCoordinator.WorkerFactory workerFactory) {
Objects.requireNonNull(ticker, "ticker must be non-null");
Objects.requireNonNull(activeDeployments, "activeDeployments must be non-null");
Objects.requireNonNull(clientSettings, "clientSettings must be non-null");
Objects.requireNonNull(runtimeFactory, "runtimeFactory must be non-null");
Objects.requireNonNull(bindings, "bindings must be non-null");
Map<RedisRole, RedisRoleBinding> activeBindings = new EnumMap<>(RedisRole.class);
activeDeployments.forEach(
(role, ignored) -> {
RedisRoleBinding binding = bindings.get(role);
if (binding != null) {
activeBindings.put(role, binding);
}
});
this.bindings = Map.copyOf(activeBindings);
Map<RedisRole, Set<Capability>> safeCapabilities = new EnumMap<>(RedisRole.class);
Objects.requireNonNull(capabilities, "capabilities must be non-null")
.forEach((role, values) -> safeCapabilities.put(role, Set.copyOf(values)));
this.capabilities = Map.copyOf(safeCapabilities);
this.clock = Objects.requireNonNull(clock, "clock must be non-null");
this.runtimeFactory = runtimeFactory;
this.ticker = ticker;
this.observationsPort =
new SafeRedisCapabilityObservationPort(
Objects.requireNonNull(observationsPort, "observationsPort must be non-null"));
this.semanticProbe = RedisSemanticReadinessProbe.system(this.clock);
Map<RedisRole, RedisSemanticProbePlan> plans = new EnumMap<>(RedisRole.class);
activeBindings.forEach(
(role, ignored) ->
plans.put(
role,
RedisSemanticProbePlan.forRole(
role, this.capabilities.getOrDefault(role, Set.of()))));
this.probePlans = Map.copyOf(plans);
this.probeTimeout = clientSettings.commandTimeout();
this.drainTimeout = Objects.requireNonNull(drainTimeout, "drainTimeout must be non-null");
this.maximumInFlight = maximumInFlight;
this.maximumCommandBytes = maximumCommandBytes;
this.maximumInFlightBytes = maximumInFlightBytes;
this.defaultWriteTtl =
Objects.requireNonNull(defaultWriteTtl, "defaultWriteTtl must be non-null");
if (drainTimeout.compareTo(clientSettings.overallTimeout().plusMillis(100)) < 0) {
throw new IllegalArgumentException(
"Redis route drain timeout must include the runtime overall timeout and a 100ms safety"
+ " margin");
}
activeDeployments.forEach(RedisCanonicalRoleRegistry::rejectUnsupportedTopology);
Map<RedisRole, RedisDeploymentSettings.Sentinel> sentinelDeployments =
sentinelDeployments(activeDeployments);
if (!sentinelDeployments.isEmpty() && sentinelConnector == null) {
throw new IllegalStateException(
"Redis Sentinel refresh connector is required for every active Sentinel role");
}
Map<RedisRole, RedisRoleCommandRouter> created = new EnumMap<>(RedisRole.class);
Map<RedisRole, RedisSemanticProbeObservationCache> createdObservations =
new EnumMap<>(RedisRole.class);
Map<RedisRole, RecoveryState> createdRecoveries = new EnumMap<>(RedisRole.class);
AtomicReference<RedisSentinelFailoverCoordinator> coordinatorReference =
new AtomicReference<>();
RedisSentinelFailoverCoordinator createdCoordinator = null;
try {
activeDeployments.forEach(
(role, deployment) -> {
RedisRoleCommandRouter.TopologyFailureListener topologyFailureListener =
deployment instanceof RedisDeploymentSettings.Sentinel
? (failedRoute, failure) -> {
RedisSentinelFailoverCoordinator coordinator = coordinatorReference.get();
if (coordinator != null) {
coordinator.requestRecovery(role, failedRoute);
}
}
: RedisRoleCommandRouter.TopologyFailureListener.ignore();
RedisRoutableCommandRuntime runtime;
try {
runtime =
deployment instanceof RedisDeploymentSettings.Sentinel sentinel
? connectSentinel(sentinelConnector, sentinel)
: runtimeFactory.connect(deployment);
} catch (RedisTemporaryConnectionException temporary) {
if (!isOptionalCache(role)) {
throw temporary;
}
installDormant(
role,
deployment,
created,
createdObservations,
createdRecoveries,
semanticProbeMinimumInterval,
semanticProbeMaximumStaleness,
ticker,
topologyFailureListener);
return;
}
RedisRoleCommandRouter router = newRouter(role, runtime, topologyFailureListener);
try {
RedisSemanticProbePlan plan = probePlans.get(role);
if (plan == null) {
router.probe(probeTimeout);
} else {
RedisSemanticReadinessProbe.Result qualification =
semanticProbe.probeResult(plan, router);
if (qualification.disposition()
== RedisSemanticReadinessProbe.Disposition.RETRYABLE_TRANSPORT
&& isOptionalCache(role)) {
router.close();
installDormant(
role,
deployment,
created,
createdObservations,
createdRecoveries,
semanticProbeMinimumInterval,
semanticProbeMaximumStaleness,
ticker,
topologyFailureListener);
return;
}
if (qualification.disposition()
!= RedisSemanticReadinessProbe.Disposition.SUCCEEDED) {
throw new IllegalStateException(
"Redis semantic qualification failed: " + qualification.reason().name());
}
RedisSemanticProbeObservationCache observation =
new RedisSemanticProbeObservationCache(
semanticProbeMinimumInterval,
semanticProbeMaximumStaleness,
this.clock,
ticker);
observation.seed(qualification.reason());
createdObservations.put(role, observation);
}
created.put(role, router);
} catch (RuntimeException exception) {
router.close();
throw exception;
}
});
if (!sentinelDeployments.isEmpty()) {
createdCoordinator =
new RedisSentinelFailoverCoordinator(
sentinelDeployments,
created,
sentinelConnector,
this::qualifyCandidate,
this::observeSentinelInstall,
probeTimeout,
drainTimeout,
sentinelDiscoveryRefreshPeriod,
longer(
clientSettings.shutdownTimeout().plus(SENTINEL_CLEANUP_COMPLETION_MARGIN),
drainTimeout),
Objects.requireNonNull(workerFactory, "workerFactory must be non-null"));
coordinatorReference.set(createdCoordinator);
}
} catch (RuntimeException exception) {
if (createdCoordinator != null) {
createdCoordinator.close();
}
created.values().forEach(RedisRoleCommandRouter::close);
throw exception;
}
this.routers = Map.copyOf(created);
this.observations = Map.copyOf(createdObservations);
this.recoveries = Map.copyOf(createdRecoveries);
this.failoverCoordinator = createdCoordinator;
}
Set<RedisRole> boundRoles() {
return routers.keySet();
}
boolean isClosed() {
return closed.get();
}
RedisRoleCommandRouter router(RedisRole role) {
RedisRoleCommandRouter router =
routers.get(Objects.requireNonNull(role, "role must be non-null"));
if (router == null) {
throw new IllegalStateException("Redis role is not canonically bound: " + role);
}
return router;
}
RedisRoleCommandRouter.SwapResult rotate(RedisRole role, RedisRoutableCommandRuntime candidate) {
Objects.requireNonNull(candidate, "candidate must be non-null");
RedisSemanticProbePlan plan = probePlans.get(role);
if (plan == null) {
return router(role).swap(candidate, probeTimeout, drainTimeout);
}
RedisRoleCommandRouter qualificationRouter =
new RedisRoleCommandRouter(
role,
candidate,
maximumInFlight,
maximumCommandBytes,
maximumInFlightBytes,
drainTimeout,
defaultWriteTtl);
Reason qualification = semanticProbe.probe(plan, qualificationRouter);
if (qualification != Reason.SEMANTIC_PROBE_SUCCEEDED) {
qualificationRouter.close();
return RedisRoleCommandRouter.SwapResult.PROBE_FAILED;
}
RedisRoutableCommandRuntime qualified =
qualificationRouter.releaseQualifiedRuntimeForTransfer();
RedisRoleCommandRouter.SwapResult result;
try {
result = router(role).swap(qualified, probeTimeout, drainTimeout);
} catch (RuntimeException failure) {
closeQuietly(qualified);
throw failure;
}
if (result != RedisRoleCommandRouter.SwapResult.PROBE_FAILED) {
observations.get(role).seed(Reason.SEMANTIC_PROBE_SUCCEEDED);
}
return result;
}
void probe(RedisRole role) {
router(role).probe(probeTimeout);
}
@Override
public Snapshot snapshot() {
List<RoleHealth> roles = new ArrayList<>(bindings.size());
for (RedisRole role : RedisRole.values()) {
RedisRoleBinding binding = bindings.get(role);
if (binding != null) {
roles.add(probeHealth(role, binding));
}
}
return new Snapshot(clock.instant(), roles);
}
@Override
public void close() {
if (closed.compareAndSet(false, true)) {
if (failoverCoordinator != null) {
failoverCoordinator.close();
}
recoveries.values().forEach(recovery -> recovery.markTerminal(terminalClosed()));
routers.values().forEach(RedisRoleCommandRouter::close);
}
}
private static void rejectUnsupportedTopology(
RedisRole role, RedisDeploymentSettings deployment) {
if (role == RedisRole.SESSION && deployment instanceof RedisDeploymentSettings.Cluster) {
throw new UnsupportedOperationException(
"Redis SESSION role cannot use Cluster until session rotation preserves one hash slot");
}
}
private RoleHealth probeHealth(RedisRole role, RedisRoleBinding binding) {
RedisRoleCommandRouter router = router(role);
RedisSemanticProbeObservationCache observationCache = observations.get(role);
RedisSemanticProbeObservationCache.Observation observation;
RecoveryState recovery = recoveries.get(role);
if (closed.get()) {
observation = observationCache.seed(Reason.ROUTE_CLOSED);
} else if (recovery != null && !recovery.active()) {
observation =
recovery.deployment() instanceof RedisDeploymentSettings.Sentinel
? observationCache.seed(Reason.COMMAND_UNAVAILABLE)
: observationCache.observe(() -> recover(role, recovery).reason());
} else if (router.isClosed()) {
observation = observationCache.seed(Reason.ROUTE_CLOSED);
} else if (router.hadRecentCommandFailure()) {
observation = observationCache.seed(Reason.RECENT_COMMAND_FAILURE);
} else {
observation =
observationCache.observe(() -> semanticProbe.probe(probePlans.get(role), router));
}
if (closed.get() && observation.reason() != Reason.ROUTE_CLOSED) {
observation = observationCache.seed(Reason.ROUTE_CLOSED);
}
Reason reason = observation.reason();
State state =
switch (reason) {
case SEMANTIC_PROBE_SUCCEEDED -> State.AVAILABLE;
case COMMAND_SATURATED -> State.OVERLOADED;
default -> State.UNAVAILABLE;
};
RoleHealth health =
new RoleHealth(
Role.valueOf(role.name()),
binding.deploymentId(),
binding.required(),
EvictionPolicy.valueOf(
binding.expectedEviction().trim().replace('-', '_').toUpperCase(Locale.ROOT)),
EvictionAttestation.CONFIGURED_EXPECTATION_ONLY,
capabilities.getOrDefault(role, Set.of()),
state,
reason,
observation.observedAt(),
observation.age().toMillis(),
observation.stale());
capabilities
.getOrDefault(role, Set.of())
.forEach(
capability ->
observationsPort.observe(
new RedisCapabilityObservationEvent.ReadinessObserved(
RedisCapabilityObservationEvent.Capability.valueOf(capability.name()),
RedisCapabilityObservationEvent.Role.valueOf(health.role().name()),
health.state(),
health.reason(),
health.required()
? RedisCapabilityObservationEvent.Requirement.REQUIRED
: RedisCapabilityObservationEvent.Requirement.OPTIONAL)));
return health;
}
private RedisSemanticReadinessProbe.Result recover(RedisRole role, RecoveryState recovery) {
if (closed.get()) {
return recovery.markTerminal(terminalClosed());
}
RedisSemanticReadinessProbe.Result terminal = recovery.terminal();
if (terminal != null) {
return terminal;
}
RedisRoutableCommandRuntime candidate;
try {
candidate = runtimeFactory.connect(recovery.deployment());
} catch (RedisTemporaryConnectionException temporary) {
return retryableUnavailable();
} catch (RuntimeException permanent) {
return recovery.markTerminal(terminalUnavailable());
}
if (closed.get()) {
closeQuietly(candidate);
return recovery.markTerminal(terminalClosed());
}
RedisRoleCommandRouter qualificationRouter = newRouter(role, candidate);
RedisSemanticReadinessProbe.Result qualification =
semanticProbe.probeResult(probePlans.get(role), qualificationRouter);
if (closed.get()) {
qualificationRouter.close();
return recovery.markTerminal(terminalClosed());
}
if (qualification.disposition() == RedisSemanticReadinessProbe.Disposition.TERMINAL_CONTRACT) {
qualificationRouter.close();
return recovery.markTerminal(qualification);
}
if (qualification.disposition()
== RedisSemanticReadinessProbe.Disposition.RETRYABLE_TRANSPORT) {
qualificationRouter.close();
return qualification;
}
RedisRoutableCommandRuntime qualified =
qualificationRouter.releaseQualifiedRuntimeForTransfer();
if (closed.get()) {
closeQuietly(qualified);
return recovery.markTerminal(terminalClosed());
}
RedisRoleCommandRouter.SwapResult swap;
try {
swap = router(role).swap(qualified, probeTimeout, drainTimeout);
} catch (RuntimeException failure) {
closeQuietly(qualified);
return closed.get() ? recovery.markTerminal(terminalClosed()) : retryableUnavailable();
}
if (swap == RedisRoleCommandRouter.SwapResult.PROBE_FAILED) {
return retryableUnavailable();
}
if (closed.get() || !recovery.markActive()) {
return recovery.markTerminal(terminalClosed());
}
return qualification;
}
private void installDormant(
RedisRole role,
RedisDeploymentSettings deployment,
Map<RedisRole, RedisRoleCommandRouter> created,
Map<RedisRole, RedisSemanticProbeObservationCache> createdObservations,
Map<RedisRole, RecoveryState> createdRecoveries,
Duration minimumInterval,
Duration maximumStaleness,
LongSupplier ticker,
RedisRoleCommandRouter.TopologyFailureListener topologyFailureListener) {
created.put(
role,
newRouter(
role,
new RedisDormantCommandRuntime(deployment.deploymentId()),
topologyFailureListener));
RedisSemanticProbeObservationCache observation =
new RedisSemanticProbeObservationCache(minimumInterval, maximumStaleness, clock, ticker);
observation.seed(Reason.COMMAND_UNAVAILABLE);
createdObservations.put(role, observation);
createdRecoveries.put(role, new RecoveryState(deployment));
}
private RedisRoleCommandRouter newRouter(RedisRole role, RedisRoutableCommandRuntime runtime) {
return newRouter(role, runtime, RedisRoleCommandRouter.TopologyFailureListener.ignore());
}
private RedisRoleCommandRouter newRouter(
RedisRole role,
RedisRoutableCommandRuntime runtime,
RedisRoleCommandRouter.TopologyFailureListener topologyFailureListener) {
return new RedisRoleCommandRouter(
role,
runtime,
maximumInFlight,
maximumCommandBytes,
maximumInFlightBytes,
drainTimeout,
defaultWriteTtl,
ticker,
observationsPort,
RedisDrainWaiter.system(),
topologyFailureListener);
}
private RedisSentinelFailoverCoordinator.CandidateQualification qualifyCandidate(
RedisRole role, RedisRoutableCommandRuntime candidate) {
Objects.requireNonNull(candidate, "candidate must be non-null");
if (closed.get()) {
return RedisSentinelFailoverCoordinator.CandidateQualification.REJECTED;
}
RedisSemanticProbePlan plan = probePlans.get(role);
if (plan == null) {
return RedisSentinelFailoverCoordinator.CandidateQualification.ACCEPTED;
}
RedisRoleCommandRouter qualificationRouter;
try {
qualificationRouter = newRouter(role, candidate);
} catch (RuntimeException failure) {
return RedisSentinelFailoverCoordinator.CandidateQualification.REJECTED;
}
RedisSemanticReadinessProbe.Result qualification;
try {
qualification = semanticProbe.probeResult(plan, qualificationRouter);
} catch (RuntimeException failure) {
detachQualificationRouter(qualificationRouter);
return RedisSentinelFailoverCoordinator.CandidateQualification.REJECTED;
}
if (!detachQualificationRouter(qualificationRouter)) {
return RedisSentinelFailoverCoordinator.CandidateQualification.REJECTED;
}
return qualification.disposition() == RedisSemanticReadinessProbe.Disposition.SUCCEEDED
&& !closed.get()
? RedisSentinelFailoverCoordinator.CandidateQualification.ACCEPTED
: RedisSentinelFailoverCoordinator.CandidateQualification.REJECTED;
}
private void observeSentinelInstall(RedisRole role, RedisRoleCommandRouter.SwapResult result) {
if (result == RedisRoleCommandRouter.SwapResult.DRAINED
|| result == RedisRoleCommandRouter.SwapResult.FORCED_AFTER_TIMEOUT) {
RedisSemanticProbeObservationCache observation = observations.get(role);
if (observation != null) {
observation.seed(Reason.SEMANTIC_PROBE_SUCCEEDED);
}
RecoveryState recovery = recoveries.get(role);
if (recovery != null) {
recovery.markActive();
}
}
}
private static boolean detachQualificationRouter(RedisRoleCommandRouter qualificationRouter) {
try {
qualificationRouter.releaseQualifiedRuntimeForTransfer();
return true;
} catch (RuntimeException failure) {
return false;
}
}
private static RedisRoutableCommandRuntime connectSentinel(
RedisSentinelRuntimeConnector connector, RedisDeploymentSettings.Sentinel deployment) {
RedisSentinelDiscoveredRoute route = connector.discover(deployment);
return connector.connect(deployment, route);
}
private static Map<RedisRole, RedisDeploymentSettings.Sentinel> sentinelDeployments(
Map<RedisRole, RedisDeploymentSettings> deployments) {
EnumMap<RedisRole, RedisDeploymentSettings.Sentinel> sentinels = new EnumMap<>(RedisRole.class);
deployments.forEach(
(role, deployment) -> {
if (deployment instanceof RedisDeploymentSettings.Sentinel sentinel) {
sentinels.put(role, sentinel);
}
});
return Map.copyOf(sentinels);
}
private static Duration longer(Duration first, Duration second) {
return first.compareTo(second) >= 0 ? first : second;
}
private boolean isOptionalCache(RedisRole role) {
RedisRoleBinding binding = bindings.get(role);
return role == RedisRole.CACHE && binding != null && !binding.required();
}
private static RedisSemanticReadinessProbe.Result retryableUnavailable() {
return new RedisSemanticReadinessProbe.Result(
Reason.COMMAND_UNAVAILABLE, RedisSemanticReadinessProbe.Disposition.RETRYABLE_TRANSPORT);
}
private static RedisSemanticReadinessProbe.Result terminalUnavailable() {
return new RedisSemanticReadinessProbe.Result(
Reason.COMMAND_UNAVAILABLE, RedisSemanticReadinessProbe.Disposition.TERMINAL_CONTRACT);
}
private static RedisSemanticReadinessProbe.Result terminalClosed() {
return new RedisSemanticReadinessProbe.Result(
Reason.ROUTE_CLOSED, RedisSemanticReadinessProbe.Disposition.TERMINAL_CONTRACT);
}
private static void closeQuietly(RedisRoutableCommandRuntime runtime) {
try {
runtime.close();
} catch (RuntimeException ignored) {
// Recovery cleanup cannot expose provider detail through health.
}
}
private static final class RecoveryState {
private final RedisDeploymentSettings deployment;
private volatile RedisSemanticReadinessProbe.Result terminal;
private volatile boolean active;
private RecoveryState(RedisDeploymentSettings deployment) {
this.deployment = deployment;
}
private synchronized RedisDeploymentSettings deployment() {
return deployment;
}
private synchronized RedisSemanticReadinessProbe.Result terminal() {
return terminal;
}
private synchronized boolean active() {
return active;
}
private synchronized RedisSemanticReadinessProbe.Result markTerminal(
RedisSemanticReadinessProbe.Result result) {
if (!active && terminal == null) {
terminal = result;
}
return terminal == null ? result : terminal;
}
private synchronized boolean markActive() {
if (terminal != null) {
return false;
}
active = true;
return true;
}
}
}
@@ -0,0 +1,175 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.shared.health.RedisHealthSnapshotProvider;
import java.time.Duration;
import java.util.Objects;
/** Closed, identity-free operational facts emitted only inside the Redis adapter leaf. */
final class RedisCapabilityObservationEvent {
static final long MAXIMUM_DURATION_NANOS = Duration.ofMinutes(5).toNanos();
static final int MAXIMUM_IN_FLIGHT_COMMANDS = 4096;
static final long MAXIMUM_IN_FLIGHT_BYTES = 268_435_456L;
private RedisCapabilityObservationEvent() {}
sealed interface Event
permits OperationCompleted, AdmissionChanged, ReadinessObserved, LifecycleDrainCompleted {}
record OperationCompleted(
Capability capability,
Role role,
Operation operation,
Outcome outcome,
Certainty certainty,
long durationNanos)
implements Event {
public OperationCompleted {
Objects.requireNonNull(capability, "capability must be non-null");
Objects.requireNonNull(role, "role must be non-null");
Objects.requireNonNull(operation, "operation must be non-null");
Objects.requireNonNull(outcome, "outcome must be non-null");
Objects.requireNonNull(certainty, "certainty must be non-null");
if (durationNanos < 0 || durationNanos > MAXIMUM_DURATION_NANOS) {
throw new IllegalArgumentException("durationNanos must be non-negative and bounded");
}
}
}
record AdmissionChanged(
Role role,
AdmissionState admission,
InFlightState state,
int inFlightCommands,
long inFlightBytes)
implements Event {
public AdmissionChanged {
Objects.requireNonNull(role, "role must be non-null");
Objects.requireNonNull(admission, "admission must be non-null");
Objects.requireNonNull(state, "state must be non-null");
if (inFlightCommands < 0 || inFlightCommands > MAXIMUM_IN_FLIGHT_COMMANDS) {
throw new IllegalArgumentException("inFlightCommands must be non-negative and bounded");
}
if (inFlightBytes < 0 || inFlightBytes > MAXIMUM_IN_FLIGHT_BYTES) {
throw new IllegalArgumentException("inFlightBytes must be non-negative and bounded");
}
}
}
record ReadinessObserved(
Capability capability,
Role role,
RedisHealthSnapshotProvider.State state,
RedisHealthSnapshotProvider.Reason reason,
Requirement requirement)
implements Event {
public ReadinessObserved {
Objects.requireNonNull(capability, "capability must be non-null");
Objects.requireNonNull(role, "role must be non-null");
Objects.requireNonNull(state, "state must be non-null");
Objects.requireNonNull(reason, "reason must be non-null");
Objects.requireNonNull(requirement, "requirement must be non-null");
}
}
record LifecycleDrainCompleted(Role role, DrainOutcome drainOutcome) implements Event {
public LifecycleDrainCompleted {
Objects.requireNonNull(role, "role must be non-null");
Objects.requireNonNull(drainOutcome, "drainOutcome must be non-null");
}
}
enum Capability {
CACHE,
RATE_LIMIT,
IDEMPOTENCY,
EFFICIENCY_LEASE,
SESSION,
RUNTIME
}
enum Role {
CACHE,
COORDINATION,
SESSION
}
enum Operation {
LOOKUP,
RECORD,
INVALIDATE,
REFRESH_CLAIM,
REFRESH_RELEASE,
RATE_EVALUATE,
IDEMPOTENCY_CLAIM,
IDEMPOTENCY_START,
IDEMPOTENCY_RENEW,
IDEMPOTENCY_COMPLETE,
IDEMPOTENCY_FAIL,
IDEMPOTENCY_RELEASE,
IDEMPOTENCY_INSPECT,
LEASE_ACQUIRE,
LEASE_INSPECT,
LEASE_RENEW,
LEASE_RELEASE,
SESSION_CREATE,
SESSION_INSPECT,
SESSION_SAVE,
SESSION_TOUCH,
SESSION_REVOKE,
SESSION_ROTATE,
ROUTE_COMMAND
}
enum Outcome {
SUCCESS,
HIT,
MISS,
DENIED,
CONTENDED,
CONFLICT,
INCOMPATIBLE,
UNAVAILABLE,
OVERLOADED,
CLOSED,
INDETERMINATE,
STALE,
SKIPPED,
TOMBSTONED,
ABSOLUTE_EXPIRED
}
enum Certainty {
DEFINITE,
NOT_APPLIED,
INDETERMINATE
}
enum AdmissionState {
ADMITTED,
REJECTED_SATURATED,
REJECTED_CLOSED,
NOT_APPLICABLE
}
enum InFlightState {
IDLE,
ACTIVE,
SATURATED
}
enum Requirement {
OPTIONAL,
REQUIRED
}
enum DrainOutcome {
DRAINED,
FORCED_AFTER_TIMEOUT,
INTERRUPTED
}
}
@@ -0,0 +1,7 @@
package dev.caskeleton.adapter.outbound.cache.redis;
@FunctionalInterface
interface RedisCapabilityObservationPort {
void observe(RedisCapabilityObservationEvent.Event event);
}
@@ -0,0 +1,125 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.util.Objects;
import java.util.function.Function;
import java.util.function.LongSupplier;
import java.util.function.Supplier;
/** Measures one logical semantic operation without accepting request identity or wire material. */
final class RedisCapabilityObserver {
private static final long UNAVAILABLE_TICK = Long.MIN_VALUE;
private final RedisCapabilityObservationPort observations;
private final LongSupplier ticker;
RedisCapabilityObserver(RedisCapabilityObservationPort observations, LongSupplier ticker) {
this.observations =
new SafeRedisCapabilityObservationPort(
Objects.requireNonNull(observations, "observations must be non-null"));
this.ticker = Objects.requireNonNull(ticker, "ticker must be non-null");
}
static RedisCapabilityObserver disabled() {
return new RedisCapabilityObserver(
NoOpRedisCapabilityObservationPort.instance(), System::nanoTime);
}
<T> T observe(
RedisCapabilityObservationEvent.Capability capability,
RedisCapabilityObservationEvent.Role role,
RedisCapabilityObservationEvent.Operation operation,
Supplier<T> action,
Function<T, Classification> classifier) {
return observe(
capability,
role,
operation,
action,
classifier,
ignored ->
new Classification(
RedisCapabilityObservationEvent.Outcome.UNAVAILABLE,
RedisCapabilityObservationEvent.Certainty.NOT_APPLIED));
}
<T> T observe(
RedisCapabilityObservationEvent.Capability capability,
RedisCapabilityObservationEvent.Role role,
RedisCapabilityObservationEvent.Operation operation,
Supplier<T> action,
Function<T, Classification> classifier,
Function<RuntimeException, Classification> failureClassifier) {
Objects.requireNonNull(action, "action must be non-null");
Objects.requireNonNull(classifier, "classifier must be non-null");
Objects.requireNonNull(failureClassifier, "failureClassifier must be non-null");
long started = safeTick();
T result;
try {
result = action.get();
} catch (RuntimeException failure) {
Classification failureClassification;
try {
failureClassification =
Objects.requireNonNull(
failureClassifier.apply(failure), "failure classification must be non-null");
} catch (RuntimeException diagnosticFailure) {
throw failure;
}
completedSafely(capability, role, operation, failureClassification, started);
throw failure;
}
Classification classification;
try {
classification =
Objects.requireNonNull(classifier.apply(result), "classification must be non-null");
} catch (RuntimeException diagnosticFailure) {
return result;
}
completedSafely(capability, role, operation, classification, started);
return result;
}
private void completedSafely(
RedisCapabilityObservationEvent.Capability capability,
RedisCapabilityObservationEvent.Role role,
RedisCapabilityObservationEvent.Operation operation,
Classification classification,
long started) {
try {
long finished = safeTick();
long elapsed =
started == UNAVAILABLE_TICK || finished == UNAVAILABLE_TICK ? 0L : finished - started;
long bounded =
Math.min(RedisCapabilityObservationEvent.MAXIMUM_DURATION_NANOS, Math.max(0L, elapsed));
observations.observe(
new RedisCapabilityObservationEvent.OperationCompleted(
capability,
role,
operation,
classification.outcome(),
classification.certainty(),
bounded));
} catch (RuntimeException ignored) {
// Diagnostic timing/event construction cannot change the authoritative command result.
}
}
private long safeTick() {
try {
return ticker.getAsLong();
} catch (RuntimeException ignored) {
return UNAVAILABLE_TICK;
}
}
record Classification(
RedisCapabilityObservationEvent.Outcome outcome,
RedisCapabilityObservationEvent.Certainty certainty) {
Classification {
Objects.requireNonNull(outcome, "outcome must be non-null");
Objects.requireNonNull(certainty, "certainty must be non-null");
}
}
}
@@ -0,0 +1,321 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.function.Supplier;
/**
* Validated catalog-owned invocation. It is the only program identity carried through command
* ports; raw SHA, Lua source and raw key collections never cross those ports.
*/
final class RedisCatalogProgramInvocation {
enum ReplyShape {
VALUE,
READ_ONLY_VALUE,
MULTI,
READ_ONLY_MULTI
}
private final RedisProgramDescriptor descriptor;
private final byte[] exactScript;
private final String externalId;
private final List<Key> keys;
private final List<Argument> arguments;
private final ReplyShape replyShape;
private final int encodedBytes;
private final Supplier<Duration> remainingBudget;
private RedisCatalogProgramInvocation(
RedisProgramCatalog owner,
RedisProgramDescriptor descriptor,
List<byte[]> keys,
List<byte[]> arguments,
ReplyShape replyShape,
Supplier<Duration> remainingBudget) {
Objects.requireNonNull(owner, "owner must be non-null");
this.descriptor = Objects.requireNonNull(descriptor, "descriptor must be non-null");
this.exactScript = descriptor.scriptBytes();
this.externalId = descriptor.id().externalId();
if (owner.descriptor(descriptor.id()) != descriptor) {
throw new IllegalArgumentException("Redis program descriptor is not owned by this catalog");
}
Objects.requireNonNull(keys, "keys must be non-null");
Objects.requireNonNull(arguments, "arguments must be non-null");
if (keys.size() != descriptor.keyCount() || arguments.size() != descriptor.argumentCount()) {
throw new IllegalArgumentException("Redis program signature does not match descriptor");
}
List<Key> safeKeys = new ArrayList<>(keys.size());
long bytes = 0;
for (byte[] key : keys) {
if (key == null || key.length < 1 || key.length > descriptor.maximumKeyBytes()) {
throw new IllegalArgumentException("Redis program key is out of bounds");
}
safeKeys.add(new Key(key));
bytes += key.length;
}
List<Argument> safeArguments = new ArrayList<>(arguments.size());
for (byte[] argument : arguments) {
if (argument == null
|| argument.length < 1
|| argument.length > descriptor.maximumArgumentBytes()) {
throw new IllegalArgumentException("Redis program argument is out of bounds");
}
Argument safeArgument = new Argument(argument);
safeArguments.add(safeArgument);
bytes += safeArgument.encodedLength();
}
if (bytes > Integer.MAX_VALUE) {
throw new IllegalArgumentException("Redis program invocation is too large");
}
this.keys = List.copyOf(safeKeys);
this.arguments = List.copyOf(safeArguments);
this.replyShape = Objects.requireNonNull(replyShape, "replyShape must be non-null");
this.encodedBytes = (int) bytes;
this.remainingBudget = remainingBudget;
}
private RedisCatalogProgramInvocation(RedisSemanticReadinessProbe.AclProbeMaterial material) {
this.descriptor = null;
this.externalId = "semantic-capability-acl-v1";
this.exactScript = RedisSemanticAclProbeCatalog.scriptBytes();
List<byte[]> keys = material.copyKeys();
Objects.requireNonNull(keys, "keys must be non-null");
List<Key> safeKeys = new ArrayList<>(keys.size());
long bytes = 0;
for (byte[] key : keys) {
if (key == null || key.length < 1 || key.length > 512) {
throw new IllegalArgumentException("Redis program key is out of bounds");
}
safeKeys.add(new Key(key));
bytes += key.length;
}
byte[] encodedCapability =
Objects.requireNonNull(material.capability(), "capability must be non-null")
.name()
.getBytes(java.nio.charset.StandardCharsets.US_ASCII);
List<Argument> safeArguments = List.of(new Argument(encodedCapability));
bytes += encodedCapability.length;
if (bytes > Integer.MAX_VALUE) {
throw new IllegalArgumentException("Redis program invocation is too large");
}
this.keys = List.copyOf(safeKeys);
this.arguments = List.copyOf(safeArguments);
this.replyShape = ReplyShape.READ_ONLY_VALUE;
this.encodedBytes = (int) bytes;
this.remainingBudget = null;
}
static RedisCatalogProgramInvocation capabilityOwned(
RedisProgramCatalog owner, RedisCatalogProgramMaterial material, ReplyShape replyShape) {
RedisProgramDescriptor descriptor = owner.descriptor(material.programId());
return new RedisCatalogProgramInvocation(
owner, descriptor, material.copyKeys(), material.copyArguments(), replyShape, null);
}
static RedisCatalogProgramInvocation primitiveOwned(
RedisProgramCatalog owner,
RedisProgramDescriptor descriptor,
RedisPrimitiveInvocation primitive,
ReplyShape replyShape) {
if (primitive.descriptor().programId() != descriptor.id()) {
throw new IllegalArgumentException("primitive program identity is inconsistent");
}
return new RedisCatalogProgramInvocation(
owner,
descriptor,
primitiveKeys(primitive),
primitiveArguments(descriptor, primitive),
replyShape,
primitive::remainingDeadline);
}
static RedisCatalogProgramInvocation boundedGetOwned(
RedisProgramCatalog owner,
RedisProgramDescriptor descriptor,
RedisPhysicalKey key,
int maximumValueBytes) {
if (descriptor.id() != RedisProgramId.BOUNDED_GET_V1) {
throw new IllegalArgumentException("bounded GET descriptor is required");
}
return new RedisCatalogProgramInvocation(
owner,
descriptor,
List.of(RedisPhysicalKey.WireCodec.copy(key)),
List.of(
Integer.toString(maximumValueBytes)
.getBytes(java.nio.charset.StandardCharsets.US_ASCII)),
ReplyShape.READ_ONLY_VALUE,
null);
}
static RedisCatalogProgramInvocation semanticAclProbe(
RedisSemanticReadinessProbe.AclProbeMaterial material) {
return new RedisCatalogProgramInvocation(
Objects.requireNonNull(material, "semantic ACL material must be non-null"));
}
private static List<byte[]> primitiveKeys(RedisPrimitiveInvocation primitive) {
return primitive.keys().stream()
.map(RedisPrimitiveKey::physicalKey)
.map(RedisPhysicalKey.WireCodec::copy)
.toList();
}
private static List<byte[]> primitiveArguments(
RedisProgramDescriptor descriptor, RedisPrimitiveInvocation primitive) {
if (descriptor.id() == RedisProgramId.BOUNDED_GET_V1) {
return List.of(
Integer.toString(primitive.descriptor().maximumValueBytes())
.getBytes(java.nio.charset.StandardCharsets.US_ASCII));
}
if (!(primitive.arguments() instanceof RedisPrimitiveInvocation.ProgramArguments arguments)) {
throw new IllegalArgumentException("primitive program arguments are not closed");
}
return arguments.programValues().stream().map(RedisPrimitiveValue::copyEncoded).toList();
}
RedisProgramDescriptor descriptor() {
if (descriptor == null) {
throw new IllegalStateException("Redis exact program has no manifest descriptor");
}
return descriptor;
}
RedisProgramId programIdOrNull() {
return descriptor == null ? null : descriptor.id();
}
String externalId() {
return externalId;
}
private byte[] copyExactScript() {
return exactScript.clone();
}
ReplyShape replyShape() {
return replyShape;
}
int keyCount() {
return keys.size();
}
int argumentCount() {
return arguments.size();
}
private byte[] copyArgument(int index) {
return arguments.get(index).copyEncoded();
}
int encodedBytes() {
return encodedBytes;
}
Duration boundedTimeout(Duration defaultTimeout) {
Objects.requireNonNull(defaultTimeout, "defaultTimeout must be non-null");
if (remainingBudget == null) {
return defaultTimeout;
}
Duration remaining = remainingBudget.get();
return remaining.compareTo(defaultTimeout) < 0 ? remaining : defaultTimeout;
}
private byte[][] copyKeysArray() {
byte[][] result = new byte[keys.size()][];
for (int index = 0; index < keys.size(); index++) {
result[index] = keys.get(index).copyEncoded();
}
return result;
}
private List<byte[]> copyKeys() {
List<byte[]> result = new ArrayList<>(keys.size());
for (Key key : keys) {
result.add(key.copyEncoded());
}
return List.copyOf(result);
}
private List<byte[]> copyArguments() {
List<byte[]> result = new ArrayList<>(arguments.size());
for (Argument argument : arguments) {
result.add(argument.copyEncoded());
}
return List.copyOf(result);
}
String sha1() {
return RedisScriptRecovery.sha1(exactScript);
}
private byte[][] copyArgumentsArray() {
byte[][] result = new byte[arguments.size()][];
for (int index = 0; index < arguments.size(); index++) {
result[index] = arguments.get(index).copyEncoded();
}
return result;
}
private static final class Argument {
private final byte[] encoded;
private Argument(byte[] encoded) {
this.encoded = encoded.clone();
}
private int encodedLength() {
return encoded.length;
}
private byte[] copyEncoded() {
return encoded.clone();
}
}
private static final class Key {
private final byte[] encoded;
private Key(byte[] encoded) {
this.encoded = encoded.clone();
}
private byte[] copyEncoded() {
return encoded.clone();
}
}
/** Sole terminal wire unwrap; every byte is derived from an already validated invocation. */
static final class WireCodec {
private WireCodec() {}
static byte[] exactScript(RedisCatalogProgramInvocation invocation) {
return invocation.copyExactScript();
}
static byte[] argument(RedisCatalogProgramInvocation invocation, int index) {
return invocation.copyArgument(index);
}
static byte[][] keysArray(RedisCatalogProgramInvocation invocation) {
return invocation.copyKeysArray();
}
static byte[][] argumentsArray(RedisCatalogProgramInvocation invocation) {
return invocation.copyArgumentsArray();
}
static List<byte[]> keys(RedisCatalogProgramInvocation invocation) {
return invocation.copyKeys();
}
static List<byte[]> arguments(RedisCatalogProgramInvocation invocation) {
return invocation.copyArguments();
}
}
}
@@ -0,0 +1,27 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.util.List;
/**
* Closed capability-owned material consumed only while constructing a catalog invocation.
*
* <p>Every permitted implementation has a private constructor in its semantic owner. No command
* executor receives this raw material and no arbitrary package peer can implement the contract.
*/
sealed interface RedisCatalogProgramMaterial
permits RedisAtomicPrimitives.ProgramMaterial,
RedisEdgeRateLimitProvider.ProgramInvocation,
RedisEfficiencyLeaseProvider.ProgramInvocation,
RedisEfficiencyLeaseHandle.ProgramInvocation,
RedisIdempotencyStoreProvider.ProgramInvocation,
RedisLuaVersionedSessionStore.ProgramInvocation,
RedisSemanticReadinessProbe.ProgramInvocation {
RedisProgramId programId();
RedisCatalogProgramInvocation.ReplyShape replyShape();
List<byte[]> copyKeys();
List<byte[]> copyArguments();
}
@@ -0,0 +1,43 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.util.ArrayList;
import java.util.List;
/** Bounded defensive reply from one catalog invocation. */
final class RedisCatalogProgramReply {
private final byte[] value;
private final List<byte[]> fields;
private RedisCatalogProgramReply(byte[] value, List<byte[]> fields) {
this.value = value == null ? null : value.clone();
this.fields = defensive(fields);
}
static RedisCatalogProgramReply value(byte[] value) {
return new RedisCatalogProgramReply(value, List.of());
}
static RedisCatalogProgramReply multi(List<byte[]> fields) {
return new RedisCatalogProgramReply(null, fields);
}
byte[] copyValue() {
return value == null ? null : value.clone();
}
List<byte[]> copyFields() {
return defensive(fields);
}
private static List<byte[]> defensive(List<byte[]> fields) {
if (fields == null || fields.isEmpty()) {
return List.of();
}
List<byte[]> safe = new ArrayList<>(fields.size());
for (byte[] field : fields) {
safe.add(field == null ? null : field.clone());
}
return List.copyOf(safe);
}
}
@@ -1,5 +1,7 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.util.Objects;
/** Adapter-internal transport failure with explicit overload and mutation certainty. */
final class RedisCommandFailureException extends RuntimeException {
@@ -7,11 +9,18 @@ final class RedisCommandFailureException extends RuntimeException {
private final Kind kind;
private final Certainty certainty;
private final RecoveryHint recoveryHint;
RedisCommandFailureException(Kind kind, Certainty certainty, String message, Throwable cause) {
this(kind, certainty, RecoveryHint.NONE, message, cause);
}
RedisCommandFailureException(
Kind kind, Certainty certainty, RecoveryHint recoveryHint, String message, Throwable cause) {
super(message, cause);
this.kind = kind;
this.certainty = certainty;
this.kind = Objects.requireNonNull(kind, "kind must be non-null");
this.certainty = Objects.requireNonNull(certainty, "certainty must be non-null");
this.recoveryHint = Objects.requireNonNull(recoveryHint, "recoveryHint must be non-null");
}
Kind kind() {
@@ -22,13 +31,23 @@ final class RedisCommandFailureException extends RuntimeException {
return certainty;
}
RecoveryHint recoveryHint() {
return recoveryHint;
}
enum Kind {
UNAVAILABLE,
OVERLOADED
OVERLOADED,
ACL_DENIED
}
enum Certainty {
NOT_APPLIED,
INDETERMINATE
}
enum RecoveryHint {
NONE,
REDISCOVER_SENTINEL
}
}
@@ -0,0 +1,57 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.time.Duration;
import java.util.Objects;
/** Adapter-internal immutable connection/admission profile for one dedicated Redis role. */
record RedisConnectionProfile(
String host,
int port,
String password,
Duration commandTimeout,
Duration legacyTtl,
int maximumReadableValueBytes,
int maximumCommandBytes,
int maximumQueuedCommands,
int maximumInFlightBytes) {
private static final int LEGACY_COMMAND_OVERHEAD_BYTES = 4_096;
RedisConnectionProfile {
Objects.requireNonNull(host, "host must be non-null");
Objects.requireNonNull(password, "password must be non-null");
Objects.requireNonNull(commandTimeout, "commandTimeout must be non-null");
Objects.requireNonNull(legacyTtl, "legacyTtl must be non-null");
if (maximumReadableValueBytes < 1 || maximumCommandBytes < 1) {
throw new IllegalArgumentException("Redis byte bounds must be positive");
}
}
static RedisConnectionProfile cache(RedisRuntimeSettings settings) {
Objects.requireNonNull(settings, "settings must be non-null");
return new RedisConnectionProfile(
settings.host(),
settings.port(),
settings.password(),
settings.commandTimeout(),
settings.positiveTtl(),
settings.maximumReadableValueBytes(),
settings.maximumCommandBytes(),
settings.maximumQueuedCommands(),
settings.maximumInFlightBytes());
}
static RedisConnectionProfile rateLimit(RedisLegacyStandaloneSettings settings) {
Objects.requireNonNull(settings, "settings must be non-null");
return new RedisConnectionProfile(
settings.host(),
settings.port(),
settings.password(),
settings.commandTimeout(),
Duration.ofSeconds(1),
settings.maximumCommandBytes() - LEGACY_COMMAND_OVERHEAD_BYTES,
settings.maximumCommandBytes(),
settings.maximumQueuedCommands(),
settings.maximumInFlightBytes());
}
}
@@ -0,0 +1,40 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.time.Duration;
import java.util.List;
import java.util.Objects;
/** Signed exact counter helpers; increment is atomic with initial TTL. */
final class RedisCounterPrimitives {
private final RedisPrimitiveCatalog catalog;
private final RedisPrimitiveExecutor executor;
RedisCounterPrimitives(RedisPrimitiveCatalog catalog, RedisPrimitiveCommands commands) {
this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null");
this.executor = new RedisPrimitiveExecutor(catalog, commands);
}
RedisPrimitiveKey key(String slot, String identity) {
return catalog.keyFactory(RedisPrimitiveId.COUNTER_READ).key(slot, identity);
}
RedisPrimitiveReply read(RedisPrimitiveKey key) {
return executor.execute(
RedisPrimitiveId.COUNTER_READ, List.of(key), RedisPrimitiveInvocation.NoArguments.INSTANCE);
}
RedisCounterResult increment(
RedisPrimitiveKey key, long delta, long minimum, long maximum, Duration initialTimeToLive) {
try {
return RedisCounterResult.from(
executor.execute(
RedisPrimitiveId.COUNTER_INCREMENT_INITIAL_TTL,
List.of(key),
new RedisPrimitiveInvocation.CounterArguments(
delta, minimum, maximum, initialTimeToLive)));
} catch (RedisCommandFailureException failure) {
return RedisCounterResult.failed(failure);
}
}
}
@@ -0,0 +1,63 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.util.OptionalLong;
/**
* Exact signed counter outcome; the resulting value is never reinterpreted as an affected count.
*/
record RedisCounterResult(
Status status, Certainty certainty, OptionalLong value, String diagnosticCode) {
enum Status {
UPDATED,
LIMIT_EXCEEDED,
OVERFLOW,
MISSING_TTL,
MALFORMED_VALUE,
WRONG_TYPE,
INVALID,
UNKNOWN
}
enum Certainty {
APPLIED,
NOT_APPLIED,
INDETERMINATE
}
RedisCounterResult {
if (status == null || certainty == null || value == null) {
throw new IllegalArgumentException("counter result is invalid");
}
diagnosticCode = diagnosticCode == null ? "" : diagnosticCode;
}
static RedisCounterResult from(RedisPrimitiveReply reply) {
Status status =
switch (reply.status()) {
case UPDATED -> Status.UPDATED;
case LIMIT_EXCEEDED -> Status.LIMIT_EXCEEDED;
case OVERFLOW -> Status.OVERFLOW;
case MISSING_TTL -> Status.MISSING_TTL;
case MALFORMED_VALUE -> Status.MALFORMED_VALUE;
case WRONG_TYPE -> Status.WRONG_TYPE;
case INVALID, TTL_APPLY_FAILED -> Status.INVALID;
default -> Status.UNKNOWN;
};
return new RedisCounterResult(
status,
status == Status.UPDATED ? Certainty.APPLIED : Certainty.NOT_APPLIED,
reply.signedNumber(),
reply.diagnosticCode());
}
static RedisCounterResult failed(RedisCommandFailureException failure) {
return new RedisCounterResult(
Status.UNKNOWN,
failure.certainty() == RedisCommandFailureException.Certainty.INDETERMINATE
? Certainty.INDETERMINATE
: Certainty.NOT_APPLIED,
OptionalLong.empty(),
"");
}
}
@@ -0,0 +1,85 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings;
import dev.caskeleton.adapter.outbound.cache.redis.security.RedisRotatableRuntime;
import java.time.Duration;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
/** Lifecycle-safe Redis deployment runtime with no public native command surface. */
final class RedisDeploymentRuntime implements RedisRotatableRuntime {
public enum Topology {
STANDALONE,
SENTINEL,
CLUSTER
}
public record Timeouts(
Duration connect, Duration acquire, Duration command, Duration overall, Duration shutdown) {
public Timeouts {
Objects.requireNonNull(connect, "connect must be non-null");
Objects.requireNonNull(acquire, "acquire must be non-null");
Objects.requireNonNull(command, "command must be non-null");
Objects.requireNonNull(overall, "overall must be non-null");
Objects.requireNonNull(shutdown, "shutdown must be non-null");
}
}
private final String deploymentId;
private final Topology topology;
private final Timeouts timeouts;
private final RedisNativeClientHandle nativeClient;
private final RedisLettuceUris credentialOwner;
private final AtomicBoolean closed = new AtomicBoolean();
RedisDeploymentRuntime(
String deploymentId,
Topology topology,
RedisClientRuntimeSettings settings,
RedisNativeClientHandle nativeClient,
RedisLettuceUris credentialOwner) {
this.deploymentId = Objects.requireNonNull(deploymentId, "deploymentId must be non-null");
this.topology = Objects.requireNonNull(topology, "topology must be non-null");
Objects.requireNonNull(settings, "settings must be non-null");
this.timeouts =
new Timeouts(
settings.connectTimeout(),
settings.acquireTimeout(),
settings.commandTimeout(),
settings.overallTimeout(),
settings.shutdownTimeout());
this.nativeClient = Objects.requireNonNull(nativeClient, "nativeClient must be non-null");
this.credentialOwner =
Objects.requireNonNull(credentialOwner, "credentialOwner must be non-null");
}
@Override
public String deploymentId() {
return deploymentId;
}
public Topology topology() {
return topology;
}
public Timeouts timeouts() {
return timeouts;
}
public boolean isClosed() {
return closed.get();
}
@Override
public void close() {
if (closed.compareAndSet(false, true)) {
try {
nativeClient.close(timeouts.shutdown());
} finally {
credentialOwner.close();
}
}
}
}
@@ -0,0 +1,118 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings;
import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole;
import dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings;
import dev.caskeleton.adapter.outbound.cache.redis.security.RedisSslOptionsFactory;
import io.lettuce.core.SslOptions;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
/** Creates one topology-native runtime only for an explicitly bound Redis role. */
final class RedisDeploymentRuntimeFactory {
private final RedisLettuceUriFactory uriFactory;
private final RedisSslOptionsFactory sslOptionsFactory;
private final RedisLettuceClientOptionsFactory optionsFactory;
private final RedisNativeClientFactory nativeClientFactory;
RedisDeploymentRuntimeFactory(
RedisLettuceUriFactory uriFactory, RedisSslOptionsFactory sslOptionsFactory) {
this(
uriFactory,
sslOptionsFactory,
new RedisLettuceClientOptionsFactory(),
new LettuceRedisNativeClientFactory());
}
RedisDeploymentRuntimeFactory(
RedisLettuceUriFactory uriFactory,
RedisSslOptionsFactory sslOptionsFactory,
RedisLettuceClientOptionsFactory optionsFactory,
RedisNativeClientFactory nativeClientFactory) {
this.uriFactory = Objects.requireNonNull(uriFactory, "uriFactory must be non-null");
this.sslOptionsFactory =
Objects.requireNonNull(sslOptionsFactory, "sslOptionsFactory must be non-null");
this.optionsFactory = Objects.requireNonNull(optionsFactory, "optionsFactory must be non-null");
this.nativeClientFactory =
Objects.requireNonNull(nativeClientFactory, "nativeClientFactory must be non-null");
}
Optional<RedisDeploymentRuntime> createIfBound(
RedisRole role,
Map<RedisRole, RedisDeploymentSettings> activeDeployments,
RedisClientRuntimeSettings clientSettings) {
Objects.requireNonNull(role, "role must be non-null");
Objects.requireNonNull(activeDeployments, "activeDeployments must be non-null");
RedisDeploymentSettings deployment = activeDeployments.get(role);
if (deployment == null) {
return Optional.empty();
}
return Optional.of(create(deployment, clientSettings));
}
RedisDeploymentRuntime create(
RedisDeploymentSettings deployment, RedisClientRuntimeSettings clientSettings) {
Objects.requireNonNull(deployment, "deployment must be non-null");
Objects.requireNonNull(clientSettings, "clientSettings must be non-null");
if (deployment instanceof RedisDeploymentSettings.Sentinel) {
throw new UnsupportedOperationException(
"Redis Sentinel separate discovery and data trust is unsupported by one Lettuce SSL"
+ " context");
}
SslOptions sslOptions =
sslOptionsFactory.create(deployment.dataTls(), clientSettings.tlsHandshakeTimeout());
RedisLettuceUris uris = uriFactory.create(deployment, clientSettings);
try {
return switch (uris) {
case RedisLettuceUris.Standalone standalone ->
runtime(
deployment,
RedisDeploymentRuntime.Topology.STANDALONE,
clientSettings,
nativeClientFactory.openStandalone(
standalone.dataUri(),
optionsFactory.clientOptions(clientSettings, sslOptions),
clientSettings),
uris);
case RedisLettuceUris.Cluster cluster ->
runtime(
deployment,
RedisDeploymentRuntime.Topology.CLUSTER,
clientSettings,
nativeClientFactory.openCluster(
cluster.seedUris(),
optionsFactory.clusterClientOptions(clientSettings, sslOptions),
clientSettings),
uris);
case RedisLettuceUris.SentinelDiscovery ignored ->
throw new IllegalStateException("Redis Sentinel fail-closed guard was bypassed");
case RedisLettuceUris.SentinelData ignored ->
throw new IllegalStateException("Redis Sentinel fail-closed guard was bypassed");
};
} catch (RuntimeException exception) {
uris.close();
throw exception;
}
}
private static RedisDeploymentRuntime runtime(
RedisDeploymentSettings deployment,
RedisDeploymentRuntime.Topology topology,
RedisClientRuntimeSettings settings,
RedisNativeClientHandle client,
RedisLettuceUris credentialOwner) {
try {
return new RedisDeploymentRuntime(
deployment.deploymentId(), topology, settings, client, credentialOwner);
} catch (RuntimeException exception) {
try {
client.close(settings.shutdownTimeout());
} finally {
credentialOwner.close();
}
throw exception;
}
}
}
@@ -0,0 +1,76 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.time.Duration;
import java.util.Objects;
/** Non-owning unavailable route used while an optional CACHE deployment awaits recovery. */
final class RedisDormantCommandRuntime implements RedisRoutableCommandRuntime {
private final String deploymentId;
RedisDormantCommandRuntime(String deploymentId) {
this.deploymentId = Objects.requireNonNull(deploymentId, "deploymentId must be non-null");
}
@Override
public void probe(Duration timeout) {
throw unavailable();
}
@Override
public String deploymentId() {
return deploymentId;
}
@Override
public byte[] get(RedisPhysicalKey key) {
throw unavailable();
}
@Override
public void set(RedisPhysicalKey key, RedisBinaryValue value, Duration timeToLive) {
throw unavailable();
}
@Override
public long delete(RedisPhysicalKey key) {
throw unavailable();
}
@Override
public RedisPrimitiveReply execute(RedisPrimitiveInvocation invocation) {
throw unavailable();
}
@Override
public RedisCatalogProgramReply executeCatalogProgram(RedisCatalogProgramInvocation invocation) {
throw unavailable();
}
@Override
public String loadCatalogProgram(RedisCatalogProgramInvocation invocation) {
throw unavailable();
}
@Override
public long publish(byte[] channel, byte[] message) {
throw unavailable();
}
@Override
public Subscription subscribe(byte[] channel, Listener listener) {
Objects.requireNonNull(listener, "listener must be non-null");
return () -> {};
}
@Override
public void close() {}
private static RedisCommandFailureException unavailable() {
return new RedisCommandFailureException(
RedisCommandFailureException.Kind.UNAVAILABLE,
RedisCommandFailureException.Certainty.NOT_APPLIED,
"Redis optional role is temporarily unavailable",
null);
}
}
@@ -0,0 +1,50 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.time.Duration;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import java.util.function.IntSupplier;
@FunctionalInterface
interface RedisDrainWaiter {
Result await(IntSupplier inFlight, Object monitor, Duration timeout);
static RedisDrainWaiter system() {
return (inFlight, monitor, timeout) -> {
Objects.requireNonNull(inFlight, "inFlight must be non-null");
Objects.requireNonNull(monitor, "monitor must be non-null");
Objects.requireNonNull(timeout, "timeout must be non-null");
long deadline = saturatedAdd(System.nanoTime(), timeout.toNanos());
synchronized (monitor) {
while (inFlight.getAsInt() > 0) {
long remaining = deadline - System.nanoTime();
if (remaining <= 0) {
return Result.TIMED_OUT;
}
try {
TimeUnit.NANOSECONDS.timedWait(monitor, remaining);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
return Result.INTERRUPTED;
}
}
return Result.DRAINED;
}
};
}
private static long saturatedAdd(long left, long right) {
try {
return Math.addExact(left, right);
} catch (ArithmeticException ignored) {
return Long.MAX_VALUE;
}
}
enum Result {
DRAINED,
TIMED_OUT,
INTERRUPTED
}
}
@@ -0,0 +1,553 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyBuilder;
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyDigest;
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyNamespace;
import dev.caskeleton.shared.ratelimit.EdgeRateLimitPort;
import dev.caskeleton.shared.ratelimit.RateLimitAlgorithm;
import dev.caskeleton.shared.ratelimit.RateLimitDecision;
import dev.caskeleton.shared.ratelimit.RateLimitOutcome;
import dev.caskeleton.shared.ratelimit.RateLimitPolicy;
import dev.caskeleton.shared.ratelimit.RateLimitRequest;
import dev.caskeleton.shared.ratelimit.RateParameters;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.function.LongSupplier;
/** Provider-neutral edge-rate port backed by one exact Redis Lua program per policy evaluation. */
final class RedisEdgeRateLimitProvider implements EdgeRateLimitPort, AutoCloseable {
private static final int PROGRAM_SCHEMA_VERSION = 2;
private static final long SCALE = 1_000_000L;
private static final long MAXIMUM_LIMIT = 1_000_000_000L;
private static final Duration MAXIMUM_WINDOW = Duration.ofDays(1);
private static final Duration MAXIMUM_GRACE = Duration.ofDays(1);
private static final Duration MAXIMUM_CLOCK_REGRESSION = Duration.ofHours(1);
private static final int MAXIMUM_KEY_BYTES = 512;
private final Map<String, RateLimitPolicy> policies;
private final RedisProgramCatalog catalog;
private final RedisRateProgramExecutor executor;
private final String application;
private final String environment;
private final int hashKeyVersion;
private final int keyVersion;
private final byte[] hmacSecret;
private final Clock clock;
private final Duration failureRetryAfter;
private final Duration minimumCallerBudget;
private final RedisCapabilityObserver observer;
private final ReentrantReadWriteLock lifecycle = new ReentrantReadWriteLock();
private boolean closed;
RedisEdgeRateLimitProvider(
Map<String, RateLimitPolicy> policies,
RedisProgramCatalog catalog,
RedisRateProgramExecutor executor,
String application,
String environment,
int hashKeyVersion,
int keyVersion,
byte[] hmacSecret,
Clock clock,
Duration failureRetryAfter) {
this(
policies,
catalog,
executor,
application,
environment,
hashKeyVersion,
keyVersion,
hmacSecret,
clock,
failureRetryAfter,
Duration.ZERO,
NoOpRedisCapabilityObservationPort.instance(),
System::nanoTime);
}
RedisEdgeRateLimitProvider(
Map<String, RateLimitPolicy> policies,
RedisProgramCatalog catalog,
RedisRateProgramExecutor executor,
String application,
String environment,
int hashKeyVersion,
int keyVersion,
byte[] hmacSecret,
Clock clock,
Duration failureRetryAfter,
Duration minimumCallerBudget) {
this(
policies,
catalog,
executor,
application,
environment,
hashKeyVersion,
keyVersion,
hmacSecret,
clock,
failureRetryAfter,
minimumCallerBudget,
NoOpRedisCapabilityObservationPort.instance(),
System::nanoTime);
}
RedisEdgeRateLimitProvider(
Map<String, RateLimitPolicy> policies,
RedisProgramCatalog catalog,
RedisRateProgramExecutor executor,
String application,
String environment,
int hashKeyVersion,
int keyVersion,
byte[] hmacSecret,
Clock clock,
Duration failureRetryAfter,
Duration minimumCallerBudget,
RedisCapabilityObservationPort observations,
LongSupplier ticker) {
this.policies = Map.copyOf(Objects.requireNonNull(policies, "policies must be non-null"));
if (this.policies.isEmpty()) {
throw new IllegalArgumentException("Redis rate-limit provider requires at least one policy");
}
this.policies.forEach(
(id, policy) -> {
Objects.requireNonNull(policy, "rate-limit policy must be non-null");
if (!id.equals(policy.policyId())) {
throw new IllegalArgumentException("rate-limit policy map key must match policyId");
}
validateProviderBounds(policy);
});
this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null");
this.executor = Objects.requireNonNull(executor, "executor must be non-null");
this.application = Objects.requireNonNull(application, "application must be non-null");
this.environment = Objects.requireNonNull(environment, "environment must be non-null");
this.hashKeyVersion = hashKeyVersion;
this.keyVersion = keyVersion;
this.hmacSecret =
Arrays.copyOf(
Objects.requireNonNull(hmacSecret, "hmacSecret must be non-null"), hmacSecret.length);
if (this.hmacSecret.length < 32) {
throw new IllegalArgumentException(
"rate-limit key HMAC secret must contain at least 32 bytes");
}
this.clock = Objects.requireNonNull(clock, "clock must be non-null");
this.failureRetryAfter =
Objects.requireNonNull(failureRetryAfter, "failureRetryAfter must be non-null");
if (failureRetryAfter.isZero()
|| failureRetryAfter.isNegative()
|| failureRetryAfter.compareTo(Duration.ofDays(30)) > 0) {
throw new IllegalArgumentException("failureRetryAfter must be positive and bounded");
}
this.minimumCallerBudget =
Objects.requireNonNull(minimumCallerBudget, "minimumCallerBudget must be non-null");
if (minimumCallerBudget.isNegative()
|| minimumCallerBudget.compareTo(Duration.ofSeconds(30)) > 0) {
throw new IllegalArgumentException("minimumCallerBudget must be non-negative and bounded");
}
this.observer = new RedisCapabilityObserver(observations, ticker);
RateLimitPolicy first = this.policies.values().iterator().next();
namespace(first, "state");
}
@Override
public RateLimitOutcome evaluate(RateLimitRequest request) {
return observer.observe(
RedisCapabilityObservationEvent.Capability.RATE_LIMIT,
RedisCapabilityObservationEvent.Role.COORDINATION,
RedisCapabilityObservationEvent.Operation.RATE_EVALUATE,
() -> evaluateWithLifecycle(request),
RedisEdgeRateLimitProvider::classify);
}
private RateLimitOutcome evaluateWithLifecycle(RateLimitRequest request) {
lifecycle.readLock().lock();
try {
if (closed) {
throw new IllegalStateException("Redis rate-limit provider is closed");
}
return evaluateOpen(request);
} finally {
lifecycle.readLock().unlock();
}
}
private RateLimitOutcome evaluateOpen(RateLimitRequest request) {
Objects.requireNonNull(request, "request must be non-null");
RateLimitPolicy policy = policies.get(request.policyId());
if (policy == null) {
return incompatible(
request.policyId(), RateLimitOutcome.IncompatibleCategory.STATE_INCOMPATIBLE);
}
if (request.cost() > policy.maximumCost()) {
throw new IllegalArgumentException("rate-limit request cost exceeds policy maximumCost");
}
Instant now = clock.instant();
if (!request.callerDeadline().isAfter(now)
|| Duration.between(now, request.callerDeadline()).compareTo(minimumCallerBudget) < 0) {
return unavailable(
policy.policyId(), RateLimitOutcome.UnavailableCategory.NO_MUTATION_CONFIRMED);
}
ProgramInvocation invocation = invocation(policy, request);
RedisRateProgramReply reply;
try {
reply = execute(invocation);
} catch (RedisProgramCompatibilityException exception) {
return incompatible(
policy.policyId(), RateLimitOutcome.IncompatibleCategory.REPLY_INCOMPATIBLE);
} catch (RedisCommandFailureException exception) {
if (!canReplayIndeterminate(policy, request, exception)) {
return mapCommandFailure(policy.policyId(), exception);
}
try {
reply = execute(invocation);
} catch (RedisProgramCompatibilityException retryException) {
return incompatible(
policy.policyId(), RateLimitOutcome.IncompatibleCategory.REPLY_INCOMPATIBLE);
} catch (RedisCommandFailureException retryException) {
return mapCommandFailure(policy.policyId(), retryException);
}
}
return mapReply(policy, reply);
}
@Override
public void close() {
lifecycle.writeLock().lock();
try {
if (!closed) {
Arrays.fill(hmacSecret, (byte) 0);
closed = true;
}
} finally {
lifecycle.writeLock().unlock();
}
}
boolean destroyed() {
lifecycle.readLock().lock();
try {
if (!closed) {
return false;
}
for (byte value : hmacSecret) {
if (value != 0) {
return false;
}
}
return true;
} finally {
lifecycle.readLock().unlock();
}
}
private RedisRateProgramReply execute(ProgramInvocation invocation) {
return executor.execute(catalog.capabilityInvocation(invocation));
}
private boolean canReplayIndeterminate(
RateLimitPolicy policy, RateLimitRequest request, RedisCommandFailureException exception) {
if (exception.certainty() != RedisCommandFailureException.Certainty.INDETERMINATE
|| !policy.evaluationDedupPolicy().enabled()
|| request.evaluationId().isEmpty()
|| minimumCallerBudget.isZero()) {
return false;
}
Instant now = clock.instant();
return request.callerDeadline().isAfter(now)
&& Duration.between(now, request.callerDeadline()).compareTo(minimumCallerBudget) >= 0;
}
private RateLimitOutcome mapReply(RateLimitPolicy policy, RedisRateProgramReply reply) {
Objects.requireNonNull(reply, "rate program reply must be non-null");
return switch (reply.status()) {
case ALLOWED -> evaluated(policy, reply, RedisRateProgramDecision.ALLOWED);
case DENIED -> evaluated(policy, reply, RedisRateProgramDecision.DENIED);
case DEDUP_REPLAY -> evaluated(policy, reply, reply.decision());
case CLOCK_UNSAFE ->
unavailable(policy.policyId(), RateLimitOutcome.UnavailableCategory.CLOCK_UNSAFE);
case STATE_INCOMPATIBLE ->
incompatible(policy.policyId(), RateLimitOutcome.IncompatibleCategory.STATE_INCOMPATIBLE);
case INVALID ->
incompatible(
policy.policyId(), RateLimitOutcome.IncompatibleCategory.PROGRAM_INCOMPATIBLE);
};
}
private RateLimitOutcome evaluated(
RateLimitPolicy policy, RedisRateProgramReply reply, RedisRateProgramDecision decision) {
if (decision == RedisRateProgramDecision.NONE) {
return incompatible(
policy.policyId(), RateLimitOutcome.IncompatibleCategory.REPLY_INCOMPATIBLE);
}
boolean allowed = decision == RedisRateProgramDecision.ALLOWED;
long expectedLimit = limit(policy);
if (reply.limit() != expectedLimit
|| reply.remaining() > reply.limit()
|| reply.effectiveNowMillis() < reply.serverNowMillis()
|| (allowed && reply.retryAfterMillis() != 0)
|| (!allowed && reply.retryAfterMillis() < 1)
|| reply.resetAtMillis() < reply.effectiveNowMillis()) {
return incompatible(
policy.policyId(), RateLimitOutcome.IncompatibleCategory.REPLY_INCOMPATIBLE);
}
RateLimitDecision.DecisionCertainty certainty =
policy.algorithm() == RateLimitAlgorithm.SLIDING_COUNTER
? RateLimitDecision.DecisionCertainty.APPROXIMATE_ALGORITHM
: RateLimitDecision.DecisionCertainty.CERTAIN;
try {
return new RateLimitOutcome.Evaluated(
new RateLimitDecision(
allowed,
reply.limit(),
reply.remaining(),
Duration.ofMillis(reply.retryAfterMillis()),
Instant.ofEpochMilli(reply.resetAtMillis()),
policy.policyId(),
policy.policyRevision(),
RateLimitDecision.DecisionSource.GLOBAL_REDIS,
certainty));
} catch (RuntimeException exception) {
return incompatible(
policy.policyId(), RateLimitOutcome.IncompatibleCategory.REPLY_INCOMPATIBLE);
}
}
private RateLimitOutcome mapCommandFailure(
String policyId, RedisCommandFailureException exception) {
if (exception.certainty() == RedisCommandFailureException.Certainty.INDETERMINATE) {
return new RateLimitOutcome.Indeterminate(policyId, failureRetryAfter);
}
RateLimitOutcome.UnavailableCategory category =
exception.kind() == RedisCommandFailureException.Kind.OVERLOADED
? RateLimitOutcome.UnavailableCategory.ADMISSION_REJECTED
: RateLimitOutcome.UnavailableCategory.UNAVAILABLE_BEFORE_SEND;
return unavailable(policyId, category);
}
private RateLimitOutcome unavailable(
String policyId, RateLimitOutcome.UnavailableCategory category) {
return new RateLimitOutcome.Unavailable(policyId, failureRetryAfter, category);
}
private static RateLimitOutcome incompatible(
String policyId, RateLimitOutcome.IncompatibleCategory category) {
return new RateLimitOutcome.Incompatible(policyId, category);
}
private static RedisCapabilityObserver.Classification classify(RateLimitOutcome outcome) {
if (outcome instanceof RateLimitOutcome.Evaluated evaluated) {
return classification(
evaluated.decision().allowed()
? RedisCapabilityObservationEvent.Outcome.SUCCESS
: RedisCapabilityObservationEvent.Outcome.DENIED,
RedisCapabilityObservationEvent.Certainty.DEFINITE);
}
if (outcome instanceof RateLimitOutcome.Indeterminate) {
return classification(
RedisCapabilityObservationEvent.Outcome.INDETERMINATE,
RedisCapabilityObservationEvent.Certainty.INDETERMINATE);
}
if (outcome instanceof RateLimitOutcome.Incompatible) {
return classification(
RedisCapabilityObservationEvent.Outcome.INCOMPATIBLE,
RedisCapabilityObservationEvent.Certainty.DEFINITE);
}
RateLimitOutcome.Unavailable unavailable = (RateLimitOutcome.Unavailable) outcome;
return classification(
unavailable.category() == RateLimitOutcome.UnavailableCategory.ADMISSION_REJECTED
? RedisCapabilityObservationEvent.Outcome.OVERLOADED
: RedisCapabilityObservationEvent.Outcome.UNAVAILABLE,
RedisCapabilityObservationEvent.Certainty.NOT_APPLIED);
}
private static RedisCapabilityObserver.Classification classification(
RedisCapabilityObservationEvent.Outcome outcome,
RedisCapabilityObservationEvent.Certainty certainty) {
return new RedisCapabilityObserver.Classification(outcome, certainty);
}
private ProgramInvocation invocation(RateLimitPolicy policy, RateLimitRequest request) {
String algorithm = algorithmId(policy.algorithm());
RedisKeyDigest digest =
RedisKeyDigest.sensitive(
hashKeyVersion,
hmacSecret,
List.of(
utf8(policy.policyId()),
utf8(policy.policyRevision()),
utf8(algorithm),
utf8(request.subjectDigest())));
List<byte[]> keys =
List.of(
physicalKey(policy, digest, "state"),
physicalKey(policy, digest, "dedup"),
physicalKey(policy, digest, "dedup-order"));
String evaluationId =
policy.evaluationDedupPolicy().enabled() && !request.evaluationId().isEmpty()
? request.evaluationId()
: "-";
List<byte[]> arguments =
switch (policy.parameters()) {
case RateParameters.FixedWindow fixed ->
commonArguments(policy, request.cost(), fixed.limit(), fixed.window(), evaluationId);
case RateParameters.SlidingCounter sliding ->
commonArguments(
policy, request.cost(), sliding.limit(), sliding.window(), evaluationId);
case RateParameters.TokenBucket token ->
List.of(
ascii(PROGRAM_SCHEMA_VERSION),
utf8(policy.policyRevision()),
ascii(Math.multiplyExact(token.capacity(), SCALE)),
ascii(Math.multiplyExact(token.refillTokens(), SCALE)),
ascii(token.refillPeriod().toMillis()),
ascii(Math.multiplyExact(request.cost(), SCALE)),
ascii(policy.cleanupGrace().toMillis()),
ascii(policy.maximumClockRegression().toMillis()),
utf8(evaluationId),
ascii(policy.evaluationDedupPolicy().timeToLive().toMillis()),
ascii(policy.evaluationDedupPolicy().maximumEntries()),
ascii(policy.evaluationDedupPolicy().maximumStoredBytes()));
};
RedisProgramId programId =
switch (policy.algorithm()) {
case FIXED_WINDOW -> RedisProgramId.RATE_FIXED_WINDOW_V2;
case SLIDING_COUNTER -> RedisProgramId.RATE_SLIDING_COUNTER_V2;
case TOKEN_BUCKET -> RedisProgramId.RATE_TOKEN_BUCKET_V2;
};
return new ProgramInvocation(programId, keys, arguments);
}
private static List<byte[]> commonArguments(
RateLimitPolicy policy, long cost, long limit, Duration window, String evaluationId) {
return List.of(
ascii(PROGRAM_SCHEMA_VERSION),
utf8(policy.policyRevision()),
ascii(limit),
ascii(cost),
ascii(window.toMillis()),
ascii(policy.cleanupGrace().toMillis()),
ascii(policy.maximumClockRegression().toMillis()),
utf8(evaluationId),
ascii(policy.evaluationDedupPolicy().timeToLive().toMillis()),
ascii(policy.evaluationDedupPolicy().maximumEntries()),
ascii(policy.evaluationDedupPolicy().maximumStoredBytes()));
}
private byte[] physicalKey(RateLimitPolicy policy, RedisKeyDigest digest, String kind) {
return utf8(RedisKeyBuilder.build(namespace(policy, kind), digest));
}
private RedisKeyNamespace namespace(RateLimitPolicy policy, String kind) {
return new RedisKeyNamespace(
application,
environment,
"rate",
policy.policyId(),
hashKeyVersion,
keyVersion,
kind,
MAXIMUM_KEY_BYTES);
}
private static void validateProviderBounds(RateLimitPolicy policy) {
if (policy.cleanupGrace().compareTo(MAXIMUM_GRACE) > 0
|| policy.maximumClockRegression().compareTo(MAXIMUM_CLOCK_REGRESSION) > 0) {
throw new IllegalArgumentException("rate-limit grace or clock regression exceeds v2 bounds");
}
switch (policy.parameters()) {
case RateParameters.FixedWindow fixed -> {
boundedLimitAndWindow(fixed.limit(), fixed.window());
}
case RateParameters.SlidingCounter sliding -> {
boundedLimitAndWindow(sliding.limit(), sliding.window());
}
case RateParameters.TokenBucket token -> {
if (token.capacity() > MAXIMUM_LIMIT
|| token.refillPeriod().compareTo(MAXIMUM_WINDOW) > 0) {
throw new IllegalArgumentException("token-bucket policy exceeds v2 program bounds");
}
}
}
}
private static void boundedLimitAndWindow(long limit, Duration window) {
if (limit > MAXIMUM_LIMIT || window.compareTo(MAXIMUM_WINDOW) > 0) {
throw new IllegalArgumentException("rate-limit policy exceeds v2 program bounds");
}
}
private static long limit(RateLimitPolicy policy) {
return switch (policy.parameters()) {
case RateParameters.FixedWindow fixed -> fixed.limit();
case RateParameters.SlidingCounter sliding -> sliding.limit();
case RateParameters.TokenBucket token -> token.capacity();
};
}
private static String algorithmId(RateLimitAlgorithm algorithm) {
return switch (algorithm) {
case FIXED_WINDOW -> "fixed-window";
case SLIDING_COUNTER -> "sliding-window-counter";
case TOKEN_BUCKET -> "token-bucket";
};
}
private static byte[] ascii(long value) {
return Long.toString(value).getBytes(StandardCharsets.US_ASCII);
}
private static byte[] utf8(String value) {
return value.getBytes(StandardCharsets.UTF_8);
}
static final class ProgramInvocation implements RedisCatalogProgramMaterial {
private final RedisProgramId programId;
private final List<byte[]> keys;
private final List<byte[]> arguments;
private ProgramInvocation(RedisProgramId programId, List<byte[]> keys, List<byte[]> arguments) {
this.programId = Objects.requireNonNull(programId, "programId must be non-null");
this.keys =
Objects.requireNonNull(keys, "keys must be non-null").stream()
.map(byte[]::clone)
.toList();
this.arguments =
Objects.requireNonNull(arguments, "arguments must be non-null").stream()
.map(byte[]::clone)
.toList();
}
@Override
public RedisProgramId programId() {
return programId;
}
@Override
public RedisCatalogProgramInvocation.ReplyShape replyShape() {
return RedisCatalogProgramInvocation.ReplyShape.MULTI;
}
@Override
public List<byte[]> copyKeys() {
return keys.stream().map(byte[]::clone).toList();
}
@Override
public List<byte[]> copyArguments() {
return arguments.stream().map(byte[]::clone).toList();
}
}
}
@@ -0,0 +1,56 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole;
import dev.caskeleton.adapter.outbound.cache.redis.security.RedisCredentialMaterialProvider;
import dev.caskeleton.application.lease.DistributedLeasePort;
import java.time.Clock;
import java.util.Arrays;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/** Canonical COORDINATION-role composition for the owner-safe Redis efficiency lease. */
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(RedisLeaseSettings.class)
@ConditionalOnProperty(
name = "ca-skeleton.capabilities.lease.provider",
havingValue = "redis",
matchIfMissing = false)
public class RedisEfficiencyLeaseConfig {
@Bean(name = "distributedLeasePort", destroyMethod = "close")
@ConditionalOnProperty(
name = "ca-skeleton.capabilities.lease.provider",
havingValue = "redis",
matchIfMissing = false)
DistributedLeasePort distributedLeasePort(
RedisLeaseSettings settings,
RedisCanonicalRoleRegistry roleRegistry,
RedisCredentialMaterialProvider credentialProvider,
ObjectProvider<Clock> clockProvider,
ObjectProvider<RedisCapabilityObservationPort> observationsProvider) {
settings.validateActive();
Clock clock = clockProvider.getIfAvailable(Clock::systemUTC);
RedisCapabilityObservationPort observations =
observationsProvider.getIfUnique(NoOpRedisCapabilityObservationPort::instance);
byte[] hmacSecret =
RedisHmacMaterialResolver.resolve(
settings.keyHmacSecretReference(), credentialProvider, clock, "efficiency-lease");
try {
return RedisEfficiencyLeaseProvider.create(
settings.namespaceApplication(),
settings.namespaceEnvironment(),
settings.hashKeyVersion(),
settings.keyVersion(),
hmacSecret,
roleRegistry.router(RedisRole.COORDINATION),
clock,
settings.driftBudget(),
observations);
} finally {
Arrays.fill(hmacSecret, (byte) 0);
}
}
}
@@ -0,0 +1,394 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.application.lease.LeaseAttempt;
import dev.caskeleton.application.lease.LeaseHandle;
import dev.caskeleton.application.lease.LeaseReleaseOutcome;
import dev.caskeleton.application.lease.LeaseRenewOutcome;
import dev.caskeleton.application.lease.LeaseState;
import dev.caskeleton.application.lease.LeaseUnavailableCategory;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.LongSupplier;
/** Thread-safe local validity handle for one Redis efficiency lease. */
final class RedisEfficiencyLeaseHandle implements LeaseHandle {
private static final int PROGRAM_SCHEMA_VERSION = 1;
private static final Duration MAXIMUM_LEASE = Duration.ofHours(24);
private final byte[] key;
private final LeaseAttempt attempt;
private final RedisLeaseProgramExecutor programs;
private final RedisLeaseLifecycle lifecycle;
private final LongSupplier nanoTime;
private final long driftNanos;
private final Instant acquiredAt;
private final AtomicLong validityDeadlineNanos = new AtomicLong();
private final AtomicReference<Instant> observedServerExpiry;
private final AtomicReference<LeaseState> state = new AtomicReference<>(LeaseState.ACTIVE);
private final Object mutationMonitor = new Object();
private final RedisCapabilityObserver observer;
RedisEfficiencyLeaseHandle(
byte[] key,
LeaseAttempt attempt,
RedisLeaseProgramExecutor programs,
RedisLeaseLifecycle lifecycle,
LongSupplier nanoTime,
Duration driftBudget,
Instant acquiredAt,
RedisLeaseProgramReply reply,
long commandStartedNanos,
long commandFinishedNanos,
RedisCapabilityObserver observer) {
this.key = Objects.requireNonNull(key, "key must be non-null").clone();
this.attempt = Objects.requireNonNull(attempt, "attempt must be non-null");
this.programs = Objects.requireNonNull(programs, "programs must be non-null");
this.lifecycle = Objects.requireNonNull(lifecycle, "lifecycle must be non-null");
this.nanoTime = Objects.requireNonNull(nanoTime, "nanoTime must be non-null");
this.driftNanos = Objects.requireNonNull(driftBudget, "driftBudget must be non-null").toNanos();
this.acquiredAt = Objects.requireNonNull(acquiredAt, "acquiredAt must be non-null");
this.observedServerExpiry =
new AtomicReference<>(Instant.ofEpochMilli(reply.serverExpiryMillis()));
this.observer = Objects.requireNonNull(observer, "observer must be non-null");
updateValidity(reply.remainingMillis(), commandStartedNanos, commandFinishedNanos);
}
@Override
public String ownerToken() {
return attempt.ownerToken();
}
@Override
public String operationId() {
return attempt.operationId();
}
@Override
public Instant acquiredAt() {
return acquiredAt;
}
@Override
public Duration remainingValidity() {
if (state.get() != LeaseState.ACTIVE) {
return Duration.ZERO;
}
long remaining = validityDeadlineNanos.get() - nanoTime.getAsLong();
if (remaining <= 0) {
state.compareAndSet(LeaseState.ACTIVE, LeaseState.LOST);
return Duration.ZERO;
}
return Duration.ofNanos(remaining);
}
@Override
public Instant observedServerExpiry() {
return observedServerExpiry.get();
}
@Override
public LeaseState state() {
remainingValidity();
return state.get();
}
@Override
public LeaseRenewOutcome renew(Duration leaseTtl) {
Objects.requireNonNull(leaseTtl, "leaseTtl must be non-null");
if (leaseTtl.isZero()
|| leaseTtl.isNegative()
|| leaseTtl.compareTo(MAXIMUM_LEASE) > 0
|| !Duration.ofMillis(leaseTtl.toMillis()).equals(leaseTtl)) {
throw new IllegalArgumentException("leaseTtl must be positive, bounded, whole milliseconds");
}
return observer.observe(
RedisCapabilityObservationEvent.Capability.EFFICIENCY_LEASE,
RedisCapabilityObservationEvent.Role.COORDINATION,
RedisCapabilityObservationEvent.Operation.LEASE_RENEW,
() -> renewOpen(leaseTtl),
RedisEfficiencyLeaseHandle::classifyRenew);
}
private LeaseRenewOutcome renewOpen(Duration leaseTtl) {
synchronized (mutationMonitor) {
LeaseState current = state();
if (current == LeaseState.RELEASED || current == LeaseState.LOST) {
return new LeaseRenewOutcome.Absent();
}
if (current == LeaseState.UNKNOWN) {
return new LeaseRenewOutcome.Indeterminate(attempt.operationId());
}
long started = nanoTime.getAsLong();
RedisLeaseProgramReply reply;
try {
reply =
lifecycle.withOpen(
() ->
programs.execute(
new ProgramInvocation(
RedisProgramId.LEASE_RENEW_V1,
key,
List.of(
ascii(PROGRAM_SCHEMA_VERSION),
ascii(attempt.ownerToken()),
ascii(attempt.operationId()),
ascii(leaseTtl.toMillis())))));
} catch (RedisCommandFailureException failure) {
state.set(LeaseState.UNKNOWN);
return failure.certainty() == RedisCommandFailureException.Certainty.INDETERMINATE
? new LeaseRenewOutcome.Indeterminate(attempt.operationId())
: new LeaseRenewOutcome.Unavailable(category(failure));
} catch (RedisProgramCompatibilityException | IllegalArgumentException failure) {
state.set(LeaseState.UNKNOWN);
return new LeaseRenewOutcome.Unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
} catch (IllegalStateException failure) {
state.set(LeaseState.UNKNOWN);
return new LeaseRenewOutcome.Unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
}
long finished = nanoTime.getAsLong();
return mapRenew(reply, started, finished);
}
}
private LeaseRenewOutcome mapRenew(RedisLeaseProgramReply reply, long started, long finished) {
return switch (reply.status()) {
case "RENEWED" -> {
if (!validLiveReply(reply)) {
state.set(LeaseState.UNKNOWN);
yield new LeaseRenewOutcome.Unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
}
observedServerExpiry.set(Instant.ofEpochMilli(reply.serverExpiryMillis()));
if (!updateValidity(reply.remainingMillis(), started, finished)) {
yield new LeaseRenewOutcome.Unavailable(LeaseUnavailableCategory.DEADLINE_EXPIRED);
}
yield new LeaseRenewOutcome.Renewed(remainingValidity());
}
case "ABSENT" -> {
state.set(LeaseState.LOST);
yield new LeaseRenewOutcome.Absent();
}
case "NOT_OWNER", "OWNER_OPERATION_CONFLICT" -> {
state.set(LeaseState.LOST);
yield new LeaseRenewOutcome.NotOwner();
}
case "STATE_INCOMPATIBLE", "INVALID" -> {
state.set(LeaseState.UNKNOWN);
yield new LeaseRenewOutcome.Unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
}
default -> {
state.set(LeaseState.UNKNOWN);
yield new LeaseRenewOutcome.Unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
}
};
}
@Override
public LeaseReleaseOutcome release() {
return observer.observe(
RedisCapabilityObservationEvent.Capability.EFFICIENCY_LEASE,
RedisCapabilityObservationEvent.Role.COORDINATION,
RedisCapabilityObservationEvent.Operation.LEASE_RELEASE,
this::releaseOpen,
RedisEfficiencyLeaseHandle::classifyRelease);
}
private LeaseReleaseOutcome releaseOpen() {
synchronized (mutationMonitor) {
if (state.get() == LeaseState.RELEASED) {
return new LeaseReleaseOutcome.AlreadyAbsent();
}
RedisLeaseProgramReply reply;
try {
reply =
lifecycle.withOpen(
() ->
programs.execute(
new ProgramInvocation(
RedisProgramId.LEASE_RELEASE_V1,
key,
List.of(
ascii(PROGRAM_SCHEMA_VERSION),
ascii(attempt.ownerToken()),
ascii(attempt.operationId())))));
} catch (RedisCommandFailureException failure) {
state.set(LeaseState.UNKNOWN);
return failure.certainty() == RedisCommandFailureException.Certainty.INDETERMINATE
? new LeaseReleaseOutcome.Indeterminate(attempt.operationId())
: new LeaseReleaseOutcome.Unavailable(category(failure));
} catch (RedisProgramCompatibilityException | IllegalArgumentException failure) {
state.set(LeaseState.UNKNOWN);
return new LeaseReleaseOutcome.Unavailable(
LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
} catch (IllegalStateException failure) {
state.set(LeaseState.UNKNOWN);
return new LeaseReleaseOutcome.Unavailable(
LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
}
return mapRelease(reply);
}
}
private LeaseReleaseOutcome mapRelease(RedisLeaseProgramReply reply) {
return switch (reply.status()) {
case "RELEASED" -> {
state.set(LeaseState.RELEASED);
yield new LeaseReleaseOutcome.Released();
}
case "ALREADY_ABSENT" -> {
state.set(LeaseState.RELEASED);
yield new LeaseReleaseOutcome.AlreadyAbsent();
}
case "NOT_OWNER", "OWNER_OPERATION_CONFLICT" -> {
state.set(LeaseState.LOST);
yield new LeaseReleaseOutcome.NotOwner();
}
case "STATE_INCOMPATIBLE", "INVALID" -> {
state.set(LeaseState.UNKNOWN);
yield new LeaseReleaseOutcome.Unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
}
default -> {
state.set(LeaseState.UNKNOWN);
yield new LeaseReleaseOutcome.Unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
}
};
}
private boolean updateValidity(long remainingMillis, long started, long finished) {
long commandElapsed = Math.max(0L, finished - started);
long rawValidity;
try {
rawValidity = Math.multiplyExact(remainingMillis, 1_000_000L);
} catch (ArithmeticException failure) {
state.set(LeaseState.UNKNOWN);
return false;
}
long effective = rawValidity - commandElapsed - driftNanos;
if (effective <= 0) {
validityDeadlineNanos.set(finished);
state.set(LeaseState.LOST);
return false;
}
validityDeadlineNanos.set(saturatedAdd(finished, effective));
state.set(LeaseState.ACTIVE);
return true;
}
private boolean validLiveReply(RedisLeaseProgramReply reply) {
return reply.remainingMillis() > 0
&& reply.remainingMillis() <= MAXIMUM_LEASE.toMillis()
&& reply.stateRevision() > 0
&& reply.serverExpiryMillis() >= reply.serverNowMillis()
&& reply.serverExpiryMillis() - reply.serverNowMillis() == reply.remainingMillis()
&& attempt.operationId().equals(reply.operationId());
}
private static LeaseUnavailableCategory category(RedisCommandFailureException failure) {
return failure.kind() == RedisCommandFailureException.Kind.OVERLOADED
? LeaseUnavailableCategory.ADMISSION_REJECTED
: LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND;
}
private static long saturatedAdd(long left, long right) {
try {
return Math.addExact(left, right);
} catch (ArithmeticException failure) {
return Long.MAX_VALUE;
}
}
private static RedisCapabilityObserver.Classification classifyRenew(LeaseRenewOutcome outcome) {
if (outcome instanceof LeaseRenewOutcome.Renewed) {
return definite(RedisCapabilityObservationEvent.Outcome.SUCCESS);
}
if (outcome instanceof LeaseRenewOutcome.NotOwner) {
return definite(RedisCapabilityObservationEvent.Outcome.CONFLICT);
}
if (outcome instanceof LeaseRenewOutcome.Absent) {
return definite(RedisCapabilityObservationEvent.Outcome.MISS);
}
if (outcome instanceof LeaseRenewOutcome.Indeterminate) {
return indeterminate();
}
return unavailable();
}
private static RedisCapabilityObserver.Classification classifyRelease(
LeaseReleaseOutcome outcome) {
if (outcome instanceof LeaseReleaseOutcome.Released
|| outcome instanceof LeaseReleaseOutcome.AlreadyAbsent) {
return definite(RedisCapabilityObservationEvent.Outcome.SUCCESS);
}
if (outcome instanceof LeaseReleaseOutcome.NotOwner) {
return definite(RedisCapabilityObservationEvent.Outcome.CONFLICT);
}
if (outcome instanceof LeaseReleaseOutcome.Indeterminate) {
return indeterminate();
}
return unavailable();
}
private static RedisCapabilityObserver.Classification definite(
RedisCapabilityObservationEvent.Outcome outcome) {
return new RedisCapabilityObserver.Classification(
outcome, RedisCapabilityObservationEvent.Certainty.DEFINITE);
}
private static RedisCapabilityObserver.Classification indeterminate() {
return new RedisCapabilityObserver.Classification(
RedisCapabilityObservationEvent.Outcome.INDETERMINATE,
RedisCapabilityObservationEvent.Certainty.INDETERMINATE);
}
private static RedisCapabilityObserver.Classification unavailable() {
return new RedisCapabilityObserver.Classification(
RedisCapabilityObservationEvent.Outcome.UNAVAILABLE,
RedisCapabilityObservationEvent.Certainty.NOT_APPLIED);
}
private static byte[] ascii(long value) {
return Long.toString(value).getBytes(StandardCharsets.US_ASCII);
}
private static byte[] ascii(String value) {
return value.getBytes(StandardCharsets.US_ASCII);
}
static final class ProgramInvocation implements RedisCatalogProgramMaterial {
private final RedisProgramId programId;
private final byte[] key;
private final List<byte[]> arguments;
private ProgramInvocation(RedisProgramId programId, byte[] key, List<byte[]> arguments) {
this.programId = Objects.requireNonNull(programId, "programId must be non-null");
this.key = Objects.requireNonNull(key, "key must be non-null").clone();
this.arguments = arguments.stream().map(byte[]::clone).toList();
}
@Override
public RedisProgramId programId() {
return programId;
}
@Override
public RedisCatalogProgramInvocation.ReplyShape replyShape() {
return RedisCatalogProgramInvocation.ReplyShape.MULTI;
}
@Override
public List<byte[]> copyKeys() {
return List.of(key.clone());
}
@Override
public List<byte[]> copyArguments() {
return arguments.stream().map(byte[]::clone).toList();
}
}
}
@@ -0,0 +1,503 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.application.lease.DistributedLeasePort;
import dev.caskeleton.application.lease.LeaseAcquireOutcome;
import dev.caskeleton.application.lease.LeaseAttempt;
import dev.caskeleton.application.lease.LeaseInspectionOutcome;
import dev.caskeleton.application.lease.LeaseInspectionRequest;
import dev.caskeleton.application.lease.LeaseRequest;
import dev.caskeleton.application.lease.LeaseUnavailableCategory;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.time.Clock;
import java.time.Duration;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.ThreadLocalRandom;
import java.util.function.LongSupplier;
/**
* Redis owner-safe efficiency lease provider.
*
* <p>This provider has no fencing token and must not authorize correctness-sensitive writes.
*/
final class RedisEfficiencyLeaseProvider implements DistributedLeasePort, AutoCloseable {
private static final int PROGRAM_SCHEMA_VERSION = 1;
private static final Duration MAXIMUM_RETRY_AFTER = Duration.ofMinutes(5);
private final RedisLeaseKeyFactory keys;
private final RedisLeaseProgramExecutor programs;
private final RedisLeaseTokenGenerator tokens;
private final Clock clock;
private final LongSupplier nanoTime;
private final Duration driftBudget;
private final RedisLeaseWaitStrategy waitStrategy;
private final RedisLeaseLifecycle lifecycle = new RedisLeaseLifecycle();
private final RedisCapabilityObserver observer;
RedisEfficiencyLeaseProvider(
RedisLeaseKeyFactory keys,
RedisLeaseProgramExecutor programs,
RedisLeaseTokenGenerator tokens,
Clock clock,
LongSupplier nanoTime,
Duration driftBudget,
RedisLeaseWaitStrategy waitStrategy) {
this(
keys,
programs,
tokens,
clock,
nanoTime,
driftBudget,
waitStrategy,
NoOpRedisCapabilityObservationPort.instance());
}
RedisEfficiencyLeaseProvider(
RedisLeaseKeyFactory keys,
RedisLeaseProgramExecutor programs,
RedisLeaseTokenGenerator tokens,
Clock clock,
LongSupplier nanoTime,
Duration driftBudget,
RedisLeaseWaitStrategy waitStrategy,
RedisCapabilityObservationPort observations) {
this.keys = Objects.requireNonNull(keys, "keys must be non-null");
this.programs = Objects.requireNonNull(programs, "programs must be non-null");
this.tokens = Objects.requireNonNull(tokens, "tokens must be non-null");
this.clock = Objects.requireNonNull(clock, "clock must be non-null");
this.nanoTime = Objects.requireNonNull(nanoTime, "nanoTime must be non-null");
this.driftBudget = Objects.requireNonNull(driftBudget, "driftBudget must be non-null");
if (driftBudget.isNegative()
|| driftBudget.compareTo(Duration.ofSeconds(5)) > 0
|| !Duration.ofMillis(driftBudget.toMillis()).equals(driftBudget)) {
throw new IllegalArgumentException("driftBudget must be non-negative, bounded milliseconds");
}
this.waitStrategy = Objects.requireNonNull(waitStrategy, "waitStrategy must be non-null");
this.observer = new RedisCapabilityObserver(observations, nanoTime);
}
static RedisEfficiencyLeaseProvider create(
String application,
String environment,
int hashKeyVersion,
int keyVersion,
byte[] hmacSecret,
RedisStructuredCommands commands,
Clock clock,
Duration driftBudget) {
return create(
application,
environment,
hashKeyVersion,
keyVersion,
hmacSecret,
commands,
clock,
driftBudget,
NoOpRedisCapabilityObservationPort.instance());
}
static RedisEfficiencyLeaseProvider create(
String application,
String environment,
int hashKeyVersion,
int keyVersion,
byte[] hmacSecret,
RedisStructuredCommands commands,
Clock clock,
Duration driftBudget,
RedisCapabilityObservationPort observations) {
RedisProgramCatalog catalog = RedisProgramCatalog.efficiencyLease();
return new RedisEfficiencyLeaseProvider(
new RedisLeaseKeyFactory(application, environment, hashKeyVersion, keyVersion, hmacSecret),
new RedisLeaseProgramExecutor(catalog, commands),
new RedisLeaseTokenGenerator(new SecureRandom()),
clock,
System::nanoTime,
driftBudget,
RedisLeaseWaitStrategy.parking(),
observations);
}
@Override
public LeaseAttempt newAttempt(String operationId) {
return lifecycle.withOpen(() -> new LeaseAttempt(tokens.next(), operationId));
}
@Override
public LeaseAcquireOutcome tryAcquire(LeaseRequest request) {
return observer.observe(
RedisCapabilityObservationEvent.Capability.EFFICIENCY_LEASE,
RedisCapabilityObservationEvent.Role.COORDINATION,
RedisCapabilityObservationEvent.Operation.LEASE_ACQUIRE,
() -> tryAcquireOpen(request),
RedisEfficiencyLeaseProvider::classifyAcquire);
}
private LeaseAcquireOutcome tryAcquireOpen(LeaseRequest request) {
Objects.requireNonNull(request, "request must be non-null");
byte[] key;
try {
key = lifecycle.withOpen(() -> keys.physicalKey(request.purpose(), request.resourceDigest()));
} catch (IllegalStateException failure) {
return unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
}
long waitStarted = nanoTime.getAsLong();
int backoffAttempt = 0;
while (true) {
long commandStarted = nanoTime.getAsLong();
RedisLeaseProgramReply reply;
try {
reply =
lifecycle.withOpen(
() ->
programs.execute(
new ProgramInvocation(
RedisProgramId.LEASE_ACQUIRE_V1,
key,
List.of(
ascii(PROGRAM_SCHEMA_VERSION),
ascii(request.attempt().ownerToken()),
ascii(request.attempt().operationId()),
ascii(request.leaseTtl().toMillis())))));
} catch (RedisCommandFailureException failure) {
return mapAcquireFailure(request, failure);
} catch (RedisProgramCompatibilityException | IllegalArgumentException failure) {
return unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
} catch (IllegalStateException failure) {
return unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
}
long commandFinished = nanoTime.getAsLong();
LeaseAcquireOutcome mapped = mapAcquire(request, key, reply, commandStarted, commandFinished);
long elapsedWaitNanos = Math.max(0L, commandFinished - waitStarted);
if (!(mapped instanceof LeaseAcquireOutcome.Contended contended)
|| request.waitTimeout().isZero()
|| elapsedWaitNanos >= request.waitTimeout().toNanos()) {
return mapped;
}
long remainingWaitNanos = request.waitTimeout().toNanos() - elapsedWaitNanos;
Duration pause =
pause(
contended.retryAfter(),
Duration.ofNanos(Math.max(0L, remainingWaitNanos)),
backoffAttempt++);
if (pause.isZero()) {
return mapped;
}
try {
waitStrategy.await(pause);
} catch (RedisLeaseWaitInterruptedException interrupted) {
return unavailable(LeaseUnavailableCategory.DEADLINE_EXPIRED);
}
}
}
private LeaseAcquireOutcome mapAcquire(
LeaseRequest request,
byte[] key,
RedisLeaseProgramReply reply,
long commandStarted,
long commandFinished) {
return switch (reply.status()) {
case "ACQUIRED" -> {
if (!validOwnedReply(request.attempt(), request.leaseTtl(), reply)) {
yield unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
}
RedisEfficiencyLeaseHandle handle =
handle(request.attempt(), key, reply, commandStarted, commandFinished);
if (handle.state() != dev.caskeleton.application.lease.LeaseState.ACTIVE) {
handle.release();
yield unavailable(LeaseUnavailableCategory.DEADLINE_EXPIRED);
}
yield new LeaseAcquireOutcome.Acquired(handle);
}
case "REPLAYED_SAME_OPERATION" -> {
if (!validOwnedReply(request.attempt(), Duration.ofHours(24), reply)) {
yield unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
}
RedisEfficiencyLeaseHandle handle =
handle(request.attempt(), key, reply, commandStarted, commandFinished);
if (handle.state() != dev.caskeleton.application.lease.LeaseState.ACTIVE) {
handle.release();
yield unavailable(LeaseUnavailableCategory.DEADLINE_EXPIRED);
}
yield new LeaseAcquireOutcome.ReplayedSameOperation(handle);
}
case "CONTENDED" -> {
if (!validLiveReply(reply)) {
yield unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
}
yield new LeaseAcquireOutcome.Contended(
Duration.ofMillis(Math.min(reply.remainingMillis(), MAXIMUM_RETRY_AFTER.toMillis())));
}
case "OWNER_OPERATION_CONFLICT" ->
validLiveReply(reply)
? new LeaseAcquireOutcome.OwnerOperationConflict()
: unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
case "STATE_INCOMPATIBLE", "INVALID" ->
unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
default -> unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
};
}
@Override
public LeaseInspectionOutcome inspect(LeaseInspectionRequest request) {
return observer.observe(
RedisCapabilityObservationEvent.Capability.EFFICIENCY_LEASE,
RedisCapabilityObservationEvent.Role.COORDINATION,
RedisCapabilityObservationEvent.Operation.LEASE_INSPECT,
() -> inspectOpen(request),
RedisEfficiencyLeaseProvider::classifyInspection);
}
private LeaseInspectionOutcome inspectOpen(LeaseInspectionRequest request) {
Objects.requireNonNull(request, "request must be non-null");
byte[] key;
try {
key = lifecycle.withOpen(() -> keys.physicalKey(request.purpose(), request.resourceDigest()));
} catch (IllegalStateException failure) {
return new LeaseInspectionOutcome.Unavailable(
LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
}
long started = nanoTime.getAsLong();
RedisLeaseProgramReply reply;
try {
reply =
lifecycle.withOpen(
() ->
programs.execute(
new ProgramInvocation(
RedisProgramId.LEASE_INSPECT_V1,
key,
List.of(
ascii(PROGRAM_SCHEMA_VERSION),
ascii(request.attempt().ownerToken()),
ascii(request.attempt().operationId())))));
} catch (RedisCommandFailureException failure) {
return failure.certainty() == RedisCommandFailureException.Certainty.INDETERMINATE
? new LeaseInspectionOutcome.Indeterminate(request.attempt().operationId())
: new LeaseInspectionOutcome.Unavailable(category(failure));
} catch (RedisProgramCompatibilityException | IllegalArgumentException failure) {
return new LeaseInspectionOutcome.Unavailable(
LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
} catch (IllegalStateException failure) {
return new LeaseInspectionOutcome.Unavailable(
LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
}
long finished = nanoTime.getAsLong();
return mapInspection(request, key, reply, started, finished);
}
private LeaseInspectionOutcome mapInspection(
LeaseInspectionRequest request,
byte[] key,
RedisLeaseProgramReply reply,
long started,
long finished) {
return switch (reply.status()) {
case "OWNED" -> {
if (!validOwnedReply(request.attempt(), Duration.ofHours(24), reply)) {
yield new LeaseInspectionOutcome.Unavailable(
LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
}
RedisEfficiencyLeaseHandle handle =
handle(request.attempt(), key, reply, started, finished);
yield handle.state() == dev.caskeleton.application.lease.LeaseState.ACTIVE
? new LeaseInspectionOutcome.Owned(handle)
: new LeaseInspectionOutcome.Unavailable(LeaseUnavailableCategory.DEADLINE_EXPIRED);
}
case "ABSENT" -> new LeaseInspectionOutcome.Absent();
case "NOT_OWNER" -> new LeaseInspectionOutcome.NotOwner();
case "OWNER_OPERATION_CONFLICT" -> new LeaseInspectionOutcome.OwnerOperationConflict();
case "STATE_INCOMPATIBLE", "INVALID" ->
new LeaseInspectionOutcome.Unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
default ->
new LeaseInspectionOutcome.Unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
};
}
private RedisEfficiencyLeaseHandle handle(
LeaseAttempt attempt,
byte[] key,
RedisLeaseProgramReply reply,
long commandStarted,
long commandFinished) {
return new RedisEfficiencyLeaseHandle(
key,
attempt,
programs,
lifecycle,
nanoTime,
driftBudget,
clock.instant(),
reply,
commandStarted,
commandFinished,
observer);
}
private static boolean validOwnedReply(
LeaseAttempt attempt, Duration maximumTtl, RedisLeaseProgramReply reply) {
return reply.remainingMillis() > 0
&& reply.remainingMillis() <= maximumTtl.toMillis()
&& reply.stateRevision() > 0
&& reply.serverExpiryMillis() >= reply.serverNowMillis()
&& reply.serverExpiryMillis() - reply.serverNowMillis() == reply.remainingMillis()
&& attempt.operationId().equals(reply.operationId());
}
private static boolean validLiveReply(RedisLeaseProgramReply reply) {
return reply.remainingMillis() > 0
&& reply.remainingMillis() <= Duration.ofHours(24).toMillis()
&& reply.stateRevision() > 0
&& reply.serverExpiryMillis() >= reply.serverNowMillis()
&& reply.serverExpiryMillis() - reply.serverNowMillis() == reply.remainingMillis();
}
private static Duration pause(Duration contention, Duration remainingWait, int backoffAttempt) {
if (remainingWait.isZero()) {
return Duration.ZERO;
}
int shift = Math.min(backoffAttempt, 7);
long capMillis = Math.min(250L, 2L << shift);
long jitterMillis = ThreadLocalRandom.current().nextLong(1L, capMillis + 1L);
long millis =
Math.min(
jitterMillis,
Math.min(Math.max(1L, contention.toMillis()), Math.max(0L, remainingWait.toMillis())));
return millis < 1 ? Duration.ZERO : Duration.ofMillis(millis);
}
private static LeaseAcquireOutcome mapAcquireFailure(
LeaseRequest request, RedisCommandFailureException failure) {
if (failure.certainty() == RedisCommandFailureException.Certainty.INDETERMINATE) {
return new LeaseAcquireOutcome.Indeterminate(request.attempt().operationId());
}
if (failure.kind() == RedisCommandFailureException.Kind.OVERLOADED) {
return new LeaseAcquireOutcome.Overloaded();
}
return unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
}
private static LeaseAcquireOutcome unavailable(LeaseUnavailableCategory category) {
return new LeaseAcquireOutcome.Unavailable(category);
}
private static LeaseUnavailableCategory category(RedisCommandFailureException failure) {
return failure.kind() == RedisCommandFailureException.Kind.OVERLOADED
? LeaseUnavailableCategory.ADMISSION_REJECTED
: LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND;
}
private static RedisCapabilityObserver.Classification classifyAcquire(
LeaseAcquireOutcome outcome) {
if (outcome instanceof LeaseAcquireOutcome.Acquired
|| outcome instanceof LeaseAcquireOutcome.ReplayedSameOperation) {
return definite(RedisCapabilityObservationEvent.Outcome.SUCCESS);
}
if (outcome instanceof LeaseAcquireOutcome.Contended) {
return definite(RedisCapabilityObservationEvent.Outcome.CONTENDED);
}
if (outcome instanceof LeaseAcquireOutcome.OwnerOperationConflict) {
return definite(RedisCapabilityObservationEvent.Outcome.CONFLICT);
}
if (outcome instanceof LeaseAcquireOutcome.Overloaded) {
return new RedisCapabilityObserver.Classification(
RedisCapabilityObservationEvent.Outcome.OVERLOADED,
RedisCapabilityObservationEvent.Certainty.NOT_APPLIED);
}
if (outcome instanceof LeaseAcquireOutcome.Indeterminate) {
return indeterminate();
}
return unavailable();
}
private static RedisCapabilityObserver.Classification classifyInspection(
LeaseInspectionOutcome outcome) {
if (outcome instanceof LeaseInspectionOutcome.Owned) {
return definite(RedisCapabilityObservationEvent.Outcome.SUCCESS);
}
if (outcome instanceof LeaseInspectionOutcome.Absent) {
return definite(RedisCapabilityObservationEvent.Outcome.MISS);
}
if (outcome instanceof LeaseInspectionOutcome.NotOwner
|| outcome instanceof LeaseInspectionOutcome.OwnerOperationConflict) {
return definite(RedisCapabilityObservationEvent.Outcome.CONFLICT);
}
if (outcome instanceof LeaseInspectionOutcome.Indeterminate) {
return indeterminate();
}
return unavailable();
}
private static RedisCapabilityObserver.Classification definite(
RedisCapabilityObservationEvent.Outcome outcome) {
return new RedisCapabilityObserver.Classification(
outcome, RedisCapabilityObservationEvent.Certainty.DEFINITE);
}
private static RedisCapabilityObserver.Classification indeterminate() {
return new RedisCapabilityObserver.Classification(
RedisCapabilityObservationEvent.Outcome.INDETERMINATE,
RedisCapabilityObservationEvent.Certainty.INDETERMINATE);
}
private static RedisCapabilityObserver.Classification unavailable() {
return new RedisCapabilityObserver.Classification(
RedisCapabilityObservationEvent.Outcome.UNAVAILABLE,
RedisCapabilityObservationEvent.Certainty.NOT_APPLIED);
}
@Override
public void close() {
lifecycle.close(keys::close);
}
boolean destroyed() {
return lifecycle.closed() && keys.destroyed();
}
private static byte[] ascii(long value) {
return Long.toString(value).getBytes(StandardCharsets.US_ASCII);
}
private static byte[] ascii(String value) {
return value.getBytes(StandardCharsets.US_ASCII);
}
static final class ProgramInvocation implements RedisCatalogProgramMaterial {
private final RedisProgramId programId;
private final byte[] key;
private final List<byte[]> arguments;
private ProgramInvocation(RedisProgramId programId, byte[] key, List<byte[]> arguments) {
this.programId = Objects.requireNonNull(programId, "programId must be non-null");
this.key = Objects.requireNonNull(key, "key must be non-null").clone();
this.arguments = arguments.stream().map(byte[]::clone).toList();
}
@Override
public RedisProgramId programId() {
return programId;
}
@Override
public RedisCatalogProgramInvocation.ReplyShape replyShape() {
return RedisCatalogProgramInvocation.ReplyShape.MULTI;
}
@Override
public List<byte[]> copyKeys() {
return List.of(key.clone());
}
@Override
public List<byte[]> copyArguments() {
return arguments.stream().map(byte[]::clone).toList();
}
}
}
@@ -0,0 +1,39 @@
package dev.caskeleton.adapter.outbound.cache.redis;
/** Bounded WGS84 coordinate whose text form is always redacted. */
record RedisGeoCoordinate(double longitude, double latitude) {
RedisGeoCoordinate {
if (!Double.isFinite(longitude)
|| !Double.isFinite(latitude)
|| longitude < -180
|| longitude > 180
|| latitude < -85.05112878
|| latitude > 85.05112878) {
throw new IllegalArgumentException("geo coordinate exceeds descriptor bounds");
}
if (canonical(longitude).length() > 20 || canonical(latitude).length() > 20) {
throw new IllegalArgumentException("geo coordinate exceeds canonical encoding bounds");
}
}
String canonicalLongitude() {
return canonical(longitude);
}
String canonicalLatitude() {
return canonical(latitude);
}
private static String canonical(double value) {
if (value == 0) {
return "0";
}
return java.math.BigDecimal.valueOf(value).stripTrailingZeros().toPlainString();
}
@Override
public String toString() {
return "RedisGeoCoordinate[redacted]";
}
}
@@ -0,0 +1,81 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.time.Duration;
import java.util.List;
import java.util.Objects;
/** Privacy-sensitive bounded GEO helpers; coordinates are never returned or stringified. */
final class RedisGeoPrimitives {
private final RedisPrimitiveCatalog catalog;
private final RedisPrimitiveExecutor executor;
private final RedisPrimitiveDescriptor admission;
RedisGeoPrimitives(RedisPrimitiveCatalog catalog, RedisPrimitiveCommands commands) {
this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null");
this.executor = new RedisPrimitiveExecutor(catalog, commands);
this.admission = catalog.descriptor(RedisPrimitiveId.GEO_ADD);
}
RedisPrimitiveKey key(String slot, String identity) {
return catalog.keyFactory(RedisPrimitiveId.GEO_ADD).key(slot, identity);
}
RedisPrimitiveValue member(String member) {
return RedisPrimitiveValue.utf8(member, admission.maximumMemberBytes());
}
RedisPrimitiveMutationResult admitOrUpdate(
RedisPrimitiveKey key,
RedisPrimitiveValue member,
RedisGeoCoordinate coordinate,
Duration initialTimeToLive) {
return executor.mutate(
RedisPrimitiveId.GEO_ADD,
List.of(key),
new RedisPrimitiveInvocation.GeoAdmissionArguments(
member,
coordinate,
RedisPrimitiveLimit.of(admission.maximumElements(), admission),
initialTimeToLive));
}
RedisPrimitiveReply search(
RedisPrimitiveKey key,
RedisGeoCoordinate center,
double radiusMeters,
int count,
RedisPrimitiveInvocation.GeoArguments.Sort sort) {
RedisPrimitiveDescriptor descriptor = catalog.descriptor(RedisPrimitiveId.GEO_SEARCH);
return executor.execute(
RedisPrimitiveId.GEO_SEARCH,
List.of(key),
new RedisPrimitiveInvocation.GeoArguments(
center,
RedisPrimitiveInvocation.GeoArguments.Shape.RADIUS,
radiusMeters,
0,
RedisPrimitiveLimit.of(count, descriptor),
sort));
}
RedisPrimitiveReply searchBox(
RedisPrimitiveKey key,
RedisGeoCoordinate center,
double widthMeters,
double heightMeters,
int count,
RedisPrimitiveInvocation.GeoArguments.Sort sort) {
RedisPrimitiveDescriptor descriptor = catalog.descriptor(RedisPrimitiveId.GEO_SEARCH);
return executor.execute(
RedisPrimitiveId.GEO_SEARCH,
List.of(key),
new RedisPrimitiveInvocation.GeoArguments(
center,
RedisPrimitiveInvocation.GeoArguments.Shape.BOX,
widthMeters,
heightMeters,
RedisPrimitiveLimit.of(count, descriptor),
sort));
}
}
@@ -0,0 +1,98 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.time.Duration;
import java.util.List;
import java.util.Objects;
/** Bounded hash helpers. Field growth is admitted atomically against descriptor capacity. */
final class RedisHashPrimitives {
private final RedisPrimitiveCatalog catalog;
private final RedisPrimitiveExecutor executor;
private final RedisPrimitiveDescriptor putDescriptor;
RedisHashPrimitives(RedisPrimitiveCatalog catalog, RedisPrimitiveCommands commands) {
this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null");
this.executor = new RedisPrimitiveExecutor(catalog, commands);
this.putDescriptor = catalog.descriptor(RedisPrimitiveId.HASH_SET_FIELDS);
}
RedisPrimitiveKey key(String slot, String identity) {
return catalog.keyFactory(RedisPrimitiveId.HASH_GET).key(slot, identity);
}
RedisPrimitiveValue field(String field) {
return RedisPrimitiveValue.utf8(field, putDescriptor.maximumFieldBytes());
}
RedisPrimitiveValue value(String value) {
return RedisPrimitiveValue.utf8(value, putDescriptor.maximumValueBytes());
}
RedisPrimitiveMutationResult put(
RedisPrimitiveKey key,
RedisPrimitiveValue field,
RedisPrimitiveValue value,
Duration initialTimeToLive) {
return executor.mutate(
RedisPrimitiveId.HASH_SET_FIELDS,
List.of(key),
new RedisPrimitiveInvocation.HashAdmissionArguments(
field,
value,
RedisPrimitiveLimit.of(putDescriptor.maximumElements(), putDescriptor),
initialTimeToLive));
}
RedisPrimitiveReply get(RedisPrimitiveKey key, RedisPrimitiveValue field) {
return executor.execute(
RedisPrimitiveId.HASH_GET,
List.of(key),
new RedisPrimitiveInvocation.BinaryArguments(List.of(field)));
}
RedisPrimitiveReply multiGet(RedisPrimitiveKey key, List<RedisPrimitiveValue> fields) {
RedisPrimitiveDescriptor descriptor = catalog.descriptor(RedisPrimitiveId.HASH_MGET);
RedisPrimitiveLimit.of(fields.size(), descriptor);
return executor.execute(
RedisPrimitiveId.HASH_MGET,
List.of(key),
new RedisPrimitiveInvocation.BinaryArguments(fields));
}
RedisPrimitiveMutationResult delete(RedisPrimitiveKey key, List<RedisPrimitiveValue> fields) {
RedisPrimitiveDescriptor descriptor = catalog.descriptor(RedisPrimitiveId.HASH_DELETE_FIELDS);
RedisPrimitiveLimit.of(fields.size(), descriptor);
return executor.mutate(
RedisPrimitiveId.HASH_DELETE_FIELDS,
List.of(key),
new RedisPrimitiveInvocation.BinaryArguments(fields));
}
RedisPrimitiveScanOutcome<RedisPrimitiveHashEntry> scan(
RedisPrimitiveKey key, RedisPrimitiveCursor cursor, long routeEpoch) {
RedisPrimitiveDescriptor descriptor = catalog.descriptor(RedisPrimitiveId.HASH_SCAN_PAGE);
cursor.validateFor(catalog, descriptor, key, routeEpoch);
return RedisPrimitiveScanOutcome.from(
executor.execute(
RedisPrimitiveId.HASH_SCAN_PAGE,
List.of(key),
new RedisPrimitiveInvocation.ScanPageArguments(
cursor, descriptor.maximumElements(), descriptor.maximumResultBytes())),
RedisPrimitiveHashEntry.class);
}
RedisPrimitiveMutationResult compareRevision(
RedisPrimitiveKey key,
RedisPrimitiveInvocation.HashRevisionArguments.ExpectedKind expectedKind,
String expectedRevision,
String nextRevision,
RedisPrimitiveValue value,
Duration initialTimeToLive) {
return executor.mutate(
RedisPrimitiveId.HASH_REVISION_CAS,
List.of(key),
new RedisPrimitiveInvocation.HashRevisionArguments(
expectedKind, expectedRevision, nextRevision, value, initialTimeToLive));
}
}
@@ -0,0 +1,54 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.redis.security.RedisCredentialMaterialProvider;
import dev.caskeleton.adapter.outbound.cache.redis.security.RedisSecretReference;
import dev.caskeleton.adapter.outbound.cache.redis.security.VersionedRedisCredentialMaterial;
import java.time.Clock;
import java.util.Arrays;
import java.util.Base64;
/** Resolves bounded Base64 HMAC material without retaining a raw configuration secret. */
final class RedisHmacMaterialResolver {
private RedisHmacMaterialResolver() {}
static byte[] resolve(
String reference,
RedisCredentialMaterialProvider credentialProvider,
Clock clock,
String capability) {
try (VersionedRedisCredentialMaterial material =
credentialProvider.resolve(RedisSecretReference.parse(reference))) {
if (material.isExpiredAt(clock.instant())) {
throw failure(capability);
}
byte[] decoded =
material.useSecret(
chars -> {
byte[] encoded = new byte[chars.length];
try {
for (int index = 0; index < chars.length; index++) {
if (chars[index] > 0x7f) {
throw failure(capability);
}
encoded[index] = (byte) chars[index];
}
return Base64.getDecoder().decode(encoded);
} finally {
Arrays.fill(encoded, (byte) 0);
}
});
if (decoded.length < 32 || decoded.length > 4096) {
Arrays.fill(decoded, (byte) 0);
throw failure(capability);
}
return decoded;
} catch (RuntimeException ignored) {
throw failure(capability);
}
}
private static IllegalStateException failure(String capability) {
return new IllegalStateException("Redis " + capability + " HMAC material resolution failed");
}
}
@@ -0,0 +1,54 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/** Approximate HLL helpers forbidden for billing, authorization, quota, audit and security. */
final class RedisHyperLogLogPrimitives {
private final RedisPrimitiveCatalog catalog;
private final RedisPrimitiveExecutor executor;
private final RedisPrimitiveDescriptor addDescriptor;
RedisHyperLogLogPrimitives(RedisPrimitiveCatalog catalog, RedisPrimitiveCommands commands) {
this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null");
this.executor = new RedisPrimitiveExecutor(catalog, commands);
this.addDescriptor = catalog.descriptor(RedisPrimitiveId.HLL_ADD);
}
RedisPrimitiveKey key(String slot, String identity) {
return catalog.keyFactory(RedisPrimitiveId.HLL_ADD).key(slot, identity);
}
RedisPrimitiveValue element(String value) {
return RedisPrimitiveValue.utf8(value, addDescriptor.maximumValueBytes());
}
RedisPrimitiveMutationResult add(RedisPrimitiveKey key, List<RedisPrimitiveValue> elements) {
RedisPrimitiveLimit.of(elements.size(), addDescriptor);
return executor.mutate(
RedisPrimitiveId.HLL_ADD,
List.of(key),
new RedisPrimitiveInvocation.BinaryArguments(elements));
}
RedisPrimitiveReply count(RedisPrimitiveKey key) {
return executor.execute(
RedisPrimitiveId.HLL_COUNT, List.of(key), RedisPrimitiveInvocation.NoArguments.INSTANCE);
}
RedisPrimitiveMutationResult merge(
RedisPrimitiveKey destination, List<RedisPrimitiveKey> sources) {
RedisPrimitiveDescriptor descriptor = catalog.descriptor(RedisPrimitiveId.HLL_MERGE_SAME_SLOT);
if (sources.isEmpty() || sources.size() > descriptor.maximumKeys() - 1) {
throw new IllegalArgumentException("HLL merge fan-in exceeds descriptor bounds");
}
ArrayList<RedisPrimitiveKey> keys = new ArrayList<>();
keys.add(destination);
keys.addAll(sources);
descriptor.validateKeys(keys);
return executor.mutate(
RedisPrimitiveId.HLL_MERGE_SAME_SLOT, keys, RedisPrimitiveInvocation.NoArguments.INSTANCE);
}
}
@@ -0,0 +1,77 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole;
import dev.caskeleton.adapter.outbound.cache.redis.security.RedisCredentialMaterialProvider;
import dev.caskeleton.application.idempotency.IdempotencyExecutorV2;
import dev.caskeleton.application.idempotency.IdempotencyStorePortV2;
import java.security.SecureRandom;
import java.time.Clock;
import java.util.Arrays;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/** Canonical COORDINATION-role composition for Redis request-replay idempotency V2. */
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(RedisIdempotencySettings.class)
@ConditionalOnProperty(
name = "ca-skeleton.capabilities.idempotency.provider",
havingValue = "redis",
matchIfMissing = false)
public class RedisIdempotencyConfig {
@Bean(name = "redisIdempotencyStoreV2", destroyMethod = "close")
@ConditionalOnProperty(
name = "ca-skeleton.capabilities.idempotency.provider",
havingValue = "redis",
matchIfMissing = false)
IdempotencyStorePortV2 redisIdempotencyStoreV2(
RedisIdempotencySettings settings,
RedisCanonicalRoleRegistry roleRegistry,
RedisCredentialMaterialProvider credentialProvider,
ObjectProvider<Clock> clockProvider,
ObjectProvider<RedisCapabilityObservationPort> observationsProvider) {
settings.validateActive();
Clock clock = clockProvider.getIfAvailable(Clock::systemUTC);
RedisCapabilityObservationPort observations =
observationsProvider.getIfUnique(NoOpRedisCapabilityObservationPort::instance);
byte[] hmacSecret =
RedisHmacMaterialResolver.resolve(
settings.keyHmacSecretReference(), credentialProvider, clock, "idempotency");
RedisProgramCatalog catalog = RedisProgramCatalog.idempotencyV2();
try {
return new RedisIdempotencyStoreProvider(
new RedisIdempotencyKeyFactory(
settings.namespaceApplication(),
settings.namespaceEnvironment(),
settings.hashKeyVersion(),
settings.keyVersion(),
hmacSecret),
new RedisIdempotencyProgramExecutor(catalog, roleRegistry.router(RedisRole.COORDINATION)),
new RedisIdempotencyRecordCodec(),
new RedisIdempotencyTokenGenerator(new SecureRandom()),
observations,
System::nanoTime);
} finally {
Arrays.fill(hmacSecret, (byte) 0);
}
}
@Bean(name = "idempotencyExecutorV2")
@ConditionalOnProperty(
name = "ca-skeleton.capabilities.idempotency.provider",
havingValue = "redis",
matchIfMissing = false)
IdempotencyExecutorV2 idempotencyExecutorV2(
IdempotencyStorePortV2 store, RedisIdempotencySettings settings) {
return new IdempotencyExecutorV2(
store,
settings.processingLease(),
settings.replayTtl(),
settings.failureRetention(),
settings.responseCodecId(),
settings.policyRevision());
}
}
@@ -0,0 +1,78 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyBuilder;
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyDigest;
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyNamespace;
import dev.caskeleton.application.idempotency.IdempotencyScope;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
/** HMAC-pseudonymizes every request-replay scope dimension into one Cluster-safe record key. */
final class RedisIdempotencyKeyFactory implements AutoCloseable {
private final RedisKeyNamespace namespace;
private final byte[] hmacSecret;
private final AtomicBoolean closed = new AtomicBoolean();
RedisIdempotencyKeyFactory(
String application,
String environment,
int hashKeyVersion,
int keyVersion,
byte[] hmacSecret) {
this.namespace =
new RedisKeyNamespace(
application,
environment,
"idempotency",
"request",
hashKeyVersion,
keyVersion,
"record",
512);
this.hmacSecret =
Arrays.copyOf(
Objects.requireNonNull(hmacSecret, "hmacSecret must be non-null"), hmacSecret.length);
if (this.hmacSecret.length < 32) {
throw new IllegalArgumentException(
"idempotency scope HMAC secret requires at least 32 bytes");
}
}
byte[] physicalKey(IdempotencyScope scope) {
if (closed.get()) {
throw new IllegalStateException("idempotency key material is closed");
}
Objects.requireNonNull(scope, "scope must be non-null");
RedisKeyDigest digest =
RedisKeyDigest.sensitive(
namespace.hashKeyVersion(),
hmacSecret,
List.of(
utf8(scope.tenant() == null ? "-" : scope.tenant()),
utf8(scope.principal()),
utf8(scope.idempotencyKey()),
utf8(scope.useCaseName())));
return utf8(RedisKeyBuilder.build(namespace, digest));
}
@Override
public void close() {
if (closed.compareAndSet(false, true)) {
Arrays.fill(hmacSecret, (byte) 0);
}
}
boolean destroyed() {
return closed.get()
&& java.util.stream.IntStream.range(0, hmacSecret.length)
.allMatch(index -> hmacSecret[index] == 0);
}
private static byte[] utf8(String value) {
return value.getBytes(StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,44 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.function.Supplier;
/** Prevents request-replay commands from racing provider close and HMAC destruction. */
final class RedisIdempotencyLifecycle {
private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
private boolean closed;
<T> T withOpen(Supplier<T> operation) {
lock.readLock().lock();
try {
if (closed) {
throw new IllegalStateException("Redis idempotency provider is closed");
}
return operation.get();
} finally {
lock.readLock().unlock();
}
}
void close(Runnable destroy) {
lock.writeLock().lock();
try {
if (!closed) {
destroy.run();
closed = true;
}
} finally {
lock.writeLock().unlock();
}
}
boolean closed() {
lock.readLock().lock();
try {
return closed;
} finally {
lock.readLock().unlock();
}
}
}
@@ -0,0 +1,74 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Objects;
/** Executes and fail-closed parses the fixed six-field request-replay program protocol. */
final class RedisIdempotencyProgramExecutor {
private static final long MAXIMUM_EXACT_LUA_INTEGER = 9_007_199_254_740_991L;
private final RedisProgramCatalog catalog;
private final RedisStructuredCommands commands;
RedisIdempotencyProgramExecutor(RedisProgramCatalog catalog, RedisStructuredCommands commands) {
this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null");
this.commands = Objects.requireNonNull(commands, "commands must be non-null");
}
RedisIdempotencyProgramReply execute(RedisIdempotencyStoreProvider.ProgramInvocation material) {
RedisProgramId id = material.programId();
RedisProgramDescriptor descriptor = catalog.descriptor(id);
List<byte[]> result =
RedisScriptRecovery.evalMulti(commands, catalog.capabilityInvocation(material));
if (result == null || result.size() != 6) {
throw incompatible(id);
}
for (byte[] field : result) {
if (field == null || field.length > descriptor.maximumReplyFieldBytes()) {
throw incompatible(id);
}
}
String status = ascii(result.get(0), id);
if (!descriptor.statuses().contains(status)) {
throw new RedisProgramCompatibilityException(id, status);
}
return new RedisIdempotencyProgramReply(
status,
unsigned(result.get(1), id),
unsigned(result.get(2), id),
ascii(result.get(3), id),
ascii(result.get(4), id),
ascii(result.get(5), id));
}
private static String ascii(byte[] value, RedisProgramId id) {
for (byte character : value) {
if (character < 0x20 || character > 0x7e) {
throw incompatible(id);
}
}
return new String(value, StandardCharsets.US_ASCII);
}
private static long unsigned(byte[] value, RedisProgramId id) {
String encoded = ascii(value, id);
if (!encoded.matches("0|[1-9][0-9]{0,15}")) {
throw incompatible(id);
}
try {
long parsed = Long.parseLong(encoded);
if (parsed > MAXIMUM_EXACT_LUA_INTEGER) {
throw incompatible(id);
}
return parsed;
} catch (NumberFormatException exception) {
throw incompatible(id);
}
}
private static RedisProgramCompatibilityException incompatible(RedisProgramId id) {
return new RedisProgramCompatibilityException(id, "<malformed-idempotency-reply>");
}
}
@@ -0,0 +1,10 @@
package dev.caskeleton.adapter.outbound.cache.redis;
/** Six-field bounded reply shared by all request-replay programs. */
record RedisIdempotencyProgramReply(
String status,
long attempt,
long expiresAtMillis,
String payload,
String digest,
String operationId) {}
@@ -0,0 +1,111 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.application.idempotency.StoredResponse;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
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.util.Base64;
import java.util.HexFormat;
import java.util.Objects;
/** Bounded UTF-8/Base64URL response codec used by the Redis request-replay programs. */
final class RedisIdempotencyRecordCodec {
static final int MAXIMUM_PAYLOAD_BYTES = 8_192;
static final int MAXIMUM_ENCODED_BYTES = 10_924;
private static final HexFormat HEX = HexFormat.of();
EncodedResponse encode(StoredResponse response) {
Objects.requireNonNull(response, "response must be non-null");
byte[] payload = strictUtf8(response.payload());
if (payload.length > MAXIMUM_PAYLOAD_BYTES) {
throw new IllegalArgumentException("idempotency response exceeds the Redis payload bound");
}
String encoded =
payload.length == 0 ? "-" : Base64.getUrlEncoder().withoutPadding().encodeToString(payload);
return new EncodedResponse(encoded, sha256(payload));
}
StoredResponse decode(String encodedPayload, String expectedDigest) {
Objects.requireNonNull(encodedPayload, "encodedPayload must be non-null");
if (expectedDigest == null || !expectedDigest.matches("[0-9a-f]{64}")) {
throw new RedisProgramCompatibilityException(
RedisProgramId.IDEMPOTENCY_INSPECT_V1, "<malformed-response-digest>");
}
if (encodedPayload.length() > MAXIMUM_ENCODED_BYTES) {
throw new RedisProgramCompatibilityException(
RedisProgramId.IDEMPOTENCY_INSPECT_V1, "<oversized-response>");
}
byte[] decoded;
try {
decoded =
"-".equals(encodedPayload) ? new byte[0] : Base64.getUrlDecoder().decode(encodedPayload);
} catch (IllegalArgumentException exception) {
throw new RedisProgramCompatibilityException(
RedisProgramId.IDEMPOTENCY_INSPECT_V1, "<malformed-response>");
}
if (decoded.length > MAXIMUM_PAYLOAD_BYTES) {
throw new RedisProgramCompatibilityException(
RedisProgramId.IDEMPOTENCY_INSPECT_V1, "<oversized-response>");
}
if (!MessageDigest.isEqual(
expectedDigest.getBytes(StandardCharsets.US_ASCII),
sha256(decoded).getBytes(StandardCharsets.US_ASCII))) {
throw new RedisProgramCompatibilityException(
RedisProgramId.IDEMPOTENCY_INSPECT_V1, "<response-digest-mismatch>");
}
try {
return new StoredResponse(
StandardCharsets.UTF_8
.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(decoded))
.toString());
} catch (CharacterCodingException exception) {
throw new RedisProgramCompatibilityException(
RedisProgramId.IDEMPOTENCY_INSPECT_V1, "<invalid-utf8-response>");
}
}
private static byte[] strictUtf8(String value) {
try {
ByteBuffer encoded =
StandardCharsets.UTF_8
.newEncoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.encode(CharBuffer.wrap(value));
byte[] result = new byte[encoded.remaining()];
encoded.get(result);
return result;
} catch (CharacterCodingException exception) {
throw new IllegalArgumentException("idempotency response is not valid Unicode", exception);
}
}
private static String sha256(byte[] value) {
try {
return HEX.formatHex(MessageDigest.getInstance("SHA-256").digest(value));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 unavailable", exception);
}
}
record EncodedResponse(String payload, String digest) {
EncodedResponse {
Objects.requireNonNull(payload, "payload must be non-null");
if (payload.isEmpty() || payload.length() > MAXIMUM_ENCODED_BYTES) {
throw new IllegalArgumentException("encoded idempotency response is out of bounds");
}
if (digest == null || !digest.matches("[0-9a-f]{64}")) {
throw new IllegalArgumentException("idempotency response digest is invalid");
}
}
}
}
@@ -0,0 +1,77 @@
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 dev.caskeleton.application.idempotency.IdempotencyClaimAttempt;
import dev.caskeleton.application.idempotency.IdempotencyClaimRequest;
import dev.caskeleton.application.idempotency.IdempotencyScope;
import dev.caskeleton.application.idempotency.RequestFingerprint;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.ConstructorBinding;
/** Canonical Redis request-replay policy, separate from topology and credential material. */
@ConfigurationProperties(prefix = "ca-skeleton.capabilities.idempotency")
public record RedisIdempotencySettings(
String provider,
String keyHmacSecretReference,
String namespaceApplication,
String namespaceEnvironment,
int hashKeyVersion,
int keyVersion,
Duration processingLease,
Duration replayTtl,
Duration failureRetention,
String responseCodecId,
String policyRevision) {
@ConstructorBinding
public RedisIdempotencySettings {
provider = provider == null ? "" : provider.trim();
keyHmacSecretReference = keyHmacSecretReference == null ? "" : keyHmacSecretReference.trim();
namespaceApplication = defaultText(namespaceApplication, "ca-skeleton");
namespaceEnvironment = defaultText(namespaceEnvironment, "local");
hashKeyVersion = hashKeyVersion == 0 ? 1 : hashKeyVersion;
keyVersion = keyVersion == 0 ? 1 : keyVersion;
processingLease = processingLease == null ? Duration.ofSeconds(30) : processingLease;
replayTtl = replayTtl == null ? Duration.ofHours(24) : replayTtl;
failureRetention = failureRetention == null ? Duration.ofHours(24) : failureRetention;
responseCodecId = defaultText(responseCodecId, "json-v2");
policyRevision = defaultText(policyRevision, "request-replay-v2");
new RedisKeyNamespace(
namespaceApplication,
namespaceEnvironment,
"idempotency",
"validation",
hashKeyVersion,
keyVersion,
"record",
512);
new IdempotencyClaimRequest(
IdempotencyScope.of("validation", "validation", "validation"),
new RequestFingerprint("0".repeat(64)),
new IdempotencyClaimAttempt("validation_owner", "validation_operation"),
processingLease,
replayTtl,
responseCodecId,
policyRevision);
if (failureRetention.isZero()
|| failureRetention.isNegative()
|| failureRetention.compareTo(Duration.ofDays(30)) > 0) {
throw new IllegalArgumentException("failureRetention must be positive and at most 30 days");
}
}
void validateActive() {
if (!"redis".equals(provider)) {
throw new IllegalArgumentException(
"idempotency provider must be redis when this adapter is active");
}
RedisSecretReference.parse(keyHmacSecretReference);
}
private static String defaultText(String value, String fallback) {
return value == null || value.isBlank() ? fallback : value.trim();
}
}
@@ -0,0 +1,568 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.application.idempotency.IdempotencyClaimAttempt;
import dev.caskeleton.application.idempotency.IdempotencyClaimOutcome;
import dev.caskeleton.application.idempotency.IdempotencyClaimRequest;
import dev.caskeleton.application.idempotency.IdempotencyCompleteOutcome;
import dev.caskeleton.application.idempotency.IdempotencyFailOutcome;
import dev.caskeleton.application.idempotency.IdempotencyFailureDisposition;
import dev.caskeleton.application.idempotency.IdempotencyInspection;
import dev.caskeleton.application.idempotency.IdempotencyInspectionRequest;
import dev.caskeleton.application.idempotency.IdempotencyOwner;
import dev.caskeleton.application.idempotency.IdempotencyReleaseOutcome;
import dev.caskeleton.application.idempotency.IdempotencyRenewOutcome;
import dev.caskeleton.application.idempotency.IdempotencyStartOutcome;
import dev.caskeleton.application.idempotency.IdempotencyStorePortV2;
import dev.caskeleton.application.idempotency.StoredResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Objects;
import java.util.function.LongSupplier;
/**
* Redis request-replay candidate provider.
*
* <p>Atomic ownership protects one Redis record only. This provider does not claim cross-store
* exactly-once behavior for business side effects.
*/
final class RedisIdempotencyStoreProvider implements IdempotencyStorePortV2, AutoCloseable {
private static final int PROGRAM_SCHEMA_VERSION = 2;
private static final Duration MAXIMUM_RETRY_AFTER = Duration.ofMinutes(5);
private final RedisIdempotencyKeyFactory keys;
private final RedisIdempotencyProgramExecutor programs;
private final RedisIdempotencyRecordCodec responses;
private final RedisIdempotencyTokenGenerator tokens;
private final RedisIdempotencyLifecycle lifecycle = new RedisIdempotencyLifecycle();
private final RedisCapabilityObserver observer;
RedisIdempotencyStoreProvider(
RedisIdempotencyKeyFactory keys,
RedisIdempotencyProgramExecutor programs,
RedisIdempotencyRecordCodec responses,
RedisIdempotencyTokenGenerator tokens) {
this(
keys,
programs,
responses,
tokens,
NoOpRedisCapabilityObservationPort.instance(),
System::nanoTime);
}
RedisIdempotencyStoreProvider(
RedisIdempotencyKeyFactory keys,
RedisIdempotencyProgramExecutor programs,
RedisIdempotencyRecordCodec responses,
RedisIdempotencyTokenGenerator tokens,
RedisCapabilityObservationPort observations,
LongSupplier ticker) {
this.keys = Objects.requireNonNull(keys, "keys must be non-null");
this.programs = Objects.requireNonNull(programs, "programs must be non-null");
this.responses = Objects.requireNonNull(responses, "responses must be non-null");
this.tokens = Objects.requireNonNull(tokens, "tokens must be non-null");
this.observer = new RedisCapabilityObserver(observations, ticker);
}
@Override
public IdempotencyClaimAttempt newClaimAttempt(String operationId) {
return lifecycle.withOpen(() -> new IdempotencyClaimAttempt(tokens.next(), operationId));
}
@Override
public IdempotencyClaimOutcome claim(IdempotencyClaimRequest request) {
return observer.observe(
RedisCapabilityObservationEvent.Capability.IDEMPOTENCY,
RedisCapabilityObservationEvent.Role.COORDINATION,
RedisCapabilityObservationEvent.Operation.IDEMPOTENCY_CLAIM,
() -> claimOpen(request),
RedisIdempotencyStoreProvider::classifyClaim);
}
private IdempotencyClaimOutcome claimOpen(IdempotencyClaimRequest request) {
Objects.requireNonNull(request, "request must be non-null");
RedisIdempotencyProgramReply reply;
try {
reply =
execute(
RedisProgramId.IDEMPOTENCY_CLAIM_V1,
request.scope(),
List.of(
ascii(PROGRAM_SCHEMA_VERSION),
ascii(request.fingerprint().hex()),
ascii(request.claimAttempt().ownerToken()),
ascii(request.claimAttempt().operationId()),
ascii(request.processingLeaseTtl().toMillis()),
ascii(request.recoveryRetention().toMillis()),
ascii(request.responseCodecId()),
ascii(request.policyRevision())));
} catch (RedisCommandFailureException failure) {
return failure.certainty() == RedisCommandFailureException.Certainty.INDETERMINATE
? new IdempotencyClaimOutcome.Indeterminate(request.claimAttempt().operationId())
: new IdempotencyClaimOutcome.Unavailable();
} catch (RedisProgramCompatibilityException
| IllegalArgumentException
| IllegalStateException failure) {
return new IdempotencyClaimOutcome.Unavailable();
}
try {
return mapClaim(request, reply);
} catch (RedisProgramCompatibilityException | IllegalArgumentException failure) {
return new IdempotencyClaimOutcome.Unavailable();
}
}
private IdempotencyClaimOutcome mapClaim(
IdempotencyClaimRequest request, RedisIdempotencyProgramReply reply) {
return switch (reply.status()) {
case "ACQUIRED" ->
new IdempotencyClaimOutcome.Acquired(
owner(request, reply.attempt()), instant(reply.expiresAtMillis()));
case "REPLAYED_ACQUIRE" ->
new IdempotencyClaimOutcome.ReplayedAcquire(
owner(request, reply.attempt()), instant(reply.expiresAtMillis()));
case "TAKEN_OVER_CLAIMED" ->
new IdempotencyClaimOutcome.TakenOverClaimed(
owner(request, reply.attempt()), instant(reply.expiresAtMillis()));
case "COMPLETED_REPLAY" ->
new IdempotencyClaimOutcome.CompletedReplay(
responses.decode(reply.payload(), reply.digest()), instant(reply.expiresAtMillis()));
case "IN_PROGRESS" ->
new IdempotencyClaimOutcome.InProgress(
Duration.ofMillis(Math.min(reply.expiresAtMillis(), MAXIMUM_RETRY_AFTER.toMillis())),
reply.attempt());
case "RECOVERY_REQUIRED" -> new IdempotencyClaimOutcome.RecoveryRequired(reply.attempt());
case "FINGERPRINT_MISMATCH" -> new IdempotencyClaimOutcome.FingerprintMismatch();
case "OWNER_OPERATION_CONFLICT" -> new IdempotencyClaimOutcome.OwnerOperationConflict();
case "STATE_INCOMPATIBLE", "INVALID" -> new IdempotencyClaimOutcome.Unavailable();
default -> new IdempotencyClaimOutcome.Unavailable();
};
}
@Override
public IdempotencyStartOutcome markExecutionStarted(IdempotencyOwner owner, String operationId) {
Objects.requireNonNull(owner, "owner must be non-null");
return mutation(
RedisCapabilityObservationEvent.Operation.IDEMPOTENCY_START,
operationId,
RedisProgramId.IDEMPOTENCY_START_V1,
owner,
List.of(
ascii(PROGRAM_SCHEMA_VERSION),
ascii(owner.ownerToken()),
ascii(owner.attempt()),
ascii(operationId)),
reply ->
new IdempotencyStartOutcome(
IdempotencyStartOutcome.Status.valueOf(reply.status()), null),
IdempotencyStartOutcome::indeterminate,
IdempotencyStartOutcome::unavailable,
RedisIdempotencyStoreProvider::classifyStart);
}
@Override
public IdempotencyRenewOutcome renew(
IdempotencyOwner owner, Duration processingLeaseTtl, String operationId) {
Objects.requireNonNull(owner, "owner must be non-null");
positive(processingLeaseTtl, Duration.ofHours(24), "processingLeaseTtl");
return mutation(
RedisCapabilityObservationEvent.Operation.IDEMPOTENCY_RENEW,
operationId,
RedisProgramId.IDEMPOTENCY_RENEW_V1,
owner,
List.of(
ascii(PROGRAM_SCHEMA_VERSION),
ascii(owner.ownerToken()),
ascii(owner.attempt()),
ascii(processingLeaseTtl.toMillis()),
ascii(operationId)),
reply ->
new IdempotencyRenewOutcome(
IdempotencyRenewOutcome.Status.valueOf(reply.status()), null),
IdempotencyRenewOutcome::indeterminate,
IdempotencyRenewOutcome::unavailable,
RedisIdempotencyStoreProvider::classifyRenew);
}
@Override
public IdempotencyCompleteOutcome complete(
IdempotencyOwner owner, StoredResponse response, Duration replayTtl, String operationId) {
Objects.requireNonNull(owner, "owner must be non-null");
positive(replayTtl, Duration.ofDays(30), "replayTtl");
RedisIdempotencyRecordCodec.EncodedResponse encoded = responses.encode(response);
return mutation(
RedisCapabilityObservationEvent.Operation.IDEMPOTENCY_COMPLETE,
operationId,
RedisProgramId.IDEMPOTENCY_COMPLETE_V1,
owner,
List.of(
ascii(PROGRAM_SCHEMA_VERSION),
ascii(owner.ownerToken()),
ascii(owner.attempt()),
ascii(encoded.payload()),
ascii(encoded.digest()),
ascii(replayTtl.toMillis()),
ascii(operationId)),
reply ->
new IdempotencyCompleteOutcome(
IdempotencyCompleteOutcome.Status.valueOf(reply.status()), null),
IdempotencyCompleteOutcome::indeterminate,
IdempotencyCompleteOutcome::unavailable,
RedisIdempotencyStoreProvider::classifyComplete);
}
@Override
public IdempotencyFailOutcome markFailed(
IdempotencyOwner owner,
IdempotencyFailureDisposition disposition,
Duration retention,
String operationId) {
Objects.requireNonNull(owner, "owner must be non-null");
Objects.requireNonNull(disposition, "disposition must be non-null");
positive(retention, Duration.ofDays(30), "retention");
return mutation(
RedisCapabilityObservationEvent.Operation.IDEMPOTENCY_FAIL,
operationId,
RedisProgramId.IDEMPOTENCY_FAIL_V1,
owner,
List.of(
ascii(PROGRAM_SCHEMA_VERSION),
ascii(owner.ownerToken()),
ascii(owner.attempt()),
ascii(disposition.name()),
ascii(retention.toMillis()),
ascii(operationId)),
reply ->
new IdempotencyFailOutcome(IdempotencyFailOutcome.Status.valueOf(reply.status()), null),
IdempotencyFailOutcome::indeterminate,
IdempotencyFailOutcome::unavailable,
RedisIdempotencyStoreProvider::classifyFail);
}
@Override
public IdempotencyReleaseOutcome releaseBeforeExecution(
IdempotencyOwner owner, String operationId) {
Objects.requireNonNull(owner, "owner must be non-null");
return mutation(
RedisCapabilityObservationEvent.Operation.IDEMPOTENCY_RELEASE,
operationId,
RedisProgramId.IDEMPOTENCY_RELEASE_V1,
owner,
List.of(
ascii(PROGRAM_SCHEMA_VERSION),
ascii(owner.ownerToken()),
ascii(owner.attempt()),
ascii(operationId)),
reply ->
new IdempotencyReleaseOutcome(
IdempotencyReleaseOutcome.Status.valueOf(reply.status()), null),
IdempotencyReleaseOutcome::indeterminate,
IdempotencyReleaseOutcome::unavailable,
RedisIdempotencyStoreProvider::classifyRelease);
}
@Override
public IdempotencyInspection inspect(IdempotencyInspectionRequest request) {
return observer.observe(
RedisCapabilityObservationEvent.Capability.IDEMPOTENCY,
RedisCapabilityObservationEvent.Role.COORDINATION,
RedisCapabilityObservationEvent.Operation.IDEMPOTENCY_INSPECT,
() -> inspectOpen(request),
RedisIdempotencyStoreProvider::classifyInspection);
}
private IdempotencyInspection inspectOpen(IdempotencyInspectionRequest request) {
Objects.requireNonNull(request, "request must be non-null");
RedisIdempotencyProgramReply reply;
try {
reply =
execute(
RedisProgramId.IDEMPOTENCY_INSPECT_V1,
request.scope(),
List.of(
ascii(PROGRAM_SCHEMA_VERSION),
ascii(request.fingerprint().hex()),
ascii(request.claimAttempt().ownerToken()),
ascii(request.claimAttempt().operationId())));
} catch (RedisCommandFailureException
| RedisProgramCompatibilityException
| IllegalStateException failure) {
return new IdempotencyInspection.Unavailable();
}
try {
return switch (reply.status()) {
case "ABSENT" -> new IdempotencyInspection.Absent();
case "CLAIMED_SAME_OPERATION" ->
new IdempotencyInspection.ClaimedSameOperation(
inspectionOwner(request, reply.attempt()), instant(reply.expiresAtMillis()));
case "EXECUTING_SAME_OPERATION" ->
new IdempotencyInspection.ExecutingSameOperation(
inspectionOwner(request, reply.attempt()), instant(reply.expiresAtMillis()));
case "COMPLETED_REPLAY" ->
new IdempotencyInspection.CompletedReplay(
responses.decode(reply.payload(), reply.digest()),
instant(reply.expiresAtMillis()));
case "IN_PROGRESS_OTHER" -> new IdempotencyInspection.InProgressOther(reply.attempt());
case "FAILED_RETRYABLE" -> new IdempotencyInspection.FailedRetryable(reply.attempt());
case "ABANDONED" -> new IdempotencyInspection.Abandoned(reply.attempt());
case "FINGERPRINT_MISMATCH" -> new IdempotencyInspection.FingerprintMismatch();
case "OPERATION_CONFLICT" -> new IdempotencyInspection.OperationConflict();
case "STATE_INCOMPATIBLE", "INVALID" -> new IdempotencyInspection.Unavailable();
default -> new IdempotencyInspection.Unavailable();
};
} catch (RedisProgramCompatibilityException | IllegalArgumentException failure) {
return new IdempotencyInspection.Unavailable();
}
}
private <T> T mutation(
RedisCapabilityObservationEvent.Operation operation,
String operationId,
RedisProgramId program,
IdempotencyOwner owner,
List<byte[]> arguments,
java.util.function.Function<RedisIdempotencyProgramReply, T> mapper,
java.util.function.Function<String, T> indeterminate,
java.util.function.Supplier<T> unavailable,
java.util.function.Function<T, RedisCapabilityObserver.Classification> classifier) {
return observer.observe(
RedisCapabilityObservationEvent.Capability.IDEMPOTENCY,
RedisCapabilityObservationEvent.Role.COORDINATION,
operation,
() ->
mutationOpen(
operationId, program, owner, arguments, mapper, indeterminate, unavailable),
classifier);
}
private <T> T mutationOpen(
String operationId,
RedisProgramId program,
IdempotencyOwner owner,
List<byte[]> arguments,
java.util.function.Function<RedisIdempotencyProgramReply, T> mapper,
java.util.function.Function<String, T> indeterminate,
java.util.function.Supplier<T> unavailable) {
try {
RedisIdempotencyProgramReply reply = execute(program, owner.scope(), arguments);
if ("STATE_INCOMPATIBLE".equals(reply.status()) || "INVALID".equals(reply.status())) {
return unavailable.get();
}
return mapper.apply(reply);
} catch (RedisCommandFailureException failure) {
return failure.certainty() == RedisCommandFailureException.Certainty.INDETERMINATE
? indeterminate.apply(operationId)
: unavailable.get();
} catch (RedisProgramCompatibilityException
| IllegalArgumentException
| IllegalStateException failure) {
return unavailable.get();
}
}
private RedisIdempotencyProgramReply execute(
RedisProgramId program,
dev.caskeleton.application.idempotency.IdempotencyScope scope,
List<byte[]> arguments) {
return lifecycle.withOpen(
() -> programs.execute(new ProgramInvocation(program, keys.physicalKey(scope), arguments)));
}
private static IdempotencyOwner owner(IdempotencyClaimRequest request, long attempt) {
return new IdempotencyOwner(request.scope(), request.claimAttempt().ownerToken(), attempt);
}
private static IdempotencyOwner inspectionOwner(
IdempotencyInspectionRequest request, long attempt) {
return new IdempotencyOwner(request.scope(), request.claimAttempt().ownerToken(), attempt);
}
private static Instant instant(long epochMillis) {
return Instant.ofEpochMilli(epochMillis);
}
private static RedisCapabilityObserver.Classification classifyClaim(
IdempotencyClaimOutcome outcome) {
if (outcome instanceof IdempotencyClaimOutcome.Acquired
|| outcome instanceof IdempotencyClaimOutcome.ReplayedAcquire
|| outcome instanceof IdempotencyClaimOutcome.TakenOverClaimed
|| outcome instanceof IdempotencyClaimOutcome.CompletedReplay) {
return definite(RedisCapabilityObservationEvent.Outcome.SUCCESS);
}
if (outcome instanceof IdempotencyClaimOutcome.InProgress
|| outcome instanceof IdempotencyClaimOutcome.RecoveryRequired) {
return definite(RedisCapabilityObservationEvent.Outcome.CONTENDED);
}
if (outcome instanceof IdempotencyClaimOutcome.FingerprintMismatch
|| outcome instanceof IdempotencyClaimOutcome.OwnerOperationConflict) {
return definite(RedisCapabilityObservationEvent.Outcome.CONFLICT);
}
if (outcome instanceof IdempotencyClaimOutcome.Indeterminate) {
return indeterminate();
}
return notApplied();
}
private static RedisCapabilityObserver.Classification classifyInspection(
IdempotencyInspection outcome) {
if (outcome instanceof IdempotencyInspection.Unavailable) {
return notApplied();
}
if (outcome instanceof IdempotencyInspection.FingerprintMismatch
|| outcome instanceof IdempotencyInspection.OperationConflict) {
return definite(RedisCapabilityObservationEvent.Outcome.CONFLICT);
}
if (outcome instanceof IdempotencyInspection.InProgressOther) {
return definite(RedisCapabilityObservationEvent.Outcome.CONTENDED);
}
if (outcome instanceof IdempotencyInspection.Absent) {
return definite(RedisCapabilityObservationEvent.Outcome.MISS);
}
return definite(RedisCapabilityObservationEvent.Outcome.SUCCESS);
}
static RedisCapabilityObserver.Classification classifyStart(IdempotencyStartOutcome outcome) {
return switch (outcome.status()) {
case STARTED, ALREADY_STARTED_SAME_OPERATION ->
definite(RedisCapabilityObservationEvent.Outcome.SUCCESS);
case ABSENT -> definite(RedisCapabilityObservationEvent.Outcome.MISS);
case NOT_OWNER -> definite(RedisCapabilityObservationEvent.Outcome.DENIED);
case NOT_CLAIMED, OPERATION_CONFLICT ->
definite(RedisCapabilityObservationEvent.Outcome.CONFLICT);
case INDETERMINATE -> indeterminate();
case UNAVAILABLE -> notApplied();
};
}
static RedisCapabilityObserver.Classification classifyRenew(IdempotencyRenewOutcome outcome) {
return switch (outcome.status()) {
case RENEWED, ALREADY_RENEWED_SAME_OPERATION ->
definite(RedisCapabilityObservationEvent.Outcome.SUCCESS);
case ABSENT -> definite(RedisCapabilityObservationEvent.Outcome.MISS);
case NOT_OWNER -> definite(RedisCapabilityObservationEvent.Outcome.DENIED);
case NOT_IN_PROGRESS, OPERATION_CONFLICT ->
definite(RedisCapabilityObservationEvent.Outcome.CONFLICT);
case INDETERMINATE -> indeterminate();
case UNAVAILABLE -> notApplied();
};
}
static RedisCapabilityObserver.Classification classifyComplete(
IdempotencyCompleteOutcome outcome) {
return switch (outcome.status()) {
case COMPLETED, ALREADY_COMPLETED_SAME_RESULT ->
definite(RedisCapabilityObservationEvent.Outcome.SUCCESS);
case ABSENT -> definite(RedisCapabilityObservationEvent.Outcome.MISS);
case NOT_OWNER -> definite(RedisCapabilityObservationEvent.Outcome.DENIED);
case RESPONSE_CONFLICT, NOT_IN_PROGRESS, OPERATION_CONFLICT ->
definite(RedisCapabilityObservationEvent.Outcome.CONFLICT);
case INDETERMINATE -> indeterminate();
case UNAVAILABLE -> notApplied();
};
}
static RedisCapabilityObserver.Classification classifyFail(IdempotencyFailOutcome outcome) {
return switch (outcome.status()) {
case MARKED_RETRYABLE, MARKED_ABANDONED, ALREADY_MARKED_SAME_OPERATION ->
definite(RedisCapabilityObservationEvent.Outcome.SUCCESS);
case ABSENT -> definite(RedisCapabilityObservationEvent.Outcome.MISS);
case NOT_OWNER -> definite(RedisCapabilityObservationEvent.Outcome.DENIED);
case NOT_IN_PROGRESS, OPERATION_CONFLICT ->
definite(RedisCapabilityObservationEvent.Outcome.CONFLICT);
case INDETERMINATE -> indeterminate();
case UNAVAILABLE -> notApplied();
};
}
static RedisCapabilityObserver.Classification classifyRelease(IdempotencyReleaseOutcome outcome) {
return switch (outcome.status()) {
case RELEASED_BEFORE_EXECUTION, ALREADY_RELEASED_SAME_OPERATION ->
definite(RedisCapabilityObservationEvent.Outcome.SUCCESS);
case ABSENT -> definite(RedisCapabilityObservationEvent.Outcome.MISS);
case NOT_OWNER -> definite(RedisCapabilityObservationEvent.Outcome.DENIED);
case EXECUTION_ALREADY_STARTED, OPERATION_CONFLICT ->
definite(RedisCapabilityObservationEvent.Outcome.CONFLICT);
case INDETERMINATE -> indeterminate();
case UNAVAILABLE -> notApplied();
};
}
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 notApplied() {
return new RedisCapabilityObserver.Classification(
RedisCapabilityObservationEvent.Outcome.UNAVAILABLE,
RedisCapabilityObservationEvent.Certainty.NOT_APPLIED);
}
private static void positive(Duration value, Duration maximum, String field) {
Objects.requireNonNull(value, field + " must be non-null");
if (value.isZero() || value.isNegative() || value.compareTo(maximum) > 0) {
throw new IllegalArgumentException(field + " must be positive and bounded");
}
}
private static byte[] ascii(long value) {
return Long.toString(value).getBytes(StandardCharsets.US_ASCII);
}
private static byte[] ascii(String value) {
return Objects.requireNonNull(value, "value must be non-null")
.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();
}
}
@Override
public void close() {
lifecycle.close(keys::close);
}
boolean destroyed() {
return lifecycle.closed() && keys.destroyed();
}
}
@@ -0,0 +1,25 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.Objects;
/** Generates caller-retained owner tokens without embedding request scope data. */
final class RedisIdempotencyTokenGenerator {
private final SecureRandom random;
RedisIdempotencyTokenGenerator(SecureRandom random) {
this.random = Objects.requireNonNull(random, "random must be non-null");
}
static RedisIdempotencyTokenGenerator secure() {
return new RedisIdempotencyTokenGenerator(new SecureRandom());
}
String next() {
byte[] entropy = new byte[24];
random.nextBytes(entropy);
return Base64.getUrlEncoder().withoutPadding().encodeToString(entropy);
}
}
@@ -0,0 +1,27 @@
package dev.caskeleton.adapter.outbound.cache.redis;
/**
* Adapter-private bounded invalidation transport used by canonical CACHE-role composition.
*
* <p>Delivery is intentionally at-most-once. Consumers must treat disconnect as a signal to evict
* or bypass L1 until their own recovery policy declares the subscription healthy again.
*/
interface RedisInvalidationTransport {
long publish(byte[] channel, byte[] message);
Subscription subscribe(byte[] channel, Listener listener);
interface Listener {
void onMessage(byte[] wireMessage);
void onDisconnected();
}
interface Subscription extends AutoCloseable {
@Override
void close();
}
}
@@ -0,0 +1,82 @@
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 java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
/** Produces one Cluster-safe HMAC-pseudonymous key for an efficiency lease resource. */
final class RedisLeaseKeyFactory implements AutoCloseable {
private final String application;
private final String environment;
private final int hashKeyVersion;
private final int keyVersion;
private final byte[] hmacSecret;
private final AtomicBoolean closed = new AtomicBoolean();
RedisLeaseKeyFactory(
String application,
String environment,
int hashKeyVersion,
int keyVersion,
byte[] hmacSecret) {
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("lease key HMAC secret requires at least 32 bytes");
}
}
byte[] physicalKey(String purpose, String resourceDigest) {
if (closed.get()) {
throw new IllegalStateException("lease key material is closed");
}
RedisKeyNamespace namespace =
new RedisKeyNamespace(
application,
environment,
"lease",
Objects.requireNonNull(purpose, "purpose must be non-null"),
hashKeyVersion,
keyVersion,
"owner",
512);
RedisKeyDigest digest =
RedisKeyDigest.sensitive(
hashKeyVersion, hmacSecret, List.of(utf8(purpose), utf8(resourceDigest)));
return utf8(RedisKeyBuilder.build(namespace, digest));
}
@Override
public void close() {
if (closed.compareAndSet(false, true)) {
Arrays.fill(hmacSecret, (byte) 0);
}
}
boolean destroyed() {
if (!closed.get()) {
return false;
}
for (byte value : hmacSecret) {
if (value != 0) {
return false;
}
}
return true;
}
private static byte[] utf8(String value) {
return value.getBytes(StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,44 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.function.Supplier;
/** Prevents lease commands from racing provider shutdown and secret destruction. */
final class RedisLeaseLifecycle {
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 efficiency-lease provider is closed");
}
return operation.get();
} finally {
lock.readLock().unlock();
}
}
void close(Runnable destroy) {
lock.writeLock().lock();
try {
if (!closed) {
destroy.run();
closed = true;
}
} finally {
lock.writeLock().unlock();
}
}
boolean closed() {
lock.readLock().lock();
try {
return closed;
} finally {
lock.readLock().unlock();
}
}
}
@@ -0,0 +1,81 @@
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 efficiency-lease six-field protocol. */
final class RedisLeaseProgramExecutor {
private static final long MAXIMUM_EXACT_LUA_INTEGER = 9_007_199_254_740_991L;
private final RedisProgramCatalog catalog;
private final RedisStructuredCommands commands;
RedisLeaseProgramExecutor(RedisProgramCatalog catalog, RedisStructuredCommands commands) {
this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null");
this.commands = Objects.requireNonNull(commands, "commands must be non-null");
}
RedisLeaseProgramReply execute(RedisEfficiencyLeaseProvider.ProgramInvocation material) {
return executeOwned(material);
}
RedisLeaseProgramReply execute(RedisEfficiencyLeaseHandle.ProgramInvocation material) {
return executeOwned(material);
}
private RedisLeaseProgramReply executeOwned(RedisCatalogProgramMaterial material) {
RedisProgramId id = material.programId();
RedisProgramDescriptor descriptor = catalog.descriptor(id);
RedisCatalogProgramInvocation invocation = catalog.capabilityInvocation(material);
List<byte[]> result = RedisScriptRecovery.evalMulti(commands, invocation);
if (result == null || result.size() != descriptor.replyFieldCount()) {
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 RedisLeaseProgramReply(
status,
unsigned(result.get(1), id),
unsigned(result.get(2), id),
unsigned(result.get(3), id),
unsigned(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 failure) {
throw incompatible(id);
}
}
private static RedisProgramCompatibilityException incompatible(RedisProgramId id) {
return new RedisProgramCompatibilityException(id, "<malformed-lease-reply>");
}
}
@@ -0,0 +1,10 @@
package dev.caskeleton.adapter.outbound.cache.redis;
/** Fixed six-field efficiency-lease program reply. */
record RedisLeaseProgramReply(
String status,
long remainingMillis,
long serverNowMillis,
long serverExpiryMillis,
long stateRevision,
String operationId) {}
@@ -0,0 +1,64 @@
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 and key namespace for the Redis efficiency-lease capability.
*
* <p>Topology, credentials, ACL and TLS stay in the provider/deployment registry. This settings
* group only names the HMAC material and lease-specific local validity assumptions.
*/
@ConfigurationProperties(prefix = "ca-skeleton.capabilities.lease")
public record RedisLeaseSettings(
String provider,
String keyHmacSecretReference,
String namespaceApplication,
String namespaceEnvironment,
int hashKeyVersion,
int keyVersion,
Duration driftBudget) {
private static final Duration MAXIMUM_DRIFT_BUDGET = Duration.ofSeconds(5);
@ConstructorBinding
public RedisLeaseSettings {
provider = provider == null ? "" : provider.trim();
keyHmacSecretReference = keyHmacSecretReference == null ? "" : keyHmacSecretReference.trim();
namespaceApplication = defaultText(namespaceApplication, "ca-skeleton");
namespaceEnvironment = defaultText(namespaceEnvironment, "local");
hashKeyVersion = hashKeyVersion == 0 ? 1 : hashKeyVersion;
keyVersion = keyVersion == 0 ? 1 : keyVersion;
driftBudget = driftBudget == null ? Duration.ofMillis(10) : driftBudget;
if (driftBudget.isNegative()
|| driftBudget.compareTo(MAXIMUM_DRIFT_BUDGET) > 0
|| !Duration.ofMillis(driftBudget.toMillis()).equals(driftBudget)) {
throw new IllegalArgumentException(
"lease driftBudget must be non-negative, at most 5 seconds, and use whole milliseconds");
}
new RedisKeyNamespace(
namespaceApplication,
namespaceEnvironment,
"lease",
"validation",
hashKeyVersion,
keyVersion,
"owner",
512);
}
void validateActive() {
if (!"redis".equals(provider)) {
throw new IllegalArgumentException(
"lease provider must be redis when this adapter is active");
}
RedisSecretReference.parse(keyHmacSecretReference);
}
private static String defaultText(String value, String fallback) {
return value == null || value.isBlank() ? fallback : value.trim();
}
}
@@ -0,0 +1,21 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.Objects;
/** Allocates caller-retained owner tokens without a Redis side effect. */
final class RedisLeaseTokenGenerator {
private final SecureRandom random;
RedisLeaseTokenGenerator(SecureRandom random) {
this.random = Objects.requireNonNull(random, "random must be non-null");
}
String next() {
byte[] entropy = new byte[24];
random.nextBytes(entropy);
return Base64.getUrlEncoder().withoutPadding().encodeToString(entropy);
}
}
@@ -0,0 +1,7 @@
package dev.caskeleton.adapter.outbound.cache.redis;
/** Internal signal preserving interruption while a bounded lease wait is cancelled. */
final class RedisLeaseWaitInterruptedException extends RuntimeException {
private static final long serialVersionUID = 1L;
}
@@ -0,0 +1,20 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.time.Duration;
import java.util.concurrent.locks.LockSupport;
@FunctionalInterface
interface RedisLeaseWaitStrategy {
void await(Duration duration);
static RedisLeaseWaitStrategy parking() {
return duration -> {
LockSupport.parkNanos(duration.toNanos());
if (Thread.interrupted()) {
Thread.currentThread().interrupt();
throw new RedisLeaseWaitInterruptedException();
}
};
}
}
@@ -0,0 +1,87 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.time.Duration;
import java.util.Base64;
import java.util.Objects;
/**
* Explicit migration-only settings for the retired standalone rate-limit runtime.
*
* <p>This type is not a configuration-properties target and cannot become the production primary.
*/
record RedisLegacyStandaloneSettings(
String host,
int port,
String password,
String legacyKeyHmacSecret,
Duration commandTimeout,
int maximumCommandBytes,
int maximumQueuedCommands,
int maximumInFlightBytes,
String namespaceApplication,
String namespaceEnvironment) {
private static final Duration MAXIMUM_TIMEOUT = Duration.ofSeconds(30);
RedisLegacyStandaloneSettings {
host = host == null ? "" : host.trim();
port = port == 0 ? 6379 : port;
password = password == null ? "" : password;
legacyKeyHmacSecret = legacyKeyHmacSecret == null ? "" : legacyKeyHmacSecret;
commandTimeout = commandTimeout == null ? Duration.ofSeconds(1) : commandTimeout;
maximumCommandBytes = maximumCommandBytes == 0 ? 16_384 : maximumCommandBytes;
maximumQueuedCommands = maximumQueuedCommands == 0 ? 32 : maximumQueuedCommands;
maximumInFlightBytes = maximumInFlightBytes == 0 ? 1_048_576 : maximumInFlightBytes;
namespaceApplication = defaultText(namespaceApplication, "ca-skeleton");
namespaceEnvironment = defaultText(namespaceEnvironment, "local");
if (host.length() > 253
|| host.chars().anyMatch(Character::isWhitespace)
|| host.contains("/")
|| host.contains("\\")) {
throw new IllegalArgumentException("legacy rate-limit Redis host is invalid");
}
if (port < 1 || port > 65_535) {
throw new IllegalArgumentException("legacy rate-limit Redis port must be in 1..65535");
}
Objects.requireNonNull(commandTimeout, "commandTimeout must be non-null");
if (commandTimeout.isZero()
|| commandTimeout.isNegative()
|| commandTimeout.compareTo(MAXIMUM_TIMEOUT) > 0) {
throw new IllegalArgumentException("legacy rate-limit Redis command timeout is invalid");
}
if (maximumCommandBytes < 16_384 || maximumCommandBytes > 65_536) {
throw new IllegalArgumentException("legacy rate-limit Redis command bytes are invalid");
}
if (maximumQueuedCommands < 1 || maximumQueuedCommands > 4096) {
throw new IllegalArgumentException("legacy rate-limit Redis queue bound is invalid");
}
if (maximumInFlightBytes < maximumCommandBytes || maximumInFlightBytes > 268_435_456) {
throw new IllegalArgumentException("legacy rate-limit Redis byte budget is invalid");
}
slug(namespaceApplication, "legacy rate-limit namespace application");
slug(namespaceEnvironment, "legacy rate-limit namespace environment");
}
byte[] hmacSecret() {
try {
byte[] decoded = Base64.getDecoder().decode(legacyKeyHmacSecret);
if (decoded.length < 32) {
throw new IllegalArgumentException("legacy HMAC material is too short");
}
return decoded;
} catch (IllegalArgumentException ignored) {
throw new IllegalArgumentException("legacy HMAC material is invalid");
}
}
private static void slug(String value, String field) {
if (!value.matches("[a-z][a-z0-9-]{0,62}")) {
throw new IllegalArgumentException(field + " has invalid format");
}
}
private static String defaultText(String value, String fallback) {
return value == null || value.isBlank() ? fallback : value.trim();
}
}
@@ -0,0 +1,68 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings;
import io.lettuce.core.ClientOptions;
import io.lettuce.core.SocketOptions;
import io.lettuce.core.SslOptions;
import io.lettuce.core.TimeoutOptions;
import io.lettuce.core.cluster.ClusterClientOptions;
import io.lettuce.core.cluster.ClusterTopologyRefreshOptions;
/** Builds bounded no-replay Lettuce options for standalone/Sentinel and Cluster clients. */
final class RedisLettuceClientOptionsFactory {
ClientOptions clientOptions(RedisClientRuntimeSettings settings) {
return clientOptions(settings, null);
}
public ClientOptions clientOptions(
RedisClientRuntimeSettings settings, SslOptions explicitSslOptions) {
SocketOptions socketOptions =
SocketOptions.builder().connectTimeout(settings.connectTimeout()).build();
ClientOptions.Builder builder =
ClientOptions.builder()
.autoReconnect(true)
.replayFilter(ignored -> true)
.disconnectedBehavior(ClientOptions.DisconnectedBehavior.REJECT_COMMANDS)
.requestQueueSize(settings.maximumQueuedCommands())
.socketOptions(socketOptions)
.timeoutOptions(TimeoutOptions.enabled(settings.commandTimeout()));
if (explicitSslOptions != null) {
builder.sslOptions(explicitSslOptions);
}
return builder.build();
}
ClusterClientOptions clusterClientOptions(RedisClientRuntimeSettings settings) {
return clusterClientOptions(settings, null);
}
public ClusterClientOptions clusterClientOptions(
RedisClientRuntimeSettings settings, SslOptions explicitSslOptions) {
SocketOptions socketOptions =
SocketOptions.builder().connectTimeout(settings.connectTimeout()).build();
ClusterTopologyRefreshOptions topologyRefresh =
ClusterTopologyRefreshOptions.builder()
.enablePeriodicRefresh(settings.clusterTopologyRefreshPeriod())
.enableAllAdaptiveRefreshTriggers()
.adaptiveRefreshTriggersTimeout(settings.commandTimeout())
.closeStaleConnections(true)
.dynamicRefreshSources(true)
.build();
ClusterClientOptions.Builder builder = ClusterClientOptions.builder();
builder.autoReconnect(true);
builder.replayFilter(ignored -> true);
builder.disconnectedBehavior(ClientOptions.DisconnectedBehavior.REJECT_COMMANDS);
builder.requestQueueSize(settings.maximumQueuedCommands());
builder.socketOptions(socketOptions);
builder.timeoutOptions(TimeoutOptions.enabled(settings.commandTimeout()));
if (explicitSslOptions != null) {
builder.sslOptions(explicitSslOptions);
}
builder.maxRedirects(settings.clusterMaximumRedirects());
builder.topologyRefreshOptions(topologyRefresh);
builder.validateClusterNodeMembership(true);
return builder.build();
}
}
@@ -0,0 +1,213 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings;
import dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings;
import dev.caskeleton.adapter.outbound.cache.redis.security.DestroyableRedisCredentialsProvider;
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 io.lettuce.core.RedisURI;
import io.lettuce.core.SslVerifyMode;
import java.time.Clock;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.function.Function;
/** Converts validated topology settings into deterministic credential-bearing Lettuce URIs. */
final class RedisLettuceUriFactory {
private final RedisCredentialMaterialProvider materialProvider;
private final Clock clock;
RedisLettuceUriFactory(RedisCredentialMaterialProvider materialProvider, Clock clock) {
this.materialProvider =
Objects.requireNonNull(materialProvider, "materialProvider must be non-null");
this.clock = Objects.requireNonNull(clock, "clock must be non-null");
}
RedisLettuceUris create(
RedisDeploymentSettings deployment, RedisClientRuntimeSettings clientSettings) {
Objects.requireNonNull(deployment, "deployment must be non-null");
Objects.requireNonNull(clientSettings, "clientSettings must be non-null");
return switch (deployment) {
case RedisDeploymentSettings.Standalone standalone -> standalone(standalone, clientSettings);
case RedisDeploymentSettings.Sentinel sentinel -> sentinelDiscovery(sentinel, clientSettings);
case RedisDeploymentSettings.Cluster cluster -> cluster(cluster, clientSettings);
};
}
/**
* Applies an operation to newly created credential-owning URIs.
*
* <p>A successful operation assumes ownership of the supplied URI set. If the operation fails,
* this factory destroys every credential before propagating the failure.
*/
<T> T mapOwnedUris(
RedisDeploymentSettings deployment,
RedisClientRuntimeSettings clientSettings,
Function<RedisLettuceUris, T> operation) {
Objects.requireNonNull(operation, "operation must be non-null");
RedisLettuceUris uris = create(deployment, clientSettings);
try {
return operation.apply(uris);
} catch (RuntimeException exception) {
uris.close();
throw exception;
}
}
RedisLettuceUris.SentinelDiscovery createSentinelDiscovery(
RedisDeploymentSettings.Sentinel deployment, RedisClientRuntimeSettings clientSettings) {
Objects.requireNonNull(deployment, "deployment must be non-null");
Objects.requireNonNull(clientSettings, "clientSettings must be non-null");
return sentinelDiscovery(deployment, clientSettings);
}
RedisLettuceUris.SentinelData createSentinelData(
RedisDeploymentSettings.Sentinel deployment,
RedisSentinelMasterDiscovery.DataEndpoint endpoint,
RedisClientRuntimeSettings clientSettings) {
Objects.requireNonNull(deployment, "deployment must be non-null");
Objects.requireNonNull(endpoint, "approved endpoint must be non-null");
Objects.requireNonNull(clientSettings, "clientSettings must be non-null");
validateFullTls(deployment.dataTls(), "Redis Sentinel data-node TLS");
return withPassword(
deployment.dataAuthentication(),
credentials ->
new RedisLettuceUris.SentinelData(
dataUri(
new RedisDeploymentSettings.Endpoint(endpoint.host(), endpoint.port()),
deployment.database(),
credentials,
deployment.dataTls(),
clientSettings)));
}
<T> T mapOwnedSentinelData(
RedisDeploymentSettings.Sentinel deployment,
RedisSentinelMasterDiscovery.DataEndpoint endpoint,
RedisClientRuntimeSettings clientSettings,
Function<RedisLettuceUris.SentinelData, T> operation) {
Objects.requireNonNull(operation, "operation must be non-null");
RedisLettuceUris.SentinelData uris = createSentinelData(deployment, endpoint, clientSettings);
try {
return operation.apply(uris);
} catch (RuntimeException exception) {
uris.close();
throw exception;
}
}
private RedisLettuceUris.Standalone standalone(
RedisDeploymentSettings.Standalone deployment, RedisClientRuntimeSettings clientSettings) {
if (deployment.endpoints().size() != 1) {
throw new IllegalArgumentException(
"Redis standalone deployment must contain exactly one data endpoint");
}
RedisDeploymentSettings.Endpoint endpoint = deployment.endpoints().getFirst();
RedisURI dataUri =
withPassword(
deployment.dataAuthentication(),
credentials ->
dataUri(
endpoint,
deployment.database(),
credentials,
deployment.dataTls(),
clientSettings));
return new RedisLettuceUris.Standalone(dataUri);
}
private RedisLettuceUris.SentinelDiscovery sentinelDiscovery(
RedisDeploymentSettings.Sentinel deployment, RedisClientRuntimeSettings clientSettings) {
validateFullTls(deployment.sentinelTls(), "Redis Sentinel discovery TLS");
return withPassword(
deployment.sentinelAuthentication(),
sentinelCredentials -> {
List<RedisURI> discoveryUris = new ArrayList<>();
for (RedisDeploymentSettings.Endpoint endpoint : deployment.sentinelEndpoints()) {
discoveryUris.add(
dataUri(
endpoint, 0, sentinelCredentials, deployment.sentinelTls(), clientSettings));
}
return new RedisLettuceUris.SentinelDiscovery(discoveryUris);
});
}
private RedisLettuceUris.Cluster cluster(
RedisDeploymentSettings.Cluster deployment, RedisClientRuntimeSettings clientSettings) {
if (deployment.database() != 0) {
throw new IllegalArgumentException("Redis Cluster data URI must use database 0");
}
return withPassword(
deployment.dataAuthentication(),
credentials -> {
List<RedisURI> seedUris =
deployment.seedEndpoints().stream()
.map(
endpoint ->
dataUri(endpoint, 0, credentials, deployment.dataTls(), clientSettings))
.toList();
return new RedisLettuceUris.Cluster(seedUris);
});
}
private RedisURI dataUri(
RedisDeploymentSettings.Endpoint endpoint,
int database,
DestroyableRedisCredentialsProvider credentials,
RedisDeploymentSettings.Tls tls,
RedisClientRuntimeSettings clientSettings) {
validateFullTls(tls, "Redis data URI TLS");
return RedisURI.Builder.redis(endpoint.host(), endpoint.port())
.withAuthentication(credentials)
.withDatabase(database)
.withClientName(clientSettings.clientName())
.withTimeout(clientSettings.commandTimeout())
.withSsl(true)
.withVerifyPeer(SslVerifyMode.FULL)
.build();
}
private void validateFullTls(RedisDeploymentSettings.Tls tls, String field) {
Objects.requireNonNull(tls, field + " must be non-null");
if (!tls.enabled() || !tls.verifyHostname()) {
throw new IllegalArgumentException(field + " must use TLS with FULL hostname verification");
}
RedisSecretReference.parse(tls.trustBundleReference());
}
private <T> T withPassword(
RedisDeploymentSettings.Authentication authentication,
Function<DestroyableRedisCredentialsProvider, T> operation) {
RedisSecretReference reference = RedisSecretReference.parse(authentication.passwordReference());
try (VersionedRedisCredentialMaterial material = resolve(reference)) {
if (material == null) {
throw new IllegalStateException("Redis credential material resolution returned no value");
}
if (material.isExpiredAt(clock.instant())) {
throw new IllegalStateException("Redis credential material is expired");
}
return material.useSecret(
password -> {
DestroyableRedisCredentialsProvider credentials =
DestroyableRedisCredentialsProvider.from(authentication.username(), password);
try {
return operation.apply(credentials);
} catch (RuntimeException exception) {
credentials.destroy();
throw exception;
}
});
}
}
private VersionedRedisCredentialMaterial resolve(RedisSecretReference reference) {
try {
return materialProvider.resolve(reference);
} catch (RuntimeException ignored) {
throw new IllegalStateException("Redis credential material resolution failed");
}
}
}
@@ -0,0 +1,119 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import io.lettuce.core.RedisURI;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
import javax.security.auth.Destroyable;
/** Topology-specific, credential-bearing Lettuce URI configuration. */
sealed interface RedisLettuceUris extends AutoCloseable
permits RedisLettuceUris.Standalone,
RedisLettuceUris.SentinelDiscovery,
RedisLettuceUris.SentinelData,
RedisLettuceUris.Cluster {
@Override
void close();
final class Standalone implements RedisLettuceUris {
private final RedisURI dataUri;
private final AtomicBoolean closed = new AtomicBoolean();
Standalone(RedisURI dataUri) {
this.dataUri = Objects.requireNonNull(dataUri, "dataUri must be non-null");
}
RedisURI dataUri() {
return dataUri;
}
@Override
public void close() {
if (closed.compareAndSet(false, true)) {
destroyCredentials(List.of(dataUri));
}
}
}
final class SentinelDiscovery implements RedisLettuceUris {
private final List<RedisURI> discoveryUris;
private final AtomicBoolean closed = new AtomicBoolean();
SentinelDiscovery(List<RedisURI> discoveryUris) {
this.discoveryUris = List.copyOf(discoveryUris);
}
List<RedisURI> discoveryUris() {
return discoveryUris;
}
@Override
public void close() {
if (closed.compareAndSet(false, true)) {
destroyCredentials(discoveryUris);
}
}
}
final class SentinelData implements RedisLettuceUris {
private final RedisURI dataUri;
private final AtomicBoolean closed = new AtomicBoolean();
SentinelData(RedisURI dataUri) {
this.dataUri = Objects.requireNonNull(dataUri, "dataUri must be non-null");
}
RedisURI dataUri() {
return dataUri;
}
@Override
public void close() {
if (closed.compareAndSet(false, true)) {
destroyCredentials(List.of(dataUri));
}
}
}
final class Cluster implements RedisLettuceUris {
private final List<RedisURI> seedUris;
private final AtomicBoolean closed = new AtomicBoolean();
Cluster(List<RedisURI> seedUris) {
this.seedUris = List.copyOf(seedUris);
}
List<RedisURI> seedUris() {
return seedUris;
}
@Override
public void close() {
if (closed.compareAndSet(false, true)) {
destroyCredentials(seedUris);
}
}
}
static void destroyCredentials(List<RedisURI> uris) {
var destroyed = Collections.newSetFromMap(new IdentityHashMap<Destroyable, Boolean>());
for (RedisURI uri : uris) {
if (uri.getCredentialsProvider() instanceof Destroyable destroyable
&& destroyed.add(destroyable)) {
try {
destroyable.destroy();
} catch (javax.security.auth.DestroyFailedException ignored) {
// The adapter-owned providers do not throw; remain fail-safe for alternate
// implementations.
}
}
}
}
}
@@ -0,0 +1,56 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.time.Duration;
import java.util.List;
import java.util.Objects;
/** Non-blocking bounded list helpers; this is not a durable messaging abstraction. */
final class RedisListPrimitives {
private final RedisPrimitiveCatalog catalog;
private final RedisPrimitiveExecutor executor;
private final RedisPrimitiveDescriptor admission;
RedisListPrimitives(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.LIST_ADMIT);
}
RedisPrimitiveKey key(String slot, String identity) {
return catalog.keyFactory(RedisPrimitiveId.LIST_ADMIT).key(slot, identity);
}
RedisPrimitiveValue value(String value) {
return RedisPrimitiveValue.utf8(value, admission.maximumValueBytes());
}
RedisPrimitiveMutationResult admit(
RedisPrimitiveKey key, RedisPrimitiveValue value, Duration initialTimeToLive) {
return executor.mutate(
RedisPrimitiveId.LIST_ADMIT,
List.of(key),
new RedisPrimitiveInvocation.CapacityArguments(
value,
RedisPrimitiveLimit.of(admission.maximumElements(), admission),
initialTimeToLive));
}
RedisPrimitiveReply pop(RedisPrimitiveKey key) {
return executor.execute(
RedisPrimitiveId.LIST_POP, List.of(key), RedisPrimitiveInvocation.NoArguments.INSTANCE);
}
RedisPrimitiveMutationResult trimNewest(RedisPrimitiveKey key, int retainCount) {
RedisPrimitiveDescriptor descriptor =
catalog.descriptor(RedisPrimitiveId.LIST_TRIM_FIXED_BOUNDS);
RedisPrimitiveLimit retain = RedisPrimitiveLimit.of(retainCount, descriptor);
return executor.mutate(
RedisPrimitiveId.LIST_TRIM_FIXED_BOUNDS,
List.of(key),
new RedisPrimitiveInvocation.AtomicArguments(
List.of(
RedisPrimitiveValue.utf8(Integer.toString(retain.value()), 128),
RedisPrimitiveValue.utf8(Integer.toString(descriptor.maximumElements()), 128))));
}
}
@@ -0,0 +1,51 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.time.Duration;
import java.util.Objects;
/**
* Finite accounting, expiry, reconciliation and subscriber bounds for the optional cache-only L1.
*
* <p>The weight budget is a conservative admission/eviction accounting proxy, not a JVM heap
* reservation or proof of an exact object-layout byte count.
*/
record RedisLocalCachePolicy(
int maximumEntries,
long maximumWeightBytes,
long maximumEntryWeightBytes,
Duration localTimeToLive,
Duration generationRecheckInterval,
int invalidationQueueCapacity) {
private static final int MAXIMUM_ENTRIES = 1_000_000;
private static final long MAXIMUM_WEIGHT_BYTES = 1_073_741_824L;
private static final Duration MAXIMUM_LOCAL_TTL = Duration.ofHours(1);
private static final int MAXIMUM_QUEUE_CAPACITY = 65_536;
RedisLocalCachePolicy {
if (maximumEntries < 1 || maximumEntries > MAXIMUM_ENTRIES) {
throw new IllegalArgumentException("maximumEntries must be in 1..1000000");
}
if (maximumWeightBytes < 1 || maximumWeightBytes > MAXIMUM_WEIGHT_BYTES) {
throw new IllegalArgumentException("maximumWeightBytes must be in 1..1073741824");
}
if (maximumEntryWeightBytes < 1 || maximumEntryWeightBytes > maximumWeightBytes) {
throw new IllegalArgumentException(
"maximumEntryWeightBytes must be positive and not exceed maximumWeightBytes");
}
localTimeToLive = positive(localTimeToLive, MAXIMUM_LOCAL_TTL, "localTimeToLive");
generationRecheckInterval =
positive(generationRecheckInterval, localTimeToLive, "generationRecheckInterval");
if (invalidationQueueCapacity < 1 || invalidationQueueCapacity > MAXIMUM_QUEUE_CAPACITY) {
throw new IllegalArgumentException("invalidationQueueCapacity must be in 1..65536");
}
}
private static Duration positive(Duration value, Duration maximum, String field) {
Objects.requireNonNull(value, field + " must be non-null");
if (value.isZero() || value.isNegative() || value.compareTo(maximum) > 0) {
throw new IllegalArgumentException(field + " must be positive and bounded");
}
return value;
}
}
@@ -0,0 +1,526 @@
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.CacheObservationEvent;
import dev.caskeleton.application.cache.CacheObservationPort;
import dev.caskeleton.application.cache.CacheRecordMetadata;
import dev.caskeleton.application.cache.CacheRecordOutcome;
import dev.caskeleton.application.cache.CacheRegionPort;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.DateTimeException;
import java.time.Duration;
import java.time.Instant;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.function.Consumer;
/**
* Optional bounded L1 decorator for semantic cache values only.
*
* <p>Pub/Sub messages are best-effort eviction hints. A local entry never outlives either its own
* TTL or the L2 envelope hard expiry, and the region generation is periodically reconciled.
* Disconnect/queue overflow flushes every local entry and requires a successful generation read
* before L1 can admit data again.
*/
final class RedisLocalCacheRegion implements CacheRegionPort<String, String>, AutoCloseable {
private static final long ENTRY_OVERHEAD_BYTES = 128;
private final String cacheName;
private final RedisCacheL2Region l2;
private final RedisLocalCachePolicy policy;
private final Clock clock;
private final CacheObservationPort observations;
private final String invalidationChannel;
private final RedisCacheInvalidationMessage.Codec messageCodec;
private final Consumer<String> publisher;
private final RedisCacheInvalidationSubscriber invalidationSubscriber;
private final LinkedHashMap<String, LocalEntry> entries = new LinkedHashMap<>(16, 0.75f, true);
private long localWeightBytes;
private String observedGeneration;
private Instant nextGenerationRecheck = Instant.MIN;
private boolean forceGenerationRecheck = true;
private boolean generationProbeInProgress;
private long invalidationEpoch;
RedisLocalCacheRegion(
String cacheName,
RedisCacheL2Region l2,
RedisLocalCachePolicy policy,
Clock clock,
CacheObservationPort observations,
String invalidationChannel,
RedisCacheInvalidationMessage.Codec messageCodec,
Consumer<String> publisher) {
this.cacheName = boundedCacheName(cacheName);
this.l2 = Objects.requireNonNull(l2, "l2 must be non-null");
this.policy = Objects.requireNonNull(policy, "policy must be non-null");
this.clock = Objects.requireNonNull(clock, "clock must be non-null");
this.observations = Objects.requireNonNull(observations, "observations must be non-null");
this.invalidationChannel =
Objects.requireNonNull(invalidationChannel, "invalidationChannel must be non-null");
this.messageCodec = Objects.requireNonNull(messageCodec, "messageCodec must be non-null");
this.publisher = Objects.requireNonNull(publisher, "publisher must be non-null");
this.invalidationSubscriber =
new RedisCacheInvalidationSubscriber(
policy.invalidationQueueCapacity(),
new RedisCacheInvalidationSubscriber.Target() {
@Override
public void apply(RedisCacheInvalidationMessage message) {
applyInvalidationHint(message);
}
@Override
public void disconnected() {
subscriberDisconnected();
}
@Override
public void overflow() {
subscriberOverflow();
}
@Override
public void malformedMessage() {
observeMaintenance(
CacheObservationEvent.MaintenanceAction.SUBSCRIBER_EVENT,
CacheObservationEvent.MaintenanceResult.DROPPED,
CacheObservationEvent.MaintenanceCause.MALFORMED_MESSAGE,
0);
}
});
}
RedisCacheInvalidationSubscriber invalidationSubscriber() {
return invalidationSubscriber;
}
String invalidationChannel() {
return invalidationChannel;
}
RedisCacheInvalidationMessage.Codec invalidationMessageCodec() {
return messageCodec;
}
@Override
public CacheLookup<String> lookup(String key) {
invalidationSubscriber.drain();
Instant now = clock.instant();
long localTierPermit = reconcileGenerationIfRequired(now);
String identity = l2.localEntryIdentity(key);
if (localTierPermit >= 0) {
CacheLookup.Hit<String> local = localHit(identity, now, localTierPermit);
if (local != null) {
return local;
}
} else {
observeLookup(
CacheObservationEvent.Tier.LOCAL_L1,
CacheObservationEvent.LookupResult.BYPASS,
Duration.ZERO);
}
CacheLookup<String> lookup = l2.lookup(key);
observeLookup(CacheObservationEvent.Tier.REDIS_L2, lookupResult(lookup), Duration.ZERO);
if (localTierPermit >= 0 && lookup instanceof CacheLookup.Hit<String> hit) {
admit(identity, hit, now, localTierPermit);
}
return lookup;
}
@Override
public CacheRecordOutcome record(String key, String value, CacheRecordMetadata metadata) {
CacheRecordOutcome outcome = l2.record(key, value, metadata);
if (outcome == CacheRecordOutcome.RECORDED) {
invalidateLocalIdentity(
l2.localEntryIdentity(key), CacheObservationEvent.MaintenanceCause.INVALIDATION);
publish(RedisCacheInvalidationMessage.key(l2.localEntryIdentity(key)));
}
return outcome;
}
@Override
public CacheRecordOutcome recordAbsent(
String key, AuthoritativeAbsence reason, CacheRecordMetadata metadata) {
CacheRecordOutcome outcome = l2.recordAbsent(key, reason, metadata);
if (outcome == CacheRecordOutcome.RECORDED) {
invalidateLocalIdentity(
l2.localEntryIdentity(key), CacheObservationEvent.MaintenanceCause.INVALIDATION);
publish(RedisCacheInvalidationMessage.key(l2.localEntryIdentity(key)));
}
return outcome;
}
@Override
public CacheInvalidationOutcome invalidate(String key) {
String identity = l2.localEntryIdentity(key);
CacheInvalidationOutcome outcome = l2.invalidate(key);
invalidateLocalIdentity(identity, CacheObservationEvent.MaintenanceCause.INVALIDATION);
if (outcome == CacheInvalidationOutcome.INVALIDATED
|| outcome == CacheInvalidationOutcome.INDETERMINATE) {
publish(RedisCacheInvalidationMessage.key(identity));
}
return outcome;
}
@Override
public CacheInvalidationOutcome invalidateRegion() {
CacheInvalidationOutcome outcome = l2.invalidateRegion();
invalidateAllLocal(
CacheObservationEvent.MaintenanceCause.INVALIDATION,
CacheObservationEvent.MaintenanceAction.FLUSH,
CacheObservationEvent.MaintenanceResult.FLUSHED);
if (outcome == CacheInvalidationOutcome.INVALIDATED
|| outcome == CacheInvalidationOutcome.INDETERMINATE) {
try {
publish(RedisCacheInvalidationMessage.region(l2.currentRegionGeneration()));
} catch (RuntimeException ignored) {
observeMaintenance(
CacheObservationEvent.MaintenanceAction.SUBSCRIBER_EVENT,
CacheObservationEvent.MaintenanceResult.ERROR,
CacheObservationEvent.MaintenanceCause.RECONCILIATION_FAILURE,
0);
}
}
return outcome;
}
synchronized int localEntryCount() {
return entries.size();
}
synchronized long localWeightBytes() {
return localWeightBytes;
}
@Override
public void close() {
invalidateAllLocal(
CacheObservationEvent.MaintenanceCause.INVALIDATION,
CacheObservationEvent.MaintenanceAction.FLUSH,
CacheObservationEvent.MaintenanceResult.FLUSHED);
messageCodec.close();
}
private synchronized CacheLookup.Hit<String> localHit(
String identity, Instant now, long permitEpoch) {
if (!permitCurrent(permitEpoch)) {
return null;
}
LocalEntry entry = entries.get(identity);
if (entry == null) {
observeLookup(
CacheObservationEvent.Tier.LOCAL_L1,
CacheObservationEvent.LookupResult.MISS,
Duration.ZERO);
return null;
}
if (!now.isBefore(entry.localExpiresAt()) || !now.isBefore(entry.hit().hardExpiresAt())) {
remove(identity);
observeMaintenance(
CacheObservationEvent.MaintenanceAction.EVICT,
CacheObservationEvent.MaintenanceResult.SUCCESS,
CacheObservationEvent.MaintenanceCause.TTL,
1);
observeLookup(
CacheObservationEvent.Tier.LOCAL_L1,
CacheObservationEvent.LookupResult.MISS,
Duration.ZERO);
return null;
}
CacheLookup.Hit<String> hit = entry.hit();
CacheLookup.Hit<String> current =
new CacheLookup.Hit<>(
hit.value(),
now.isBefore(hit.softExpiresAt())
? CacheLookup.Freshness.FRESH
: CacheLookup.Freshness.STALE,
hit.sourceRevision(),
hit.softExpiresAt(),
hit.hardExpiresAt(),
hit.observationToken(),
hit.writeCondition());
observeLookup(
CacheObservationEvent.Tier.LOCAL_L1,
CacheObservationEvent.LookupResult.HIT,
nonNegativeDuration(entry.admittedAt(), now));
return current;
}
private synchronized void admit(
String identity, CacheLookup.Hit<String> hit, Instant observedAt, long permitEpoch) {
if (!permitCurrent(permitEpoch)) {
return;
}
if (!observedAt.isBefore(hit.hardExpiresAt())) {
return;
}
long weight = conservativeEntryWeightBytes(identity, hit.value());
if (weight > policy.maximumEntryWeightBytes() || weight > policy.maximumWeightBytes()) {
observeMaintenance(
CacheObservationEvent.MaintenanceAction.EVICT,
CacheObservationEvent.MaintenanceResult.DROPPED,
CacheObservationEvent.MaintenanceCause.WEIGHT,
0);
return;
}
LocalEntry old = entries.remove(identity);
if (old != null) {
localWeightBytes -= old.weightBytes();
}
while (!entries.isEmpty()
&& (entries.size() >= policy.maximumEntries()
|| localWeightBytes + weight > policy.maximumWeightBytes())) {
boolean cardinality = entries.size() >= policy.maximumEntries();
Iterator<Map.Entry<String, LocalEntry>> iterator = entries.entrySet().iterator();
Map.Entry<String, LocalEntry> eldest = iterator.next();
localWeightBytes -= eldest.getValue().weightBytes();
iterator.remove();
observeMaintenance(
CacheObservationEvent.MaintenanceAction.EVICT,
CacheObservationEvent.MaintenanceResult.SUCCESS,
cardinality
? CacheObservationEvent.MaintenanceCause.CARDINALITY
: CacheObservationEvent.MaintenanceCause.WEIGHT,
1);
}
Instant localExpiresAt =
earlier(hit.hardExpiresAt(), plus(observedAt, policy.localTimeToLive()));
entries.put(identity, new LocalEntry(hit, observedAt, localExpiresAt, weight));
localWeightBytes += weight;
}
private long reconcileGenerationIfRequired(Instant now) {
long probeEpoch;
synchronized (this) {
if (!forceGenerationRecheck && now.isBefore(nextGenerationRecheck)) {
return invalidationEpoch;
}
if (generationProbeInProgress) {
return -1;
}
generationProbeInProgress = true;
probeEpoch = invalidationEpoch;
}
String current;
try {
current = l2.currentRegionGeneration();
} catch (RuntimeException exception) {
synchronized (this) {
generationProbeInProgress = false;
invalidationEpoch++;
forceGenerationRecheck = true;
nextGenerationRecheck = now;
flushInsideLock(
CacheObservationEvent.MaintenanceCause.RECONCILIATION_FAILURE,
CacheObservationEvent.MaintenanceAction.RECONCILE,
CacheObservationEvent.MaintenanceResult.ERROR);
return -1;
}
}
synchronized (this) {
generationProbeInProgress = false;
if (probeEpoch != invalidationEpoch) {
forceGenerationRecheck = true;
nextGenerationRecheck = now;
return -1;
}
boolean changed = observedGeneration != null && !observedGeneration.equals(current);
if (changed) {
invalidationEpoch++;
flushInsideLock(
CacheObservationEvent.MaintenanceCause.GENERATION_CHANGED,
CacheObservationEvent.MaintenanceAction.RECONCILE,
CacheObservationEvent.MaintenanceResult.FLUSHED);
} else {
observeMaintenance(
CacheObservationEvent.MaintenanceAction.RECONCILE,
CacheObservationEvent.MaintenanceResult.UNCHANGED,
CacheObservationEvent.MaintenanceCause.INVALIDATION,
0);
}
observedGeneration = current;
forceGenerationRecheck = false;
nextGenerationRecheck = plus(now, policy.generationRecheckInterval());
return invalidationEpoch;
}
}
private void applyInvalidationHint(RedisCacheInvalidationMessage message) {
if (message instanceof RedisCacheInvalidationMessage.Key key) {
invalidateLocalIdentity(key.value(), CacheObservationEvent.MaintenanceCause.INVALIDATION);
return;
}
invalidateAllLocal(
CacheObservationEvent.MaintenanceCause.INVALIDATION,
CacheObservationEvent.MaintenanceAction.SUBSCRIBER_EVENT,
CacheObservationEvent.MaintenanceResult.FLUSHED);
}
private void subscriberDisconnected() {
invalidateAllLocal(
CacheObservationEvent.MaintenanceCause.SUBSCRIBER_DISCONNECTED,
CacheObservationEvent.MaintenanceAction.FLUSH,
CacheObservationEvent.MaintenanceResult.FLUSHED);
}
private void subscriberOverflow() {
invalidateAllLocal(
CacheObservationEvent.MaintenanceCause.SUBSCRIBER_OVERFLOW,
CacheObservationEvent.MaintenanceAction.FLUSH,
CacheObservationEvent.MaintenanceResult.FLUSHED);
}
private synchronized void invalidateLocalIdentity(
String identity, CacheObservationEvent.MaintenanceCause cause) {
invalidationEpoch++;
LocalEntry removed = remove(identity);
if (removed != null) {
observeMaintenance(
CacheObservationEvent.MaintenanceAction.EVICT,
CacheObservationEvent.MaintenanceResult.SUCCESS,
cause,
1);
}
}
private LocalEntry remove(String identity) {
LocalEntry removed = entries.remove(identity);
if (removed != null) {
localWeightBytes -= removed.weightBytes();
}
return removed;
}
private synchronized void invalidateAllLocal(
CacheObservationEvent.MaintenanceCause cause,
CacheObservationEvent.MaintenanceAction action,
CacheObservationEvent.MaintenanceResult result) {
invalidationEpoch++;
forceGenerationRecheck = true;
flushInsideLock(cause, action, result);
}
private void flushInsideLock(
CacheObservationEvent.MaintenanceCause cause,
CacheObservationEvent.MaintenanceAction action,
CacheObservationEvent.MaintenanceResult result) {
int affected = entries.size();
entries.clear();
localWeightBytes = 0;
observeMaintenance(action, result, cause, affected);
}
private boolean permitCurrent(long permitEpoch) {
return permitEpoch == invalidationEpoch && !forceGenerationRecheck;
}
private void publish(RedisCacheInvalidationMessage message) {
try {
publisher.accept(messageCodec.encode(message));
observeMaintenance(
CacheObservationEvent.MaintenanceAction.SUBSCRIBER_EVENT,
CacheObservationEvent.MaintenanceResult.SUCCESS,
CacheObservationEvent.MaintenanceCause.INVALIDATION,
0);
} catch (RuntimeException ignored) {
observeMaintenance(
CacheObservationEvent.MaintenanceAction.SUBSCRIBER_EVENT,
CacheObservationEvent.MaintenanceResult.ERROR,
CacheObservationEvent.MaintenanceCause.RECONCILIATION_FAILURE,
0);
}
}
private void observeLookup(
CacheObservationEvent.Tier tier,
CacheObservationEvent.LookupResult result,
Duration entryAge) {
observe(new CacheObservationEvent.Lookup(cacheName, tier, result, entryAge));
}
private void observeMaintenance(
CacheObservationEvent.MaintenanceAction action,
CacheObservationEvent.MaintenanceResult result,
CacheObservationEvent.MaintenanceCause cause,
int affectedEntries) {
observe(
new CacheObservationEvent.LocalMaintenance(
cacheName, action, result, cause, affectedEntries));
}
private void observe(CacheObservationEvent event) {
try {
observations.observe(event);
} catch (RuntimeException ignored) {
// Metrics/logging must never change cache semantics.
}
}
private static CacheObservationEvent.LookupResult lookupResult(CacheLookup<String> lookup) {
if (lookup instanceof CacheLookup.Hit<?> || lookup instanceof CacheLookup.NegativeHit<?>) {
return CacheObservationEvent.LookupResult.HIT;
}
if (lookup instanceof CacheLookup.Miss<?>) {
return CacheObservationEvent.LookupResult.MISS;
}
return CacheObservationEvent.LookupResult.ERROR;
}
/**
* Accounts UTF-8 identity/value bytes plus a fixed conservative allowance for the entry, lookup
* metadata, timestamps and map-node references. It is not an exact JVM heap measurement.
*/
static long conservativeEntryWeightBytes(String identity, String value) {
return Math.addExact(
ENTRY_OVERHEAD_BYTES,
Math.addExact(
identity.getBytes(StandardCharsets.UTF_8).length,
value.getBytes(StandardCharsets.UTF_8).length));
}
private static Instant earlier(Instant first, Instant second) {
return first.isBefore(second) ? first : second;
}
private static Instant plus(Instant value, Duration duration) {
try {
return value.plus(duration);
} catch (ArithmeticException | DateTimeException exception) {
throw new IllegalStateException(
"local cache expiry exceeds supported instant range", exception);
}
}
private static Duration nonNegativeDuration(Instant from, Instant to) {
return to.isBefore(from) ? Duration.ZERO : Duration.between(from, to);
}
private static String boundedCacheName(String value) {
if (value == null || !value.matches("[a-z][a-z0-9-]{0,62}")) {
throw new IllegalArgumentException(
"cacheName must be a code-owned lower-case slug with 1..63 characters");
}
return value;
}
private record LocalEntry(
CacheLookup.Hit<String> hit, Instant admittedAt, Instant localExpiresAt, long weightBytes) {
private LocalEntry {
Objects.requireNonNull(hit, "hit must be non-null");
Objects.requireNonNull(admittedAt, "admittedAt must be non-null");
Objects.requireNonNull(localExpiresAt, "localExpiresAt must be non-null");
if (weightBytes < 1) {
throw new IllegalArgumentException("weightBytes must be positive");
}
}
}
}
@@ -0,0 +1,45 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.ConstructorBinding;
/** Typed, default-off settings for the cache-only local L1 tier. */
@ConfigurationProperties(prefix = "app.cache.redis.l1")
public record RedisLocalCacheSettings(
boolean enabled,
int maximumEntries,
long maximumWeightBytes,
long maximumEntryWeightBytes,
Duration timeToLive,
Duration generationRecheckInterval,
int invalidationQueueCapacity) {
@ConstructorBinding
public RedisLocalCacheSettings {
maximumEntries = maximumEntries == 0 ? 10_000 : maximumEntries;
maximumWeightBytes = maximumWeightBytes == 0 ? 67_108_864 : maximumWeightBytes;
maximumEntryWeightBytes = maximumEntryWeightBytes == 0 ? 1_048_576 : maximumEntryWeightBytes;
timeToLive = timeToLive == null ? Duration.ofSeconds(30) : timeToLive;
generationRecheckInterval =
generationRecheckInterval == null ? Duration.ofSeconds(5) : generationRecheckInterval;
invalidationQueueCapacity = invalidationQueueCapacity == 0 ? 1024 : invalidationQueueCapacity;
new RedisLocalCachePolicy(
maximumEntries,
maximumWeightBytes,
maximumEntryWeightBytes,
timeToLive,
generationRecheckInterval,
invalidationQueueCapacity);
}
RedisLocalCachePolicy policy() {
return new RedisLocalCachePolicy(
maximumEntries,
maximumWeightBytes,
maximumEntryWeightBytes,
timeToLive,
generationRecheckInterval,
invalidationQueueCapacity);
}
}
@@ -1,17 +1,11 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.List;
import java.util.Objects;
/** Executes an exact catalog script through EVALSHA, with EVAL allowed only after NOSCRIPT. */
final class RedisLuaProgramExecutor implements RedisProgramExecutor {
private static final HexFormat HEX = HexFormat.of();
private final RedisProgramCatalog catalog;
private final RedisBinaryCommands commands;
@@ -21,19 +15,13 @@ final class RedisLuaProgramExecutor implements RedisProgramExecutor {
}
@Override
public String execute(
RedisProgramDescriptor descriptor, List<byte[]> keys, List<byte[]> arguments) {
Objects.requireNonNull(descriptor, "descriptor must be non-null");
public String execute(RedisCatalogProgramInvocation invocation) {
Objects.requireNonNull(invocation, "invocation must be non-null");
RedisProgramDescriptor descriptor = invocation.descriptor();
if (catalog.descriptor(descriptor.id()) != descriptor) {
throw new IllegalArgumentException("Redis program descriptor is not owned by this catalog");
}
validate(descriptor, keys, arguments);
byte[] result;
try {
result = commands.evalSha(sha1(descriptor.scriptBytes()), keys, arguments);
} catch (RedisNoScriptException noScript) {
result = commands.eval(descriptor.scriptBytes(), keys, arguments);
}
byte[] result = RedisScriptRecovery.evalValue(commands, invocation);
if (result == null || result.length == 0 || result.length > 128) {
throw new IllegalStateException("Redis program returned an invalid status payload");
}
@@ -43,33 +31,4 @@ final class RedisLuaProgramExecutor implements RedisProgramExecutor {
}
return status;
}
private static void validate(
RedisProgramDescriptor descriptor, List<byte[]> keys, List<byte[]> arguments) {
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");
}
for (byte[] key : keys) {
bounded(key, descriptor.maximumKeyBytes(), "key");
}
for (byte[] argument : arguments) {
bounded(argument, descriptor.maximumArgumentBytes(), "argument");
}
}
private static void bounded(byte[] value, int maximumBytes, String field) {
if (value == null || value.length < 1 || value.length > maximumBytes) {
throw new IllegalArgumentException("Redis program " + field + " is out of bounds");
}
}
private static String sha1(byte[] script) {
try {
return HEX.formatHex(MessageDigest.getInstance("SHA-1").digest(script));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-1 unavailable for Redis script identity", exception);
}
}
}
@@ -0,0 +1,621 @@
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 java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.Arrays;
import java.util.Base64;
import java.util.HexFormat;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.LongSupplier;
/**
* Executes the closed Redis session Lua set with pseudonymous keys and bounded fail-closed replies.
*/
final class RedisLuaVersionedSessionStore implements VersionedRedisSessionStore, AutoCloseable {
private static final SecureRandom RANDOM = new SecureRandom();
private static final Base64.Encoder BASE64 = Base64.getEncoder();
private static final Base64.Decoder BASE64_DECODER = Base64.getDecoder();
private static final HexFormat HEX = HexFormat.of();
private final RedisStructuredCommands commands;
private final RedisProgramCatalog catalog;
private final RedisKeyNamespace liveNamespace;
private final RedisKeyNamespace tombstoneNamespace;
private final int hashKeyVersion;
private final byte[] hmacSecret;
private final AtomicBoolean closed = new AtomicBoolean();
private final RedisCapabilityObserver observer;
RedisLuaVersionedSessionStore(
RedisStructuredCommands commands,
String application,
String environment,
int hashKeyVersion,
int keyVersion,
byte[] hmacSecret) {
this(
commands,
application,
environment,
hashKeyVersion,
keyVersion,
hmacSecret,
NoOpRedisCapabilityObservationPort.instance(),
System::nanoTime);
}
RedisLuaVersionedSessionStore(
RedisStructuredCommands commands,
String application,
String environment,
int hashKeyVersion,
int keyVersion,
byte[] hmacSecret,
RedisCapabilityObservationPort observations,
LongSupplier ticker) {
this.commands = Objects.requireNonNull(commands, "commands must be non-null");
this.catalog = RedisProgramCatalog.sessionV1();
this.liveNamespace =
new RedisKeyNamespace(
application,
environment,
"session",
"repository",
hashKeyVersion,
keyVersion,
"live",
512);
this.tombstoneNamespace =
new RedisKeyNamespace(
application,
environment,
"session",
"repository",
hashKeyVersion,
keyVersion,
"tombstone",
512);
this.hashKeyVersion = hashKeyVersion;
this.hmacSecret =
Arrays.copyOf(
Objects.requireNonNull(hmacSecret, "hmacSecret must be non-null"), hmacSecret.length);
if (this.hmacSecret.length < 32) {
throw new IllegalArgumentException("session key HMAC secret requires at least 32 bytes");
}
this.observer = new RedisCapabilityObserver(observations, ticker);
}
@Override
public SessionMutationAttempt newMutationAttempt() {
ensureOpen();
byte[] random = new byte[24];
RANDOM.nextBytes(random);
return new SessionMutationAttempt(
Base64.getUrlEncoder().withoutPadding().encodeToString(random));
}
@Override
public SessionCreateOutcome create(SessionCreateCommand command) {
return observer.observe(
RedisCapabilityObservationEvent.Capability.SESSION,
RedisCapabilityObservationEvent.Role.SESSION,
RedisCapabilityObservationEvent.Operation.SESSION_CREATE,
() -> createOpen(command),
RedisLuaVersionedSessionStore::classifyCreate);
}
private SessionCreateOutcome createOpen(SessionCreateCommand command) {
Objects.requireNonNull(command, "command must be non-null");
try {
String status =
status(
execute(
RedisProgramId.SESSION_CREATE_V1,
keys(command.sessionId()),
List.of(
base64(command.payload()),
ascii(command.newRevision()),
ascii(command.absoluteExpiresAt()),
ascii(command.lastAccessedAt()),
ascii(command.idleTimeout().toMillis()),
ascii(command.attempt().operationId()),
ascii(digest(command.payload())))));
return SessionCreateOutcome.valueOf(status);
} catch (RedisCommandFailureException failure) {
return mutationFailure(
failure, SessionCreateOutcome.INDETERMINATE, SessionCreateOutcome.UNAVAILABLE);
}
}
@Override
public SessionInspectionOutcome inspect(SessionInspectionCommand command) {
return observer.observe(
RedisCapabilityObservationEvent.Capability.SESSION,
RedisCapabilityObservationEvent.Role.SESSION,
RedisCapabilityObservationEvent.Operation.SESSION_INSPECT,
() -> inspectOpen(command),
RedisLuaVersionedSessionStore::classifyInspection);
}
private SessionInspectionOutcome inspectOpen(SessionInspectionCommand command) {
Objects.requireNonNull(command, "command must be non-null");
try {
List<byte[]> reply =
execute(
RedisProgramId.SESSION_INSPECT_V1,
keys(command.sessionId()),
List.of(ascii(command.now())));
String status = status(reply);
return switch (status) {
case "LIVE" -> live(reply);
case "TOMBSTONED" -> new SessionInspectionOutcome.Tombstoned();
case "ABSENT" -> new SessionInspectionOutcome.Absent();
case "ABSOLUTE_EXPIRED" -> new SessionInspectionOutcome.AbsoluteExpired();
default -> throw incompatible(RedisProgramId.SESSION_INSPECT_V1, status);
};
} catch (RedisCommandFailureException failure) {
return new SessionInspectionOutcome.Unavailable();
}
}
@Override
public SessionSaveOutcome saveIfLive(SessionSaveCommand command) {
return observer.observe(
RedisCapabilityObservationEvent.Capability.SESSION,
RedisCapabilityObservationEvent.Role.SESSION,
RedisCapabilityObservationEvent.Operation.SESSION_SAVE,
() -> saveIfLiveOpen(command),
RedisLuaVersionedSessionStore::classifySave);
}
private SessionSaveOutcome saveIfLiveOpen(SessionSaveCommand command) {
Objects.requireNonNull(command, "command must be non-null");
try {
String status =
status(
execute(
RedisProgramId.SESSION_SAVE_IF_LIVE_V1,
keys(command.sessionId()),
List.of(
base64(command.payload()),
ascii(command.expectedRevision()),
ascii(command.newRevision()),
ascii(command.absoluteExpiresAt()),
ascii(command.lastAccessedAt()),
ascii(command.idleTimeout().toMillis()),
ascii(command.attempt().operationId()),
ascii(digest(command.payload())))));
return SessionSaveOutcome.valueOf(status);
} catch (RedisCommandFailureException failure) {
return mutationFailure(
failure, SessionSaveOutcome.INDETERMINATE, SessionSaveOutcome.UNAVAILABLE);
}
}
@Override
public SessionTouchOutcome touchIfLive(SessionTouchCommand command) {
return observer.observe(
RedisCapabilityObservationEvent.Capability.SESSION,
RedisCapabilityObservationEvent.Role.SESSION,
RedisCapabilityObservationEvent.Operation.SESSION_TOUCH,
() -> touchIfLiveOpen(command),
RedisLuaVersionedSessionStore::classifyTouch);
}
private SessionTouchOutcome touchIfLiveOpen(SessionTouchCommand command) {
Objects.requireNonNull(command, "command must be non-null");
try {
String status =
status(
execute(
RedisProgramId.SESSION_TOUCH_IF_LIVE_V1,
keys(command.sessionId()),
List.of(
ascii(command.expectedRevision()),
ascii(command.now()),
ascii(command.absoluteExpiresAt()),
ascii(command.idleTimeout().toMillis()),
ascii(command.touchInterval().toMillis()),
ascii(command.attempt().operationId()))));
return SessionTouchOutcome.valueOf(status);
} catch (RedisCommandFailureException failure) {
return mutationFailure(
failure, SessionTouchOutcome.INDETERMINATE, SessionTouchOutcome.UNAVAILABLE);
}
}
@Override
public SessionRevokeOutcome tombstoneAndDelete(SessionRevokeCommand command) {
return observer.observe(
RedisCapabilityObservationEvent.Capability.SESSION,
RedisCapabilityObservationEvent.Role.SESSION,
RedisCapabilityObservationEvent.Operation.SESSION_REVOKE,
() -> tombstoneAndDeleteOpen(command),
RedisLuaVersionedSessionStore::classifyRevoke);
}
private SessionRevokeOutcome tombstoneAndDeleteOpen(SessionRevokeCommand command) {
Objects.requireNonNull(command, "command must be non-null");
try {
String status =
status(
execute(
RedisProgramId.SESSION_TOMBSTONE_AND_DELETE_V1,
keys(command.sessionId()),
List.of(
ascii(command.expectedRevision()),
ascii(command.tombstoneTimeToLive().toMillis()),
ascii(command.attempt().operationId()))));
return SessionRevokeOutcome.valueOf(status);
} catch (RedisCommandFailureException failure) {
return mutationFailure(
failure, SessionRevokeOutcome.INDETERMINATE, SessionRevokeOutcome.UNAVAILABLE);
}
}
@Override
public SessionRotateOutcome rotate(SessionRotateCommand command) {
return observer.observe(
RedisCapabilityObservationEvent.Capability.SESSION,
RedisCapabilityObservationEvent.Role.SESSION,
RedisCapabilityObservationEvent.Operation.SESSION_ROTATE,
() -> rotateOpen(command),
RedisLuaVersionedSessionStore::classifyRotate);
}
private SessionRotateOutcome rotateOpen(SessionRotateCommand command) {
Objects.requireNonNull(command, "command must be non-null");
try {
RedisKeyPair oldKeys = physicalKeys(command.oldSessionId());
RedisKeyPair newKeys = physicalKeys(command.newSessionId());
String status =
status(
execute(
RedisProgramId.SESSION_ROTATE_V1,
List.of(oldKeys.live(), oldKeys.tombstone(), newKeys.live(), newKeys.tombstone()),
List.of(
base64(command.payload()),
ascii(command.expectedRevision()),
ascii(command.newRevision()),
ascii(command.absoluteExpiresAt()),
ascii(command.lastAccessedAt()),
ascii(command.idleTimeout().toMillis()),
ascii(command.tombstoneTimeToLive().toMillis()),
ascii(command.attempt().operationId()),
ascii(digest(command.payload())),
ascii(newKeys.resourceDigest()))));
return SessionRotateOutcome.valueOf(status);
} catch (RedisCommandFailureException failure) {
return mutationFailure(
failure, SessionRotateOutcome.INDETERMINATE, SessionRotateOutcome.UNAVAILABLE);
}
}
private List<byte[]> execute(RedisProgramId program, List<byte[]> keys, List<byte[]> arguments) {
ensureOpen();
RedisProgramDescriptor descriptor = catalog.descriptor(program);
if (keys.size() != descriptor.keyCount() || arguments.size() != descriptor.argumentCount()) {
throw new IllegalArgumentException("Redis session invocation shape is invalid");
}
validateFields(keys, descriptor.maximumKeyBytes(), "key", false);
validateFields(arguments, descriptor.maximumArgumentBytes(), "argument", false);
List<byte[]> reply =
RedisScriptRecovery.evalMulti(
commands,
catalog.capabilityInvocation(new ProgramInvocation(program, keys, arguments)));
if (reply == null || reply.size() != descriptor.replyFieldCount()) {
throw incompatible(program, "<malformed-reply>");
}
validateFields(reply, descriptor.maximumReplyFieldBytes(), "reply", true);
String status = status(reply);
if (!descriptor.statuses().contains(status)) {
throw incompatible(program, status);
}
return copy(reply);
}
private SessionInspectionOutcome.Live live(List<byte[]> reply) {
if (reply.size() != 5) {
throw incompatible(RedisProgramId.SESSION_INSPECT_V1, "<malformed-live-reply>");
}
try {
byte[] payload = BASE64_DECODER.decode(asciiText(reply.get(1)));
return new SessionInspectionOutcome.Live(
payload,
positiveLong(reply.get(2)),
Instant.ofEpochMilli(positiveLong(reply.get(3))),
Instant.ofEpochMilli(positiveLong(reply.get(4))));
} catch (IllegalArgumentException exception) {
throw incompatible(RedisProgramId.SESSION_INSPECT_V1, "<malformed-live-reply>");
}
}
private List<byte[]> keys(String sessionId) {
RedisKeyPair keys = physicalKeys(sessionId);
return List.of(keys.live(), keys.tombstone());
}
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 = copy(keys);
this.arguments = copy(arguments);
}
@Override
public RedisProgramId programId() {
return programId;
}
@Override
public RedisCatalogProgramInvocation.ReplyShape replyShape() {
return RedisCatalogProgramInvocation.ReplyShape.MULTI;
}
@Override
public List<byte[]> copyKeys() {
return copy(keys);
}
@Override
public List<byte[]> copyArguments() {
return copy(arguments);
}
}
private RedisKeyPair physicalKeys(String sessionId) {
ensureOpen();
RedisKeyDigest keyDigest =
RedisKeyDigest.sensitive(
hashKeyVersion, hmacSecret, List.of(sessionId.getBytes(StandardCharsets.US_ASCII)));
return new RedisKeyPair(
ascii(RedisKeyBuilder.build(liveNamespace, keyDigest)),
ascii(RedisKeyBuilder.build(tombstoneNamespace, keyDigest)),
keyDigest.resourceDigest());
}
private static String status(List<byte[]> reply) {
String value = asciiText(reply.getFirst());
if (!value.matches("[A-Z][A-Z_]{1,63}")) {
throw incompatible(null, "<malformed-status>");
}
return value;
}
private static long positiveLong(byte[] value) {
String text = asciiText(value);
if (!text.matches("[1-9][0-9]{0,18}")) {
throw new IllegalArgumentException("expected positive decimal");
}
return Long.parseLong(text);
}
private static String asciiText(byte[] value) {
for (byte character : value) {
if (character < 0x20 || character > 0x7e) {
throw new IllegalArgumentException("expected printable ASCII");
}
}
return new String(value, StandardCharsets.US_ASCII);
}
private static void validateFields(
List<byte[]> fields, int maximumBytes, String label, boolean allowEmpty) {
for (byte[] field : fields) {
if (field == null || (!allowEmpty && field.length < 1) || field.length > maximumBytes) {
throw new IllegalArgumentException("Redis session " + label + " field is out of bounds");
}
}
}
private static <T> T mutationFailure(
RedisCommandFailureException failure, T indeterminate, T unavailable) {
return failure.certainty() == RedisCommandFailureException.Certainty.INDETERMINATE
? indeterminate
: unavailable;
}
private static RedisCapabilityObserver.Classification classifyCreate(
SessionCreateOutcome outcome) {
return classifyMutation(
outcome == SessionCreateOutcome.CREATED
|| outcome == SessionCreateOutcome.ALREADY_CREATED_SAME_OPERATION,
outcome == SessionCreateOutcome.INDETERMINATE,
outcome == SessionCreateOutcome.UNAVAILABLE,
outcome == SessionCreateOutcome.EXISTS_CONFLICT
|| outcome == SessionCreateOutcome.TOMBSTONED);
}
static RedisCapabilityObserver.Classification classifyInspection(
SessionInspectionOutcome outcome) {
return switch (outcome) {
case SessionInspectionOutcome.Live ignored ->
definite(RedisCapabilityObservationEvent.Outcome.HIT);
case SessionInspectionOutcome.Absent ignored ->
definite(RedisCapabilityObservationEvent.Outcome.MISS);
case SessionInspectionOutcome.Tombstoned ignored ->
definite(RedisCapabilityObservationEvent.Outcome.TOMBSTONED);
case SessionInspectionOutcome.AbsoluteExpired ignored ->
definite(RedisCapabilityObservationEvent.Outcome.ABSOLUTE_EXPIRED);
case SessionInspectionOutcome.Unavailable ignored -> unavailable();
};
}
private static RedisCapabilityObserver.Classification classifySave(SessionSaveOutcome outcome) {
return classifyMutation(
outcome == SessionSaveOutcome.SAVED
|| outcome == SessionSaveOutcome.ALREADY_SAVED_SAME_OPERATION,
outcome == SessionSaveOutcome.INDETERMINATE,
outcome == SessionSaveOutcome.UNAVAILABLE,
outcome == SessionSaveOutcome.STALE_REVISION
|| outcome == SessionSaveOutcome.MUTATION_CONFLICT
|| outcome == SessionSaveOutcome.TOMBSTONED);
}
private static RedisCapabilityObserver.Classification classifyTouch(SessionTouchOutcome outcome) {
return classifyMutation(
outcome == SessionTouchOutcome.TOUCHED
|| outcome == SessionTouchOutcome.ALREADY_TOUCHED_SAME_OPERATION
|| outcome == SessionTouchOutcome.TOUCH_NOT_DUE,
outcome == SessionTouchOutcome.INDETERMINATE,
outcome == SessionTouchOutcome.UNAVAILABLE,
outcome == SessionTouchOutcome.STALE_REVISION
|| outcome == SessionTouchOutcome.MUTATION_CONFLICT
|| outcome == SessionTouchOutcome.TOMBSTONED);
}
private static RedisCapabilityObserver.Classification classifyRevoke(
SessionRevokeOutcome outcome) {
return classifyMutation(
outcome == SessionRevokeOutcome.REVOKED_AND_DELETED
|| outcome == SessionRevokeOutcome.TOMBSTONED_ABSENT
|| outcome == SessionRevokeOutcome.ALREADY_REVOKED_SAME_OPERATION,
outcome == SessionRevokeOutcome.INDETERMINATE,
outcome == SessionRevokeOutcome.UNAVAILABLE,
outcome == SessionRevokeOutcome.STALE_REVISION
|| outcome == SessionRevokeOutcome.OPERATION_CONFLICT);
}
private static RedisCapabilityObserver.Classification classifyRotate(
SessionRotateOutcome outcome) {
return classifyMutation(
outcome == SessionRotateOutcome.ROTATED
|| outcome == SessionRotateOutcome.ALREADY_ROTATED_SAME_OPERATION,
outcome == SessionRotateOutcome.INDETERMINATE,
outcome == SessionRotateOutcome.UNAVAILABLE,
outcome == SessionRotateOutcome.STALE_REVISION
|| outcome == SessionRotateOutcome.OLD_TOMBSTONED
|| outcome == SessionRotateOutcome.NEW_ID_CONFLICT);
}
private static RedisCapabilityObserver.Classification classifyMutation(
boolean success, boolean indeterminate, boolean unavailable, boolean conflict) {
if (success) {
return definite(RedisCapabilityObservationEvent.Outcome.SUCCESS);
}
if (indeterminate) {
return new RedisCapabilityObserver.Classification(
RedisCapabilityObservationEvent.Outcome.INDETERMINATE,
RedisCapabilityObservationEvent.Certainty.INDETERMINATE);
}
if (unavailable) {
return unavailable();
}
return definite(
conflict
? RedisCapabilityObservationEvent.Outcome.CONFLICT
: RedisCapabilityObservationEvent.Outcome.MISS);
}
private static RedisCapabilityObserver.Classification definite(
RedisCapabilityObservationEvent.Outcome outcome) {
return new RedisCapabilityObserver.Classification(
outcome, RedisCapabilityObservationEvent.Certainty.DEFINITE);
}
private static RedisCapabilityObserver.Classification unavailable() {
return new RedisCapabilityObserver.Classification(
RedisCapabilityObservationEvent.Outcome.UNAVAILABLE,
RedisCapabilityObservationEvent.Certainty.NOT_APPLIED);
}
private static List<byte[]> copy(List<byte[]> values) {
return values.stream().map(byte[]::clone).toList();
}
private static byte[] base64(byte[] value) {
return BASE64.encode(value);
}
private static byte[] ascii(long value) {
return ascii(Long.toString(value));
}
private static byte[] ascii(Instant value) {
return ascii(value.toEpochMilli());
}
private static byte[] ascii(String value) {
return value.getBytes(StandardCharsets.US_ASCII);
}
private static String digest(byte[] value) {
try {
return HEX.formatHex(MessageDigest.getInstance("SHA-256").digest(value));
} catch (GeneralSecurityException exception) {
throw new IllegalStateException(
"SHA-256 unavailable for Redis session payload digest", exception);
}
}
private void ensureOpen() {
if (closed.get()) {
throw new IllegalStateException("Redis session key material is closed");
}
}
@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 RedisSessionProgramCompatibilityException incompatible(
RedisProgramId program, String detail) {
return new RedisSessionProgramCompatibilityException(
program == null ? "<unknown>" : program.externalId(), detail);
}
private static final class RedisKeyPair {
private final byte[] live;
private final byte[] tombstone;
private final String resourceDigest;
private RedisKeyPair(byte[] live, byte[] tombstone, String resourceDigest) {
this.live = live.clone();
this.tombstone = tombstone.clone();
this.resourceDigest = resourceDigest;
}
private byte[] live() {
return live.clone();
}
private byte[] tombstone() {
return tombstone.clone();
}
private String resourceDigest() {
return resourceDigest;
}
}
}
final class RedisSessionProgramCompatibilityException extends RuntimeException {
RedisSessionProgramCompatibilityException(String program, String detail) {
super("Redis session program reply is incompatible: " + program + " " + detail);
}
}
@@ -0,0 +1,17 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings;
import io.lettuce.core.ClientOptions;
import io.lettuce.core.RedisURI;
import io.lettuce.core.cluster.ClusterClientOptions;
import java.util.List;
/** Injectable native-client creation seam for deterministic, network-free topology tests. */
interface RedisNativeClientFactory {
RedisNativeClientHandle openStandalone(
RedisURI uri, ClientOptions options, RedisClientRuntimeSettings settings);
RedisNativeClientHandle openCluster(
List<RedisURI> seedUris, ClusterClientOptions options, RedisClientRuntimeSettings settings);
}
@@ -0,0 +1,11 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.time.Duration;
/** Package-private lifecycle handle that prevents native command APIs from escaping. */
interface RedisNativeClientHandle {
Class<?> nativeClientType();
void close(Duration timeout);
}
@@ -0,0 +1,12 @@
package dev.caskeleton.adapter.outbound.cache.redis;
/** Closed key material created only inside the semantic capability that owns the key. */
sealed interface RedisOwnedPhysicalKeyMaterial
permits LettuceRedisRuntime.LegacyKeyMaterial,
RedisRoleCommandRouter.LegacyKeyMaterial,
RedisStringCacheRegion.CacheKeyMaterial,
RedisCacheConsistencyStore.ConsistencyKeyMaterial,
RedisSemanticReadinessProbe.ProbeKeyMaterial {
byte[] copyEncodedKey();
}
@@ -0,0 +1,73 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Objects;
/** Opaque adapter-private physical key. Command ports never accept caller-owned key bytes. */
final class RedisPhysicalKey {
private static final int MAXIMUM_KEY_BYTES = 1_024;
private final byte[] encoded;
private RedisPhysicalKey(byte[] encoded) {
Objects.requireNonNull(encoded, "Redis physical key must be non-null");
if (encoded.length < 1 || encoded.length > MAXIMUM_KEY_BYTES) {
throw new IllegalArgumentException("Redis physical key is out of bounds");
}
this.encoded = encoded.clone();
}
static RedisPhysicalKey owned(RedisOwnedPhysicalKeyMaterial material) {
return new RedisPhysicalKey(
Objects.requireNonNull(material, "key material must be non-null").copyEncodedKey());
}
static RedisPhysicalKey primitive(RedisPrimitiveKey key) {
Objects.requireNonNull(key, "primitive key must be non-null");
String encoded =
"ca:primitive:"
+ key.family()
+ ":v"
+ key.version()
+ ":{"
+ key.slot()
+ "}:"
+ key.identity();
return new RedisPhysicalKey(encoded.getBytes(StandardCharsets.UTF_8));
}
int encodedLength() {
return encoded.length;
}
private byte[] copyEncoded() {
return encoded.clone();
}
@Override
public boolean equals(Object other) {
return other instanceof RedisPhysicalKey candidate && Arrays.equals(encoded, candidate.encoded);
}
@Override
public int hashCode() {
return Arrays.hashCode(encoded);
}
@Override
public String toString() {
return "RedisPhysicalKey[redacted]";
}
/** Sole terminal unwrap; callers cannot supply or replace key bytes through this API. */
static final class WireCodec {
private WireCodec() {}
static byte[] copy(RedisPhysicalKey key) {
return Objects.requireNonNull(key, "key must be non-null").copyEncoded();
}
}
}
@@ -0,0 +1,204 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole;
import java.time.Duration;
import java.util.Collection;
import java.util.EnumMap;
import java.util.Map;
final class RedisPrimitiveCatalog {
private static final int SCHEMA_REVISION = 1;
private final Map<RedisPrimitiveId, RedisPrimitiveDescriptor> descriptors;
private RedisPrimitiveCatalog(Map<RedisPrimitiveId, RedisPrimitiveDescriptor> descriptors) {
this.descriptors = Map.copyOf(descriptors);
}
static RedisPrimitiveCatalog standard() {
Map<RedisPrimitiveId, RedisPrimitiveDescriptor> values = new EnumMap<>(RedisPrimitiveId.class);
for (RedisPrimitiveId id : RedisPrimitiveId.values()) {
values.put(id, compileDescriptor(id));
}
return new RedisPrimitiveCatalog(values);
}
RedisPrimitiveDescriptor descriptor(RedisPrimitiveId id) {
RedisPrimitiveDescriptor descriptor = descriptors.get(id);
if (descriptor == null) {
throw new IllegalArgumentException("unknown primitive id");
}
return descriptor;
}
Collection<RedisPrimitiveDescriptor> descriptors() {
return descriptors.values();
}
RedisPrimitiveKeyFactory keyFactory(RedisPrimitiveId id) {
return RedisPrimitiveKeyFactory.canonical(this, id);
}
int schemaRevision() {
return SCHEMA_REVISION;
}
RedisStringValuePrimitives strings(RedisPrimitiveCommands commands) {
return new RedisStringValuePrimitives(this, commands);
}
RedisCounterPrimitives counters(RedisPrimitiveCommands commands) {
return new RedisCounterPrimitives(this, commands);
}
RedisHashPrimitives hashes(RedisPrimitiveCommands commands) {
return new RedisHashPrimitives(this, commands);
}
RedisSetPrimitives sets(RedisPrimitiveCommands commands) {
return new RedisSetPrimitives(this, commands);
}
RedisSortedSetPrimitives sortedSets(RedisPrimitiveCommands commands) {
return new RedisSortedSetPrimitives(this, commands);
}
RedisListPrimitives lists(RedisPrimitiveCommands commands) {
return new RedisListPrimitives(this, commands);
}
RedisBitmapPrimitives bitmaps(RedisPrimitiveCommands commands) {
return new RedisBitmapPrimitives(this, commands);
}
RedisHyperLogLogPrimitives hyperLogLogs(RedisPrimitiveCommands commands) {
return new RedisHyperLogLogPrimitives(this, commands);
}
RedisGeoPrimitives geo(RedisPrimitiveCommands commands) {
return new RedisGeoPrimitives(this, commands);
}
private static RedisPrimitiveDescriptor compileDescriptor(RedisPrimitiveId id) {
RedisPrimitiveSemanticClass semantic =
switch (id.structure()) {
case LIST -> RedisPrimitiveSemanticClass.BEST_EFFORT_NOT_MESSAGING;
case BITMAP -> RedisPrimitiveSemanticClass.NON_AUTHORITATIVE_FIXED_DOMAIN_BITMAP;
case HYPERLOGLOG -> RedisPrimitiveSemanticClass.APPROXIMATE_NON_AUTHORITATIVE_HLL;
case GEO -> RedisPrimitiveSemanticClass.PRIVACY_SENSITIVE_NON_AUTHORITATIVE_GEO;
default -> RedisPrimitiveSemanticClass.EXACT;
};
RedisRole role =
id.structure() == RedisPrimitiveStructure.COUNTER
? RedisRole.COORDINATION
: RedisRole.CACHE;
boolean bulk =
switch (id) {
case STRING_MGET, HLL_MERGE_SAME_SLOT -> true;
default -> false;
};
boolean read =
switch (id) {
case STRING_GET,
STRING_MGET,
COUNTER_READ,
HASH_GET,
HASH_MGET,
HASH_SCAN_PAGE,
SET_CONTAINS,
SET_CARDINALITY,
SET_SCAN_PAGE,
ZSET_COUNT,
ZSET_RANK_PAGE,
ZSET_SCORE_PAGE,
BITMAP_GET,
BITMAP_COUNT_FIXED_RANGE,
HLL_COUNT,
GEO_SEARCH ->
true;
default -> false;
};
RedisProgramId programId =
switch (id) {
case STRING_GET -> RedisProgramId.BOUNDED_GET_V1;
case STRING_MGET -> RedisProgramId.BOUNDED_MGET_V1;
case STRING_COMPARE_SET -> RedisProgramId.COMPARE_AND_SET_WITH_TTL_V1;
case STRING_COMPARE_DELETE -> RedisProgramId.COMPARE_AND_DELETE;
case COUNTER_INCREMENT_INITIAL_TTL -> RedisProgramId.INCREMENT_WITH_INITIAL_TTL_V1;
case HASH_SET_FIELDS -> RedisProgramId.BOUNDED_HASH_FIELD_ADMISSION_V1;
case HASH_SCAN_PAGE -> RedisProgramId.BOUNDED_HASH_SCAN_PAGE_V1;
case HASH_REVISION_CAS -> RedisProgramId.HASH_REVISION_CAS_V1;
case SET_ADMIT -> RedisProgramId.BOUNDED_SET_ADMISSION_V1;
case SET_SCAN_PAGE -> RedisProgramId.BOUNDED_SET_SCAN_PAGE_V1;
case ZSET_ADD -> RedisProgramId.BOUNDED_ZSET_ADMISSION_V1;
case ZSET_TRIM_BOUNDED -> RedisProgramId.ZSET_BOUNDED_TRIM_V1;
case LIST_ADMIT -> RedisProgramId.BOUNDED_LIST_ADMISSION_V1;
case LIST_TRIM_FIXED_BOUNDS -> RedisProgramId.GUARDED_LIST_TRIM_V1;
case GEO_ADD -> RedisProgramId.BOUNDED_GEO_ADMISSION_V1;
default -> null;
};
RedisPrimitiveDescriptor.TtlPolicy ttl =
read
? RedisPrimitiveDescriptor.TtlPolicy.PRESERVE_EXISTING
: id == RedisPrimitiveId.STRING_COMPARE_DELETE
? RedisPrimitiveDescriptor.TtlPolicy.PRESERVE_EXISTING
: id == RedisPrimitiveId.ZSET_TRIM_BOUNDED
|| id == RedisPrimitiveId.LIST_TRIM_FIXED_BOUNDS
? RedisPrimitiveDescriptor.TtlPolicy.REQUIRE_PRECREATED_EXPIRING_KEY
: programId != null
? RedisPrimitiveDescriptor.TtlPolicy.ATOMIC_INITIAL_TTL
: switch (id) {
case STRING_SET_PX, STRING_SET_NX_PX, STRING_SET_XX_PX ->
RedisPrimitiveDescriptor.TtlPolicy.REQUIRED_PX;
case BITMAP_SET, HLL_ADD, HLL_MERGE_SAME_SLOT ->
RedisPrimitiveDescriptor.TtlPolicy.PERSISTENT_ONLY;
default -> RedisPrimitiveDescriptor.TtlPolicy.PRESERVE_EXISTING;
};
return new RedisPrimitiveDescriptor(
id,
id.structure(),
semantic,
role,
family(id.structure()),
1,
512,
16_000,
1_024,
4_096,
switch (id) {
case STRING_MGET, HLL_MERGE_SAME_SLOT -> 4;
default -> 1;
},
256,
16_384,
49_152,
ttl,
bulk
? RedisPrimitiveDescriptor.SlotRule.SAME_SLOT
: RedisPrimitiveDescriptor.SlotRule.SINGLE_KEY,
Duration.ofSeconds(2),
read
? RedisPrimitiveDescriptor.RetrySafety.SAFE_READ
: RedisPrimitiveDescriptor.RetrySafety.NON_REPLAY_SAFE_MUTATION,
read
? RedisPrimitiveDescriptor.TimeoutCertainty.NOT_APPLIED_FOR_READ
: RedisPrimitiveDescriptor.TimeoutCertainty.INDETERMINATE_FOR_MUTATION,
"redis.primitive." + id.name().toLowerCase(java.util.Locale.ROOT).replace('_', '.'),
programId);
}
private static String family(RedisPrimitiveStructure structure) {
return switch (structure) {
case STRING -> "string-value";
case COUNTER -> "counter";
case HASH -> "hash";
case SET -> "set";
case SORTED_SET -> "sorted-set";
case LIST -> "list";
case BITMAP -> "bitmap";
case HYPERLOGLOG -> "hyperloglog";
case GEO -> "geo";
};
}
}
@@ -0,0 +1,7 @@
package dev.caskeleton.adapter.outbound.cache.redis;
/** One closed primitive transport boundary; implementations switch only on RedisPrimitiveId. */
interface RedisPrimitiveCommands {
RedisPrimitiveReply execute(RedisPrimitiveInvocation invocation);
}
@@ -0,0 +1,85 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.Objects;
/** Bounded maintenance cursor with explicit route/key/schema ownership. */
record RedisPrimitiveCursor(
int catalogRevision,
long routeEpoch,
String keyFamily,
String physicalKeyDigest,
String rawCursor,
ObservationSemantics observationSemantics) {
enum ObservationSemantics {
DUPLICATES_POSSIBLE_MUTATIONS_UNDEFINED
}
RedisPrimitiveCursor {
if (catalogRevision < 1 || routeEpoch < 0) {
throw new IllegalArgumentException("primitive cursor ownership is invalid");
}
Objects.requireNonNull(keyFamily, "keyFamily must be non-null");
Objects.requireNonNull(physicalKeyDigest, "physicalKeyDigest must be non-null");
Objects.requireNonNull(rawCursor, "rawCursor must be non-null");
Objects.requireNonNull(observationSemantics, "observationSemantics must be non-null");
if (!physicalKeyDigest.matches("[0-9a-f]{64}")) {
throw new IllegalArgumentException("primitive cursor key digest is invalid");
}
if (!rawCursor.matches("0|[1-9][0-9]{0,19}")) {
throw new IllegalArgumentException("primitive cursor token is invalid");
}
}
static RedisPrimitiveCursor initial(
RedisPrimitiveCatalog catalog,
RedisPrimitiveDescriptor descriptor,
RedisPrimitiveKey key,
long routeEpoch) {
return new RedisPrimitiveCursor(
catalog.schemaRevision(),
routeEpoch,
descriptor.keyFamily(),
digest(key),
"0",
ObservationSemantics.DUPLICATES_POSSIBLE_MUTATIONS_UNDEFINED);
}
void validateFor(
RedisPrimitiveCatalog catalog,
RedisPrimitiveDescriptor descriptor,
RedisPrimitiveKey key,
long expectedRouteEpoch) {
if (catalogRevision != catalog.schemaRevision()
|| routeEpoch != expectedRouteEpoch
|| !keyFamily.equals(descriptor.keyFamily())
|| !physicalKeyDigest.equals(digest(key))) {
throw new IllegalArgumentException("primitive cursor does not belong to this route/key");
}
}
RedisPrimitiveCursor advance(String nextRawCursor) {
return new RedisPrimitiveCursor(
catalogRevision,
routeEpoch,
keyFamily,
physicalKeyDigest,
nextRawCursor,
observationSemantics);
}
private static String digest(RedisPrimitiveKey key) {
Objects.requireNonNull(key, "primitive cursor key must be non-null");
try {
return HexFormat.of()
.formatHex(
MessageDigest.getInstance("SHA-256")
.digest(RedisPhysicalKey.WireCodec.copy(key.physicalKey())));
} catch (NoSuchAlgorithmException exception) {
throw new LinkageError("SHA-256 is unavailable", exception);
}
}
}
@@ -0,0 +1,123 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole;
import java.time.Duration;
import java.util.List;
import java.util.Objects;
record RedisPrimitiveDescriptor(
RedisPrimitiveId id,
RedisPrimitiveStructure structure,
RedisPrimitiveSemanticClass semanticClass,
RedisRole boundRole,
String keyFamily,
int keyVersion,
int maximumKeyBytes,
int maximumValueBytes,
int maximumFieldBytes,
int maximumMemberBytes,
int maximumKeys,
int maximumElements,
int maximumEncodedBytes,
int maximumResultBytes,
TtlPolicy ttlPolicy,
SlotRule slotRule,
Duration totalDeadline,
RetrySafety retrySafety,
TimeoutCertainty timeoutCertainty,
String lowCardinalityOperation,
RedisProgramId programId) {
enum TtlPolicy {
REQUIRED_PX,
ATOMIC_INITIAL_TTL,
PRESERVE_EXISTING,
REQUIRE_PRECREATED_EXPIRING_KEY,
PERSISTENT_ONLY
}
enum SlotRule {
SINGLE_KEY,
SAME_SLOT
}
enum RetrySafety {
SAFE_READ,
IDEMPOTENT_MUTATION,
NON_REPLAY_SAFE_MUTATION
}
enum TimeoutCertainty {
NOT_APPLIED_FOR_READ,
INDETERMINATE_FOR_MUTATION
}
RedisPrimitiveDescriptor {
Objects.requireNonNull(id, "id must be non-null");
if (structure != id.structure()) {
throw new IllegalArgumentException("primitive structure does not match id");
}
Objects.requireNonNull(semanticClass, "semanticClass must be non-null");
Objects.requireNonNull(boundRole, "boundRole must be non-null");
if (keyFamily == null || !keyFamily.matches("[a-z][a-z0-9-]{2,31}")) {
throw new IllegalArgumentException("primitive key family is invalid");
}
if (keyVersion < 1
|| maximumKeyBytes < 16
|| maximumKeyBytes > 512
|| maximumValueBytes < 1
|| maximumValueBytes > 1_048_576
|| maximumFieldBytes < 1
|| maximumFieldBytes > 1_024
|| maximumMemberBytes < 1
|| maximumMemberBytes > 4_096
|| maximumKeys < 1
|| maximumKeys > 32
|| maximumElements < 1
|| maximumElements > 1_024
|| maximumEncodedBytes < 1
|| maximumEncodedBytes > 4_194_304
|| maximumResultBytes < 1
|| maximumResultBytes > 4_194_304) {
throw new IllegalArgumentException("primitive descriptor bounds are invalid");
}
Objects.requireNonNull(ttlPolicy, "ttlPolicy must be non-null");
Objects.requireNonNull(slotRule, "slotRule must be non-null");
if (totalDeadline == null
|| totalDeadline.isZero()
|| totalDeadline.isNegative()
|| totalDeadline.compareTo(Duration.ofSeconds(5)) > 0) {
throw new IllegalArgumentException("primitive total deadline is invalid");
}
Objects.requireNonNull(retrySafety, "retrySafety must be non-null");
Objects.requireNonNull(timeoutCertainty, "timeoutCertainty must be non-null");
if (lowCardinalityOperation == null
|| !lowCardinalityOperation.matches("redis\\.primitive\\.[a-z0-9.-]+")) {
throw new IllegalArgumentException("primitive operation identity is invalid");
}
}
List<RedisPrimitiveKey> validateKeys(List<RedisPrimitiveKey> keys) {
Objects.requireNonNull(keys, "keys must be non-null");
if (keys.isEmpty() || keys.size() > maximumKeys) {
throw new IllegalArgumentException("primitive key count exceeds descriptor bounds");
}
String expectedSlot = null;
for (RedisPrimitiveKey key : keys) {
if (!keyFamily.equals(key.family())
|| keyVersion != key.version()
|| key.encodedLength() > maximumKeyBytes) {
throw new IllegalArgumentException("primitive key family or bounds are incompatible");
}
if (expectedSlot == null) {
expectedSlot = key.slot();
} else if (slotRule == SlotRule.SAME_SLOT && !expectedSlot.equals(key.slot())) {
throw new IllegalArgumentException("primitive keys must use the same slot");
}
}
if (slotRule == SlotRule.SINGLE_KEY && keys.size() != 1) {
throw new IllegalArgumentException("primitive requires a single key");
}
return List.copyOf(keys);
}
}
@@ -0,0 +1,30 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.util.Objects;
import java.util.Optional;
/** Per-element result keeps missing/wrong-type ambiguity or value presence explicit. */
record RedisPrimitiveElementResult(Status status, Optional<RedisPrimitiveValue> value) {
enum Status {
PRESENT,
MISSING,
ABSENT_OR_WRONG_TYPE
}
RedisPrimitiveElementResult {
Objects.requireNonNull(status, "element status must be non-null");
Objects.requireNonNull(value, "element value must be non-null");
if ((status == Status.PRESENT) != value.isPresent()) {
throw new IllegalArgumentException("element status and value disagree");
}
}
static RedisPrimitiveElementResult present(RedisPrimitiveValue value) {
return new RedisPrimitiveElementResult(Status.PRESENT, Optional.of(value));
}
static RedisPrimitiveElementResult missing() {
return new RedisPrimitiveElementResult(Status.MISSING, Optional.empty());
}
}
@@ -0,0 +1,46 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.util.List;
import java.util.Objects;
import java.util.function.LongSupplier;
/** Validates catalog identity and dispatches through one admitted primitive command boundary. */
final class RedisPrimitiveExecutor {
private final RedisPrimitiveCatalog catalog;
private final RedisPrimitiveCommands commands;
private final LongSupplier ticker;
RedisPrimitiveExecutor(RedisPrimitiveCatalog catalog, RedisPrimitiveCommands commands) {
this(catalog, commands, System::nanoTime);
}
RedisPrimitiveExecutor(
RedisPrimitiveCatalog catalog, RedisPrimitiveCommands commands, LongSupplier ticker) {
this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null");
this.commands = Objects.requireNonNull(commands, "commands must be non-null");
this.ticker = Objects.requireNonNull(ticker, "ticker must be non-null");
}
RedisPrimitiveReply execute(
RedisPrimitiveId id,
List<RedisPrimitiveKey> keys,
RedisPrimitiveInvocation.Arguments arguments) {
RedisPrimitiveDescriptor descriptor = catalog.descriptor(id);
RedisPrimitiveInvocation invocation =
new RedisPrimitiveInvocation(catalog, descriptor, keys, arguments, ticker);
invocation.remainingDeadline();
return commands.execute(invocation);
}
RedisPrimitiveMutationResult mutate(
RedisPrimitiveId id,
List<RedisPrimitiveKey> keys,
RedisPrimitiveInvocation.Arguments arguments) {
try {
return RedisPrimitiveMutationResult.from(execute(id, keys, arguments));
} catch (RedisCommandFailureException failure) {
return RedisPrimitiveMutationResult.failed(failure);
}
}
}
@@ -0,0 +1,11 @@
package dev.caskeleton.adapter.outbound.cache.redis;
/** One binary-safe HSCAN field/value pair. */
record RedisPrimitiveHashEntry(RedisPrimitiveValue field, RedisPrimitiveValue value) {
RedisPrimitiveHashEntry {
if (field == null || value == null) {
throw new IllegalArgumentException("hash scan entry must be complete");
}
}
}
@@ -0,0 +1,51 @@
package dev.caskeleton.adapter.outbound.cache.redis;
enum RedisPrimitiveId {
STRING_GET(RedisPrimitiveStructure.STRING),
STRING_MGET(RedisPrimitiveStructure.STRING),
STRING_SET_PX(RedisPrimitiveStructure.STRING),
STRING_SET_NX_PX(RedisPrimitiveStructure.STRING),
STRING_SET_XX_PX(RedisPrimitiveStructure.STRING),
STRING_COMPARE_SET(RedisPrimitiveStructure.STRING),
STRING_COMPARE_DELETE(RedisPrimitiveStructure.STRING),
COUNTER_READ(RedisPrimitiveStructure.COUNTER),
COUNTER_INCREMENT_INITIAL_TTL(RedisPrimitiveStructure.COUNTER),
HASH_GET(RedisPrimitiveStructure.HASH),
HASH_MGET(RedisPrimitiveStructure.HASH),
HASH_SET_FIELDS(RedisPrimitiveStructure.HASH),
HASH_DELETE_FIELDS(RedisPrimitiveStructure.HASH),
HASH_SCAN_PAGE(RedisPrimitiveStructure.HASH),
HASH_REVISION_CAS(RedisPrimitiveStructure.HASH),
SET_CONTAINS(RedisPrimitiveStructure.SET),
SET_REMOVE(RedisPrimitiveStructure.SET),
SET_CARDINALITY(RedisPrimitiveStructure.SET),
SET_SCAN_PAGE(RedisPrimitiveStructure.SET),
SET_ADMIT(RedisPrimitiveStructure.SET),
ZSET_ADD(RedisPrimitiveStructure.SORTED_SET),
ZSET_REMOVE(RedisPrimitiveStructure.SORTED_SET),
ZSET_COUNT(RedisPrimitiveStructure.SORTED_SET),
ZSET_RANK_PAGE(RedisPrimitiveStructure.SORTED_SET),
ZSET_SCORE_PAGE(RedisPrimitiveStructure.SORTED_SET),
ZSET_TRIM_BOUNDED(RedisPrimitiveStructure.SORTED_SET),
LIST_POP(RedisPrimitiveStructure.LIST),
LIST_TRIM_FIXED_BOUNDS(RedisPrimitiveStructure.LIST),
LIST_ADMIT(RedisPrimitiveStructure.LIST),
BITMAP_GET(RedisPrimitiveStructure.BITMAP),
BITMAP_SET(RedisPrimitiveStructure.BITMAP),
BITMAP_COUNT_FIXED_RANGE(RedisPrimitiveStructure.BITMAP),
HLL_ADD(RedisPrimitiveStructure.HYPERLOGLOG),
HLL_COUNT(RedisPrimitiveStructure.HYPERLOGLOG),
HLL_MERGE_SAME_SLOT(RedisPrimitiveStructure.HYPERLOGLOG),
GEO_ADD(RedisPrimitiveStructure.GEO),
GEO_SEARCH(RedisPrimitiveStructure.GEO);
private final RedisPrimitiveStructure structure;
RedisPrimitiveId(RedisPrimitiveStructure structure) {
this.structure = structure;
}
RedisPrimitiveStructure structure() {
return structure;
}
}
@@ -0,0 +1,771 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.time.Duration;
import java.util.List;
import java.util.Objects;
import java.util.function.LongSupplier;
/** Closed descriptor-owned invocation; no Redis command name, raw key or Lua source is carried. */
final class RedisPrimitiveInvocation {
enum WriteCondition {
ALWAYS,
IF_ABSENT,
IF_PRESENT
}
sealed interface Arguments
permits NoArguments,
BinaryArguments,
ExpiringWrite,
ProgramArguments,
ScanArguments,
RangeArguments,
ScoreRangeArguments,
SortedSetArguments,
BitmapArguments,
BitmapCountArguments,
GeoArguments {
int encodedBytes();
}
enum NoArguments implements Arguments {
INSTANCE;
@Override
public int encodedBytes() {
return 0;
}
}
record BinaryArguments(List<RedisPrimitiveValue> values) implements Arguments {
BinaryArguments {
values = List.copyOf(Objects.requireNonNull(values, "values must be non-null"));
if (values.isEmpty()) {
throw new IllegalArgumentException("primitive binary arguments must not be empty");
}
}
@Override
public int encodedBytes() {
return checkedBytes(values);
}
}
record ExpiringWrite(
RedisPrimitiveValue value, RedisTtlMillis timeToLive, WriteCondition condition)
implements Arguments {
ExpiringWrite(RedisPrimitiveValue value, Duration timeToLive, WriteCondition condition) {
this(value, RedisTtlMillis.from(timeToLive), condition);
}
ExpiringWrite {
Objects.requireNonNull(value, "value must be non-null");
Objects.requireNonNull(timeToLive, "timeToLive must be non-null");
Objects.requireNonNull(condition, "condition must be non-null");
}
@Override
public int encodedBytes() {
return Math.addExact(value.encodedLength(), Long.BYTES);
}
}
sealed interface ProgramArguments extends Arguments
permits AtomicArguments,
CounterArguments,
CapacityArguments,
CompareSetArguments,
HashAdmissionArguments,
HashRevisionArguments,
SortedSetAdmissionArguments,
GeoAdmissionArguments,
MgetArguments,
ScanPageArguments {
List<RedisPrimitiveValue> programValues();
@Override
default int encodedBytes() {
return checkedBytes(programValues());
}
}
record AtomicArguments(List<RedisPrimitiveValue> values) implements ProgramArguments {
AtomicArguments {
values = List.copyOf(Objects.requireNonNull(values, "values must be non-null"));
if (values.isEmpty()) {
throw new IllegalArgumentException("primitive atomic arguments must not be empty");
}
}
@Override
public List<RedisPrimitiveValue> programValues() {
return values;
}
}
record CounterArguments(long delta, long minimum, long maximum, RedisTtlMillis initialTimeToLive)
implements ProgramArguments {
CounterArguments(long delta, long minimum, long maximum, Duration initialTimeToLive) {
this(delta, minimum, maximum, RedisTtlMillis.from(initialTimeToLive));
}
CounterArguments {
if (delta == 0 || minimum > maximum) {
throw new IllegalArgumentException("counter bounds are invalid");
}
Objects.requireNonNull(initialTimeToLive, "initialTimeToLive must be non-null");
}
@Override
public List<RedisPrimitiveValue> programValues() {
return List.of(
ascii(Long.toString(delta)),
ascii(Long.toString(minimum)),
ascii(Long.toString(maximum)),
ascii(Long.toString(initialTimeToLive.value())));
}
}
record CapacityArguments(
RedisPrimitiveValue value, RedisPrimitiveLimit capacity, RedisTtlMillis initialTimeToLive)
implements ProgramArguments {
CapacityArguments(
RedisPrimitiveValue value, RedisPrimitiveLimit capacity, Duration initialTimeToLive) {
this(value, capacity, RedisTtlMillis.from(initialTimeToLive));
}
CapacityArguments {
Objects.requireNonNull(value, "value must be non-null");
Objects.requireNonNull(capacity, "capacity must be non-null");
Objects.requireNonNull(initialTimeToLive, "initialTimeToLive must be non-null");
}
@Override
public List<RedisPrimitiveValue> programValues() {
return List.of(
value,
ascii(Integer.toString(capacity.value())),
ascii(Long.toString(initialTimeToLive.value())));
}
}
record CompareSetArguments(
ExpectedKind expectedKind,
RedisPrimitiveValue expectedValue,
RedisPrimitiveValue newValue,
RedisTtlMillis timeToLive)
implements ProgramArguments {
enum ExpectedKind {
ABSENT,
VALUE
}
CompareSetArguments(
ExpectedKind expectedKind,
RedisPrimitiveValue expectedValue,
RedisPrimitiveValue newValue,
Duration timeToLive) {
this(expectedKind, expectedValue, newValue, RedisTtlMillis.from(timeToLive));
}
CompareSetArguments {
Objects.requireNonNull(expectedKind, "expectedKind must be non-null");
Objects.requireNonNull(newValue, "newValue must be non-null");
if ((expectedKind == ExpectedKind.ABSENT && expectedValue != null)
|| (expectedKind == ExpectedKind.VALUE && expectedValue == null)) {
throw new IllegalArgumentException("compare-set expectation is invalid");
}
Objects.requireNonNull(timeToLive, "timeToLive must be non-null");
}
@Override
public List<RedisPrimitiveValue> programValues() {
return List.of(
ascii(expectedKind.name()),
expectedKind == ExpectedKind.ABSENT ? ascii("-") : expectedValue,
newValue,
ascii(Long.toString(timeToLive.value())));
}
}
record HashAdmissionArguments(
RedisPrimitiveValue field,
RedisPrimitiveValue value,
RedisPrimitiveLimit capacity,
RedisTtlMillis initialTimeToLive)
implements ProgramArguments {
HashAdmissionArguments(
RedisPrimitiveValue field,
RedisPrimitiveValue value,
RedisPrimitiveLimit capacity,
Duration initialTimeToLive) {
this(field, value, capacity, RedisTtlMillis.from(initialTimeToLive));
}
HashAdmissionArguments {
Objects.requireNonNull(field, "field must be non-null");
Objects.requireNonNull(value, "value must be non-null");
Objects.requireNonNull(capacity, "capacity must be non-null");
Objects.requireNonNull(initialTimeToLive, "initialTimeToLive must be non-null");
}
@Override
public List<RedisPrimitiveValue> programValues() {
return List.of(
field,
value,
ascii(Integer.toString(capacity.value())),
ascii(Long.toString(initialTimeToLive.value())));
}
}
record HashRevisionArguments(
ExpectedKind expectedKind,
String expectedRevision,
String nextRevision,
RedisPrimitiveValue value,
RedisTtlMillis initialTimeToLive)
implements ProgramArguments {
enum ExpectedKind {
ABSENT,
VALUE
}
HashRevisionArguments(
ExpectedKind expectedKind,
String expectedRevision,
String nextRevision,
RedisPrimitiveValue value,
Duration initialTimeToLive) {
this(
expectedKind,
expectedRevision,
nextRevision,
value,
RedisTtlMillis.from(initialTimeToLive));
}
HashRevisionArguments {
Objects.requireNonNull(expectedKind, "expectedKind must be non-null");
Objects.requireNonNull(expectedRevision, "expectedRevision must be non-null");
Objects.requireNonNull(nextRevision, "nextRevision must be non-null");
Objects.requireNonNull(value, "value must be non-null");
if ((expectedKind == ExpectedKind.ABSENT && !expectedRevision.isEmpty())
|| (expectedKind == ExpectedKind.VALUE && !token(expectedRevision))
|| !token(nextRevision)
|| expectedRevision.equals(nextRevision)) {
throw new IllegalArgumentException("hash revision token is invalid");
}
Objects.requireNonNull(initialTimeToLive, "initialTimeToLive must be non-null");
}
@Override
public List<RedisPrimitiveValue> programValues() {
return List.of(
ascii(expectedKind.name()),
ascii(expectedRevision.isEmpty() ? "-" : expectedRevision),
ascii(nextRevision),
value,
ascii(Long.toString(initialTimeToLive.value())));
}
}
record SortedSetAdmissionArguments(
RedisPrimitiveValue member,
RedisSortedSetScore score,
RedisPrimitiveLimit capacity,
RedisTtlMillis initialTimeToLive)
implements ProgramArguments {
SortedSetAdmissionArguments(
RedisPrimitiveValue member,
RedisSortedSetScore score,
RedisPrimitiveLimit capacity,
Duration initialTimeToLive) {
this(member, score, capacity, RedisTtlMillis.from(initialTimeToLive));
}
SortedSetAdmissionArguments {
Objects.requireNonNull(member, "member must be non-null");
Objects.requireNonNull(score, "score must be non-null");
Objects.requireNonNull(capacity, "capacity must be non-null");
Objects.requireNonNull(initialTimeToLive, "initialTimeToLive must be non-null");
}
@Override
public List<RedisPrimitiveValue> programValues() {
return List.of(
member,
ascii(score.canonical()),
ascii(Integer.toString(capacity.value())),
ascii(Long.toString(initialTimeToLive.value())));
}
}
record GeoAdmissionArguments(
RedisPrimitiveValue member,
RedisGeoCoordinate coordinate,
RedisPrimitiveLimit capacity,
RedisTtlMillis initialTimeToLive)
implements ProgramArguments {
GeoAdmissionArguments(
RedisPrimitiveValue member,
RedisGeoCoordinate coordinate,
RedisPrimitiveLimit capacity,
Duration initialTimeToLive) {
this(member, coordinate, capacity, RedisTtlMillis.from(initialTimeToLive));
}
GeoAdmissionArguments {
Objects.requireNonNull(member, "member must be non-null");
Objects.requireNonNull(coordinate, "coordinate must be non-null");
Objects.requireNonNull(capacity, "capacity must be non-null");
Objects.requireNonNull(initialTimeToLive, "initialTimeToLive must be non-null");
}
@Override
public List<RedisPrimitiveValue> programValues() {
return List.of(
member,
ascii(coordinate.canonicalLongitude()),
ascii(coordinate.canonicalLatitude()),
ascii(Integer.toString(capacity.value())),
ascii(Long.toString(initialTimeToLive.value())));
}
}
record MgetArguments(int requestedKeyCount, int maximumResultBytes, int maximumValueBytes)
implements ProgramArguments {
MgetArguments {
if (requestedKeyCount < 1
|| requestedKeyCount > 4
|| maximumResultBytes < 1
|| maximumResultBytes > 2_097_152
|| maximumValueBytes < 1
|| maximumValueBytes > 1_048_576) {
throw new IllegalArgumentException("bounded MGET arguments are invalid");
}
}
@Override
public List<RedisPrimitiveValue> programValues() {
return List.of(
ascii(Integer.toString(requestedKeyCount)),
ascii(Integer.toString(maximumResultBytes)),
ascii(Integer.toString(maximumValueBytes)));
}
}
record ScanPageArguments(
RedisPrimitiveCursor cursor, int corruptionCeiling, int maximumResultBytes)
implements ProgramArguments {
ScanPageArguments {
Objects.requireNonNull(cursor, "cursor must be non-null");
if (corruptionCeiling < 1
|| corruptionCeiling > 1024
|| maximumResultBytes < 1
|| maximumResultBytes > 2_097_152) {
throw new IllegalArgumentException("bounded scan arguments are invalid");
}
}
@Override
public List<RedisPrimitiveValue> programValues() {
return List.of(
ascii(cursor.rawCursor()),
ascii(Integer.toString(corruptionCeiling)),
ascii(Integer.toString(maximumResultBytes)));
}
}
record ScanArguments(RedisPrimitiveCursor cursor, RedisPrimitiveLimit limit, long routeEpoch)
implements Arguments {
ScanArguments {
Objects.requireNonNull(cursor, "cursor must be non-null");
Objects.requireNonNull(limit, "limit must be non-null");
if (routeEpoch < 0) {
throw new IllegalArgumentException("route epoch must be non-negative");
}
}
@Override
public int encodedBytes() {
return Math.addExact(cursor.rawCursor().length(), Integer.BYTES);
}
}
record RangeArguments(long first, long last, RedisPrimitiveLimit limit) implements Arguments {
RangeArguments {
Objects.requireNonNull(limit, "limit must be non-null");
if (first < 0 || last < first) {
throw new IllegalArgumentException("primitive range is invalid");
}
}
@Override
public int encodedBytes() {
return Long.BYTES * 2 + Integer.BYTES;
}
}
record ScoreRangeArguments(
RedisSortedSetScore minimum,
RedisSortedSetScore maximum,
long offset,
RedisPrimitiveLimit limit)
implements Arguments {
ScoreRangeArguments {
Objects.requireNonNull(minimum, "minimum score must be non-null");
Objects.requireNonNull(maximum, "maximum score must be non-null");
Objects.requireNonNull(limit, "limit must be non-null");
if (offset < 0) {
throw new IllegalArgumentException("sorted-set score range offset must be non-negative");
}
}
@Override
public int encodedBytes() {
return Math.addExact(
minimum.canonical().length() + maximum.canonical().length(), Long.BYTES + Integer.BYTES);
}
}
record SortedSetArguments(
RedisPrimitiveValue member, RedisSortedSetScore score, RedisPrimitiveLimit capacity)
implements Arguments {
SortedSetArguments {
Objects.requireNonNull(member, "member must be non-null");
Objects.requireNonNull(score, "score must be non-null");
Objects.requireNonNull(capacity, "capacity must be non-null");
}
@Override
public int encodedBytes() {
return Math.addExact(member.encodedLength(), score.canonical().length() + Integer.BYTES);
}
}
record BitmapArguments(RedisBitmapOffset first, RedisBitmapOffset last, int bit)
implements Arguments {
BitmapArguments {
Objects.requireNonNull(first, "first offset must be non-null");
Objects.requireNonNull(last, "last offset must be non-null");
if (last.value() < first.value() || bit < -1 || bit > 1) {
throw new IllegalArgumentException("bitmap arguments are invalid");
}
}
@Override
public int encodedBytes() {
return Long.BYTES * 2 + Integer.BYTES;
}
}
record BitmapCountArguments(RedisBitmapByteOffset first, RedisBitmapByteOffset last)
implements Arguments {
BitmapCountArguments {
Objects.requireNonNull(first, "first byte offset must be non-null");
Objects.requireNonNull(last, "last byte offset must be non-null");
if (last.value() < first.value()) {
throw new IllegalArgumentException("bitmap byte range is inverted");
}
}
@Override
public int encodedBytes() {
return Long.BYTES * 2;
}
}
record GeoArguments(
RedisGeoCoordinate coordinate,
Shape shape,
double firstMeters,
double secondMeters,
RedisPrimitiveLimit limit,
Sort sort)
implements Arguments {
enum Shape {
RADIUS,
BOX
}
enum Sort {
ASCENDING,
DESCENDING
}
GeoArguments {
Objects.requireNonNull(coordinate, "coordinate must be non-null");
Objects.requireNonNull(shape, "shape must be non-null");
Objects.requireNonNull(limit, "limit must be non-null");
Objects.requireNonNull(sort, "sort must be non-null");
if (!Double.isFinite(firstMeters)
|| firstMeters <= 0
|| firstMeters > 100_000
|| !Double.isFinite(secondMeters)
|| secondMeters < 0
|| secondMeters > 100_000
|| (shape == Shape.RADIUS && secondMeters != 0)
|| (shape == Shape.BOX && secondMeters == 0)) {
throw new IllegalArgumentException("geo shape exceeds descriptor bounds");
}
}
@Override
public int encodedBytes() {
return Double.BYTES * 4 + Integer.BYTES * 2;
}
}
private final RedisPrimitiveDescriptor descriptor;
private final List<RedisPrimitiveKey> keys;
private final Arguments arguments;
private final long startedAtNanos;
private final long budgetNanos;
private final LongSupplier ticker;
private final int encodedRequestBytes;
RedisPrimitiveInvocation(
RedisPrimitiveCatalog owner,
RedisPrimitiveDescriptor descriptor,
List<RedisPrimitiveKey> keys,
Arguments arguments,
LongSupplier ticker) {
Objects.requireNonNull(owner, "owner must be non-null");
this.descriptor = Objects.requireNonNull(descriptor, "descriptor must be non-null");
if (owner.descriptor(descriptor.id()) != descriptor) {
throw new IllegalArgumentException("primitive descriptor is not owned by catalog");
}
this.keys = descriptor.validateKeys(keys);
this.arguments = Objects.requireNonNull(arguments, "arguments must be non-null");
validateArguments(descriptor, this.arguments);
long bytes = arguments.encodedBytes();
for (RedisPrimitiveKey key : this.keys) {
bytes = Math.addExact(bytes, key.encodedLength());
}
if (bytes > descriptor.maximumEncodedBytes()) {
throw new IllegalArgumentException("primitive request exceeds descriptor byte bounds");
}
this.encodedRequestBytes = Math.toIntExact(bytes);
this.ticker = Objects.requireNonNull(ticker, "ticker must be non-null");
this.startedAtNanos = ticker.getAsLong();
this.budgetNanos = descriptor.totalDeadline().toNanos();
}
RedisPrimitiveDescriptor descriptor() {
return descriptor;
}
List<RedisPrimitiveKey> keys() {
return keys;
}
Arguments arguments() {
return arguments;
}
int encodedRequestBytes() {
return encodedRequestBytes;
}
Duration remainingDeadline() {
long elapsed = ticker.getAsLong() - startedAtNanos;
if (elapsed < 0 || elapsed >= budgetNanos) {
throw new RedisCommandFailureException(
RedisCommandFailureException.Kind.UNAVAILABLE,
RedisCommandFailureException.Certainty.NOT_APPLIED,
"Redis primitive total deadline expired before dispatch",
null);
}
return Duration.ofNanos(budgetNanos - elapsed);
}
private static int checkedBytes(List<RedisPrimitiveValue> values) {
int total = 0;
for (RedisPrimitiveValue value : values) {
total = Math.addExact(total, value.encodedLength());
}
return total;
}
private static void validateArguments(RedisPrimitiveDescriptor descriptor, Arguments arguments) {
switch (descriptor.id()) {
case STRING_GET, COUNTER_READ, SET_CARDINALITY, LIST_POP, HLL_COUNT ->
require(arguments, NoArguments.class);
case STRING_SET_PX, STRING_SET_NX_PX, STRING_SET_XX_PX -> {
ExpiringWrite write = require(arguments, ExpiringWrite.class);
bounded(write.value(), descriptor.maximumValueBytes(), "value");
}
case STRING_COMPARE_SET -> {
CompareSetArguments compare = require(arguments, CompareSetArguments.class);
if (compare.expectedValue() != null) {
bounded(compare.expectedValue(), descriptor.maximumValueBytes(), "expected value");
}
bounded(compare.newValue(), descriptor.maximumValueBytes(), "new value");
}
case STRING_COMPARE_DELETE -> {
AtomicArguments compare = require(arguments, AtomicArguments.class);
exactly(compare.values(), 1);
bounded(compare.values().getFirst(), descriptor.maximumValueBytes(), "expected value");
}
case STRING_MGET -> {
MgetArguments mget = require(arguments, MgetArguments.class);
if (mget.maximumResultBytes() != descriptor.maximumResultBytes()
|| mget.maximumValueBytes() != descriptor.maximumValueBytes()) {
throw new IllegalArgumentException("MGET bounds are not descriptor-owned");
}
}
case COUNTER_INCREMENT_INITIAL_TTL -> require(arguments, CounterArguments.class);
case HASH_GET, HASH_MGET, HASH_DELETE_FIELDS -> {
BinaryArguments fields = require(arguments, BinaryArguments.class);
if (fields.values().size() > descriptor.maximumElements()) {
throw new IllegalArgumentException("hash field count exceeds descriptor bounds");
}
fields.values().forEach(value -> bounded(value, descriptor.maximumFieldBytes(), "field"));
}
case HASH_SET_FIELDS -> {
HashAdmissionArguments hash = require(arguments, HashAdmissionArguments.class);
bounded(hash.field(), descriptor.maximumFieldBytes(), "field");
bounded(hash.value(), descriptor.maximumValueBytes(), "value");
validateLimit(hash.capacity(), descriptor);
}
case HASH_SCAN_PAGE, SET_SCAN_PAGE -> {
ScanPageArguments scan = require(arguments, ScanPageArguments.class);
if (scan.corruptionCeiling() > descriptor.maximumElements()
|| scan.maximumResultBytes() != descriptor.maximumResultBytes()) {
throw new IllegalArgumentException("scan bounds are not descriptor-owned");
}
}
case HASH_REVISION_CAS -> {
HashRevisionArguments revision = require(arguments, HashRevisionArguments.class);
bounded(revision.value(), descriptor.maximumValueBytes(), "value");
}
case SET_CONTAINS, SET_REMOVE -> {
BinaryArguments members = require(arguments, BinaryArguments.class);
if (members.values().size() > descriptor.maximumElements()) {
throw new IllegalArgumentException("set member count exceeds descriptor bounds");
}
members
.values()
.forEach(value -> bounded(value, descriptor.maximumMemberBytes(), "member"));
}
case SET_ADMIT -> {
CapacityArguments admission = require(arguments, CapacityArguments.class);
bounded(admission.value(), descriptor.maximumMemberBytes(), "member");
validateLimit(admission.capacity(), descriptor);
}
case ZSET_ADD -> {
SortedSetAdmissionArguments admission =
require(arguments, SortedSetAdmissionArguments.class);
bounded(admission.member(), descriptor.maximumMemberBytes(), "member");
validateLimit(admission.capacity(), descriptor);
}
case ZSET_REMOVE -> {
BinaryArguments members = require(arguments, BinaryArguments.class);
if (members.values().size() > descriptor.maximumElements()) {
throw new IllegalArgumentException("sorted-set member count exceeds descriptor bounds");
}
members
.values()
.forEach(value -> bounded(value, descriptor.maximumMemberBytes(), "member"));
}
case ZSET_COUNT, ZSET_SCORE_PAGE -> {
ScoreRangeArguments range = require(arguments, ScoreRangeArguments.class);
validateLimit(range.limit(), descriptor);
}
case ZSET_RANK_PAGE -> {
RangeArguments range = require(arguments, RangeArguments.class);
validateLimit(range.limit(), descriptor);
}
case ZSET_TRIM_BOUNDED, LIST_TRIM_FIXED_BOUNDS -> require(arguments, AtomicArguments.class);
case LIST_ADMIT -> {
CapacityArguments admission = require(arguments, CapacityArguments.class);
bounded(admission.value(), descriptor.maximumValueBytes(), "value");
validateLimit(admission.capacity(), descriptor);
}
case BITMAP_GET, BITMAP_SET -> {
BitmapArguments bitmap = require(arguments, BitmapArguments.class);
if (bitmap.first().maximumExclusive() != 8_388_608
|| bitmap.last().maximumExclusive() != 8_388_608) {
throw new IllegalArgumentException("bitmap offsets are not descriptor-owned");
}
}
case BITMAP_COUNT_FIXED_RANGE -> require(arguments, BitmapCountArguments.class);
case HLL_ADD -> {
BinaryArguments elements = require(arguments, BinaryArguments.class);
if (elements.values().size() > descriptor.maximumElements()) {
throw new IllegalArgumentException("HLL element count exceeds descriptor bounds");
}
elements.values().forEach(value -> bounded(value, descriptor.maximumValueBytes(), "value"));
}
case HLL_MERGE_SAME_SLOT -> require(arguments, NoArguments.class);
case GEO_ADD -> {
GeoAdmissionArguments admission = require(arguments, GeoAdmissionArguments.class);
bounded(admission.member(), descriptor.maximumMemberBytes(), "member");
validateLimit(admission.capacity(), descriptor);
}
case GEO_SEARCH -> {
GeoArguments geo = require(arguments, GeoArguments.class);
validateLimit(geo.limit(), descriptor);
}
default -> throw new IllegalArgumentException("primitive operation has no argument contract");
}
}
private static <T extends Arguments> T require(Arguments arguments, Class<T> type) {
if (!type.isInstance(arguments)) {
throw new IllegalArgumentException("primitive arguments do not match operation descriptor");
}
return type.cast(arguments);
}
private static void exactly(List<?> values, int expected) {
if (values.size() != expected) {
throw new IllegalArgumentException("primitive argument arity is invalid");
}
}
private static void bounded(RedisPrimitiveValue value, int maximumBytes, String kind) {
if (value.encodedLength() > maximumBytes) {
throw new IllegalArgumentException("primitive " + kind + " exceeds descriptor bounds");
}
}
private static void validateLimit(
RedisPrimitiveLimit limit, RedisPrimitiveDescriptor descriptor) {
if (limit.maximum() != descriptor.maximumElements()
|| limit.value() > descriptor.maximumElements()) {
throw new IllegalArgumentException("primitive limit is not descriptor-owned");
}
}
private static boolean token(String value) {
return value.matches("[A-Za-z0-9_-]{1,64}");
}
private static RedisPrimitiveValue ascii(String value) {
return RedisPrimitiveValue.utf8(value, 128);
}
}
@@ -0,0 +1,74 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
final class RedisPrimitiveKey {
private final String family;
private final int version;
private final String slot;
private final String identity;
private final RedisPhysicalKey physicalKey;
private RedisPrimitiveKey(
RedisPrimitiveKeyFactory owner,
String family,
int version,
String slot,
String identity,
int maximumKeyBytes) {
if (!Objects.requireNonNull(owner, "owner must be non-null").owns(family, version)) {
throw new IllegalArgumentException("primitive key factory does not own key family");
}
this.family = family;
this.version = version;
this.slot = Objects.requireNonNull(slot, "slot must be non-null");
this.identity = Objects.requireNonNull(identity, "identity must be non-null");
String encoded = "ca:primitive:" + family + ":v" + version + ":{" + slot + "}:" + identity;
byte[] keyBytes = encoded.getBytes(StandardCharsets.UTF_8);
if (keyBytes.length > maximumKeyBytes) {
throw new IllegalArgumentException("primitive key material is outside bounds");
}
this.physicalKey = RedisPhysicalKey.primitive(this);
}
static RedisPrimitiveKey canonical(
RedisPrimitiveKeyFactory owner,
String family,
int version,
String slot,
String identity,
int maximumKeyBytes) {
return new RedisPrimitiveKey(owner, family, version, slot, identity, maximumKeyBytes);
}
String family() {
return family;
}
int version() {
return version;
}
String slot() {
return slot;
}
String identity() {
return identity;
}
int encodedLength() {
return physicalKey.encodedLength();
}
RedisPhysicalKey physicalKey() {
return physicalKey;
}
@Override
public String toString() {
return "RedisPrimitiveKey[family=" + family + ",version=" + version + ",redacted]";
}
}
@@ -0,0 +1,37 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.util.Objects;
final class RedisPrimitiveKeyFactory {
private final RedisPrimitiveDescriptor descriptor;
private RedisPrimitiveKeyFactory(RedisPrimitiveDescriptor descriptor) {
this.descriptor = Objects.requireNonNull(descriptor, "descriptor must be non-null");
}
static RedisPrimitiveKeyFactory canonical(RedisPrimitiveCatalog catalog, RedisPrimitiveId id) {
Objects.requireNonNull(catalog, "catalog must be non-null");
return new RedisPrimitiveKeyFactory(catalog.descriptor(id));
}
RedisPrimitiveKey key(String slot, String identity) {
if (slot == null
|| !slot.matches("[A-Za-z0-9_-]{1,64}")
|| identity == null
|| !identity.matches("[A-Za-z0-9._:-]{1,256}")) {
throw new IllegalArgumentException("primitive key material is outside bounds");
}
return RedisPrimitiveKey.canonical(
this,
descriptor.keyFamily(),
descriptor.keyVersion(),
slot,
identity,
descriptor.maximumKeyBytes());
}
boolean owns(String family, int version) {
return descriptor.keyFamily().equals(family) && descriptor.keyVersion() == version;
}
}

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