feat: add production capability foundations
This commit is contained in:
@@ -15,6 +15,10 @@ Package root: `dev.caskeleton.adapter.outbound.cache`.
|
||||
- Implement semantic cache ports from `application-core` without exposing Redis concepts to core.
|
||||
- Own canonical physical keys, digesting, codec/envelope, program catalog, typed Redis atomic
|
||||
facades, runtime client adaptation, and capability-specific failure semantics.
|
||||
- Implement absolute soft/hard expiry and deterministic bounded jitter behind the semantic cache
|
||||
port; cache-aside/source protection policy remains framework-free in `application-core`.
|
||||
- Implement the provider-neutral `EdgeRateLimitPort` with dedicated coordination Redis settings,
|
||||
connection/admission, private keys and versioned atomic programs.
|
||||
- Keep the legacy cache router isolated while consumers migrate to semantic ports.
|
||||
- Reuse `adapter:outbound:support` for shared outbound concerns.
|
||||
|
||||
@@ -24,10 +28,14 @@ Package root: `dev.caskeleton.adapter.outbound.cache`.
|
||||
`src/config/architecture/modules.json` entry.
|
||||
- No inbound transport, persistence entity/repository, bootstrap, or sample dependency.
|
||||
- Cache adapters do not decide business freshness, entitlement, or domain fallback rules.
|
||||
- Physical Redis TTL must equal encoded hard expiry; future/corrupt schema must never collapse into
|
||||
an ordinary miss.
|
||||
- Application/domain code must not receive raw Redis keys, commands, Lua/Function names, SDK
|
||||
objects, topology, or connection types.
|
||||
- Cache fail-open behavior must not be reused for session, idempotency, strict quota, lease, or
|
||||
fencing.
|
||||
- Rate-limit composition must not reuse `app.cache.redis`, its connection, external client mode or
|
||||
failure-open semantics; v1 is coordination-role and fail-closed only.
|
||||
- The standalone runtime/cache service lane is R1 evidence only. Sentinel/Cluster, TLS/ACL,
|
||||
persistence/restart, eviction and fault evidence are required separately for R2.
|
||||
|
||||
|
||||
@@ -10,16 +10,157 @@
|
||||
|
||||
## 현재 readiness
|
||||
|
||||
현재 standalone runtime과 semantic string cache는 R1이다. 모듈이 Lettuce connection lifecycle,
|
||||
현재 checked-in readiness registry에는 `selected` card가 없으므로 Redis R2 release claim도
|
||||
없다.
|
||||
|
||||
| Capability card | 현재 상태 | Promotion topology |
|
||||
| --- | --- | --- |
|
||||
| cache | `implemented-candidate` | standalone |
|
||||
| edge rate limit | `implemented-candidate` | standalone |
|
||||
| request-replay idempotency | `implemented-candidate` | standalone |
|
||||
| cache refresh soft lease | `implemented-candidate` | standalone |
|
||||
| session | `implemented-candidate` | standalone |
|
||||
| fenced coordination | `not-implemented` | 없음 |
|
||||
|
||||
`implemented-candidate`는 구현과 standalone/security/fault/compatibility evidence lane이 있다는
|
||||
뜻일 뿐 release selection이나 R2 qualification이 아니다. 현재 evidence는 Sentinel/Cluster,
|
||||
k3s multi-node, topology failover, credential/certificate rotation 또는 R3를 증명하지 않는다.
|
||||
|
||||
모듈은 Lettuce connection lifecycle,
|
||||
finite command timeout, reconnect replay 차단, finite request queue/admission, positive/negative
|
||||
TTL, digest-protected bounded binary envelope, HMAC physical key,
|
||||
invalidation, Lua `EVALSHA -> NOSCRIPT -> EVAL` 실행기를 제공한다.
|
||||
TTL, absolute soft/hard expiry, deterministic bounded TTL jitter, digest-protected v2 binary
|
||||
envelope, HMAC physical key,
|
||||
invalidation, closed-catalog
|
||||
`EVALSHA -> NOSCRIPT -> SCRIPT LOAD -> digest verify -> EVALSHA` recovery를 제공한다.
|
||||
`app.cache.redis.client-mode=external`이면 프로젝트가 제공한 `RedisClient` 호환 경로를 사용하고
|
||||
managed connection을 생성하지 않는다.
|
||||
|
||||
명시적으로 Redis 7.4 image를 띄워 실행하는 standalone lane이 실제 expiry와
|
||||
compare-and-delete Lua 실행을 검증하지만 Sentinel/Cluster,
|
||||
TLS/ACL/credential rotation, restart/fault/eviction evidence, health/metrics가 없으므로 R2가 아니다.
|
||||
명시적으로 최소 지원 Redis 7.2 image를 띄워 실행하는 standalone lane이 실제 expiry,
|
||||
compare-and-delete, cache `NX`, observation-token compare-and-replace, 세 rate-limit 프로그램,
|
||||
각 프로그램의 exact-boundary/denial-no-consume, clock-regression state 불변,
|
||||
token refill remainder와 malformed hash 분류를 검증한다. TLS named-user ACL에서 semantic
|
||||
readiness의 `SCRIPT LOAD`/대표 명령 거부 증거는 있지만 Sentinel/Cluster, credential rotation,
|
||||
restart/fault/eviction과 capability 전체의 운영 증거가 완성되지 않았으므로 R2가 아니다.
|
||||
|
||||
## Role policy와 health 경계
|
||||
|
||||
Canonical role binding은 startup에 다음 정책을 fail-closed로 검증한다.
|
||||
|
||||
- `CACHE`: `required=false`, `expected-eviction=allkeys-lfu|allkeys-lru`
|
||||
- `COORDINATION`: `required=true`, `expected-eviction=noeviction`
|
||||
- `SESSION`: `required=true`, `expected-eviction=noeviction`
|
||||
|
||||
Redis 모듈은 바인딩된 role router만 사용해 capability-aware semantic probe를 수행한다. PING만으로
|
||||
ready를 선언하지 않는다. 모든 plan은 `ca-health:` namespace의 bounded opaque nonce key에 먼저
|
||||
5초 TTL을 부여하고 SET/GET round trip을 검증한다. 선택 capability별 대표 프로그램은 다음과 같다.
|
||||
|
||||
- cache: `SET_IF_ABSENT_WITH_TTL`
|
||||
- rate limit: `RATE_FIXED_WINDOW_V2`
|
||||
- request-replay idempotency: `IDEMPOTENCY_CLAIM_V1`
|
||||
- efficiency lease: `LEASE_ACQUIRE_V1`
|
||||
- session: `SESSION_CREATE_V1`
|
||||
|
||||
대표 프로그램은 catalog digest의 `EVALSHA` 경로와 bounded result schema를 검증한다. 별도의
|
||||
catalog-owned `semantic-capability-acl-v1` 프로그램은 Redis Lua API의
|
||||
`redis.acl_check_cmd`로 대표 프로그램의 exact ACL command/key surface와 `SCRIPT LOAD` 권한을
|
||||
비변경 방식으로 확인하고, `redis.REDIS_VERSION_NUM`으로 명시적인 Redis `>=7.2` policy gate를
|
||||
먼저 적용한다. 두 Lua API 상수/함수는 Redis 7.0부터 제공되지만 이 템플릿이 지원을 선언하는
|
||||
minimum은 7.2다. runtime identity에 허용해야 하는 probe key pattern은
|
||||
`~ca-health:*`다. probe는 성공/실패와 무관하게 best-effort cleanup을 수행하고, cleanup이
|
||||
거절돼도 모든 생성 key는 최대 5초 안에 만료된다.
|
||||
|
||||
각 role은 startup에 full semantic qualification을 완료한 관측을 seed한다. 이후 health scrape는
|
||||
`APP_REDIS_SEMANTIC_PROBE_MINIMUM_INTERVAL`(기본 5초) 동안 같은 관측을 재사용하고 role별
|
||||
single-flight로만 refresh한다. refresh follower는 기다리지 않으며 15초 기본
|
||||
`APP_REDIS_SEMANTIC_PROBE_MAXIMUM_STALENESS` 안에서는 이전 관측과 `semanticObservedAt`,
|
||||
`semanticAgeMillis`, `semanticStale=true`를 반환한다. 최대 staleness를 넘으면
|
||||
`SEMANTIC_OBSERVATION_STALE`로 fail closed한다. eligibility와 age는 monotonic ticker를 사용해
|
||||
wall-clock jump의 영향을 받지 않는다.
|
||||
|
||||
연결 가능한 optional/required role의 ACL, Redis 7.2 minimum, program result/schema mismatch는
|
||||
모두 startup-fatal이다. 명확히 분류된 temporary connect/PING 실패만 optional CACHE를 dormant
|
||||
route와 `COMMAND_UNAVAILABLE` 관측으로 시작하게 한다. health-triggered single-flight reconnect는
|
||||
후보에 PING과 full semantic qualification을 모두 수행한 뒤에만 기존 router를 swap하며,
|
||||
required COORDINATION/SESSION과 auth/TLS/material/unknown failure는 계속 fail closed한다.
|
||||
|
||||
Cluster에서 same-slot probe가 증명하는 범위는 해당 hash slot owner 한 노드뿐이다. 이 결과를
|
||||
cluster 전체 노드나 failover target의 version/ACL/program 호환성 증거로 확대 해석하면 안 되며,
|
||||
운영 promotion 전 별도의 cluster-wide 외부 conformance가 필요하다.
|
||||
|
||||
`shared-contract`의 framework-neutral snapshot은 role, 선택된 capability, availability,
|
||||
sanitized reason, semantic observation metadata와 expected eviction만 제공한다. semantic success, read/write failure,
|
||||
program ACL denial, program failure, admission saturation, recent command failure, closed route,
|
||||
command unavailable, probe-in-progress, stale observation은 서로 다른 bounded reason이다. endpoint, deployment ID, key/value,
|
||||
username, credential/trust reference와 server exception은 health detail에 노출하지 않는다.
|
||||
Actuator 타입과 health-group 소유권은 `app-bootstrap`에 있다. CACHE 장애는
|
||||
`redisOptional`의 `state=DEGRADED` detail로만 나타나고 readiness를 내리지 않는다.
|
||||
COORDINATION/SESSION 장애는 `redisRequired`를 `DOWN`으로 만들며, 어떤 Redis contributor도
|
||||
liveness에는 포함되지 않는다. role binding이 없으면 Redis client 생성과 Redis health
|
||||
contributor 생성은 모두 0이다.
|
||||
|
||||
이 runtime은 Redis `CONFIG GET/SET` 권한을 요구하거나 노출하지 않는다. 따라서
|
||||
`expected-eviction` 검증은 설정 의도에 대한 startup 검증이며 실제 server의
|
||||
`maxmemory-policy`를 증명하지 않는다. Snapshot/health detail은 이 한계를
|
||||
`CONFIGURED_EXPECTATION_ONLY`로, 외부 증거 상태를
|
||||
`externalEvictionAttestation=INCOMPLETE`로 명시한다. 운영 readiness를 더 강하게 만들려면 배포
|
||||
파이프라인의 외부 conformance job 또는 서명된 operator attestation으로 effective policy를
|
||||
검증해야 한다. semantic probe는 runtime `CONFIG`/`ACL` 조회나 변경 권한을 요구하지 않는다.
|
||||
|
||||
## Distributed edge rate limit
|
||||
|
||||
`shared-contract`의 `EdgeRateLimitPort` 뒤에서 fixed window, sliding-window counter, token bucket을
|
||||
정확히 하나의 versioned Lua 실행으로 평가한다. 세 프로그램은 Redis `TIME`을 한 번만 읽고, server
|
||||
time, bounded clock-regression clamp, denial-no-consume, finite state TTL과 정확히 7개 필드인 응답
|
||||
계약을 공유한다. Redis `TYPE`의 status-table/string 차이를 정규화하고 malformed hash field는
|
||||
typed incompatibility로 닫는다. Token bucket은 refill division remainder를 상태로 보존해 호출
|
||||
빈도에 따라 quota가 달라지지 않는다. Sliding counter만 algorithm certainty가 approximate이고
|
||||
나머지는 certain이다.
|
||||
|
||||
모든 closed program manifest의 `minimumRedisVersion`은 실제 minimum qualification lane과 같은
|
||||
7.2다. 더 낮은 Redis 버전은 별도 service lane이 추가되기 전까지 호환을 주장하지 않는다.
|
||||
|
||||
## Redis-backed HTTP session
|
||||
|
||||
`redis-session` readiness card는 standalone을 선택 topology로 하는 implemented candidate다.
|
||||
`RedisVersionedSessionRepository`는 Spring Session의 저장소 경계만 구현하고, 쿠키·CSRF·session
|
||||
fixation 정책은 inbound web이 소유한다. 실제 Redis 상태 변경은 manifest로 닫힌 6개 Lua 프로그램
|
||||
(create/inspect/save/touch/revoke/rotate)을 통해서만 수행한다.
|
||||
|
||||
- raw session ID는 physical key에 들어가지 않고 versioned HMAC digest로 변환된다.
|
||||
- idle timeout과 absolute lifetime을 동시에 적용하며 touch 쓰기는 설정된 interval로 제한한다.
|
||||
- logout은 revision `0`의 adapter-private force-revoke를 사용한다. 하나의 Lua 실행에서 tombstone을
|
||||
먼저 만들고 live hash를 삭제하므로 concurrent stale save가 세션을 부활시킬 수 없다.
|
||||
- rotation은 old ID tombstone과 new ID 생성을 원자적으로 수행한다. old/new ID가 서로 다른 Cluster
|
||||
slot이므로 현재 activation은 standalone만 허용하고 Cluster와 Sentinel을 startup에서 거부한다.
|
||||
- 저장 payload는 N/N-1 version을 읽는 명시적 primitive allowlist envelope다. Java serialization과
|
||||
default typing을 쓰지 않는다. SHA-256 checksum은 우발적 손상 탐지용이며 authenticity 또는 공격자
|
||||
변조 방지 보장이 아니다.
|
||||
- timeout/response loss와 OOM은 성공이나 miss로 바꾸지 않고 unavailable/indeterminate로 닫는다.
|
||||
별도 요청에서 같은 operation ID를 자동 재사용해 reconcile하지 않으므로 운영자는 timeout 뒤에
|
||||
mutation 성공을 추정하면 안 된다.
|
||||
|
||||
현재 저장소는 의도적으로 unindexed baseline이다. principal lookup, 사용자 전체 logout,
|
||||
maximum-concurrent-session 제어는 제공하지 않는다. 이 기능이 필요한 프로젝트는 별도 bounded index와
|
||||
그 index의 원자성·복구 증거를 추가해야 한다. 현재 `card-redis-session` 레인은 같은 JVM 안의 서로
|
||||
독립적인 두 runtime/repository client가 하나의 standalone Redis를 공유할 때의 logout/stale-save
|
||||
race, TLS+named ACL, partition+`noeviction` OOM/recovery, Redis 7.2/7.4 compatibility를 검증한다.
|
||||
이는 multi-process/pod, rolling deployment, pod/network failure qualification이 아니다.
|
||||
|
||||
아웃바운드 provider의 기본값은
|
||||
`ca-skeleton.capabilities.rate-limit.provider=disabled`다. `redis`로 선택하면 canonical
|
||||
`COORDINATION` role, `failure-policy=fail-closed`, default policy와 secret reference가 모두
|
||||
필요하다. `app.rate-limit.enabled`는 HTTP transport enforcement만 제어하며 provider를 암묵적으로
|
||||
선택하거나 fallback을 만들지 않는다. 설정은 `app.cache.redis`를 fallback으로 사용하지 않고,
|
||||
`distributedRateLimiter`라는 semantic port bean만 외부에 제공한다. Caller deadline이 canonical
|
||||
Redis command timeout보다 짧으면 command를 보내지 않고 typed no-mutation outcome을 반환한다.
|
||||
|
||||
Rate-limit physical key는 raw principal/IP/API key를 포함하지 않고 policy ID/revision/algorithm과
|
||||
이미 pseudonymized된 subject digest를 다시 HMAC한다. Unknown policy/state/program/reply,
|
||||
pre-send admission failure, post-dispatch indeterminate failure와 unsafe Redis clock을 서로 다른
|
||||
outcome으로 보존하며 fail-open하지 않는다. 현재 standalone과 standalone TLS+named ACL의
|
||||
`implemented-candidate` evidence가 있다. Sentinel/Cluster, topology failover,
|
||||
credential/certificate rotation, effective eviction/persistence attestation과 R3 증거는 없으며,
|
||||
checked-in `selected` card가 없으므로 R2 release claim도 없다.
|
||||
|
||||
## Application cache contract
|
||||
|
||||
@@ -36,6 +177,21 @@ TLS/ACL/credential rotation, restart/fault/eviction evidence, health/metrics가
|
||||
TTL, jitter, codec, topology와 Redis SDK 타입은 이 port에 들어가지 않는다. 실제 product의
|
||||
use case는 `CacheRegionPort`를 상속한 semantic subtype을 정의해야 한다.
|
||||
|
||||
`application-core`의 `CacheAsideExecutor`는 lookup/source/write 흐름을 공통화하고 다음을
|
||||
보장한다.
|
||||
|
||||
- fresh/negative hit에서 source를 호출하지 않음;
|
||||
- authoritative absence만 negative cache하고, miss refill은 `ONLY_IF_ABSENT`, stale/quarantine
|
||||
refill은 `ONLY_IF_OBSERVED`로 기록;
|
||||
- classified transient source failure에서만 hard expiry 전 stale fallback;
|
||||
- local single-flight의 in-flight key/waiter bound와 abandoned-flight opportunistic cleanup;
|
||||
- source bulkhead의 concurrency/admission/load deadline bound;
|
||||
- unclassified exception과 interrupt/cancellation 보존.
|
||||
|
||||
동기 source loader는 cooperative cancellation token을 확인해야 한다. 임의 source 코드를
|
||||
강제 종료하지 않으며, source가 token/deadline을 무시하면 bulkhead permit은 반환 시점까지
|
||||
점유된다.
|
||||
|
||||
## Physical key
|
||||
|
||||
`RedisKeyBuilder`만 다음 canonical shape를 만든다.
|
||||
@@ -50,24 +206,43 @@ version, 정확히 하나인 hash tag와 전체 UTF-8 byte bound를 검증한다
|
||||
|
||||
## Atomic program foundation
|
||||
|
||||
`redis/program-set.json`은 세 Lua resource의 exact digest, signature, status, complexity와 timeout
|
||||
certainty를 기록한다. `RedisAtomicPrimitives`는 compare-delete, compare-expire,
|
||||
set-if-absent-with-TTL을 typed result로 노출하고 unknown status를 compatibility failure로
|
||||
처리한다. owner/value/operation/TTL은 Redis 호출 전에 제한된다.
|
||||
`redis/*-program-set.json`과 `redis/program-set.json`은 cache/rate/idempotency/lease/session 및
|
||||
primitive Lua resource의 exact digest, signature, status, complexity와 timeout certainty를
|
||||
기록한다. `RedisAtomicPrimitives`는 compare-delete,
|
||||
compare-expire, set-if-absent-with-TTL, replace-if-observed-with-TTL을 typed result로 노출하고
|
||||
unknown status를 compatibility failure로 처리한다. owner/value/observation/operation/TTL은
|
||||
Redis 호출 전에 제한된다. `redis/rate-program-set.json`은 structured rate-limit 프로그램의
|
||||
별도 digest/signature/status manifest다.
|
||||
Generic descriptor/catalog/executor와 typed primitive facade는 package-private collaborator다.
|
||||
Spring composition에는 raw Redis key/value/TTL을 받는 bean을 노출하지 않으며, 이후 semantic
|
||||
port adapter가 내부에서만 이 facade를 사용한다.
|
||||
따라서 이 program set은 현재 internal R0 foundation이며, 실제 도메인 capability가 바로 소비할
|
||||
수 있는 production bean이나 application port가 아니다.
|
||||
이 primitive facade 자체는 application에 노출되는 범용 Redis port가 아니다. Cache, rate limit,
|
||||
idempotency, soft lease, session의 semantic provider만 closed catalog를 내부에서 소비하며, 이
|
||||
구조 자체가 release selection이나 R2 qualification을 뜻하지 않는다.
|
||||
|
||||
`RedisLuaProgramExecutor`가 catalog source로 SHA-1 script identity를 계산하여 `EVALSHA`를 먼저
|
||||
호출하고 정확히 `NOSCRIPT`일 때만 compiled script를 `EVAL`한다. signature/argument bounds는
|
||||
호출하고 정확히 `NOSCRIPT`일 때만 catalog script를 `SCRIPT LOAD`한다. 반환 digest가 예상 identity와
|
||||
같은지 확인한 뒤 `EVALSHA`를 한 번만 재시도한다. signature/argument bounds는
|
||||
client 호출 전에 다시 검증하고 descriptor catalog membership 및 반환 status membership을
|
||||
확인한다. unit lane은 강제 `NOSCRIPT` fallback을 검증하고 standalone real-service lane은
|
||||
compare-and-delete의 실제 atomic execution을 검증한다.
|
||||
확인한다. unit lane은 강제 `NOSCRIPT` load/retry를 검증하고 standalone real-service lane은
|
||||
compare-and-delete, NX, bounded trailing-digest observed replace, concurrent-writer 보존을 실제
|
||||
Redis 7.2에서 검증한다. 같은 lane은 16MiB payload의 record/read/observed-replace와
|
||||
16MiB+1 사전 거부, mutation interrupt의 `INDETERMINATE` certainty와 interrupt flag 복원도
|
||||
실행한다.
|
||||
|
||||
## Managed runtime과 semantic region
|
||||
|
||||
Canonical activation은
|
||||
`ca-skeleton.capabilities.cache.bindings.default=redis`와
|
||||
`ca-skeleton.providers.redis.roles.cache`를 함께 요구한다. 전자는 semantic policy를, 후자는
|
||||
topology/TLS/ACL credential을 소유한다. Canonical region은 legacy `app.cache.redis.host`,
|
||||
`password`, raw HMAC 값을 읽지 않고 CACHE role router와
|
||||
`RedisCredentialMaterialProvider`의 `secret://` reference만 사용한다. 같은 CACHE router가 L2
|
||||
command와 invalidation Pub/Sub을 함께 route하므로 topology rotation 때 새 subscription ACK가
|
||||
확인된 뒤 route가 교체된다. Canonical/legacy 동시 활성은 precedence를 추측하지 않고 startup에서
|
||||
거절한다. 현재 템플릿이 자동 조합하는 semantic region ID는 `default` 하나이며, 여러 product
|
||||
region은 region registry/compiler가 추가되기 전까지 자동 생성한다고 주장하지 않는다.
|
||||
|
||||
`app.cache.redis.enabled=true`이고 `client-mode=managed`(기본값)이면 `LettuceRedisRuntime`이
|
||||
단일 binary connection을 생성하고 종료 시 connection/client를 닫는다. 프로젝트가
|
||||
`RedisClient`를 직접 제공하는 경우에는 `client-mode=external`을 명시해야 한다. 이 선택을
|
||||
@@ -86,13 +261,24 @@ opaque source revision에는 대소 비교 의미가 없으므로
|
||||
`ONLY_IF_SOURCE_REVISION_NEWER`는 임의 lexical comparison을 하지 않고
|
||||
`NOT_RECORDED_PROVIDER_POLICY`를 반환한다.
|
||||
|
||||
Envelope는 source revision의 application invariant(1..128 characters)를 decode 때도 다시
|
||||
검사하고 canonical bytes의 SHA-256 digest가 맞지 않으면 corrupt schema result로 격리한다.
|
||||
Envelope v2는 source revision, soft/hard absolute expiry와 payload를 digest로 보호한다.
|
||||
`soft <= now < hard`는 stale, `hard <= now`는 expired miss다. Retired v1은 명시적 quarantine
|
||||
후 reload 대상이고 future/corrupt envelope는 fail-fast다. Integrity digest를 version byte보다
|
||||
먼저 검사하며, digest가 맞더라도 현재 v2 구조가 잘못되면 corrupt로 분류한다. Stale/retired
|
||||
lookup은 envelope digest를 opaque observation token으로 전달하고, cache-aside는 Lua에서 현재
|
||||
digest가 그 token과 같을 때만 새 envelope로 교체한다. 따라서 조회와 refresh 사이의 writer를
|
||||
삭제하거나 덮어쓰지 않는다. Source revision의 application invariant (1..128 characters)는
|
||||
decode 때도 다시 검사한다.
|
||||
|
||||
`positive-soft-ttl`, 기존 `positive-ttl`(hard), `negative-ttl`, `ttl-jitter`,
|
||||
`minimum-hard-ttl`은 startup에 immutable policy로 freeze된다. Jitter는 HMAC-derived physical
|
||||
key와 policy revision으로 결정적이며 positive soft/hard에는 같은 factor를 적용한다. Redis
|
||||
physical TTL은 envelope에 기록된 hard expiry와 같다.
|
||||
|
||||
추가 runtime setting은 `app.cache.redis.maximum-queued-commands=8`(범위 `1..4096`)과
|
||||
`app.cache.redis.maximum-in-flight-bytes=16777216`이다. command count와 retained
|
||||
request/response byte budget을 모두 통과해야 Lettuce 호출을 시작하며,
|
||||
`queue-count × (maximum-value-bytes + overhead)`도 byte bound 이하여야 한다. 이 관계는
|
||||
`app.cache.redis.maximum-in-flight-bytes=16777216`이다. 최대 readable envelope와 최대 command
|
||||
byte를 별도로 계산하며, command count와 retained request/response byte budget을 모두 통과해야
|
||||
Lettuce 호출을 시작한다. `queue-count × maximum-command-bytes`도 byte bound 이하여야 한다. 이 관계는
|
||||
timeout 완료 뒤 driver가 응답 decode 전까지 command args를 유지하는 경우도 유한하게 제한한다.
|
||||
timeout 직후에는 runtime admission population과 Lettuce retained population이 겹칠 수 있으므로
|
||||
최악 상한은 대략 `maximum-in-flight-bytes + queue-count × per-command-bound`이고, 설정 검증은
|
||||
@@ -104,6 +290,58 @@ Redis가 wire에 내보내는 bulk reply 자체를 `maximum-envelope-bytes + 1`
|
||||
Netty/codec에 먼저 할당하지 않는다. managed runtime을 활성화할 때 host가 누락되면
|
||||
`localhost`로 암묵 fallback하지 않고 startup을 실패시킨다.
|
||||
|
||||
Generation/revision fence는 mass/per-key invalidation과 source-load race를 막는다. Distributed
|
||||
refresh soft lease는 정상 시 중복 refresh를 줄이지만 TTL expiry/crash에서는 duplicate owner를
|
||||
허용하며, cache generation fence를 대체하는 correctness lock이 아니다.
|
||||
|
||||
`app.cache.redis.l1.enabled=true`는 semantic string cache 앞에만 optional local L1을 붙인다.
|
||||
L1은 maximum entries, maximum accounted weight, per-entry accounted weight, local TTL, generation
|
||||
recheck interval과 invalidation subscriber queue를 모두 finite하게 검증한다. Local expiry는 Redis
|
||||
envelope hard expiry보다 길어질 수 없다. Weight는 HMAC-derived local identity와 UTF-8 value,
|
||||
entry/lookup metadata에 대한 고정 conservative allowance를 더한 admission/eviction accounting
|
||||
proxy이며, JVM heap reservation이나 실제 object layout의 exact byte guarantee가 아니다.
|
||||
|
||||
Invalidation Pub/Sub payload는 raw semantic key를 포함하지 않고 HMAC-authenticated bounded
|
||||
message를 사용한다. Pub/Sub은 durable/exact invalidation 원장이 아니라 eviction hint다. Subscriber
|
||||
disconnect나 queue overflow는 L1 전체를 flush하고, monotonic local invalidation epoch가 진행 중인
|
||||
generation probe와 refill admission을 무효화한다. 재연결 뒤 generation을 다시 읽기 전에는 L1
|
||||
admission을 허용하지 않는다. Hint 유실 시 mass invalidation은 periodic generation recheck,
|
||||
per-key invalidation은 local TTL 안에서 Redis L2로 복귀한다.
|
||||
|
||||
이 local tier는 cache-only internal type을 요구하므로 session, idempotency, strict rate-limit,
|
||||
coordination provider에 적용할 수 없다. 해당 capability들은 local fail-open cache semantics를
|
||||
재사용하지 않는다.
|
||||
|
||||
Refresh-ahead와 probabilistic early refresh는 아직 구현하지 않았다. 둘 다 correctness baseline이
|
||||
아니며, refresh-ahead는 명시적인 bounded hot-set registry/scheduler 없이 full keyspace scan으로
|
||||
대체하지 않는다. Probabilistic early refresh도 versioned probability descriptor와 deterministic
|
||||
property test가 생기기 전에는 readiness guarantee로 광고하지 않는다. Cache card에는 standalone
|
||||
TLS+named ACL과 bounded fault evidence가 있지만 Sentinel/Cluster Pub/Sub/failover,
|
||||
credential/certificate rotation, persistence/restart, effective eviction attestation,
|
||||
multi-process/pod L1/L2 coherence와 R3 qualification은 아직 없다.
|
||||
|
||||
## Efficiency-only lease
|
||||
|
||||
`ca-skeleton.capabilities.lease.provider=redis`를 명시한 경우에만
|
||||
`DistributedLeasePort`가 생성되며, canonical `COORDINATION` role router와 별도 HMAC secret
|
||||
reference를 사용한다. 미선택 상태에서는 lease bean, secret resolution, native client와 thread
|
||||
side effect가 모두 0이다.
|
||||
|
||||
이 port의 guarantee는 오직 `EFFICIENCY_ONLY`다. acquire/inspect/renew/release는 같은
|
||||
owner token과 operation ID를 비교하고, response loss를 성공이나 실패로 추측하지 않고
|
||||
`INDETERMINATE`/`UNKNOWN`으로 유지한다. caller가 최초 send 전에 보관한 같은 attempt로 inspect
|
||||
또는 acquire replay를 해야 ownership을 복구할 수 있다. Handle validity는 Redis가 보고한 remaining
|
||||
TTL에서 command 왕복 monotonic elapsed와 drift budget을 차감하며, server expiry wall clock은
|
||||
telemetry 용도일 뿐이다. Watchdog는 worker와 registration 수, renewal cadence, application
|
||||
deadline이 모두 유한하고 lease loss/unknown에서 작업 취소 callback을 한 번만 전달한다.
|
||||
|
||||
`redisEfficiencyLeaseTest`는 pinned Redis 7.2와 다음/승인 버전에서 standalone concurrency,
|
||||
TLS/ACL, partition/response uncertainty와 compatibility를 별도 qualification한다. 이 test는
|
||||
readiness card가 아니며 cache-refresh soft lease나 fenced coordination의 증거로 재사용되지
|
||||
않는다. Fencing token과 protected-resource stale-token rejection은 구현하지 않았으므로
|
||||
`redis-fenced-coordination` card는 계속 `not-implemented`다. 이 lease만으로 결제, 재고,
|
||||
unique ID 또는 외부 장치 command 같은 correctness-sensitive write를 승인하면 안 된다.
|
||||
|
||||
## Legacy path
|
||||
|
||||
기존 `CacheStoreRouter`, `RedisCacheStore`, `FailOpenCacheStore`는 호환성을 위해 남아 있다. 이
|
||||
@@ -118,4 +356,5 @@ cd src
|
||||
./gradlew :application-core:check :adapter:outbound:cache-redis:check --console=plain
|
||||
./gradlew :adapter:outbound:cache-redis:redisServiceTest \
|
||||
-Dredis.test.host=127.0.0.1 -Dredis.test.port=6379 --console=plain
|
||||
./gradlew :adapter:outbound:cache-redis:redisEfficiencyLeaseTest --console=plain
|
||||
```
|
||||
|
||||
@@ -4,12 +4,35 @@ dependencies {
|
||||
implementation project(':adapter:outbound:support')
|
||||
|
||||
implementation 'org.springframework.boot:spring-boot-autoconfigure'
|
||||
implementation 'org.springframework.session:spring-session-core'
|
||||
implementation 'org.springframework.session:spring-session-data-redis'
|
||||
implementation 'org.springframework.data:spring-data-redis'
|
||||
implementation 'io.lettuce:lettuce-core'
|
||||
implementation 'io.micrometer:micrometer-core'
|
||||
implementation 'org.slf4j:slf4j-api'
|
||||
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
|
||||
}
|
||||
tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' }
|
||||
|
||||
sourceSets {
|
||||
redisTest {
|
||||
java.srcDir 'src/redisTest/java'
|
||||
resources.srcDir 'src/redisTest/resources'
|
||||
compileClasspath += sourceSets.main.output
|
||||
runtimeClasspath += sourceSets.main.output
|
||||
}
|
||||
}
|
||||
|
||||
configurations {
|
||||
redisTestImplementation.extendsFrom testImplementation
|
||||
redisTestCompileOnly.extendsFrom testCompileOnly
|
||||
redisTestRuntimeOnly.extendsFrom testRuntimeOnly
|
||||
}
|
||||
|
||||
dependencies {
|
||||
redisTestImplementation 'org.testcontainers:testcontainers'
|
||||
}
|
||||
|
||||
tasks.named('test') {
|
||||
useJUnitPlatform {
|
||||
excludeTags 'redis-service'
|
||||
@@ -32,3 +55,562 @@ tasks.register('redisServiceTest', Test) {
|
||||
}
|
||||
shouldRunAfter tasks.named('test')
|
||||
}
|
||||
|
||||
def verifyRedisEvidenceSourcesPresent = tasks.register('verifyRedisEvidenceSourcesPresent') {
|
||||
group = 'redis verification'
|
||||
description = 'Fails readiness lanes when the redisTest evidence source set is empty.'
|
||||
inputs.files(sourceSets.redisTest.allSource)
|
||||
doLast {
|
||||
Set<File> javaSources = sourceSets.redisTest.java.files.findAll {
|
||||
it.isFile() && it.name.endsWith('.java')
|
||||
}
|
||||
if (javaSources.isEmpty()) {
|
||||
throw new GradleException(
|
||||
'Redis evidence source set is empty; readiness tasks must not pass as NO-SOURCE.')
|
||||
}
|
||||
File imageRegistry = rootProject.file('gradle/redis-test-images.properties')
|
||||
if (!imageRegistry.isFile() || imageRegistry.length() == 0) {
|
||||
throw new GradleException(
|
||||
"Redis evidence image registry is missing or empty: ${imageRegistry}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def redisCapabilityMetadata = rootProject.ext.redisCapabilityMetadata
|
||||
|
||||
def redisSanitizedEvidenceFileNames = [
|
||||
'manifest.json',
|
||||
'capability-card.json',
|
||||
'topology-fault-timeline.json'
|
||||
] as Set<String>
|
||||
def redisSanitizedBundleSha256 = { File directory ->
|
||||
java.security.MessageDigest digest = java.security.MessageDigest.getInstance('SHA-256')
|
||||
redisSanitizedEvidenceFileNames.toList().sort().each { String name ->
|
||||
File file = new File(directory, name)
|
||||
if (!file.isFile()) {
|
||||
throw new GradleException(
|
||||
"Redis sanitized bundle is missing ${name}: ${directory}")
|
||||
}
|
||||
byte[] nameBytes = name.getBytes('UTF-8')
|
||||
byte[] contentBytes = file.bytes
|
||||
digest.update(java.nio.ByteBuffer.allocate(Long.BYTES).putLong(nameBytes.length).array())
|
||||
digest.update(nameBytes)
|
||||
digest.update(java.nio.ByteBuffer.allocate(Long.BYTES).putLong(contentBytes.length).array())
|
||||
digest.update(contentBytes)
|
||||
}
|
||||
digest.digest().encodeHex().toString()
|
||||
}
|
||||
|
||||
def registerRedisEvidenceTask = { String taskName, String tagExpression, String descriptionText ->
|
||||
def evidenceTask = tasks.register(taskName, Test) {
|
||||
group = 'redis verification'
|
||||
description = descriptionText
|
||||
dependsOn verifyRedisEvidenceSourcesPresent
|
||||
testClassesDirs = sourceSets.redisTest.output.classesDirs
|
||||
classpath = sourceSets.redisTest.runtimeClasspath
|
||||
useJUnitPlatform {
|
||||
includeTags tagExpression
|
||||
}
|
||||
failOnNoDiscoveredTests = true
|
||||
outputs.upToDateWhen { false }
|
||||
jvmArgs '-Duser.timezone=UTC'
|
||||
systemProperty 'redis.image.registry',
|
||||
rootProject.file('gradle/redis-test-images.properties').absolutePath
|
||||
List<Map<String, Object>> sanitizedTimeline = []
|
||||
List<String> declaredTags = tagExpression.split(/\s*&\s*/).toList()
|
||||
String cardTag = declaredTags.find { it.startsWith('card-') }
|
||||
String cardId = cardTag == null ? null : cardTag.substring('card-'.length())
|
||||
Set<String> evidenceCategories = [
|
||||
'standalone',
|
||||
'security',
|
||||
'sentinel',
|
||||
'cluster',
|
||||
'fault',
|
||||
'compatibility'
|
||||
] as Set<String>
|
||||
String evidenceCategory = declaredTags.find {
|
||||
it.startsWith('redis-') && evidenceCategories.contains(it.substring('redis-'.length()))
|
||||
}
|
||||
if (evidenceCategory != null) {
|
||||
evidenceCategory = evidenceCategory.substring('redis-'.length())
|
||||
}
|
||||
File evidenceDirectory = layout.buildDirectory.dir(
|
||||
"redis-evidence/${taskName}").get().asFile
|
||||
outputs.dir evidenceDirectory
|
||||
afterTest { descriptor, result ->
|
||||
String identity = "${descriptor.className ?: ''}#${descriptor.name ?: ''}"
|
||||
String identityDigest = java.security.MessageDigest.getInstance('SHA-256')
|
||||
.digest(identity.getBytes('UTF-8')).encodeHex().toString()
|
||||
sanitizedTimeline << [
|
||||
sequence : sanitizedTimeline.size() + 1,
|
||||
testCaseIdSha256: identityDigest,
|
||||
outcome : result.resultType.name(),
|
||||
durationMillis : Math.max(0L, result.endTime - result.startTime)
|
||||
]
|
||||
}
|
||||
afterSuite { descriptor, result ->
|
||||
if (descriptor.parent != null) {
|
||||
return
|
||||
}
|
||||
evidenceDirectory.mkdirs()
|
||||
Map<String, Object> card = cardId == null
|
||||
? null
|
||||
: rootProject.ext.redisReadinessCards[cardId] as Map<String, Object>
|
||||
Map<String, String> digests = rootProject.ext.redisEvidenceDigests()
|
||||
Map<String, Object> metadata = cardId == null
|
||||
? [
|
||||
providerIds : [],
|
||||
roles : [],
|
||||
programs : [],
|
||||
keyVersions : [],
|
||||
codecVersions : [],
|
||||
guarantees : ['cross-cutting Redis evidence lane'],
|
||||
nonGuarantees : ['does not qualify a capability card by itself'],
|
||||
requiredSettings: []
|
||||
]
|
||||
: redisCapabilityMetadata[cardId] as Map<String, Object>
|
||||
Map<String, Object> capabilityCard = [
|
||||
schemaVersion : 1,
|
||||
cardId : cardId,
|
||||
readiness : card?.state,
|
||||
releaseQualification: 'NOT_CLAIMED',
|
||||
promotionTopology : card?.selectedTopology,
|
||||
sourceRevision : rootProject.ext.redisEvidenceSourceRevision,
|
||||
sourceTreeState : rootProject.ext.redisEvidenceSourceTreeState,
|
||||
digests : digests,
|
||||
minimumRedisVersion: '7.2',
|
||||
providerIds : metadata.providerIds,
|
||||
roles : metadata.roles,
|
||||
programIds : metadata.programs,
|
||||
keyVersions : metadata.keyVersions,
|
||||
codecVersions : metadata.codecVersions,
|
||||
guarantees : metadata.guarantees,
|
||||
nonGuarantees : metadata.nonGuarantees,
|
||||
requiredSettings : metadata.requiredSettings,
|
||||
evidenceProfile : card?.requiredEvidence ?: []
|
||||
]
|
||||
File capabilityCardFile = new File(evidenceDirectory, 'capability-card.json')
|
||||
capabilityCardFile.setText(
|
||||
groovy.json.JsonOutput.prettyPrint(
|
||||
groovy.json.JsonOutput.toJson(capabilityCard)) + '\n',
|
||||
'UTF-8')
|
||||
Map<String, Object> timeline = [
|
||||
schemaVersion: 1,
|
||||
taskName : taskName,
|
||||
cardId : cardId,
|
||||
topology : card?.selectedTopology,
|
||||
evidence : evidenceCategory,
|
||||
timelineKind : 'SANITIZED_TEST_RESULT',
|
||||
actualEventTimeline: 'NOT_CAPTURED',
|
||||
sourceRevision: rootProject.ext.redisEvidenceSourceRevision,
|
||||
sourceTreeState: rootProject.ext.redisEvidenceSourceTreeState,
|
||||
digests : digests,
|
||||
events : sanitizedTimeline
|
||||
]
|
||||
File timelineFile = new File(evidenceDirectory, 'topology-fault-timeline.json')
|
||||
timelineFile.setText(
|
||||
groovy.json.JsonOutput.prettyPrint(
|
||||
groovy.json.JsonOutput.toJson(timeline)) + '\n',
|
||||
'UTF-8')
|
||||
Closure<String> sha256 = { File file ->
|
||||
java.security.MessageDigest.getInstance('SHA-256')
|
||||
.digest(file.bytes).encodeHex().toString()
|
||||
}
|
||||
String outcome = result.resultType.name() == 'FAILURE'
|
||||
? 'failed'
|
||||
: (result.testCount == 0 || result.skippedTestCount > 0
|
||||
? 'skipped-with-reason'
|
||||
: 'executed')
|
||||
Map<String, Object> manifest = [
|
||||
schemaVersion : 1,
|
||||
taskPath : path,
|
||||
tagExpression : tagExpression,
|
||||
cardId : cardId,
|
||||
cardState : card?.state,
|
||||
selectedTopology : card?.selectedTopology,
|
||||
evidenceCategory : evidenceCategory,
|
||||
outcome : outcome,
|
||||
tests : [
|
||||
discovered: result.testCount,
|
||||
executed : result.testCount - result.skippedTestCount,
|
||||
passed : result.successfulTestCount,
|
||||
failed : result.failedTestCount,
|
||||
errors : 0,
|
||||
skipped : result.skippedTestCount
|
||||
],
|
||||
runtimeImageAttestation: 'NOT_CAPTURED',
|
||||
actualEventTimeline: 'NOT_CAPTURED',
|
||||
releaseQualification: 'NOT_CLAIMED',
|
||||
sourceRevision : rootProject.ext.redisEvidenceSourceRevision,
|
||||
sourceTreeState : rootProject.ext.redisEvidenceSourceTreeState,
|
||||
digests : digests,
|
||||
companionSha256 : [
|
||||
capabilityCardSha256: sha256(capabilityCardFile),
|
||||
timelineSha256 : sha256(timelineFile)
|
||||
]
|
||||
]
|
||||
new File(evidenceDirectory, 'manifest.json').setText(
|
||||
groovy.json.JsonOutput.prettyPrint(
|
||||
groovy.json.JsonOutput.toJson(manifest)) + '\n',
|
||||
'UTF-8')
|
||||
}
|
||||
doFirst {
|
||||
[
|
||||
'manifest.json',
|
||||
'capability-card.json',
|
||||
'topology-fault-timeline.json'
|
||||
].each { String generatedFile ->
|
||||
new File(evidenceDirectory, generatedFile).delete()
|
||||
}
|
||||
layout.buildDirectory.file(
|
||||
"redis-evidence-sanitizer/${taskName}.sha256").get().asFile.delete()
|
||||
Set<File> matchingSources = sourceSets.redisTest.java.files.findAll { File source ->
|
||||
if (!source.isFile() || !source.name.endsWith('.java')) {
|
||||
return false
|
||||
}
|
||||
String content = source.getText('UTF-8')
|
||||
declaredTags.every { String tag -> content.contains("@Tag(\"${tag}\")") }
|
||||
}
|
||||
if (matchingSources.isEmpty()) {
|
||||
throw new GradleException(
|
||||
"${taskName}: no redisTest source declares every required tag " +
|
||||
"${declaredTags}; zero-evidence readiness must not pass.")
|
||||
}
|
||||
}
|
||||
}
|
||||
def sanitizerTask = tasks.register("${taskName}SanitizeEvidence") {
|
||||
group = 'redis verification'
|
||||
description = "Validates the bounded sanitized artifact for ${taskName} before upload."
|
||||
mustRunAfter evidenceTask
|
||||
File sanitizerMarker = layout.buildDirectory.file(
|
||||
"redis-evidence-sanitizer/${taskName}.sha256").get().asFile
|
||||
doFirst {
|
||||
sanitizerMarker.delete()
|
||||
}
|
||||
doLast {
|
||||
File evidenceDirectory = layout.buildDirectory.dir(
|
||||
"redis-evidence/${taskName}").get().asFile
|
||||
if (!evidenceDirectory.isDirectory()) {
|
||||
throw new GradleException(
|
||||
"${taskName}: sanitized evidence directory was not generated")
|
||||
}
|
||||
Set<String> allowedNames = redisSanitizedEvidenceFileNames
|
||||
List<File> files = evidenceDirectory.listFiles()?.findAll { it.isFile() } ?: []
|
||||
if (files.collect { it.name } as Set<String> != allowedNames ||
|
||||
evidenceDirectory.listFiles()?.any { it.isDirectory() }) {
|
||||
throw new GradleException(
|
||||
"${taskName}: sanitized evidence must contain exactly ${allowedNames}")
|
||||
}
|
||||
files.each { File file ->
|
||||
if (file.length() > 1_048_576L ||
|
||||
java.nio.file.Files.isSymbolicLink(file.toPath()) ||
|
||||
!file.toPath().toRealPath().startsWith(
|
||||
evidenceDirectory.toPath().toRealPath())) {
|
||||
throw new GradleException(
|
||||
"${taskName}: oversized, symlinked, or path-escaping artifact ${file}")
|
||||
}
|
||||
String text = file.getText('UTF-8')
|
||||
Map<String, java.util.regex.Pattern> forbidden = [
|
||||
pem : java.util.regex.Pattern.compile(
|
||||
'(?i)-----BEGIN [^-]*(?:PRIVATE KEY|CERTIFICATE)-----'),
|
||||
aclMaterial : java.util.regex.Pattern.compile(
|
||||
"(?i)(?:users\\.acl|--pass|[\"']password[\"']\\s*:)"),
|
||||
uriUserInfo : java.util.regex.Pattern.compile(
|
||||
'(?i)rediss?://[^\\s/@:]+:[^\\s/@]+@'),
|
||||
secretReference : java.util.regex.Pattern.compile('(?i)secret://'),
|
||||
rawMessageFields : java.util.regex.Pattern.compile(
|
||||
'(?i)"(?:stackTrace|systemOut|systemErr|exception|containerId|host|ip|port|endpoint|rawKey|physicalKey|value|sessionId|csrf|idempotencyToken|ownerToken|operationToken)"\\s*:')
|
||||
]
|
||||
forbidden.each { String marker, java.util.regex.Pattern pattern ->
|
||||
if (pattern.matcher(text).find()) {
|
||||
throw new GradleException(
|
||||
"${taskName}: sanitized artifact ${file.name} contains forbidden ${marker} material")
|
||||
}
|
||||
}
|
||||
}
|
||||
Map<String, Object> manifest = new groovy.json.JsonSlurper().parse(
|
||||
new File(evidenceDirectory, 'manifest.json')) as Map<String, Object>
|
||||
Map<String, Object> capability = new groovy.json.JsonSlurper().parse(
|
||||
new File(evidenceDirectory, 'capability-card.json')) as Map<String, Object>
|
||||
Map<String, Object> timeline = new groovy.json.JsonSlurper().parse(
|
||||
new File(evidenceDirectory, 'topology-fault-timeline.json')) as Map<String, Object>
|
||||
Set<String> manifestFields = [
|
||||
'schemaVersion',
|
||||
'taskPath',
|
||||
'tagExpression',
|
||||
'cardId',
|
||||
'cardState',
|
||||
'selectedTopology',
|
||||
'evidenceCategory',
|
||||
'outcome',
|
||||
'tests',
|
||||
'runtimeImageAttestation',
|
||||
'actualEventTimeline',
|
||||
'releaseQualification',
|
||||
'sourceRevision',
|
||||
'sourceTreeState',
|
||||
'digests',
|
||||
'companionSha256'
|
||||
] as Set<String>
|
||||
Set<String> capabilityFields = [
|
||||
'schemaVersion',
|
||||
'cardId',
|
||||
'readiness',
|
||||
'releaseQualification',
|
||||
'promotionTopology',
|
||||
'sourceRevision',
|
||||
'sourceTreeState',
|
||||
'digests',
|
||||
'minimumRedisVersion',
|
||||
'providerIds',
|
||||
'roles',
|
||||
'programIds',
|
||||
'keyVersions',
|
||||
'codecVersions',
|
||||
'guarantees',
|
||||
'nonGuarantees',
|
||||
'requiredSettings',
|
||||
'evidenceProfile'
|
||||
] as Set<String>
|
||||
Set<String> timelineFields = [
|
||||
'schemaVersion',
|
||||
'taskName',
|
||||
'cardId',
|
||||
'topology',
|
||||
'evidence',
|
||||
'timelineKind',
|
||||
'actualEventTimeline',
|
||||
'sourceRevision',
|
||||
'sourceTreeState',
|
||||
'digests',
|
||||
'events'
|
||||
] as Set<String>
|
||||
if (manifest.keySet() != manifestFields ||
|
||||
capability.keySet() != capabilityFields ||
|
||||
timeline.keySet() != timelineFields ||
|
||||
(manifest.tests as Map).keySet() != [
|
||||
'discovered',
|
||||
'executed',
|
||||
'passed',
|
||||
'failed',
|
||||
'errors',
|
||||
'skipped'
|
||||
] as Set<String> ||
|
||||
(manifest.digests as Map).keySet() != [
|
||||
'registrySha256',
|
||||
'imageRegistrySha256',
|
||||
'programSetSha256',
|
||||
'configurationSha256'
|
||||
] as Set<String> ||
|
||||
(manifest.companionSha256 as Map).keySet() != [
|
||||
'capabilityCardSha256',
|
||||
'timelineSha256'
|
||||
] as Set<String>) {
|
||||
throw new GradleException(
|
||||
"${taskName}: sanitized evidence contains unknown or missing schema fields")
|
||||
}
|
||||
List<Map<String, Object>> events = timeline.events as List<Map<String, Object>>
|
||||
if (events.size() > 10_000 ||
|
||||
events.withIndex().any { Map<String, Object> event, int index ->
|
||||
event.keySet() != [
|
||||
'sequence',
|
||||
'testCaseIdSha256',
|
||||
'outcome',
|
||||
'durationMillis'
|
||||
] as Set<String> ||
|
||||
event.sequence != index + 1 ||
|
||||
!(event.testCaseIdSha256 ==~ /[0-9a-f]{64}/) ||
|
||||
!(event.outcome in ['SUCCESS', 'FAILURE', 'SKIPPED']) ||
|
||||
!(event.durationMillis instanceof Number) ||
|
||||
(event.durationMillis as Number).longValue() < 0L
|
||||
}) {
|
||||
throw new GradleException(
|
||||
"${taskName}: sanitized test summary contains malformed events")
|
||||
}
|
||||
if ((capability.requiredSettings as List).any {
|
||||
!(it instanceof Map) ||
|
||||
(it as Map).keySet() != ['name', 'type', 'constraint'] as Set<String>
|
||||
}) {
|
||||
throw new GradleException(
|
||||
"${taskName}: capability card required settings are not a safe name/type/constraint projection")
|
||||
}
|
||||
Map<String, Object> tests = manifest.tests as Map<String, Object>
|
||||
if (!(manifest.outcome in ['executed', 'failed', 'skipped-with-reason']) ||
|
||||
events.size() != (tests.discovered as Number).intValue() ||
|
||||
events.count { it.outcome == 'SUCCESS' } !=
|
||||
(tests.passed as Number).intValue() ||
|
||||
events.count { it.outcome == 'FAILURE' } !=
|
||||
(tests.failed as Number).intValue() ||
|
||||
events.count { it.outcome == 'SKIPPED' } !=
|
||||
(tests.skipped as Number).intValue()) {
|
||||
throw new GradleException(
|
||||
"${taskName}: manifest outcome/counts do not match the sanitized test summary")
|
||||
}
|
||||
if (manifest.outcome == 'executed' &&
|
||||
((tests.discovered as Number).longValue() <= 0L ||
|
||||
(tests.executed as Number).longValue() <= 0L ||
|
||||
(tests.passed as Number).longValue() <= 0L ||
|
||||
(tests.failed as Number).longValue() != 0L ||
|
||||
(tests.errors as Number).longValue() != 0L ||
|
||||
(tests.skipped as Number).longValue() != 0L)) {
|
||||
throw new GradleException(
|
||||
"${taskName}: executed evidence must be positive with zero failure/error/skip")
|
||||
}
|
||||
if (manifest.outcome == 'failed' &&
|
||||
(tests.failed as Number).longValue() <= 0L) {
|
||||
throw new GradleException(
|
||||
"${taskName}: failed evidence must retain a positive bounded failure count")
|
||||
}
|
||||
sanitizerMarker.parentFile.mkdirs()
|
||||
String bundleSha = redisSanitizedBundleSha256(evidenceDirectory)
|
||||
sanitizerMarker.setText("${bundleSha}\n", 'UTF-8')
|
||||
if (manifest.outcome == 'skipped-with-reason') {
|
||||
throw new GradleException(
|
||||
"${taskName}: skipped or zero-executed evidence is not a passing readiness lane")
|
||||
}
|
||||
}
|
||||
}
|
||||
evidenceTask.configure {
|
||||
finalizedBy sanitizerTask
|
||||
}
|
||||
evidenceTask
|
||||
}
|
||||
|
||||
tasks.register('verifyRedisEvidenceArtifactsForUpload') {
|
||||
group = 'redis verification'
|
||||
description = 'Allows CI upload only when every generated Redis evidence directory was sanitized.'
|
||||
doLast {
|
||||
File evidenceRoot = layout.buildDirectory.dir('redis-evidence').get().asFile
|
||||
File markerRoot = layout.buildDirectory.dir('redis-evidence-sanitizer').get().asFile
|
||||
List<File> evidenceDirectories = evidenceRoot.isDirectory()
|
||||
? evidenceRoot.listFiles().findAll { it.isDirectory() }
|
||||
: []
|
||||
if (evidenceDirectories.isEmpty()) {
|
||||
throw new GradleException(
|
||||
'No sanitized Redis evidence directory exists for upload')
|
||||
}
|
||||
Set<String> evidenceTasks = evidenceDirectories.collect { it.name } as Set<String>
|
||||
Set<String> markerTasks = markerRoot.isDirectory()
|
||||
? markerRoot.listFiles().findAll {
|
||||
it.isFile() && it.name.endsWith('.sha256')
|
||||
}.collect {
|
||||
it.name.substring(0, it.name.length() - '.sha256'.length())
|
||||
} as Set<String>
|
||||
: [] as Set<String>
|
||||
if (evidenceTasks != markerTasks) {
|
||||
throw new GradleException(
|
||||
"Redis evidence upload sanitizer coverage mismatch; evidence=${evidenceTasks}, markers=${markerTasks}")
|
||||
}
|
||||
evidenceDirectories.each { File directory ->
|
||||
Set<String> files = directory.listFiles().findAll { it.isFile() }
|
||||
.collect { it.name } as Set<String>
|
||||
if (files != redisSanitizedEvidenceFileNames) {
|
||||
throw new GradleException(
|
||||
"Redis upload directory ${directory.name} is outside the sanitized allowlist")
|
||||
}
|
||||
String bundleSha = redisSanitizedBundleSha256(directory)
|
||||
String recordedSha = new File(
|
||||
markerRoot, "${directory.name}.sha256").getText('UTF-8').trim()
|
||||
if (recordedSha != bundleSha) {
|
||||
throw new GradleException(
|
||||
"Redis upload sanitizer bundle marker is stale for ${directory.name}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registerRedisEvidenceTask(
|
||||
'redisStandaloneTest',
|
||||
'redis-standalone',
|
||||
'Runs real standalone Redis evidence. Docker/service absence and zero tests fail.')
|
||||
registerRedisEvidenceTask(
|
||||
'redisSecurityTest',
|
||||
'redis-security',
|
||||
'Runs Redis TLS, ACL, secret-redaction, and fail-closed security evidence.')
|
||||
registerRedisEvidenceTask(
|
||||
'redisSentinelTest',
|
||||
'redis-sentinel',
|
||||
'Runs the explicit Redis Sentinel topology evidence lane.')
|
||||
registerRedisEvidenceTask(
|
||||
'redisClusterTest',
|
||||
'redis-cluster',
|
||||
'Runs the explicit Redis Cluster topology evidence lane.')
|
||||
registerRedisEvidenceTask(
|
||||
'redisFaultTest',
|
||||
'redis-fault',
|
||||
'Runs bounded Redis outage, response-loss, memory, and recovery evidence.')
|
||||
registerRedisEvidenceTask(
|
||||
'redisCompatibilityTest',
|
||||
'redis-compatibility',
|
||||
'Runs pinned minimum/next/approved Redis compatibility evidence.')
|
||||
registerRedisEvidenceTask(
|
||||
'redisEfficiencyLeaseTest',
|
||||
'redis-efficiency-lease',
|
||||
'Runs non-fenced EFFICIENCY_ONLY lease standalone, security, fault, and compatibility qualification.')
|
||||
|
||||
def redisCardTags = [
|
||||
redisCacheCapabilityTest : 'card-redis-cache',
|
||||
redisRateLimitCapabilityTest : 'card-redis-edge-rate-limit',
|
||||
redisIdempotencyCapabilityTest : 'card-redis-request-replay-idempotency',
|
||||
redisSoftLeaseCapabilityTest : 'card-redis-cache-refresh-soft-lease',
|
||||
redisFencedCoordinationCapabilityTest: 'card-redis-fenced-coordination',
|
||||
redisSessionCapabilityTest : 'card-redis-session'
|
||||
]
|
||||
redisCardTags.each { String taskName, String cardTag ->
|
||||
registerRedisEvidenceTask(
|
||||
taskName,
|
||||
cardTag,
|
||||
"Runs all real-service evidence owned by Redis capability card ${cardTag}.")
|
||||
}
|
||||
|
||||
def redisEvidenceTags = [
|
||||
Standalone : 'redis-standalone',
|
||||
Security : 'redis-security',
|
||||
Sentinel : 'redis-sentinel',
|
||||
Cluster : 'redis-cluster',
|
||||
Fault : 'redis-fault',
|
||||
Compatibility: 'redis-compatibility'
|
||||
]
|
||||
def redisCardTaskStems = [
|
||||
Cache : 'card-redis-cache',
|
||||
RateLimit : 'card-redis-edge-rate-limit',
|
||||
Idempotency : 'card-redis-request-replay-idempotency',
|
||||
SoftLease : 'card-redis-cache-refresh-soft-lease',
|
||||
FencedCoordination: 'card-redis-fenced-coordination',
|
||||
Session : 'card-redis-session'
|
||||
]
|
||||
redisCardTaskStems.each { String cardStem, String cardTag ->
|
||||
redisEvidenceTags.each { String evidenceStem, String evidenceTag ->
|
||||
registerRedisEvidenceTask(
|
||||
"redis${cardStem}${evidenceStem}EvidenceTest",
|
||||
"${cardTag} & ${evidenceTag}",
|
||||
"Runs ${evidenceTag} evidence owned only by ${cardTag}.")
|
||||
}
|
||||
}
|
||||
|
||||
tasks.named('check') {
|
||||
dependsOn tasks.named('redisStandaloneTest')
|
||||
}
|
||||
|
||||
def redisLabContractDirectory = rootProject.file('../infra/redis-lab')
|
||||
def redisLabContractTest = tasks.register('redisLabContractTest', Exec) {
|
||||
group = 'verification'
|
||||
description = 'Runs the VM-free Redis lab lifecycle and host-isolation contract with fake commands.'
|
||||
workingDir rootProject.projectDir
|
||||
executable 'bash'
|
||||
args new File(redisLabContractDirectory, 'test/redis-lab-contract.sh').absolutePath
|
||||
inputs.files(
|
||||
new File(redisLabContractDirectory, 'versions.env'),
|
||||
new File(redisLabContractDirectory, 'bin/redis-lab'),
|
||||
new File(redisLabContractDirectory, 'cloud-init/node.yaml'),
|
||||
new File(redisLabContractDirectory, 'lib/render-kubeconfig.awk'),
|
||||
fileTree(new File(redisLabContractDirectory, 'test/fixtures')) {
|
||||
include '**/*'
|
||||
},
|
||||
new File(redisLabContractDirectory, 'test/redis-lab-contract.sh'))
|
||||
outputs.upToDateWhen { false }
|
||||
}
|
||||
|
||||
tasks.named('check') {
|
||||
dependsOn redisLabContractTest
|
||||
}
|
||||
|
||||
@@ -1,165 +1,186 @@
|
||||
# This is a Gradle generated file for dependency locking.
|
||||
# Manual edits can break the build and are not advised.
|
||||
# This file is expected to be part of source control.
|
||||
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=testCompileClasspath
|
||||
ch.qos.logback:logback-classic:1.5.21=testCompileClasspath,testRuntimeClasspath
|
||||
ch.qos.logback:logback-core:1.5.21=testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath
|
||||
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
|
||||
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
|
||||
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=redisTestCompileClasspath,testCompileClasspath
|
||||
ch.qos.logback:logback-classic:1.5.21=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
ch.qos.logback:logback-core:1.5.21=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-annotations:2.20=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
|
||||
com.github.docker-java:docker-java-api:3.7.0=redisTestCompileClasspath,redisTestRuntimeClasspath
|
||||
com.github.docker-java:docker-java-transport-zerodep:3.7.0=redisTestCompileClasspath,redisTestRuntimeClasspath
|
||||
com.github.docker-java:docker-java-transport:3.7.0=redisTestCompileClasspath,redisTestRuntimeClasspath
|
||||
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
|
||||
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
|
||||
com.github.spotbugs:spotbugs-annotations:4.8.6=testCompileClasspath
|
||||
com.github.spotbugs:spotbugs-annotations:4.8.6=redisTestCompileClasspath,testCompileClasspath
|
||||
com.github.spotbugs:spotbugs:4.10.2=spotbugs
|
||||
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
|
||||
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor
|
||||
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor
|
||||
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor
|
||||
com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs,testCompileClasspath
|
||||
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
|
||||
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
|
||||
com.google.auto:auto-common:1.2.2=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
|
||||
com.google.code.findbugs:jsr305:3.0.2=checkstyle,redisTestCompileClasspath,spotbugs,testCompileClasspath
|
||||
com.google.code.gson:gson:2.13.2=spotbugs
|
||||
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_annotations:2.38.0=testCompileClasspath
|
||||
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_annotations:2.38.0=redisTestCompileClasspath,testCompileClasspath
|
||||
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
|
||||
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
|
||||
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
|
||||
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
|
||||
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,redisTestAnnotationProcessor,testAnnotationProcessor
|
||||
com.google.guava:guava:33.5.0-jre=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
|
||||
com.google.guava:guava:33.6.0-jre=checkstyle
|
||||
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor
|
||||
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,redisTestAnnotationProcessor,testAnnotationProcessor
|
||||
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,redisTestAnnotationProcessor,testAnnotationProcessor
|
||||
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
|
||||
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
|
||||
com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath
|
||||
com.jayway.jsonpath:json-path:2.9.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
|
||||
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath
|
||||
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
commons-beanutils:commons-beanutils:1.11.0=checkstyle
|
||||
commons-codec:commons-codec:1.19.0=redisTestCompileClasspath,redisTestRuntimeClasspath
|
||||
commons-collections:commons-collections:3.2.2=checkstyle
|
||||
commons-io:commons-io:2.20.0=redisTestCompileClasspath,redisTestRuntimeClasspath
|
||||
commons-io:commons-io:2.21.0=spotbugs
|
||||
commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
commons-logging:commons-logging:1.3.5=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
info.picocli:picocli:4.7.7=checkstyle
|
||||
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor
|
||||
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
|
||||
io.lettuce:lettuce-core:6.8.1.RELEASE=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.netty:netty-buffer:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.netty:netty-codec-base:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.netty:netty-codec-dns:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.netty:netty-common:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.netty:netty-handler:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.netty:netty-resolver-dns:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.netty:netty-resolver:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.netty:netty-transport-native-unix-common:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.netty:netty-transport:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.projectreactor:reactor-core:3.8.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.annotation:jakarta.annotation-api:3.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath
|
||||
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
|
||||
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
|
||||
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
|
||||
io.lettuce:lettuce-core:6.8.1.RELEASE=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-commons:1.16.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-core:1.16.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-observation:1.16.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.netty:netty-buffer:4.2.7.Final=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.netty:netty-codec-base:4.2.7.Final=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.netty:netty-codec-dns:4.2.7.Final=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.netty:netty-common:4.2.7.Final=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.netty:netty-handler:4.2.7.Final=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.netty:netty-resolver-dns:4.2.7.Final=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.netty:netty-resolver:4.2.7.Final=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.netty:netty-transport-native-unix-common:4.2.7.Final=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.netty:netty-transport:4.2.7.Final=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.projectreactor:reactor-core:3.8.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.activation:jakarta.activation-api:2.1.4=redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.annotation:jakarta.annotation-api:3.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
javax.inject:javax.inject:1=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
|
||||
jaxen:jaxen:2.0.0=spotbugs
|
||||
net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath
|
||||
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
|
||||
net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
|
||||
net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
|
||||
net.bytebuddy:byte-buddy-agent:1.17.8=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
net.bytebuddy:byte-buddy:1.17.8=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
net.java.dev.jna:jna:5.18.1=redisTestCompileClasspath,redisTestRuntimeClasspath
|
||||
net.minidev:accessors-smart:2.6.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
net.minidev:json-smart:2.6.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
|
||||
org.antlr:antlr4-runtime:4.13.2=checkstyle
|
||||
org.apache.bcel:bcel:6.12.0=spotbugs
|
||||
org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs
|
||||
org.apache.commons:commons-compress:1.28.0=redisTestCompileClasspath,redisTestRuntimeClasspath
|
||||
org.apache.commons:commons-lang3:3.20.0=checkstyle,redisTestCompileClasspath,redisTestRuntimeClasspath,spotbugs
|
||||
org.apache.commons:commons-text:1.15.0=spotbugs
|
||||
org.apache.commons:commons-text:1.3=checkstyle
|
||||
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
|
||||
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
|
||||
org.apache.logging.log4j:log4j-api:2.25.2=spotbugs,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.logging.log4j:log4j-api:2.25.2=redisTestCompileClasspath,redisTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
|
||||
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
|
||||
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.xbean:xbean-reflect:3.7=checkstyle
|
||||
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
|
||||
org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath
|
||||
org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.apiguardian:apiguardian-api:1.1.2=redisTestCompileClasspath,testCompileClasspath
|
||||
org.assertj:assertj-core:3.27.6=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.awaitility:awaitility:4.3.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
|
||||
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
|
||||
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
|
||||
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
|
||||
org.dom4j:dom4j:2.2.0=spotbugs
|
||||
org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.hamcrest:hamcrest:3.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.hdrhistogram:HdrHistogram:2.2.2=redisTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
|
||||
org.javassist:javassist:3.28.0-GA=checkstyle
|
||||
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath
|
||||
org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.jetbrains:annotations:17.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath
|
||||
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,redisTestAnnotationProcessor,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-api:6.0.1=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-engine:6.0.1=redisTestRuntimeClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-params:6.0.1=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter:6.0.1=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-commons:6.0.1=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-engine:6.0.1=redisTestRuntimeClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-launcher:6.0.1=redisTestRuntimeClasspath,testRuntimeClasspath
|
||||
org.junit:junit-bom:6.0.1=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.junit:junit-bom:6.1.0=spotbugs
|
||||
org.mockito:mockito-core:5.20.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.objenesis:objenesis:3.3=testRuntimeClasspath
|
||||
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.osgi:org.osgi.annotation.bundle:2.0.0=testCompileClasspath
|
||||
org.osgi:org.osgi.annotation.versioning:1.1.2=testCompileClasspath
|
||||
org.osgi:org.osgi.resource:1.0.0=testCompileClasspath
|
||||
org.osgi:org.osgi.service.serviceloader:1.0.0=testCompileClasspath
|
||||
org.latencyutils:LatencyUtils:2.0.3=redisTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
|
||||
org.mockito:mockito-core:5.20.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.mockito:mockito-junit-jupiter:5.20.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.objenesis:objenesis:3.3=redisTestRuntimeClasspath,testRuntimeClasspath
|
||||
org.opentest4j:opentest4j:1.3.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.osgi:org.osgi.annotation.bundle:2.0.0=redisTestCompileClasspath,testCompileClasspath
|
||||
org.osgi:org.osgi.annotation.versioning:1.1.2=redisTestCompileClasspath,testCompileClasspath
|
||||
org.osgi:org.osgi.resource:1.0.0=redisTestCompileClasspath,testCompileClasspath
|
||||
org.osgi:org.osgi.service.serviceloader:1.0.0=redisTestCompileClasspath,testCompileClasspath
|
||||
org.ow2.asm:asm-analysis:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-commons:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-tree:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-util:9.10.1=spotbugs
|
||||
org.ow2.asm:asm:9.10.1=spotbugs
|
||||
org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
|
||||
org.reactivestreams:reactive-streams:1.0.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.ow2.asm:asm:9.7.1=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.pcollections:pcollections:4.0.1=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
|
||||
org.reactivestreams:reactive-streams:1.0.4=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.reflections:reflections:0.10.2=checkstyle
|
||||
org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:jul-to-slf4j:2.0.17=testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
|
||||
org.rnorth.duct-tape:duct-tape:1.0.8=redisTestCompileClasspath,redisTestRuntimeClasspath
|
||||
org.skyscreamer:jsonassert:1.5.3=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:jul-to-slf4j:2.0.17=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-api:2.0.17=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
|
||||
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor
|
||||
org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-logging:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-client:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-converter:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-jackson:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-restclient:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-resttestclient:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-servlet:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-logging:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-test:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-tomcat:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-web-server:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc-test:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot:4.0.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.data:spring-data-commons:4.0.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.data:spring-data-keyvalue:4.0.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.data:spring-data-redis:4.0.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.session:spring-session-core:4.0.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.session:spring-session-data-redis:4.0.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-aop:7.0.1=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-beans:7.0.1=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-context-support:7.0.1=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-context:7.0.1=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-core:7.0.1=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-expression:7.0.1=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-oxm:7.0.1=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-test:7.0.1=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-tx:7.0.1=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-web:7.0.1=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-webmvc:7.0.1=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.testcontainers:testcontainers:2.0.2=redisTestCompileClasspath,redisTestRuntimeClasspath
|
||||
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
|
||||
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
|
||||
org.yaml:snakeyaml:2.5=testCompileClasspath,testRuntimeClasspath
|
||||
redis.clients.authentication:redis-authx-core:0.1.1-beta2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
org.xmlunit:xmlunit-core:2.10.4=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.yaml:snakeyaml:2.5=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
redis.clients.authentication:redis-authx-core:0.1.1-beta2=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-core:3.0.2=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-databind:3.0.2=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson:jackson-bom:3.0.2=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
empty=
|
||||
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.function.LongSupplier;
|
||||
|
||||
/** One daemon worker with storage bounded by the finite active Redis role count. */
|
||||
final class BoundedRedisSentinelRefreshWorker implements RedisSentinelRefreshWorker {
|
||||
|
||||
private final Object monitor = new Object();
|
||||
private final int capacity;
|
||||
private final ArrayDeque<Runnable> immediateTasks;
|
||||
private final List<RecurringTask> recurringTasks;
|
||||
private final Thread worker;
|
||||
private final LongSupplier nanoTime;
|
||||
private boolean closed;
|
||||
private boolean preferDueRecurring;
|
||||
|
||||
BoundedRedisSentinelRefreshWorker(int capacity, String threadName) {
|
||||
this(capacity, threadName, System::nanoTime);
|
||||
}
|
||||
|
||||
BoundedRedisSentinelRefreshWorker(int capacity, String threadName, LongSupplier nanoTime) {
|
||||
if (capacity < 1) {
|
||||
throw new IllegalArgumentException("Redis Sentinel worker capacity must be positive");
|
||||
}
|
||||
this.capacity = capacity;
|
||||
this.immediateTasks = new ArrayDeque<>(capacity);
|
||||
this.recurringTasks = new ArrayList<>(capacity);
|
||||
this.nanoTime = Objects.requireNonNull(nanoTime, "nanoTime must be non-null");
|
||||
this.worker =
|
||||
Thread.ofPlatform().daemon(true).name(requireText(threadName)).unstarted(this::runWorker);
|
||||
this.worker.start();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Cancellable scheduleWithFixedDelay(Runnable task, Duration delay) {
|
||||
Objects.requireNonNull(task, "task must be non-null");
|
||||
long delayNanos = positiveNanos(delay);
|
||||
RecurringTask recurring =
|
||||
new RecurringTask(task, delayNanos, nanoTime.getAsLong() + delayNanos);
|
||||
synchronized (monitor) {
|
||||
ensureOpen();
|
||||
if (recurringTasks.size() >= capacity) {
|
||||
throw new IllegalStateException("Redis Sentinel recurring task capacity is exhausted");
|
||||
}
|
||||
recurringTasks.add(recurring);
|
||||
monitor.notifyAll();
|
||||
}
|
||||
return () -> cancel(recurring);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean execute(Runnable task) {
|
||||
Objects.requireNonNull(task, "task must be non-null");
|
||||
synchronized (monitor) {
|
||||
if (closed || immediateTasks.size() >= capacity) {
|
||||
return false;
|
||||
}
|
||||
immediateTasks.addLast(task);
|
||||
monitor.notifyAll();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void shutdown(Duration timeout) {
|
||||
long timeoutNanos = positiveNanos(timeout);
|
||||
synchronized (monitor) {
|
||||
if (!closed) {
|
||||
closed = true;
|
||||
recurringTasks.forEach(task -> task.cancelled = true);
|
||||
recurringTasks.clear();
|
||||
immediateTasks.clear();
|
||||
monitor.notifyAll();
|
||||
}
|
||||
}
|
||||
worker.interrupt();
|
||||
if (Thread.currentThread() == worker) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
long millis = Math.max(1, Math.min(Long.MAX_VALUE, timeoutNanos / 1_000_000L));
|
||||
worker.join(millis);
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
private void runWorker() {
|
||||
while (true) {
|
||||
Work work;
|
||||
try {
|
||||
work = awaitWork();
|
||||
} catch (InterruptedException interrupted) {
|
||||
if (isClosed()) {
|
||||
Thread.currentThread().interrupt();
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (work == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
work.task.run();
|
||||
} catch (RuntimeException ignored) {
|
||||
// Refresh failures are deliberately contained and rendered only through sanitized health.
|
||||
} finally {
|
||||
if (work.recurring != null) {
|
||||
reschedule(work.recurring);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Work awaitWork() throws InterruptedException {
|
||||
synchronized (monitor) {
|
||||
while (!closed) {
|
||||
if (!preferDueRecurring) {
|
||||
Runnable immediate = immediateTasks.pollFirst();
|
||||
if (immediate != null) {
|
||||
preferDueRecurring = true;
|
||||
return new Work(immediate, null);
|
||||
}
|
||||
}
|
||||
long now = nanoTime.getAsLong();
|
||||
RecurringTask due = null;
|
||||
long waitNanos = Long.MAX_VALUE;
|
||||
for (RecurringTask task : recurringTasks) {
|
||||
if (task.cancelled || task.running) {
|
||||
continue;
|
||||
}
|
||||
long remaining = task.nextRunNanos - now;
|
||||
if (remaining <= 0) {
|
||||
due = task;
|
||||
break;
|
||||
}
|
||||
waitNanos = Math.min(waitNanos, remaining);
|
||||
}
|
||||
if (due != null) {
|
||||
due.running = true;
|
||||
preferDueRecurring = false;
|
||||
return new Work(due.task, due);
|
||||
}
|
||||
Runnable immediate = immediateTasks.pollFirst();
|
||||
if (immediate != null) {
|
||||
preferDueRecurring = true;
|
||||
return new Work(immediate, null);
|
||||
}
|
||||
if (waitNanos == Long.MAX_VALUE) {
|
||||
monitor.wait();
|
||||
} else {
|
||||
long millis = waitNanos / 1_000_000L;
|
||||
int nanos = (int) (waitNanos % 1_000_000L);
|
||||
monitor.wait(millis, nanos);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void reschedule(RecurringTask task) {
|
||||
synchronized (monitor) {
|
||||
task.running = false;
|
||||
if (!closed && !task.cancelled) {
|
||||
task.nextRunNanos = nanoTime.getAsLong() + task.delayNanos;
|
||||
}
|
||||
monitor.notifyAll();
|
||||
}
|
||||
}
|
||||
|
||||
private void cancel(RecurringTask task) {
|
||||
synchronized (monitor) {
|
||||
task.cancelled = true;
|
||||
recurringTasks.remove(task);
|
||||
monitor.notifyAll();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isClosed() {
|
||||
synchronized (monitor) {
|
||||
return closed;
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureOpen() {
|
||||
if (closed) {
|
||||
throw new IllegalStateException("Redis Sentinel refresh worker is closed");
|
||||
}
|
||||
}
|
||||
|
||||
private static long positiveNanos(Duration duration) {
|
||||
Objects.requireNonNull(duration, "duration must be non-null");
|
||||
if (duration.isZero() || duration.isNegative()) {
|
||||
throw new IllegalArgumentException("Redis Sentinel worker duration must be positive");
|
||||
}
|
||||
try {
|
||||
return duration.toNanos();
|
||||
} catch (ArithmeticException overflow) {
|
||||
return Long.MAX_VALUE;
|
||||
}
|
||||
}
|
||||
|
||||
private static String requireText(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException("Redis Sentinel worker name must be non-blank");
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
private record Work(Runnable task, RecurringTask recurring) {}
|
||||
|
||||
private static final class RecurringTask {
|
||||
|
||||
private final Runnable task;
|
||||
private final long delayNanos;
|
||||
private long nextRunNanos;
|
||||
private boolean running;
|
||||
private boolean cancelled;
|
||||
|
||||
private RecurringTask(Runnable task, long delayNanos, long nextRunNanos) {
|
||||
this.task = task;
|
||||
this.delayNanos = delayNanos;
|
||||
this.nextRunNanos = nextRunNanos;
|
||||
}
|
||||
}
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import io.lettuce.core.RedisChannelHandler;
|
||||
import io.lettuce.core.RedisConnectionStateListener;
|
||||
import io.lettuce.core.pubsub.RedisPubSubAdapter;
|
||||
import io.lettuce.core.pubsub.StatefulRedisPubSubConnection;
|
||||
import java.net.SocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/** Managed standalone Redis Pub/Sub listener for best-effort cache invalidation hints. */
|
||||
final class LettuceRedisCacheInvalidationSubscription implements AutoCloseable {
|
||||
|
||||
private final StatefulRedisPubSubConnection<byte[], byte[]> connection;
|
||||
private final RedisCacheInvalidationSubscriber subscriber;
|
||||
private final AtomicBoolean closed = new AtomicBoolean();
|
||||
|
||||
private LettuceRedisCacheInvalidationSubscription(
|
||||
StatefulRedisPubSubConnection<byte[], byte[]> connection,
|
||||
RedisCacheInvalidationSubscriber subscriber) {
|
||||
this.connection = connection;
|
||||
this.subscriber = subscriber;
|
||||
}
|
||||
|
||||
static LettuceRedisCacheInvalidationSubscription subscribe(
|
||||
LettuceRedisRuntime runtime,
|
||||
String channel,
|
||||
RedisCacheInvalidationMessage.Codec codec,
|
||||
RedisCacheInvalidationSubscriber subscriber) {
|
||||
Objects.requireNonNull(runtime, "runtime must be non-null");
|
||||
Objects.requireNonNull(channel, "channel must be non-null");
|
||||
Objects.requireNonNull(codec, "codec must be non-null");
|
||||
Objects.requireNonNull(subscriber, "subscriber must be non-null");
|
||||
byte[] channelBytes = channel.getBytes(StandardCharsets.US_ASCII);
|
||||
StatefulRedisPubSubConnection<byte[], byte[]> connection =
|
||||
runtime.openInvalidationSubscription();
|
||||
connection.addListener(
|
||||
new RedisPubSubAdapter<>() {
|
||||
@Override
|
||||
public void message(byte[] actualChannel, byte[] message) {
|
||||
if (!Arrays.equals(channelBytes, actualChannel) || message == null) {
|
||||
return;
|
||||
}
|
||||
codec
|
||||
.decode(new String(message, StandardCharsets.US_ASCII))
|
||||
.ifPresentOrElse(subscriber::onMessage, subscriber::onMalformedMessage);
|
||||
}
|
||||
});
|
||||
connection.addListener(
|
||||
new RedisConnectionStateListener() {
|
||||
@Override
|
||||
public void onRedisConnected(
|
||||
RedisChannelHandler<?, ?> connection, SocketAddress remoteAddress) {
|
||||
// A preceding disconnect already forced L1 flush and generation recheck.
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRedisDisconnected(RedisChannelHandler<?, ?> connection) {
|
||||
subscriber.onDisconnected();
|
||||
}
|
||||
});
|
||||
try {
|
||||
connection.sync().subscribe(channelBytes);
|
||||
return new LettuceRedisCacheInvalidationSubscription(connection, subscriber);
|
||||
} catch (RuntimeException exception) {
|
||||
connection.close();
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (closed.compareAndSet(false, true)) {
|
||||
try {
|
||||
connection.close();
|
||||
} finally {
|
||||
subscriber.onDisconnected();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings;
|
||||
import io.lettuce.core.AbstractRedisClient;
|
||||
import io.lettuce.core.ClientOptions;
|
||||
import io.lettuce.core.ConnectionFuture;
|
||||
import io.lettuce.core.RedisClient;
|
||||
import io.lettuce.core.RedisURI;
|
||||
import io.lettuce.core.api.StatefulConnection;
|
||||
import io.lettuce.core.api.StatefulRedisConnection;
|
||||
import io.lettuce.core.cluster.ClusterClientOptions;
|
||||
import io.lettuce.core.cluster.RedisClusterClient;
|
||||
import io.lettuce.core.cluster.api.StatefulRedisClusterConnection;
|
||||
import io.lettuce.core.codec.ByteArrayCodec;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/** Opens, probes, and owns topology-native Lettuce clients and connections. */
|
||||
final class LettuceRedisNativeClientFactory implements RedisNativeClientFactory {
|
||||
|
||||
interface LifecycleObserver {
|
||||
|
||||
LifecycleObserver NOOP = new LifecycleObserver() {};
|
||||
|
||||
default void clientCreated() {}
|
||||
|
||||
default void connectionClosed() {}
|
||||
|
||||
default void clientClosed() {}
|
||||
}
|
||||
|
||||
private final LifecycleObserver observer;
|
||||
|
||||
LettuceRedisNativeClientFactory() {
|
||||
this(LifecycleObserver.NOOP);
|
||||
}
|
||||
|
||||
LettuceRedisNativeClientFactory(LifecycleObserver observer) {
|
||||
this.observer = Objects.requireNonNull(observer, "observer must be non-null");
|
||||
}
|
||||
|
||||
@Override
|
||||
public RedisNativeClientHandle openStandalone(
|
||||
RedisURI uri, ClientOptions options, RedisClientRuntimeSettings settings) {
|
||||
Objects.requireNonNull(uri, "uri must be non-null");
|
||||
Objects.requireNonNull(options, "options must be non-null");
|
||||
Objects.requireNonNull(settings, "settings must be non-null");
|
||||
RedisClient client = RedisClient.create(uri);
|
||||
observer.clientCreated();
|
||||
StatefulRedisConnection<byte[], byte[]> connection = null;
|
||||
try {
|
||||
client.setOptions(options);
|
||||
long deadline = deadline(settings.overallTimeout());
|
||||
ConnectionFuture<StatefulRedisConnection<byte[], byte[]>> connect =
|
||||
client.connectAsync(ByteArrayCodec.INSTANCE, uri);
|
||||
connection =
|
||||
await(
|
||||
connect,
|
||||
boundedByRemaining(settings.acquireTimeout(), deadline),
|
||||
"Redis standalone connect");
|
||||
connection.setTimeout(settings.commandTimeout());
|
||||
await(
|
||||
connection.async().ping(),
|
||||
boundedByRemaining(settings.commandTimeout(), deadline),
|
||||
"Redis standalone probe");
|
||||
return new LettuceHandle(client, connection, settings.shutdownTimeout(), observer);
|
||||
} catch (RuntimeException exception) {
|
||||
closeFailed(client, connection, settings.shutdownTimeout(), observer, List.of(uri));
|
||||
throw sanitizedConnectFailure(exception);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public RedisNativeClientHandle openCluster(
|
||||
List<RedisURI> seedUris, ClusterClientOptions options, RedisClientRuntimeSettings settings) {
|
||||
List<RedisURI> uris =
|
||||
List.copyOf(Objects.requireNonNull(seedUris, "seedUris must be non-null"));
|
||||
Objects.requireNonNull(options, "options must be non-null");
|
||||
Objects.requireNonNull(settings, "settings must be non-null");
|
||||
RedisClusterClient client = RedisClusterClient.create(uris);
|
||||
observer.clientCreated();
|
||||
StatefulRedisClusterConnection<byte[], byte[]> connection = null;
|
||||
try {
|
||||
client.setOptions(options);
|
||||
long deadline = deadline(settings.overallTimeout());
|
||||
java.util.concurrent.CompletableFuture<StatefulRedisClusterConnection<byte[], byte[]>>
|
||||
connect = client.connectAsync(ByteArrayCodec.INSTANCE);
|
||||
connection =
|
||||
await(
|
||||
connect,
|
||||
boundedByRemaining(settings.acquireTimeout(), deadline),
|
||||
"Redis Cluster connect");
|
||||
connection.setTimeout(settings.commandTimeout());
|
||||
await(
|
||||
connection.async().ping(),
|
||||
boundedByRemaining(settings.commandTimeout(), deadline),
|
||||
"Redis Cluster probe");
|
||||
return new LettuceHandle(client, connection, settings.shutdownTimeout(), observer);
|
||||
} catch (RuntimeException exception) {
|
||||
closeFailed(client, connection, settings.shutdownTimeout(), observer, uris);
|
||||
throw sanitizedConnectFailure(exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static long deadline(Duration overallTimeout) {
|
||||
long timeoutNanos = overallTimeout.toNanos();
|
||||
long now = System.nanoTime();
|
||||
return now > Long.MAX_VALUE - timeoutNanos ? Long.MAX_VALUE : now + timeoutNanos;
|
||||
}
|
||||
|
||||
private static Duration boundedByRemaining(Duration operationTimeout, long deadline) {
|
||||
long remaining = deadline - System.nanoTime();
|
||||
if (remaining <= 0) {
|
||||
throw new IllegalStateException("Redis overall connect deadline expired");
|
||||
}
|
||||
Duration remainingDuration = Duration.ofNanos(remaining);
|
||||
return operationTimeout.compareTo(remainingDuration) < 0 ? operationTimeout : remainingDuration;
|
||||
}
|
||||
|
||||
private static <T> T await(
|
||||
java.util.concurrent.Future<T> future, Duration timeout, String operation) {
|
||||
try {
|
||||
return future.get(timeout.toNanos(), TimeUnit.NANOSECONDS);
|
||||
} catch (InterruptedException exception) {
|
||||
future.cancel(true);
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException(operation + " was interrupted");
|
||||
} catch (TimeoutException exception) {
|
||||
future.cancel(true);
|
||||
throw new IllegalStateException(operation + " exceeded its bounded timeout");
|
||||
} catch (ExecutionException exception) {
|
||||
throw new IllegalStateException(operation + " failed");
|
||||
}
|
||||
}
|
||||
|
||||
private static IllegalStateException sanitizedConnectFailure(RuntimeException ignored) {
|
||||
return new IllegalStateException("Redis connect or probe failed within its bounded deadline");
|
||||
}
|
||||
|
||||
private static void closeFailed(
|
||||
AbstractRedisClient client,
|
||||
StatefulConnection<?, ?> connection,
|
||||
Duration shutdownTimeout,
|
||||
LifecycleObserver observer,
|
||||
List<RedisURI> uris) {
|
||||
try {
|
||||
closeConnection(connection, observer);
|
||||
} finally {
|
||||
try {
|
||||
client.shutdown(Duration.ZERO, shutdownTimeout);
|
||||
} finally {
|
||||
observer.clientClosed();
|
||||
uris.forEach(LettuceRedisNativeClientFactory::destroyCredentials);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void closeConnection(
|
||||
StatefulConnection<?, ?> connection, LifecycleObserver observer) {
|
||||
if (connection == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
connection.close();
|
||||
} finally {
|
||||
observer.connectionClosed();
|
||||
}
|
||||
}
|
||||
|
||||
private static void destroyCredentials(RedisURI uri) {
|
||||
if (uri.getCredentialsProvider() instanceof javax.security.auth.Destroyable destroyable) {
|
||||
try {
|
||||
destroyable.destroy();
|
||||
} catch (javax.security.auth.DestroyFailedException ignored) {
|
||||
// The adapter-owned providers do not throw; remain fail-safe for alternate implementations.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final class LettuceHandle implements RedisNativeClientHandle {
|
||||
|
||||
private final AbstractRedisClient client;
|
||||
private final StatefulConnection<?, ?> connection;
|
||||
private final Duration configuredShutdownTimeout;
|
||||
private final LifecycleObserver observer;
|
||||
private final AtomicBoolean closed = new AtomicBoolean();
|
||||
|
||||
private LettuceHandle(
|
||||
AbstractRedisClient client,
|
||||
StatefulConnection<?, ?> connection,
|
||||
Duration configuredShutdownTimeout,
|
||||
LifecycleObserver observer) {
|
||||
this.client = client;
|
||||
this.connection = connection;
|
||||
this.configuredShutdownTimeout = configuredShutdownTimeout;
|
||||
this.observer = observer;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<?> nativeClientType() {
|
||||
return client.getClass();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close(Duration timeout) {
|
||||
Objects.requireNonNull(timeout, "timeout must be non-null");
|
||||
if (!timeout.equals(configuredShutdownTimeout)) {
|
||||
throw new IllegalArgumentException("Redis shutdown timeout differs from runtime settings");
|
||||
}
|
||||
if (closed.compareAndSet(false, true)) {
|
||||
try {
|
||||
closeConnection(connection, observer);
|
||||
} finally {
|
||||
try {
|
||||
client.shutdown(Duration.ZERO, timeout);
|
||||
} finally {
|
||||
observer.clientClosed();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+124
-70
@@ -14,10 +14,12 @@ import io.lettuce.core.TimeoutOptions;
|
||||
import io.lettuce.core.api.StatefulRedisConnection;
|
||||
import io.lettuce.core.api.sync.RedisCommands;
|
||||
import io.lettuce.core.codec.ByteArrayCodec;
|
||||
import io.lettuce.core.pubsub.StatefulRedisPubSubConnection;
|
||||
import java.net.SocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.Supplier;
|
||||
@@ -26,19 +28,7 @@ import java.util.function.Supplier;
|
||||
final class LettuceRedisRuntime implements RedisClient, RedisBinaryCommands, AutoCloseable {
|
||||
|
||||
private static final String VALUE_TOO_LARGE_ERROR = "CA_VALUE_TOO_LARGE";
|
||||
private static final byte[] BOUNDED_GET_SCRIPT =
|
||||
"""
|
||||
local limit = tonumber(ARGV[1])
|
||||
local value = redis.call('GETRANGE', KEYS[1], 0, limit)
|
||||
if #value > limit then
|
||||
return redis.error_reply('CA_VALUE_TOO_LARGE')
|
||||
end
|
||||
if #value == 0 and redis.call('EXISTS', KEYS[1]) == 0 then
|
||||
return false
|
||||
end
|
||||
return value
|
||||
"""
|
||||
.getBytes(StandardCharsets.UTF_8);
|
||||
private static final RedisProgramCatalog FOUNDATION_CATALOG = RedisProgramCatalog.foundation();
|
||||
|
||||
private final io.lettuce.core.RedisClient client;
|
||||
private final StatefulRedisConnection<byte[], byte[]> connection;
|
||||
@@ -54,17 +44,17 @@ final class LettuceRedisRuntime implements RedisClient, RedisBinaryCommands, Aut
|
||||
private LettuceRedisRuntime(
|
||||
io.lettuce.core.RedisClient client,
|
||||
StatefulRedisConnection<byte[], byte[]> connection,
|
||||
RedisRuntimeSettings settings) {
|
||||
RedisConnectionProfile settings) {
|
||||
this.client = client;
|
||||
this.connection = connection;
|
||||
this.commands = connection.sync();
|
||||
this.legacyTtl = settings.positiveTtl();
|
||||
this.legacyTtl = settings.legacyTtl();
|
||||
this.shutdownTimeout = settings.commandTimeout();
|
||||
this.commandAdmission =
|
||||
new RedisCommandAdmission(
|
||||
settings.maximumQueuedCommands(), settings.maximumInFlightBytes());
|
||||
this.maximumReadableValueBytes = settings.maximumValueBytes() + 1024 + 32;
|
||||
this.maximumCommandBytes = settings.maximumValueBytes() + 2048;
|
||||
this.maximumReadableValueBytes = settings.maximumReadableValueBytes();
|
||||
this.maximumCommandBytes = settings.maximumCommandBytes();
|
||||
connection.addListener(
|
||||
new RedisConnectionStateListener() {
|
||||
@Override
|
||||
@@ -81,6 +71,14 @@ final class LettuceRedisRuntime implements RedisClient, RedisBinaryCommands, Aut
|
||||
}
|
||||
|
||||
static LettuceRedisRuntime connect(RedisRuntimeSettings settings) {
|
||||
return connect(RedisConnectionProfile.cache(settings));
|
||||
}
|
||||
|
||||
static LettuceRedisRuntime connect(RedisLegacyStandaloneSettings settings) {
|
||||
return connect(RedisConnectionProfile.rateLimit(settings));
|
||||
}
|
||||
|
||||
private static LettuceRedisRuntime connect(RedisConnectionProfile settings) {
|
||||
RedisURI uri = redisUri(settings);
|
||||
io.lettuce.core.RedisClient client = io.lettuce.core.RedisClient.create(uri);
|
||||
client.setOptions(clientOptions(settings));
|
||||
@@ -95,6 +93,10 @@ final class LettuceRedisRuntime implements RedisClient, RedisBinaryCommands, Aut
|
||||
}
|
||||
|
||||
static RedisURI redisUri(RedisRuntimeSettings settings) {
|
||||
return redisUri(RedisConnectionProfile.cache(settings));
|
||||
}
|
||||
|
||||
private static RedisURI redisUri(RedisConnectionProfile settings) {
|
||||
RedisURI.Builder builder =
|
||||
RedisURI.Builder.redis(settings.host(), settings.port())
|
||||
.withTimeout(settings.commandTimeout());
|
||||
@@ -105,6 +107,10 @@ final class LettuceRedisRuntime implements RedisClient, RedisBinaryCommands, Aut
|
||||
}
|
||||
|
||||
static ClientOptions clientOptions(RedisRuntimeSettings settings) {
|
||||
return clientOptions(RedisConnectionProfile.cache(settings));
|
||||
}
|
||||
|
||||
private static ClientOptions clientOptions(RedisConnectionProfile settings) {
|
||||
return ClientOptions.builder()
|
||||
.autoReconnect(true)
|
||||
.replayFilter(ignored -> true)
|
||||
@@ -116,7 +122,7 @@ final class LettuceRedisRuntime implements RedisClient, RedisBinaryCommands, Aut
|
||||
|
||||
@Override
|
||||
public Optional<String> read(String key) {
|
||||
byte[] value = get(key.getBytes(StandardCharsets.UTF_8));
|
||||
byte[] value = get(RedisPhysicalKey.owned(new LegacyKeyMaterial(key)));
|
||||
return value == null
|
||||
? Optional.empty()
|
||||
: Optional.of(new String(value, StandardCharsets.UTF_8));
|
||||
@@ -124,84 +130,109 @@ final class LettuceRedisRuntime implements RedisClient, RedisBinaryCommands, Aut
|
||||
|
||||
@Override
|
||||
public void write(String key, String value) {
|
||||
set(key.getBytes(StandardCharsets.UTF_8), value.getBytes(StandardCharsets.UTF_8), legacyTtl);
|
||||
set(
|
||||
RedisPhysicalKey.owned(new LegacyKeyMaterial(key)),
|
||||
RedisBinaryValue.utf8(value),
|
||||
legacyTtl);
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] get(byte[] key) {
|
||||
byte[] limit = Integer.toString(maximumReadableValueBytes).getBytes(StandardCharsets.US_ASCII);
|
||||
try {
|
||||
byte[] value =
|
||||
execute(
|
||||
false,
|
||||
reservationBytes(
|
||||
maximumReadableValueBytes,
|
||||
List.of(BOUNDED_GET_SCRIPT),
|
||||
List.of(key),
|
||||
List.of(limit)),
|
||||
() ->
|
||||
commands.eval(
|
||||
BOUNDED_GET_SCRIPT,
|
||||
ScriptOutputType.VALUE,
|
||||
new byte[][] {key.clone()},
|
||||
limit));
|
||||
return value == null ? null : value.clone();
|
||||
} catch (RedisCommandExecutionException exception) {
|
||||
if (exception.getMessage() != null
|
||||
&& exception.getMessage().contains(VALUE_TOO_LARGE_ERROR)) {
|
||||
throw new RedisValueTooLargeException();
|
||||
}
|
||||
throw exception;
|
||||
}
|
||||
public byte[] get(RedisPhysicalKey key) {
|
||||
RedisCatalogProgramInvocation invocation =
|
||||
FOUNDATION_CATALOG.boundedGetInvocation(key, maximumReadableValueBytes);
|
||||
byte[] value = RedisScriptRecovery.evalReadOnlyValue(this, invocation);
|
||||
return value == null ? null : value.clone();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void set(byte[] key, byte[] value, Duration timeToLive) {
|
||||
public void set(RedisPhysicalKey key, RedisBinaryValue value, Duration timeToLive) {
|
||||
byte[] encodedKey = RedisPhysicalKey.WireCodec.copy(key);
|
||||
byte[] encodedValue = value.copyEncoded();
|
||||
String result =
|
||||
execute(
|
||||
true,
|
||||
reservationBytes(64, List.of(key, value)),
|
||||
reservationBytes(64, List.of(encodedKey, encodedValue)),
|
||||
() ->
|
||||
commands.set(
|
||||
key.clone(), value.clone(), SetArgs.Builder.px(timeToLive.toMillis())));
|
||||
commands.set(encodedKey, encodedValue, SetArgs.Builder.px(timeToLive.toMillis())));
|
||||
if (!"OK".equals(result)) {
|
||||
throw new IllegalStateException("Redis SET did not acknowledge the mutation");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public long delete(byte[] key) {
|
||||
return execute(true, reservationBytes(32, List.of(key)), () -> commands.del(key.clone()));
|
||||
public long delete(RedisPhysicalKey key) {
|
||||
byte[] encodedKey = RedisPhysicalKey.WireCodec.copy(key);
|
||||
return execute(true, reservationBytes(32, List.of(encodedKey)), () -> commands.del(encodedKey));
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] evalSha(String sha1, List<byte[]> keys, List<byte[]> arguments) {
|
||||
public RedisCatalogProgramReply executeCatalogProgram(RedisCatalogProgramInvocation invocation) {
|
||||
boolean mutation =
|
||||
invocation.replyShape() != RedisCatalogProgramInvocation.ReplyShape.READ_ONLY_VALUE
|
||||
&& invocation.replyShape() != RedisCatalogProgramInvocation.ReplyShape.READ_ONLY_MULTI;
|
||||
ScriptOutputType outputType =
|
||||
invocation.replyShape() == RedisCatalogProgramInvocation.ReplyShape.MULTI
|
||||
|| invocation.replyShape()
|
||||
== RedisCatalogProgramInvocation.ReplyShape.READ_ONLY_MULTI
|
||||
? ScriptOutputType.MULTI
|
||||
: ScriptOutputType.VALUE;
|
||||
try {
|
||||
return execute(
|
||||
true,
|
||||
reservationBytes(256, keys, arguments),
|
||||
() ->
|
||||
commands.evalsha(
|
||||
sha1,
|
||||
ScriptOutputType.VALUE,
|
||||
keys.toArray(byte[][]::new),
|
||||
arguments.toArray(byte[][]::new)));
|
||||
Object result =
|
||||
execute(
|
||||
mutation,
|
||||
Math.max(256, invocation.encodedBytes()),
|
||||
() ->
|
||||
commands.evalsha(
|
||||
RedisScriptRecovery.sha1(
|
||||
RedisCatalogProgramInvocation.WireCodec.exactScript(invocation)),
|
||||
outputType,
|
||||
RedisCatalogProgramInvocation.WireCodec.keysArray(invocation),
|
||||
RedisCatalogProgramInvocation.WireCodec.argumentsArray(invocation)));
|
||||
if (outputType == ScriptOutputType.MULTI) {
|
||||
@SuppressWarnings("unchecked")
|
||||
List<byte[]> fields = (List<byte[]>) result;
|
||||
return RedisCatalogProgramReply.multi(defensiveReply(fields));
|
||||
}
|
||||
return RedisCatalogProgramReply.value((byte[]) result);
|
||||
} catch (io.lettuce.core.RedisNoScriptException exception) {
|
||||
throw new RedisNoScriptException();
|
||||
} catch (RedisCommandExecutionException exception) {
|
||||
if (exception.getMessage() != null
|
||||
&& exception.getMessage().contains(VALUE_TOO_LARGE_ERROR)) {
|
||||
throw new RedisValueTooLargeException();
|
||||
}
|
||||
throw commandFailure(
|
||||
mutation,
|
||||
mutation
|
||||
? "Redis Lua program execution failed"
|
||||
: "Redis read-only Lua program execution failed",
|
||||
exception);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] eval(byte[] script, List<byte[]> keys, List<byte[]> arguments) {
|
||||
return execute(
|
||||
public String loadCatalogProgram(RedisCatalogProgramInvocation invocation) {
|
||||
byte[] script = RedisCatalogProgramInvocation.WireCodec.exactScript(invocation);
|
||||
try {
|
||||
return execute(
|
||||
true, reservationBytes(64, List.of(script)), () -> commands.scriptLoad(script.clone()));
|
||||
} catch (RedisCommandExecutionException exception) {
|
||||
throw commandFailure(true, "Redis script load failed", exception);
|
||||
}
|
||||
}
|
||||
|
||||
void publishInvalidation(String channel, String message) {
|
||||
byte[] channelBytes = channel.getBytes(StandardCharsets.US_ASCII);
|
||||
byte[] messageBytes = message.getBytes(StandardCharsets.US_ASCII);
|
||||
execute(
|
||||
true,
|
||||
reservationBytes(256, List.of(script), keys, arguments),
|
||||
() ->
|
||||
commands.eval(
|
||||
script.clone(),
|
||||
ScriptOutputType.VALUE,
|
||||
keys.toArray(byte[][]::new),
|
||||
arguments.toArray(byte[][]::new)));
|
||||
reservationBytes(64, List.of(channelBytes, messageBytes)),
|
||||
() -> commands.publish(channelBytes, messageBytes));
|
||||
}
|
||||
|
||||
StatefulRedisPubSubConnection<byte[], byte[]> openInvalidationSubscription() {
|
||||
ensureOpen();
|
||||
return client.connectPubSub(ByteArrayCodec.INSTANCE);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -252,7 +283,7 @@ final class LettuceRedisRuntime implements RedisClient, RedisBinaryCommands, Aut
|
||||
throw exception;
|
||||
} catch (RedisCommandInterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw exception;
|
||||
throw commandFailure(mutation, "Redis command was interrupted", exception);
|
||||
} catch (RedisCommandTimeoutException exception) {
|
||||
throw commandFailure(mutation, "Redis command timed out", exception);
|
||||
} catch (RedisConnectionException exception) {
|
||||
@@ -273,6 +304,13 @@ final class LettuceRedisRuntime implements RedisClient, RedisBinaryCommands, Aut
|
||||
cause);
|
||||
}
|
||||
|
||||
private static List<byte[]> defensiveReply(List<byte[]> result) {
|
||||
if (result == null) {
|
||||
return null;
|
||||
}
|
||||
return result.stream().map(value -> value == null ? null : value.clone()).toList();
|
||||
}
|
||||
|
||||
@SafeVarargs
|
||||
private static int reservationBytes(int responseBytes, List<byte[]>... groups) {
|
||||
long total = Math.max(1, responseBytes);
|
||||
@@ -289,4 +327,20 @@ final class LettuceRedisRuntime implements RedisClient, RedisBinaryCommands, Aut
|
||||
}
|
||||
return (int) total;
|
||||
}
|
||||
|
||||
static final class LegacyKeyMaterial implements RedisOwnedPhysicalKeyMaterial {
|
||||
|
||||
private final byte[] encoded;
|
||||
|
||||
private LegacyKeyMaterial(String key) {
|
||||
this.encoded =
|
||||
Objects.requireNonNull(key, "legacy key must be non-null")
|
||||
.getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] copyEncodedKey() {
|
||||
return encoded.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import dev.caskeleton.application.cache.CacheObservationEvent;
|
||||
import dev.caskeleton.application.cache.CacheObservationPort;
|
||||
import io.micrometer.core.instrument.Counter;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import io.micrometer.core.instrument.Timer;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/** Micrometer rendering for the framework-free cache observation boundary. */
|
||||
final class MicrometerCacheObservationPort implements CacheObservationPort {
|
||||
|
||||
private final MeterRegistry registry;
|
||||
private final Set<String> cacheNames;
|
||||
|
||||
MicrometerCacheObservationPort(MeterRegistry registry, Set<String> cacheNames) {
|
||||
this.registry = Objects.requireNonNull(registry, "registry must be non-null");
|
||||
this.cacheNames = Set.copyOf(Objects.requireNonNull(cacheNames, "cacheNames must be non-null"));
|
||||
if (this.cacheNames.isEmpty() || this.cacheNames.size() > 50) {
|
||||
throw new IllegalArgumentException("cacheNames must contain 1..50 startup-registered names");
|
||||
}
|
||||
if (this.cacheNames.stream().anyMatch(name -> name == null || name.isBlank())) {
|
||||
throw new IllegalArgumentException("cacheNames must contain non-blank names");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void observe(CacheObservationEvent event) {
|
||||
Objects.requireNonNull(event, "event must be non-null");
|
||||
String cacheName =
|
||||
event instanceof CacheObservationEvent.Lookup lookup
|
||||
? lookup.cacheName()
|
||||
: ((CacheObservationEvent.LocalMaintenance) event).cacheName();
|
||||
if (!cacheNames.contains(cacheName)) {
|
||||
throw new IllegalArgumentException("cacheName is not in the startup allowlist");
|
||||
}
|
||||
if (event instanceof CacheObservationEvent.Lookup lookup) {
|
||||
observeLookup(lookup);
|
||||
return;
|
||||
}
|
||||
CacheObservationEvent.LocalMaintenance maintenance =
|
||||
(CacheObservationEvent.LocalMaintenance) event;
|
||||
Counter.builder("cache.local.maintenance.total")
|
||||
.tag("cache_name", maintenance.cacheName())
|
||||
.tag("event", maintenanceEvent(maintenance))
|
||||
.register(registry)
|
||||
.increment();
|
||||
}
|
||||
|
||||
private void observeLookup(CacheObservationEvent.Lookup lookup) {
|
||||
if (lookup.tier() != CacheObservationEvent.Tier.LOCAL_L1) {
|
||||
return;
|
||||
}
|
||||
Counter.builder("cache.local.requests.total")
|
||||
.tag("cache_name", lookup.cacheName())
|
||||
.tag("result", lower(lookup.result()))
|
||||
.register(registry)
|
||||
.increment();
|
||||
if (lookup.result() == CacheObservationEvent.LookupResult.HIT) {
|
||||
Timer.builder("cache.local.entry.age.seconds")
|
||||
.tag("cache_name", lookup.cacheName())
|
||||
.register(registry)
|
||||
.record(lookup.entryAge());
|
||||
}
|
||||
}
|
||||
|
||||
private static String lower(Enum<?> value) {
|
||||
return value.name().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static String maintenanceEvent(CacheObservationEvent.LocalMaintenance event) {
|
||||
if (event.action() == CacheObservationEvent.MaintenanceAction.EVICT) {
|
||||
return "evict_" + lower(event.cause());
|
||||
}
|
||||
if (event.cause() == CacheObservationEvent.MaintenanceCause.GENERATION_CHANGED) {
|
||||
return "reconcile_generation_changed";
|
||||
}
|
||||
if (event.action() == CacheObservationEvent.MaintenanceAction.RECONCILE) {
|
||||
return event.result() == CacheObservationEvent.MaintenanceResult.ERROR
|
||||
? "reconcile_error"
|
||||
: "reconcile_unchanged";
|
||||
}
|
||||
if (event.cause() == CacheObservationEvent.MaintenanceCause.SUBSCRIBER_DISCONNECTED) {
|
||||
return "subscriber_disconnected";
|
||||
}
|
||||
if (event.cause() == CacheObservationEvent.MaintenanceCause.SUBSCRIBER_OVERFLOW) {
|
||||
return "subscriber_overflow";
|
||||
}
|
||||
if (event.cause() == CacheObservationEvent.MaintenanceCause.MALFORMED_MESSAGE) {
|
||||
return "subscriber_malformed";
|
||||
}
|
||||
if (event.action() == CacheObservationEvent.MaintenanceAction.SUBSCRIBER_EVENT
|
||||
&& event.result() == CacheObservationEvent.MaintenanceResult.FLUSHED) {
|
||||
return "flush_invalidation";
|
||||
}
|
||||
if (event.action() == CacheObservationEvent.MaintenanceAction.SUBSCRIBER_EVENT) {
|
||||
return event.result() == CacheObservationEvent.MaintenanceResult.SUCCESS
|
||||
? "subscriber_publish_success"
|
||||
: "subscriber_publish_error";
|
||||
}
|
||||
if (event.cause() == CacheObservationEvent.MaintenanceCause.INVALIDATION) {
|
||||
return "flush_invalidation";
|
||||
}
|
||||
return "other";
|
||||
}
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import io.micrometer.core.instrument.Counter;
|
||||
import io.micrometer.core.instrument.Gauge;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import io.micrometer.core.instrument.Timer;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
/** Renders the closed Redis capability event model to its six registry-approved meters. */
|
||||
final class MicrometerRedisCapabilityObservationPort implements RedisCapabilityObservationPort {
|
||||
|
||||
private final MeterRegistry registry;
|
||||
private final ConcurrentMap<
|
||||
RedisCapabilityObservationEvent.Role, AtomicReference<InFlightSnapshot>>
|
||||
inFlight = new ConcurrentHashMap<>();
|
||||
|
||||
MicrometerRedisCapabilityObservationPort(MeterRegistry registry) {
|
||||
this.registry = Objects.requireNonNull(registry, "registry must be non-null");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void observe(RedisCapabilityObservationEvent.Event event) {
|
||||
Objects.requireNonNull(event, "event must be non-null");
|
||||
switch (event) {
|
||||
case RedisCapabilityObservationEvent.OperationCompleted operation ->
|
||||
observeOperation(operation);
|
||||
case RedisCapabilityObservationEvent.AdmissionChanged admission ->
|
||||
observeAdmission(admission);
|
||||
case RedisCapabilityObservationEvent.ReadinessObserved readiness ->
|
||||
observeReadiness(readiness);
|
||||
case RedisCapabilityObservationEvent.LifecycleDrainCompleted lifecycle ->
|
||||
observeLifecycle(lifecycle);
|
||||
}
|
||||
}
|
||||
|
||||
private void observeOperation(RedisCapabilityObservationEvent.OperationCompleted event) {
|
||||
Counter.builder("redis.capability.operations.total")
|
||||
.tags(
|
||||
"capability", lower(event.capability()),
|
||||
"role", lower(event.role()),
|
||||
"operation", lower(event.operation()),
|
||||
"redis_outcome", lower(event.outcome()),
|
||||
"certainty", lower(event.certainty()))
|
||||
.register(registry)
|
||||
.increment();
|
||||
Timer.builder("redis.capability.duration.seconds")
|
||||
.tags(
|
||||
"capability", lower(event.capability()),
|
||||
"role", lower(event.role()),
|
||||
"operation", lower(event.operation()),
|
||||
"redis_outcome", lower(event.outcome()))
|
||||
.register(registry)
|
||||
.record(event.durationNanos(), TimeUnit.NANOSECONDS);
|
||||
}
|
||||
|
||||
private void observeAdmission(RedisCapabilityObservationEvent.AdmissionChanged event) {
|
||||
if (event.admission() == RedisCapabilityObservationEvent.AdmissionState.REJECTED_SATURATED
|
||||
|| event.admission() == RedisCapabilityObservationEvent.AdmissionState.REJECTED_CLOSED) {
|
||||
Counter.builder("redis.capability.admission.rejected.total")
|
||||
.tags("role", lower(event.role()), "admission", lower(event.admission()))
|
||||
.register(registry)
|
||||
.increment();
|
||||
}
|
||||
snapshot(event.role()).set(new InFlightSnapshot(event.state(), event.inFlightCommands()));
|
||||
}
|
||||
|
||||
private void observeReadiness(RedisCapabilityObservationEvent.ReadinessObserved event) {
|
||||
Counter.builder("redis.capability.readiness.total")
|
||||
.tags(
|
||||
"capability", lower(event.capability()),
|
||||
"role", lower(event.role()),
|
||||
"state", lower(event.state()),
|
||||
"reason", lower(event.reason()),
|
||||
"requirement", lower(event.requirement()))
|
||||
.register(registry)
|
||||
.increment();
|
||||
}
|
||||
|
||||
private void observeLifecycle(RedisCapabilityObservationEvent.LifecycleDrainCompleted event) {
|
||||
Counter.builder("redis.capability.lifecycle.drain.total")
|
||||
.tags("role", lower(event.role()), "drain_outcome", lower(event.drainOutcome()))
|
||||
.register(registry)
|
||||
.increment();
|
||||
}
|
||||
|
||||
private static String lower(Enum<?> value) {
|
||||
return value.name().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private AtomicReference<InFlightSnapshot> snapshot(RedisCapabilityObservationEvent.Role role) {
|
||||
return inFlight.computeIfAbsent(
|
||||
role,
|
||||
ignored -> {
|
||||
AtomicReference<InFlightSnapshot> value =
|
||||
new AtomicReference<>(
|
||||
new InFlightSnapshot(RedisCapabilityObservationEvent.InFlightState.IDLE, 0));
|
||||
for (RedisCapabilityObservationEvent.InFlightState state :
|
||||
RedisCapabilityObservationEvent.InFlightState.values()) {
|
||||
Gauge.builder(
|
||||
"redis.capability.inflight.total",
|
||||
value,
|
||||
reference -> {
|
||||
InFlightSnapshot current = reference.get();
|
||||
return current.state() == state ? current.commands() : 0;
|
||||
})
|
||||
.tags("role", lower(role), "state", lower(state))
|
||||
.register(registry);
|
||||
}
|
||||
return value;
|
||||
});
|
||||
}
|
||||
|
||||
private record InFlightSnapshot(
|
||||
RedisCapabilityObservationEvent.InFlightState state, int commands) {}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
enum NoOpRedisCapabilityObservationPort implements RedisCapabilityObservationPort {
|
||||
INSTANCE;
|
||||
|
||||
static RedisCapabilityObservationPort instance() {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void observe(RedisCapabilityObservationEvent.Event event) {
|
||||
// Intentionally disabled.
|
||||
}
|
||||
}
|
||||
+192
-2
@@ -2,6 +2,7 @@ package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
@@ -10,8 +11,9 @@ final class RedisAtomicPrimitives {
|
||||
|
||||
private static final int MAXIMUM_OWNER_BYTES = 128;
|
||||
private static final int MAXIMUM_OPERATION_ID_BYTES = 128;
|
||||
private static final int MAXIMUM_VALUE_BYTES = 1_048_576;
|
||||
private static final int MAXIMUM_VALUE_BYTES = 16_778_272;
|
||||
private static final long MAXIMUM_TTL_MILLIS = Duration.ofDays(30).toMillis();
|
||||
private static final long MAXIMUM_CONTROL_TTL_MILLIS = Duration.ofDays(31).toMillis();
|
||||
|
||||
private final RedisProgramCatalog catalog;
|
||||
private final RedisProgramExecutor executor;
|
||||
@@ -56,12 +58,110 @@ final class RedisAtomicPrimitives {
|
||||
return parse(RedisProgramId.SET_IF_ABSENT_WITH_TTL, status, SetIfAbsentResult.class);
|
||||
}
|
||||
|
||||
ReplaceIfObservedResult replaceIfObservedWithTtl(
|
||||
String key, String observationToken, byte[] value, Duration timeToLive, String operationId) {
|
||||
byte[] keyBytes = key(key);
|
||||
byte[] expectedDigest = observationDigest(observationToken);
|
||||
byte[] boundedValue = bounded(value, MAXIMUM_VALUE_BYTES, "value");
|
||||
byte[] ttl = ttl(timeToLive);
|
||||
byte[] operation =
|
||||
bounded(
|
||||
Objects.requireNonNull(operationId, "operationId must be non-null")
|
||||
.getBytes(StandardCharsets.UTF_8),
|
||||
MAXIMUM_OPERATION_ID_BYTES,
|
||||
"operationId");
|
||||
String status =
|
||||
execute(
|
||||
RedisProgramId.REPLACE_IF_OBSERVED_WITH_TTL,
|
||||
List.of(keyBytes),
|
||||
List.of(expectedDigest, boundedValue, ttl, operation));
|
||||
return parse(
|
||||
RedisProgramId.REPLACE_IF_OBSERVED_WITH_TTL, status, ReplaceIfObservedResult.class);
|
||||
}
|
||||
|
||||
GenerationInitResult initializeGeneration(String key, String candidateGeneration) {
|
||||
return initializeGeneration(key, candidateGeneration, Duration.ZERO);
|
||||
}
|
||||
|
||||
GenerationInitResult initializeGeneration(
|
||||
String key, String candidateGeneration, Duration timeToLive) {
|
||||
byte[] keyBytes = key(key);
|
||||
byte[] generation = identifier(candidateGeneration, "candidateGeneration");
|
||||
byte[] ttl = controlTtl(timeToLive);
|
||||
String status =
|
||||
execute(RedisProgramId.REGION_GENERATION_INIT, List.of(keyBytes), List.of(generation, ttl));
|
||||
return parse(RedisProgramId.REGION_GENERATION_INIT, status, GenerationInitResult.class);
|
||||
}
|
||||
|
||||
GenerationBumpResult bumpGeneration(String key, String candidateGeneration, String operationId) {
|
||||
return bumpGeneration(key, candidateGeneration, operationId, Duration.ZERO);
|
||||
}
|
||||
|
||||
GenerationBumpResult bumpGeneration(
|
||||
String key, String candidateGeneration, String operationId, Duration timeToLive) {
|
||||
byte[] keyBytes = key(key);
|
||||
byte[] generation = identifier(candidateGeneration, "candidateGeneration");
|
||||
byte[] operation = identifier(operationId, "operationId");
|
||||
byte[] ttl = controlTtl(timeToLive);
|
||||
String status =
|
||||
execute(
|
||||
RedisProgramId.REGION_GENERATION_BUMP,
|
||||
List.of(keyBytes),
|
||||
List.of(generation, operation, ttl));
|
||||
return parse(RedisProgramId.REGION_GENERATION_BUMP, status, GenerationBumpResult.class);
|
||||
}
|
||||
|
||||
RefreshClaimResult claimRefreshLease(
|
||||
String key, String ownerToken, String operationToken, Duration timeToLive) {
|
||||
byte[] keyBytes = key(key);
|
||||
byte[] owner = identifier(ownerToken, "ownerToken");
|
||||
byte[] operation = identifier(operationToken, "operationToken");
|
||||
byte[] ttl = refreshLeaseTtl(timeToLive);
|
||||
String status =
|
||||
execute(
|
||||
RedisProgramId.CACHE_REFRESH_CLAIM, List.of(keyBytes), List.of(owner, operation, ttl));
|
||||
return parse(RedisProgramId.CACHE_REFRESH_CLAIM, status, RefreshClaimResult.class);
|
||||
}
|
||||
|
||||
private String execute(RedisProgramId id, List<byte[]> keys, List<byte[]> arguments) {
|
||||
RedisProgramDescriptor descriptor = catalog.descriptor(id);
|
||||
if (keys.size() != descriptor.keyCount() || arguments.size() != descriptor.argumentCount()) {
|
||||
throw new IllegalStateException("typed Redis program signature drift for " + id.externalId());
|
||||
}
|
||||
return executor.execute(descriptor, List.copyOf(keys), List.copyOf(arguments));
|
||||
return executor.execute(catalog.capabilityInvocation(new ProgramMaterial(id, keys, arguments)));
|
||||
}
|
||||
|
||||
static final class ProgramMaterial implements RedisCatalogProgramMaterial {
|
||||
|
||||
private final RedisProgramId programId;
|
||||
private final List<byte[]> keys;
|
||||
private final List<byte[]> arguments;
|
||||
|
||||
private ProgramMaterial(RedisProgramId programId, List<byte[]> keys, List<byte[]> arguments) {
|
||||
this.programId = Objects.requireNonNull(programId, "programId must be non-null");
|
||||
this.keys = keys.stream().map(byte[]::clone).toList();
|
||||
this.arguments = arguments.stream().map(byte[]::clone).toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public RedisProgramId programId() {
|
||||
return programId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RedisCatalogProgramInvocation.ReplyShape replyShape() {
|
||||
return RedisCatalogProgramInvocation.ReplyShape.VALUE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<byte[]> copyKeys() {
|
||||
return keys.stream().map(byte[]::clone).toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<byte[]> copyArguments() {
|
||||
return arguments.stream().map(byte[]::clone).toList();
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] key(String key) {
|
||||
@@ -84,6 +184,66 @@ final class RedisAtomicPrimitives {
|
||||
return Long.toString(milliseconds).getBytes(StandardCharsets.US_ASCII);
|
||||
}
|
||||
|
||||
private static byte[] controlTtl(Duration timeToLive) {
|
||||
Objects.requireNonNull(timeToLive, "timeToLive must be non-null");
|
||||
long milliseconds;
|
||||
try {
|
||||
milliseconds = timeToLive.toMillis();
|
||||
} catch (ArithmeticException exception) {
|
||||
throw new IllegalArgumentException("control TTL exceeds supported range", exception);
|
||||
}
|
||||
if (milliseconds < 0 || milliseconds > MAXIMUM_CONTROL_TTL_MILLIS) {
|
||||
throw new IllegalArgumentException(
|
||||
"control TTL must be between 0 and " + MAXIMUM_CONTROL_TTL_MILLIS + " milliseconds");
|
||||
}
|
||||
return Long.toString(milliseconds).getBytes(StandardCharsets.US_ASCII);
|
||||
}
|
||||
|
||||
private static byte[] refreshLeaseTtl(Duration timeToLive) {
|
||||
Objects.requireNonNull(timeToLive, "timeToLive must be non-null");
|
||||
long milliseconds;
|
||||
try {
|
||||
milliseconds = timeToLive.toMillis();
|
||||
} catch (ArithmeticException exception) {
|
||||
throw new IllegalArgumentException("refresh lease TTL exceeds supported range", exception);
|
||||
}
|
||||
long maximum = Duration.ofMinutes(5).toMillis();
|
||||
if (milliseconds < 1 || milliseconds > maximum) {
|
||||
throw new IllegalArgumentException(
|
||||
"refresh lease TTL must be between 1 and " + maximum + " milliseconds");
|
||||
}
|
||||
return Long.toString(milliseconds).getBytes(StandardCharsets.US_ASCII);
|
||||
}
|
||||
|
||||
private static byte[] observationDigest(String observationToken) {
|
||||
Objects.requireNonNull(observationToken, "observationToken must be non-null");
|
||||
byte[] digest;
|
||||
try {
|
||||
digest = Base64.getUrlDecoder().decode(observationToken);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new IllegalArgumentException("observationToken must be unpadded Base64URL", exception);
|
||||
}
|
||||
if (digest.length != 32
|
||||
|| !Base64.getUrlEncoder()
|
||||
.withoutPadding()
|
||||
.encodeToString(digest)
|
||||
.equals(observationToken)) {
|
||||
throw new IllegalArgumentException(
|
||||
"observationToken must encode exactly one canonical SHA-256 digest");
|
||||
}
|
||||
return digest;
|
||||
}
|
||||
|
||||
private static byte[] identifier(String value, String field) {
|
||||
Objects.requireNonNull(value, field + " must be non-null");
|
||||
byte[] bytes = value.getBytes(StandardCharsets.US_ASCII);
|
||||
if (bytes.length < 16 || bytes.length > 64 || !value.matches("[A-Za-z0-9_-]+")) {
|
||||
throw new IllegalArgumentException(
|
||||
field + " must be a Base64URL-safe identifier of 16..64 bytes");
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private static byte[] bounded(byte[] value, int maximumBytes, String field) {
|
||||
Objects.requireNonNull(value, field + " must be non-null");
|
||||
if (value.length < 1 || value.length > maximumBytes) {
|
||||
@@ -123,4 +283,34 @@ final class RedisAtomicPrimitives {
|
||||
WRONG_TYPE,
|
||||
INVALID
|
||||
}
|
||||
|
||||
enum ReplaceIfObservedResult {
|
||||
REPLACED,
|
||||
ABSENT,
|
||||
NOT_MATCHED,
|
||||
WRONG_TYPE,
|
||||
INVALID
|
||||
}
|
||||
|
||||
enum GenerationInitResult {
|
||||
INITIALIZED,
|
||||
EXISTING,
|
||||
WRONG_TYPE,
|
||||
INVALID
|
||||
}
|
||||
|
||||
enum GenerationBumpResult {
|
||||
BUMPED,
|
||||
ALREADY_APPLIED,
|
||||
WRONG_TYPE,
|
||||
INVALID
|
||||
}
|
||||
|
||||
enum RefreshClaimResult {
|
||||
CLAIMED,
|
||||
ALREADY_OWNED,
|
||||
CONTENDED,
|
||||
WRONG_TYPE,
|
||||
INVALID
|
||||
}
|
||||
}
|
||||
|
||||
+4
-9
@@ -1,18 +1,13 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
|
||||
/** Minimal binary Redis command surface owned entirely by this adapter. */
|
||||
interface RedisBinaryCommands {
|
||||
interface RedisBinaryCommands extends RedisStructuredCommands {
|
||||
|
||||
byte[] get(byte[] key);
|
||||
byte[] get(RedisPhysicalKey key);
|
||||
|
||||
void set(byte[] key, byte[] value, Duration timeToLive);
|
||||
void set(RedisPhysicalKey key, RedisBinaryValue value, Duration timeToLive);
|
||||
|
||||
long delete(byte[] key);
|
||||
|
||||
byte[] evalSha(String sha1, List<byte[]> keys, List<byte[]> arguments);
|
||||
|
||||
byte[] eval(byte[] script, List<byte[]> keys, List<byte[]> arguments);
|
||||
long delete(RedisPhysicalKey key);
|
||||
}
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Opaque bounded adapter-private value crossing the command gateway. */
|
||||
final class RedisBinaryValue {
|
||||
|
||||
private static final int MAXIMUM_VALUE_BYTES = 16_777_216;
|
||||
|
||||
private final byte[] encoded;
|
||||
|
||||
private RedisBinaryValue(byte[] encoded) {
|
||||
Objects.requireNonNull(encoded, "Redis binary value must be non-null");
|
||||
if (encoded.length < 1 || encoded.length > MAXIMUM_VALUE_BYTES) {
|
||||
throw new IllegalArgumentException("Redis binary value is out of bounds");
|
||||
}
|
||||
this.encoded = encoded.clone();
|
||||
}
|
||||
|
||||
static RedisBinaryValue encoded(byte[] encoded) {
|
||||
return new RedisBinaryValue(encoded);
|
||||
}
|
||||
|
||||
static RedisBinaryValue utf8(String encoded) {
|
||||
Objects.requireNonNull(encoded, "Redis binary value must be non-null");
|
||||
return new RedisBinaryValue(encoded.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
int encodedLength() {
|
||||
return encoded.length;
|
||||
}
|
||||
|
||||
byte[] copyEncoded() {
|
||||
return encoded.clone();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "RedisBinaryValue[redacted]";
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
/** Descriptor-owned byte offset for BITCOUNT ranges (Redis BITCOUNT is byte-indexed). */
|
||||
record RedisBitmapByteOffset(long value) {
|
||||
|
||||
RedisBitmapByteOffset {
|
||||
if (value < 0 || value >= 1_048_576) {
|
||||
throw new IllegalArgumentException("bitmap byte offset exceeds fixed descriptor domain");
|
||||
}
|
||||
}
|
||||
|
||||
static RedisBitmapByteOffset of(long value) {
|
||||
return new RedisBitmapByteOffset(value);
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import java.util.OptionalInt;
|
||||
|
||||
/** SETBIT result preserves the previous bit instead of mislabelling it as an affected count. */
|
||||
record RedisBitmapMutationResult(
|
||||
Status status, RedisPrimitiveMutationResult.Certainty certainty, OptionalInt previousBit) {
|
||||
|
||||
enum Status {
|
||||
APPLIED,
|
||||
WRONG_TYPE,
|
||||
UNKNOWN
|
||||
}
|
||||
|
||||
RedisBitmapMutationResult {
|
||||
if (status == null || certainty == null || previousBit == null) {
|
||||
throw new IllegalArgumentException("bitmap mutation result is invalid");
|
||||
}
|
||||
previousBit.ifPresent(
|
||||
bit -> {
|
||||
if (bit != 0 && bit != 1) {
|
||||
throw new IllegalArgumentException("previous bitmap bit is invalid");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static RedisBitmapMutationResult from(RedisPrimitiveReply reply) {
|
||||
return switch (reply.status()) {
|
||||
case APPLIED ->
|
||||
new RedisBitmapMutationResult(
|
||||
Status.APPLIED,
|
||||
RedisPrimitiveMutationResult.Certainty.APPLIED,
|
||||
OptionalInt.of(Math.toIntExact(reply.signedNumber().orElseThrow())));
|
||||
case WRONG_TYPE ->
|
||||
new RedisBitmapMutationResult(
|
||||
Status.WRONG_TYPE,
|
||||
RedisPrimitiveMutationResult.Certainty.NOT_APPLIED,
|
||||
OptionalInt.empty());
|
||||
default ->
|
||||
new RedisBitmapMutationResult(
|
||||
Status.UNKNOWN,
|
||||
RedisPrimitiveMutationResult.Certainty.NOT_APPLIED,
|
||||
OptionalInt.empty());
|
||||
};
|
||||
}
|
||||
|
||||
static RedisBitmapMutationResult failed(RedisCommandFailureException failure) {
|
||||
return new RedisBitmapMutationResult(
|
||||
Status.UNKNOWN,
|
||||
failure.certainty() == RedisCommandFailureException.Certainty.INDETERMINATE
|
||||
? RedisPrimitiveMutationResult.Certainty.INDETERMINATE
|
||||
: RedisPrimitiveMutationResult.Certainty.NOT_APPLIED,
|
||||
OptionalInt.empty());
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
/** Offset constrained to a descriptor-owned fixed bitmap domain. */
|
||||
record RedisBitmapOffset(long value, long maximumExclusive) {
|
||||
|
||||
RedisBitmapOffset {
|
||||
if (maximumExclusive < 1 || value < 0 || value >= maximumExclusive) {
|
||||
throw new IllegalArgumentException("bitmap offset exceeds the fixed descriptor domain");
|
||||
}
|
||||
}
|
||||
|
||||
static RedisBitmapOffset of(long value, long maximumExclusive) {
|
||||
return new RedisBitmapOffset(value, maximumExclusive);
|
||||
}
|
||||
|
||||
long byteIndex() {
|
||||
return value / Byte.SIZE;
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Fixed-domain non-authoritative bitmap helpers. */
|
||||
final class RedisBitmapPrimitives {
|
||||
|
||||
private static final long MAXIMUM_OFFSET_EXCLUSIVE = 8_388_608;
|
||||
|
||||
private final RedisPrimitiveCatalog catalog;
|
||||
private final RedisPrimitiveExecutor executor;
|
||||
|
||||
RedisBitmapPrimitives(RedisPrimitiveCatalog catalog, RedisPrimitiveCommands commands) {
|
||||
this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null");
|
||||
this.executor = new RedisPrimitiveExecutor(catalog, commands);
|
||||
}
|
||||
|
||||
RedisPrimitiveKey key(String slot, String identity) {
|
||||
return catalog.keyFactory(RedisPrimitiveId.BITMAP_GET).key(slot, identity);
|
||||
}
|
||||
|
||||
RedisBitmapOffset offset(long value) {
|
||||
return RedisBitmapOffset.of(value, MAXIMUM_OFFSET_EXCLUSIVE);
|
||||
}
|
||||
|
||||
RedisBitmapByteOffset byteOffset(long value) {
|
||||
return RedisBitmapByteOffset.of(value);
|
||||
}
|
||||
|
||||
RedisPrimitiveReply get(RedisPrimitiveKey key, RedisBitmapOffset offset) {
|
||||
return executor.execute(
|
||||
RedisPrimitiveId.BITMAP_GET,
|
||||
List.of(key),
|
||||
new RedisPrimitiveInvocation.BitmapArguments(offset, offset, -1));
|
||||
}
|
||||
|
||||
RedisBitmapMutationResult set(RedisPrimitiveKey key, RedisBitmapOffset offset, boolean bit) {
|
||||
try {
|
||||
return RedisBitmapMutationResult.from(
|
||||
executor.execute(
|
||||
RedisPrimitiveId.BITMAP_SET,
|
||||
List.of(key),
|
||||
new RedisPrimitiveInvocation.BitmapArguments(offset, offset, bit ? 1 : 0)));
|
||||
} catch (RedisCommandFailureException failure) {
|
||||
return RedisBitmapMutationResult.failed(failure);
|
||||
}
|
||||
}
|
||||
|
||||
RedisPrimitiveReply count(
|
||||
RedisPrimitiveKey key, RedisBitmapByteOffset first, RedisBitmapByteOffset last) {
|
||||
return executor.execute(
|
||||
RedisPrimitiveId.BITMAP_COUNT_FIXED_RANGE,
|
||||
List.of(key),
|
||||
new RedisPrimitiveInvocation.BitmapCountArguments(first, last));
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import io.lettuce.core.codec.RedisCodec;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Rejects an oversized Redis bulk value before allocating its destination byte array.
|
||||
*
|
||||
* <p>RESP aggregate element count and aggregate reply bytes are additionally checked by the
|
||||
* semantic router because a codec invocation sees only one bulk element. Lettuce constructs the
|
||||
* aggregate list before that final check, so multi-value commands remain restricted to the vetted
|
||||
* program catalog and its bounded reply schemas; this codec is the pre-allocation bound for each
|
||||
* bulk element, not a claim of a pre-allocation aggregate-list bound.
|
||||
*/
|
||||
final class RedisBoundedByteArrayCodec implements RedisCodec<byte[], byte[]> {
|
||||
|
||||
private final int maximumBulkBytes;
|
||||
|
||||
RedisBoundedByteArrayCodec(int maximumBulkBytes) {
|
||||
if (maximumBulkBytes < 1024 || maximumBulkBytes > 16_777_216) {
|
||||
throw new IllegalArgumentException("Redis codec bulk byte bound must be in 1024..16777216");
|
||||
}
|
||||
this.maximumBulkBytes = maximumBulkBytes;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] decodeKey(ByteBuffer bytes) {
|
||||
return decode(bytes);
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] decodeValue(ByteBuffer bytes) {
|
||||
return decode(bytes);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ByteBuffer encodeKey(byte[] key) {
|
||||
return encode(key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ByteBuffer encodeValue(byte[] value) {
|
||||
return encode(value);
|
||||
}
|
||||
|
||||
private byte[] decode(ByteBuffer bytes) {
|
||||
Objects.requireNonNull(bytes, "Redis decode buffer must be non-null");
|
||||
if (bytes.remaining() > maximumBulkBytes) {
|
||||
throw new IllegalStateException("Redis response bulk value exceeds its configured bound");
|
||||
}
|
||||
byte[] value = new byte[bytes.remaining()];
|
||||
bytes.get(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
private ByteBuffer encode(byte[] value) {
|
||||
Objects.requireNonNull(value, "Redis encode value must be non-null");
|
||||
if (value.length > maximumBulkBytes) {
|
||||
throw new IllegalArgumentException("Redis command bulk value exceeds its configured bound");
|
||||
}
|
||||
return ByteBuffer.wrap(value);
|
||||
}
|
||||
}
|
||||
+88
-13
@@ -2,7 +2,14 @@ package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.cache.core.CacheBackend;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyNamespace;
|
||||
import dev.caskeleton.application.cache.CacheRegionPort;
|
||||
import dev.caskeleton.application.cache.CacheObservationPort;
|
||||
import dev.caskeleton.application.cache.DisabledCacheObservationPort;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import java.time.Clock;
|
||||
import java.util.Arrays;
|
||||
import java.util.Set;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
@@ -23,7 +30,11 @@ import org.springframework.context.annotation.Configuration;
|
||||
* therefore never need to know about each other — a new backend is new files only.
|
||||
*/
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@EnableConfigurationProperties(RedisRuntimeSettings.class)
|
||||
@EnableConfigurationProperties({RedisRuntimeSettings.class, RedisLocalCacheSettings.class})
|
||||
@ConditionalOnProperty(
|
||||
name = "ca-skeleton.providers.redis.legacy-migration-enabled",
|
||||
havingValue = "true",
|
||||
matchIfMissing = false)
|
||||
public class RedisCacheAdapterConfig {
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@@ -44,14 +55,17 @@ public class RedisCacheAdapterConfig {
|
||||
}
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Bean(destroyMethod = "close")
|
||||
@ConditionalOnBean(LettuceRedisRuntime.class)
|
||||
@ConditionalOnProperty(
|
||||
name = "app.cache.redis.enabled",
|
||||
havingValue = "true",
|
||||
matchIfMissing = false)
|
||||
CacheRegionPort<String, String> redisStringCacheRegion(
|
||||
LettuceRedisRuntime runtime, RedisRuntimeSettings settings) {
|
||||
RedisCacheRegionRuntime redisStringCacheRegion(
|
||||
LettuceRedisRuntime runtime,
|
||||
RedisRuntimeSettings settings,
|
||||
RedisLocalCacheSettings localSettings,
|
||||
ObjectProvider<MeterRegistry> meterRegistryProvider) {
|
||||
RedisKeyNamespace namespace =
|
||||
new RedisKeyNamespace(
|
||||
settings.namespaceApplication(),
|
||||
@@ -62,14 +76,75 @@ public class RedisCacheAdapterConfig {
|
||||
1,
|
||||
"entry",
|
||||
512);
|
||||
return new RedisStringCacheRegion(
|
||||
new RedisCacheRegionPolicy(
|
||||
namespace,
|
||||
settings.hmacSecret(),
|
||||
settings.positiveTtl(),
|
||||
settings.negativeTtl(),
|
||||
settings.maximumValueBytes()),
|
||||
runtime);
|
||||
byte[] policySecret = settings.hmacSecret();
|
||||
RedisCacheRegionPolicy policy;
|
||||
try {
|
||||
policy =
|
||||
new RedisCacheRegionPolicy(
|
||||
namespace,
|
||||
policySecret,
|
||||
"runtime-settings-v2",
|
||||
settings.positiveSoftTtl(),
|
||||
settings.positiveTtl(),
|
||||
settings.negativeTtl(),
|
||||
settings.ttlJitter(),
|
||||
settings.minimumHardTtl(),
|
||||
settings.maximumValueBytes());
|
||||
} finally {
|
||||
Arrays.fill(policySecret, (byte) 0);
|
||||
}
|
||||
RedisStringCacheRegion l2 = new RedisStringCacheRegion(policy, runtime);
|
||||
if (!localSettings.enabled()) {
|
||||
return RedisCacheRegionRuntime.l2Only(l2);
|
||||
}
|
||||
MeterRegistry meterRegistry = meterRegistryProvider.getIfAvailable();
|
||||
CacheObservationPort observations =
|
||||
meterRegistry == null
|
||||
? DisabledCacheObservationPort.instance()
|
||||
: new MicrometerCacheObservationPort(meterRegistry, Set.of(settings.semanticRegion()));
|
||||
String channel = l2.invalidationChannel();
|
||||
byte[] codecSecret = settings.hmacSecret();
|
||||
RedisCacheInvalidationMessage.Codec codec;
|
||||
try {
|
||||
codec = RedisCacheInvalidationMessage.Codec.fromOwnedSecret(codecSecret);
|
||||
} finally {
|
||||
Arrays.fill(codecSecret, (byte) 0);
|
||||
}
|
||||
return RedisCacheRegionRuntime.local(
|
||||
l2,
|
||||
new RedisLocalCacheRegion(
|
||||
settings.semanticRegion(),
|
||||
l2,
|
||||
localSettings.policy(),
|
||||
Clock.systemUTC(),
|
||||
observations,
|
||||
channel,
|
||||
codec,
|
||||
message -> runtime.publishInvalidation(channel, message)));
|
||||
}
|
||||
|
||||
@Bean(destroyMethod = "close")
|
||||
@ConditionalOnBean(LettuceRedisRuntime.class)
|
||||
@ConditionalOnProperty(
|
||||
name = {"app.cache.redis.enabled", "app.cache.redis.l1.enabled"},
|
||||
havingValue = "true",
|
||||
matchIfMissing = false)
|
||||
LettuceRedisCacheInvalidationSubscription redisCacheInvalidationSubscription(
|
||||
LettuceRedisRuntime runtime,
|
||||
@Qualifier("redisStringCacheRegion") RedisCacheRegionRuntime cacheRegion) {
|
||||
RedisLocalCacheRegion local =
|
||||
cacheRegion
|
||||
.local()
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new IllegalStateException(
|
||||
"Redis L1 invalidation subscription requires the cache-only local"
|
||||
+ " decorator"));
|
||||
return LettuceRedisCacheInvalidationSubscription.subscribe(
|
||||
runtime,
|
||||
local.invalidationChannel(),
|
||||
local.invalidationMessageCodec(),
|
||||
local.invalidationSubscriber());
|
||||
}
|
||||
|
||||
@Bean
|
||||
|
||||
+219
@@ -0,0 +1,219 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import dev.caskeleton.application.cache.CacheWriteCondition;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Duration;
|
||||
import java.util.Base64;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* Owns non-expiring random cache generations and per-key revisions.
|
||||
*
|
||||
* <p>If an evictable control key disappears, initialization chooses a new random value. An old
|
||||
* namespace therefore never becomes visible again by resetting to a constant default.
|
||||
*/
|
||||
final class RedisCacheConsistencyStore {
|
||||
|
||||
private static final String CONDITION_VERSION = "v1";
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
private static final Duration DEFAULT_KEY_REVISION_TTL = Duration.ofDays(30);
|
||||
|
||||
private final RedisBinaryCommands commands;
|
||||
private final RedisAtomicPrimitives primitives;
|
||||
private final Supplier<String> identifiers;
|
||||
private final Duration keyRevisionTtl;
|
||||
|
||||
RedisCacheConsistencyStore(RedisBinaryCommands commands) {
|
||||
this(commands, DEFAULT_KEY_REVISION_TTL);
|
||||
}
|
||||
|
||||
RedisCacheConsistencyStore(RedisBinaryCommands commands, Duration keyRevisionTtl) {
|
||||
this(
|
||||
commands,
|
||||
productionPrimitives(commands),
|
||||
RedisCacheConsistencyStore::randomIdentifier,
|
||||
keyRevisionTtl);
|
||||
}
|
||||
|
||||
RedisCacheConsistencyStore(
|
||||
RedisBinaryCommands commands,
|
||||
RedisAtomicPrimitives primitives,
|
||||
Supplier<String> identifiers) {
|
||||
this(commands, primitives, identifiers, DEFAULT_KEY_REVISION_TTL);
|
||||
}
|
||||
|
||||
RedisCacheConsistencyStore(
|
||||
RedisBinaryCommands commands,
|
||||
RedisAtomicPrimitives primitives,
|
||||
Supplier<String> identifiers,
|
||||
Duration keyRevisionTtl) {
|
||||
this.commands = Objects.requireNonNull(commands, "commands must be non-null");
|
||||
this.primitives = Objects.requireNonNull(primitives, "primitives must be non-null");
|
||||
this.identifiers = Objects.requireNonNull(identifiers, "identifiers must be non-null");
|
||||
this.keyRevisionTtl = boundedKeyRevisionTtl(keyRevisionTtl);
|
||||
}
|
||||
|
||||
Snapshot capture(String regionGenerationKey, String keyRevisionKey) {
|
||||
return new Snapshot(
|
||||
currentOrInitialize(regionGenerationKey, Duration.ZERO),
|
||||
currentOrInitialize(keyRevisionKey, keyRevisionTtl));
|
||||
}
|
||||
|
||||
String currentRegionGeneration(String regionGenerationKey) {
|
||||
return currentOrInitialize(regionGenerationKey, Duration.ZERO);
|
||||
}
|
||||
|
||||
BumpResult bumpKeyRevision(String keyRevisionKey) {
|
||||
return bumpKeyRevision(keyRevisionKey, nextIdentifier());
|
||||
}
|
||||
|
||||
BumpResult bumpKeyRevision(String keyRevisionKey, String operationId) {
|
||||
return bump(keyRevisionKey, operationId, keyRevisionTtl);
|
||||
}
|
||||
|
||||
BumpResult bumpRegionGeneration(String regionGenerationKey) {
|
||||
return bumpRegionGeneration(regionGenerationKey, nextIdentifier());
|
||||
}
|
||||
|
||||
BumpResult bumpRegionGeneration(String regionGenerationKey, String operationId) {
|
||||
return bump(regionGenerationKey, operationId, Duration.ZERO);
|
||||
}
|
||||
|
||||
Snapshot decode(CacheWriteCondition condition) {
|
||||
Objects.requireNonNull(condition, "condition must be non-null");
|
||||
if (!condition.usable()) {
|
||||
return null;
|
||||
}
|
||||
String[] components = condition.value().split("\\.", -1);
|
||||
if (components.length != 3 || !CONDITION_VERSION.equals(components[0])) {
|
||||
throw compatibility("MALFORMED_WRITE_CONDITION");
|
||||
}
|
||||
try {
|
||||
return new Snapshot(components[1], components[2]);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw compatibility("MALFORMED_WRITE_CONDITION");
|
||||
}
|
||||
}
|
||||
|
||||
private String currentOrInitialize(String key, Duration timeToLive) {
|
||||
byte[] current = commands.get(physicalKey(key));
|
||||
String candidate = current == null ? nextIdentifier() : parseState(current).generation();
|
||||
RedisAtomicPrimitives.GenerationInitResult initialized =
|
||||
primitives.initializeGeneration(key, candidate, timeToLive);
|
||||
if (initialized == RedisAtomicPrimitives.GenerationInitResult.WRONG_TYPE
|
||||
|| initialized == RedisAtomicPrimitives.GenerationInitResult.INVALID) {
|
||||
throw compatibility(initialized.name());
|
||||
}
|
||||
current = commands.get(physicalKey(key));
|
||||
if (current == null) {
|
||||
throw compatibility("MISSING_AFTER_INITIALIZATION");
|
||||
}
|
||||
return parseState(current).generation();
|
||||
}
|
||||
|
||||
private BumpResult bump(String key, String operationId, Duration timeToLive) {
|
||||
RedisAtomicPrimitives.GenerationBumpResult result =
|
||||
primitives.bumpGeneration(
|
||||
key, nextIdentifier(), validateIdentifier(operationId, "operationId"), timeToLive);
|
||||
return switch (result) {
|
||||
case BUMPED -> BumpResult.BUMPED;
|
||||
case ALREADY_APPLIED -> BumpResult.ALREADY_APPLIED;
|
||||
case WRONG_TYPE, INVALID -> throw compatibility(result.name());
|
||||
};
|
||||
}
|
||||
|
||||
private String nextIdentifier() {
|
||||
return validateIdentifier(identifiers.get(), "generated identifier");
|
||||
}
|
||||
|
||||
private static State parseState(byte[] value) {
|
||||
String state = new String(value, StandardCharsets.US_ASCII);
|
||||
int separator = state.indexOf('|');
|
||||
if (separator < 0 || separator != state.lastIndexOf('|')) {
|
||||
throw compatibility("MALFORMED_GENERATION_STATE");
|
||||
}
|
||||
try {
|
||||
String generation = validateIdentifier(state.substring(0, separator), "stored generation");
|
||||
String operation = state.substring(separator + 1);
|
||||
if (!"-".equals(operation)) {
|
||||
validateIdentifier(operation, "stored operation");
|
||||
}
|
||||
return new State(generation, operation);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw compatibility("MALFORMED_GENERATION_STATE");
|
||||
}
|
||||
}
|
||||
|
||||
private static String validateIdentifier(String value, String field) {
|
||||
if (value == null
|
||||
|| value.length() < 16
|
||||
|| value.length() > 64
|
||||
|| !value.matches("[A-Za-z0-9_-]+")) {
|
||||
throw new IllegalArgumentException(field + " must contain 16..64 Base64URL-safe characters");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static Duration boundedKeyRevisionTtl(Duration value) {
|
||||
Objects.requireNonNull(value, "keyRevisionTtl must be non-null");
|
||||
if (value.isZero() || value.isNegative() || value.compareTo(Duration.ofDays(31)) > 0) {
|
||||
throw new IllegalArgumentException("keyRevisionTtl must be positive and at most 31 days");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static RedisPhysicalKey physicalKey(String key) {
|
||||
return RedisPhysicalKey.owned(new ConsistencyKeyMaterial(key));
|
||||
}
|
||||
|
||||
private static String randomIdentifier() {
|
||||
byte[] random = new byte[16];
|
||||
RANDOM.nextBytes(random);
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(random);
|
||||
}
|
||||
|
||||
private static RedisAtomicPrimitives productionPrimitives(RedisBinaryCommands commands) {
|
||||
RedisProgramCatalog catalog = RedisProgramCatalog.foundation();
|
||||
return new RedisAtomicPrimitives(catalog, new RedisLuaProgramExecutor(catalog, commands));
|
||||
}
|
||||
|
||||
private static RedisProgramCompatibilityException compatibility(String status) {
|
||||
return new RedisProgramCompatibilityException(RedisProgramId.REGION_GENERATION_INIT, status);
|
||||
}
|
||||
|
||||
enum BumpResult {
|
||||
BUMPED,
|
||||
ALREADY_APPLIED
|
||||
}
|
||||
|
||||
record Snapshot(String generation, String keyRevision) {
|
||||
|
||||
Snapshot {
|
||||
generation = validateIdentifier(generation, "generation");
|
||||
keyRevision = validateIdentifier(keyRevision, "keyRevision");
|
||||
}
|
||||
|
||||
CacheWriteCondition toWriteCondition() {
|
||||
return new CacheWriteCondition(CONDITION_VERSION + "." + generation + "." + keyRevision);
|
||||
}
|
||||
}
|
||||
|
||||
private record State(String generation, String operation) {}
|
||||
|
||||
static final class ConsistencyKeyMaterial implements RedisOwnedPhysicalKeyMaterial {
|
||||
|
||||
private final byte[] encodedKey;
|
||||
|
||||
private ConsistencyKeyMaterial(String key) {
|
||||
this.encodedKey =
|
||||
Objects.requireNonNull(key, "key must be non-null").getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] copyEncodedKey() {
|
||||
return encodedKey.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
+170
-82
@@ -2,117 +2,186 @@ package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import dev.caskeleton.application.cache.AuthoritativeAbsence;
|
||||
import dev.caskeleton.application.cache.CacheLookup;
|
||||
import dev.caskeleton.application.cache.CacheObservationToken;
|
||||
import java.nio.BufferUnderflowException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.CharacterCodingException;
|
||||
import java.nio.charset.CodingErrorAction;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Strict versioned binary envelope for positive and authoritative-negative cache entries. */
|
||||
final class RedisCacheEnvelopeCodec {
|
||||
|
||||
private static final int MAGIC = 0x43414348;
|
||||
private static final byte VERSION = 1;
|
||||
private static final int VERSION = 2;
|
||||
private static final byte POSITIVE = 1;
|
||||
private static final byte NEGATIVE = 2;
|
||||
private static final int CONTENT_HEADER_BYTES =
|
||||
Integer.BYTES + Byte.BYTES + Byte.BYTES + Short.BYTES + Integer.BYTES;
|
||||
private static final int COMMON_HEADER_BYTES = Integer.BYTES + Byte.BYTES + Byte.BYTES;
|
||||
private static final int POSITIVE_HEADER_BYTES =
|
||||
COMMON_HEADER_BYTES + Short.BYTES + Integer.BYTES + Long.BYTES + Long.BYTES;
|
||||
private static final int NEGATIVE_HEADER_BYTES = COMMON_HEADER_BYTES + Integer.BYTES + Long.BYTES;
|
||||
private static final int DIGEST_BYTES = 32;
|
||||
|
||||
private RedisCacheEnvelopeCodec() {}
|
||||
|
||||
static byte[] positive(String value, String sourceRevision, int maximumValueBytes) {
|
||||
return encode(
|
||||
POSITIVE,
|
||||
utf8(Objects.requireNonNull(value, "value must be non-null")),
|
||||
sourceRevision,
|
||||
maximumValueBytes);
|
||||
}
|
||||
|
||||
static byte[] negative(
|
||||
AuthoritativeAbsence reason, String sourceRevision, int maximumValueBytes) {
|
||||
Objects.requireNonNull(reason, "reason must be non-null");
|
||||
return encode(NEGATIVE, utf8(reason.name()), sourceRevision, maximumValueBytes);
|
||||
}
|
||||
|
||||
static Decoded decode(byte[] envelope, int maximumValueBytes) {
|
||||
if (envelope == null
|
||||
|| envelope.length < CONTENT_HEADER_BYTES + DIGEST_BYTES
|
||||
|| envelope.length > maximumValueBytes + 1024 + DIGEST_BYTES) {
|
||||
return incompatible(CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE);
|
||||
static byte[] positive(
|
||||
String value,
|
||||
String sourceRevision,
|
||||
Instant softExpiresAt,
|
||||
Instant hardExpiresAt,
|
||||
int maximumValueBytes) {
|
||||
Objects.requireNonNull(softExpiresAt, "softExpiresAt must be non-null");
|
||||
Objects.requireNonNull(hardExpiresAt, "hardExpiresAt must be non-null");
|
||||
if (softExpiresAt.isAfter(hardExpiresAt)) {
|
||||
throw new IllegalArgumentException("softExpiresAt must not be after hardExpiresAt");
|
||||
}
|
||||
try {
|
||||
ByteBuffer buffer = ByteBuffer.wrap(envelope, 0, envelope.length - DIGEST_BYTES);
|
||||
if (buffer.getInt() != MAGIC) {
|
||||
return incompatible(CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE);
|
||||
}
|
||||
byte version = buffer.get();
|
||||
if (version > VERSION) {
|
||||
return incompatible(CacheLookup.SchemaCategory.FUTURE_VERSION);
|
||||
}
|
||||
if (version < VERSION) {
|
||||
return incompatible(CacheLookup.SchemaCategory.RETIRED_VERSION);
|
||||
}
|
||||
byte[] expectedDigest = sha256(Arrays.copyOf(envelope, envelope.length - DIGEST_BYTES));
|
||||
byte[] actualDigest =
|
||||
Arrays.copyOfRange(envelope, envelope.length - DIGEST_BYTES, envelope.length);
|
||||
if (!MessageDigest.isEqual(expectedDigest, actualDigest)) {
|
||||
return incompatible(CacheLookup.SchemaCategory.CORRUPT_ENVELOPE);
|
||||
}
|
||||
byte type = buffer.get();
|
||||
int revisionSize = Short.toUnsignedInt(buffer.getShort());
|
||||
int payloadSize = buffer.getInt();
|
||||
if (revisionSize < 1
|
||||
|| revisionSize > 512
|
||||
|| payloadSize < 1
|
||||
|| payloadSize > maximumValueBytes
|
||||
|| buffer.remaining() != revisionSize + payloadSize) {
|
||||
return incompatible(CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE);
|
||||
}
|
||||
byte[] revision = new byte[revisionSize];
|
||||
byte[] payload = new byte[payloadSize];
|
||||
buffer.get(revision);
|
||||
buffer.get(payload);
|
||||
String sourceRevision = strictUtf8(revision);
|
||||
if (!validSourceRevision(sourceRevision)) {
|
||||
return incompatible(CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE);
|
||||
}
|
||||
if (type == POSITIVE) {
|
||||
return new Positive(strictUtf8(payload), sourceRevision);
|
||||
}
|
||||
if (type == NEGATIVE) {
|
||||
return new Negative(AuthoritativeAbsence.valueOf(strictUtf8(payload)));
|
||||
}
|
||||
return incompatible(CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE);
|
||||
} catch (IllegalArgumentException | CharacterCodingException exception) {
|
||||
return incompatible(CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] encode(
|
||||
byte type, byte[] payload, String sourceRevision, int maximumValueBytes) {
|
||||
byte[] revision =
|
||||
utf8(Objects.requireNonNull(sourceRevision, "sourceRevision must be non-null"));
|
||||
if (!validSourceRevision(sourceRevision) || revision.length > 512) {
|
||||
throw new IllegalArgumentException(
|
||||
"sourceRevision must contain 1..128 characters and at most 512 UTF-8 bytes");
|
||||
}
|
||||
if (payload.length < 1 || payload.length > maximumValueBytes) {
|
||||
throw new IllegalArgumentException("cache payload exceeds configured maximum bytes");
|
||||
}
|
||||
byte[] payload =
|
||||
checkedPayload(
|
||||
utf8(Objects.requireNonNull(value, "value must be non-null")), maximumValueBytes);
|
||||
byte[] content =
|
||||
ByteBuffer.allocate(CONTENT_HEADER_BYTES + revision.length + payload.length)
|
||||
ByteBuffer.allocate(POSITIVE_HEADER_BYTES + revision.length + payload.length)
|
||||
.putInt(MAGIC)
|
||||
.put(VERSION)
|
||||
.put(type)
|
||||
.put((byte) VERSION)
|
||||
.put(POSITIVE)
|
||||
.putShort((short) revision.length)
|
||||
.putInt(payload.length)
|
||||
.putLong(softExpiresAt.toEpochMilli())
|
||||
.putLong(hardExpiresAt.toEpochMilli())
|
||||
.put(revision)
|
||||
.put(payload)
|
||||
.array();
|
||||
return withDigest(content);
|
||||
}
|
||||
|
||||
static byte[] negative(
|
||||
AuthoritativeAbsence reason, Instant hardExpiresAt, int maximumValueBytes) {
|
||||
Objects.requireNonNull(reason, "reason must be non-null");
|
||||
Objects.requireNonNull(hardExpiresAt, "hardExpiresAt must be non-null");
|
||||
byte[] payload = checkedPayload(utf8(reason.name()), maximumValueBytes);
|
||||
byte[] content =
|
||||
ByteBuffer.allocate(NEGATIVE_HEADER_BYTES + payload.length)
|
||||
.putInt(MAGIC)
|
||||
.put((byte) VERSION)
|
||||
.put(NEGATIVE)
|
||||
.putInt(payload.length)
|
||||
.putLong(hardExpiresAt.toEpochMilli())
|
||||
.put(payload)
|
||||
.array();
|
||||
return withDigest(content);
|
||||
}
|
||||
|
||||
static Decoded decode(byte[] envelope, int maximumValueBytes) {
|
||||
validateMaximumValueBytes(maximumValueBytes);
|
||||
if (envelope == null) {
|
||||
return incompatible(CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE);
|
||||
}
|
||||
if (envelope.length < COMMON_HEADER_BYTES + DIGEST_BYTES
|
||||
|| envelope.length > maximumValueBytes + 1024 + DIGEST_BYTES) {
|
||||
return incompatible(CacheLookup.SchemaCategory.CORRUPT_ENVELOPE);
|
||||
}
|
||||
CacheObservationToken observationToken = CacheObservationToken.unavailable();
|
||||
try {
|
||||
int contentLength = envelope.length - DIGEST_BYTES;
|
||||
byte[] expectedDigest = sha256(Arrays.copyOf(envelope, contentLength));
|
||||
byte[] actualDigest = Arrays.copyOfRange(envelope, contentLength, envelope.length);
|
||||
if (!MessageDigest.isEqual(expectedDigest, actualDigest)) {
|
||||
return incompatible(CacheLookup.SchemaCategory.CORRUPT_ENVELOPE);
|
||||
}
|
||||
observationToken = observationToken(actualDigest);
|
||||
ByteBuffer buffer = ByteBuffer.wrap(envelope, 0, contentLength);
|
||||
if (buffer.getInt() != MAGIC) {
|
||||
return incompatible(CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE, observationToken);
|
||||
}
|
||||
int version = Byte.toUnsignedInt(buffer.get());
|
||||
if (version > VERSION) {
|
||||
return incompatible(CacheLookup.SchemaCategory.FUTURE_VERSION, observationToken);
|
||||
}
|
||||
if (version < VERSION) {
|
||||
return incompatible(CacheLookup.SchemaCategory.RETIRED_VERSION, observationToken);
|
||||
}
|
||||
byte type = buffer.get();
|
||||
if (type == POSITIVE) {
|
||||
return decodePositive(buffer, maximumValueBytes, observationToken);
|
||||
}
|
||||
if (type == NEGATIVE) {
|
||||
return decodeNegative(buffer, maximumValueBytes, observationToken);
|
||||
}
|
||||
return incompatible(CacheLookup.SchemaCategory.CORRUPT_ENVELOPE, observationToken);
|
||||
} catch (BufferUnderflowException
|
||||
| IllegalArgumentException
|
||||
| CharacterCodingException exception) {
|
||||
return incompatible(CacheLookup.SchemaCategory.CORRUPT_ENVELOPE, observationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private static Decoded decodePositive(
|
||||
ByteBuffer buffer, int maximumValueBytes, CacheObservationToken observationToken)
|
||||
throws CharacterCodingException {
|
||||
int revisionSize = Short.toUnsignedInt(buffer.getShort());
|
||||
int payloadSize = buffer.getInt();
|
||||
Instant softExpiresAt = Instant.ofEpochMilli(buffer.getLong());
|
||||
Instant hardExpiresAt = Instant.ofEpochMilli(buffer.getLong());
|
||||
if (revisionSize < 1
|
||||
|| revisionSize > 512
|
||||
|| payloadSize < 1
|
||||
|| payloadSize > maximumValueBytes
|
||||
|| buffer.remaining() != revisionSize + payloadSize
|
||||
|| softExpiresAt.isAfter(hardExpiresAt)) {
|
||||
return incompatible(CacheLookup.SchemaCategory.CORRUPT_ENVELOPE, observationToken);
|
||||
}
|
||||
byte[] revision = new byte[revisionSize];
|
||||
byte[] payload = new byte[payloadSize];
|
||||
buffer.get(revision);
|
||||
buffer.get(payload);
|
||||
String sourceRevision = strictUtf8(revision);
|
||||
if (!validSourceRevision(sourceRevision)) {
|
||||
return incompatible(CacheLookup.SchemaCategory.CORRUPT_ENVELOPE, observationToken);
|
||||
}
|
||||
return new Positive(
|
||||
strictUtf8(payload), sourceRevision, softExpiresAt, hardExpiresAt, observationToken);
|
||||
}
|
||||
|
||||
private static Decoded decodeNegative(
|
||||
ByteBuffer buffer, int maximumValueBytes, CacheObservationToken observationToken)
|
||||
throws CharacterCodingException {
|
||||
int payloadSize = buffer.getInt();
|
||||
Instant hardExpiresAt = Instant.ofEpochMilli(buffer.getLong());
|
||||
if (payloadSize < 1 || payloadSize > maximumValueBytes || buffer.remaining() != payloadSize) {
|
||||
return incompatible(CacheLookup.SchemaCategory.CORRUPT_ENVELOPE, observationToken);
|
||||
}
|
||||
byte[] payload = new byte[payloadSize];
|
||||
buffer.get(payload);
|
||||
return new Negative(
|
||||
AuthoritativeAbsence.valueOf(strictUtf8(payload)), hardExpiresAt, observationToken);
|
||||
}
|
||||
|
||||
private static byte[] checkedPayload(byte[] payload, int maximumValueBytes) {
|
||||
validateMaximumValueBytes(maximumValueBytes);
|
||||
if (payload.length < 1 || payload.length > maximumValueBytes) {
|
||||
throw new IllegalArgumentException("cache payload exceeds configured maximum bytes");
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
private static void validateMaximumValueBytes(int maximumValueBytes) {
|
||||
if (maximumValueBytes < 1 || maximumValueBytes > 16_777_216) {
|
||||
throw new IllegalArgumentException("maximumValueBytes must be in 1..16777216");
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] withDigest(byte[] content) {
|
||||
return ByteBuffer.allocate(content.length + DIGEST_BYTES)
|
||||
.put(content)
|
||||
.put(sha256(content))
|
||||
@@ -136,6 +205,11 @@ final class RedisCacheEnvelopeCodec {
|
||||
return !sourceRevision.isBlank() && sourceRevision.length() <= 128;
|
||||
}
|
||||
|
||||
private static CacheObservationToken observationToken(byte[] digest) {
|
||||
return new CacheObservationToken(
|
||||
Base64.getUrlEncoder().withoutPadding().encodeToString(digest));
|
||||
}
|
||||
|
||||
private static byte[] sha256(byte[] content) {
|
||||
try {
|
||||
return MessageDigest.getInstance("SHA-256").digest(content);
|
||||
@@ -145,14 +219,28 @@ final class RedisCacheEnvelopeCodec {
|
||||
}
|
||||
|
||||
private static Incompatible incompatible(CacheLookup.SchemaCategory category) {
|
||||
return new Incompatible(category);
|
||||
return new Incompatible(category, CacheObservationToken.unavailable());
|
||||
}
|
||||
|
||||
private static Incompatible incompatible(
|
||||
CacheLookup.SchemaCategory category, CacheObservationToken observationToken) {
|
||||
return new Incompatible(category, observationToken);
|
||||
}
|
||||
|
||||
sealed interface Decoded permits Positive, Negative, Incompatible {}
|
||||
|
||||
record Positive(String value, String sourceRevision) implements Decoded {}
|
||||
record Positive(
|
||||
String value,
|
||||
String sourceRevision,
|
||||
Instant softExpiresAt,
|
||||
Instant hardExpiresAt,
|
||||
CacheObservationToken observationToken)
|
||||
implements Decoded {}
|
||||
|
||||
record Negative(AuthoritativeAbsence reason) implements Decoded {}
|
||||
record Negative(
|
||||
AuthoritativeAbsence reason, Instant hardExpiresAt, CacheObservationToken observationToken)
|
||||
implements Decoded {}
|
||||
|
||||
record Incompatible(CacheLookup.SchemaCategory category) implements Decoded {}
|
||||
record Incompatible(CacheLookup.SchemaCategory category, CacheObservationToken observationToken)
|
||||
implements Decoded {}
|
||||
}
|
||||
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
/** Authenticated, bounded Pub/Sub hint that contains no raw semantic cache key. */
|
||||
sealed interface RedisCacheInvalidationMessage {
|
||||
|
||||
String value();
|
||||
|
||||
static RedisCacheInvalidationMessage key(String localEntryIdentity) {
|
||||
return new Key(localEntryIdentity);
|
||||
}
|
||||
|
||||
static RedisCacheInvalidationMessage region(String generation) {
|
||||
return new Region(generation);
|
||||
}
|
||||
|
||||
record Key(String value) implements RedisCacheInvalidationMessage {
|
||||
|
||||
public Key {
|
||||
value = boundedAscii(value, "localEntryIdentity", 1024);
|
||||
}
|
||||
}
|
||||
|
||||
record Region(String value) implements RedisCacheInvalidationMessage {
|
||||
|
||||
public Region {
|
||||
if (value == null
|
||||
|| value.length() < 16
|
||||
|| value.length() > 64
|
||||
|| !value.matches("[A-Za-z0-9_-]+")) {
|
||||
throw new IllegalArgumentException(
|
||||
"generation must contain 16..64 Base64URL-safe characters");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HMAC protects hints from cross-channel corruption; Redis ACL still owns publisher authority.
|
||||
*/
|
||||
final class Codec implements AutoCloseable {
|
||||
|
||||
private static final int MAXIMUM_WIRE_CHARACTERS = 4096;
|
||||
private static final Base64.Encoder ENCODER = Base64.getUrlEncoder().withoutPadding();
|
||||
private static final Base64.Decoder DECODER = Base64.getUrlDecoder();
|
||||
|
||||
private final byte[] secret;
|
||||
private final AtomicBoolean destroyed = new AtomicBoolean();
|
||||
|
||||
Codec(byte[] secret) {
|
||||
Objects.requireNonNull(secret, "secret must be non-null");
|
||||
if (secret.length < 32) {
|
||||
throw new IllegalArgumentException("message HMAC secret must contain at least 32 bytes");
|
||||
}
|
||||
this.secret = secret.clone();
|
||||
}
|
||||
|
||||
static Codec fromOwnedSecret(byte[] ownedSecret) {
|
||||
Objects.requireNonNull(ownedSecret, "ownedSecret must be non-null");
|
||||
try {
|
||||
return new Codec(ownedSecret);
|
||||
} finally {
|
||||
Arrays.fill(ownedSecret, (byte) 0);
|
||||
}
|
||||
}
|
||||
|
||||
synchronized String encode(RedisCacheInvalidationMessage message) {
|
||||
ensureUsable();
|
||||
Objects.requireNonNull(message, "message must be non-null");
|
||||
String kind = message instanceof Key ? "K" : "R";
|
||||
byte[] payload = (kind + "\n" + message.value()).getBytes(StandardCharsets.US_ASCII);
|
||||
return "v1." + ENCODER.encodeToString(payload) + "." + ENCODER.encodeToString(hmac(payload));
|
||||
}
|
||||
|
||||
synchronized Optional<RedisCacheInvalidationMessage> decode(String wire) {
|
||||
ensureUsable();
|
||||
if (wire == null || wire.length() < 8 || wire.length() > MAXIMUM_WIRE_CHARACTERS) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String[] components = wire.split("\\.", -1);
|
||||
if (components.length != 3 || !"v1".equals(components[0])) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
byte[] payload = DECODER.decode(components[1]);
|
||||
byte[] suppliedMac = DECODER.decode(components[2]);
|
||||
if (!MessageDigest.isEqual(hmac(payload), suppliedMac)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String decoded = new String(payload, StandardCharsets.US_ASCII);
|
||||
int separator = decoded.indexOf('\n');
|
||||
if (separator != 1 || separator != decoded.lastIndexOf('\n')) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String value = decoded.substring(separator + 1);
|
||||
return switch (decoded.charAt(0)) {
|
||||
case 'K' -> Optional.of(key(value));
|
||||
case 'R' -> Optional.of(region(value));
|
||||
default -> Optional.empty();
|
||||
};
|
||||
} catch (IllegalArgumentException exception) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] hmac(byte[] payload) {
|
||||
byte[] secretCopy = secret.clone();
|
||||
try {
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(secretCopy, "HmacSHA256"));
|
||||
return mac.doFinal(payload);
|
||||
} catch (GeneralSecurityException exception) {
|
||||
throw new IllegalStateException("HmacSHA256 unavailable for invalidation hints", exception);
|
||||
} finally {
|
||||
Arrays.fill(secretCopy, (byte) 0);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void close() {
|
||||
if (destroyed.compareAndSet(false, true)) {
|
||||
Arrays.fill(secret, (byte) 0);
|
||||
}
|
||||
}
|
||||
|
||||
synchronized boolean destroyed() {
|
||||
if (!destroyed.get()) {
|
||||
return false;
|
||||
}
|
||||
for (byte value : secret) {
|
||||
if (value != 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void ensureUsable() {
|
||||
if (destroyed.get()) {
|
||||
throw new IllegalStateException("invalidation message codec is destroyed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String boundedAscii(String value, String field, int maximumCharacters) {
|
||||
if (value == null
|
||||
|| value.isBlank()
|
||||
|| value.length() > maximumCharacters
|
||||
|| value.chars().anyMatch(character -> character < 0x21 || character > 0x7e)) {
|
||||
throw new IllegalArgumentException(
|
||||
field + " must contain bounded non-whitespace ASCII characters");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ArrayBlockingQueue;
|
||||
|
||||
/**
|
||||
* Bounded handoff between a Redis Pub/Sub callback and cache request threads.
|
||||
*
|
||||
* <p>Pub/Sub has no replay. Disconnect or queue overflow therefore flushes L1 immediately and
|
||||
* forces a generation read before local entries may be repopulated.
|
||||
*/
|
||||
final class RedisCacheInvalidationSubscriber {
|
||||
|
||||
interface Target {
|
||||
|
||||
void apply(RedisCacheInvalidationMessage message);
|
||||
|
||||
void disconnected();
|
||||
|
||||
void overflow();
|
||||
|
||||
void malformedMessage();
|
||||
}
|
||||
|
||||
private final ArrayBlockingQueue<RedisCacheInvalidationMessage> hints;
|
||||
private final Target target;
|
||||
|
||||
RedisCacheInvalidationSubscriber(int capacity, Target target) {
|
||||
if (capacity < 1 || capacity > 65_536) {
|
||||
throw new IllegalArgumentException("subscriber capacity must be in 1..65536");
|
||||
}
|
||||
this.hints = new ArrayBlockingQueue<>(capacity);
|
||||
this.target = Objects.requireNonNull(target, "target must be non-null");
|
||||
}
|
||||
|
||||
void onMessage(RedisCacheInvalidationMessage message) {
|
||||
Objects.requireNonNull(message, "message must be non-null");
|
||||
if (hints.offer(message)) {
|
||||
return;
|
||||
}
|
||||
hints.clear();
|
||||
target.overflow();
|
||||
}
|
||||
|
||||
void onDisconnected() {
|
||||
hints.clear();
|
||||
target.disconnected();
|
||||
}
|
||||
|
||||
void onMalformedMessage() {
|
||||
target.malformedMessage();
|
||||
}
|
||||
|
||||
void drain() {
|
||||
RedisCacheInvalidationMessage hint;
|
||||
while ((hint = hints.poll()) != null) {
|
||||
target.apply(hint);
|
||||
}
|
||||
}
|
||||
|
||||
int queuedHintCount() {
|
||||
return hints.size();
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/** Lifecycle wrapper that decodes canonical CACHE-role invalidation traffic. */
|
||||
final class RedisCacheInvalidationSubscription implements AutoCloseable {
|
||||
|
||||
private final RedisInvalidationTransport.Subscription delegate;
|
||||
private final RedisCacheInvalidationSubscriber subscriber;
|
||||
private final AtomicBoolean closed = new AtomicBoolean();
|
||||
|
||||
private RedisCacheInvalidationSubscription(
|
||||
RedisInvalidationTransport.Subscription delegate,
|
||||
RedisCacheInvalidationSubscriber subscriber) {
|
||||
this.delegate = Objects.requireNonNull(delegate, "delegate must be non-null");
|
||||
this.subscriber = Objects.requireNonNull(subscriber, "subscriber must be non-null");
|
||||
}
|
||||
|
||||
static RedisCacheInvalidationSubscription subscribe(
|
||||
RedisInvalidationTransport transport,
|
||||
String channel,
|
||||
RedisCacheInvalidationMessage.Codec codec,
|
||||
RedisCacheInvalidationSubscriber subscriber) {
|
||||
Objects.requireNonNull(transport, "transport must be non-null");
|
||||
Objects.requireNonNull(channel, "channel must be non-null");
|
||||
Objects.requireNonNull(codec, "codec must be non-null");
|
||||
Objects.requireNonNull(subscriber, "subscriber must be non-null");
|
||||
RedisInvalidationTransport.Subscription delegate =
|
||||
transport.subscribe(
|
||||
channel.getBytes(StandardCharsets.US_ASCII),
|
||||
new RedisInvalidationTransport.Listener() {
|
||||
@Override
|
||||
public void onMessage(byte[] wireMessage) {
|
||||
if (wireMessage == null) {
|
||||
subscriber.onMalformedMessage();
|
||||
return;
|
||||
}
|
||||
codec
|
||||
.decode(new String(wireMessage, StandardCharsets.US_ASCII))
|
||||
.ifPresentOrElse(subscriber::onMessage, subscriber::onMalformedMessage);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisconnected() {
|
||||
subscriber.onDisconnected();
|
||||
}
|
||||
});
|
||||
return new RedisCacheInvalidationSubscription(delegate, subscriber);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (closed.compareAndSet(false, true)) {
|
||||
try {
|
||||
delegate.close();
|
||||
} finally {
|
||||
subscriber.onDisconnected();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import dev.caskeleton.application.cache.CacheRegionPort;
|
||||
|
||||
/**
|
||||
* Internal cache-only L2 surface needed by the local decorator.
|
||||
*
|
||||
* <p>Session, idempotency, rate-limit and coordination providers do not implement this type and
|
||||
* therefore cannot accidentally receive the fail-open local tier.
|
||||
*/
|
||||
interface RedisCacheL2Region extends CacheRegionPort<String, String> {
|
||||
|
||||
/** Stable HMAC-derived identity; never the raw semantic key. */
|
||||
String localEntryIdentity(String key);
|
||||
|
||||
/** Current region generation used to recover from missed best-effort invalidation hints. */
|
||||
String currentRegionGeneration();
|
||||
}
|
||||
+295
@@ -0,0 +1,295 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyBuilder;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyDigest;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyNamespace;
|
||||
import dev.caskeleton.application.cache.CacheRefreshClaimAttempt;
|
||||
import dev.caskeleton.application.cache.CacheRefreshClaimOutcome;
|
||||
import dev.caskeleton.application.cache.CacheRefreshCoordinationPort;
|
||||
import dev.caskeleton.application.cache.CacheRefreshOperationToken;
|
||||
import dev.caskeleton.application.cache.CacheRefreshOwnerToken;
|
||||
import dev.caskeleton.application.cache.CacheRefreshReleaseOutcome;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Duration;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.LongSupplier;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* Redis-backed cache refresh admission lease.
|
||||
*
|
||||
* <p>The lease only suppresses duplicate refresh work. Cache generation/revision fences remain the
|
||||
* correctness mechanism for invalidation races.
|
||||
*/
|
||||
final class RedisCacheRefreshCoordinator
|
||||
implements CacheRefreshCoordinationPort<String>, AutoCloseable {
|
||||
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
|
||||
private final RedisKeyNamespace namespace;
|
||||
private final byte[] hmacSecret;
|
||||
private final RedisAtomicPrimitives primitives;
|
||||
private final Supplier<String> tokens;
|
||||
private final RedisCapabilityObserver observer;
|
||||
private final AtomicBoolean destroyed = new AtomicBoolean();
|
||||
|
||||
RedisCacheRefreshCoordinator(
|
||||
RedisKeyNamespace namespace, byte[] hmacSecret, RedisBinaryCommands commands) {
|
||||
this(
|
||||
namespace,
|
||||
hmacSecret,
|
||||
productionPrimitives(commands),
|
||||
RedisCacheRefreshCoordinator::randomToken,
|
||||
NoOpRedisCapabilityObservationPort.instance(),
|
||||
System::nanoTime);
|
||||
}
|
||||
|
||||
RedisCacheRefreshCoordinator(
|
||||
RedisKeyNamespace namespace,
|
||||
byte[] hmacSecret,
|
||||
RedisBinaryCommands commands,
|
||||
RedisCapabilityObservationPort observations,
|
||||
LongSupplier ticker) {
|
||||
this(
|
||||
namespace,
|
||||
hmacSecret,
|
||||
productionPrimitives(commands),
|
||||
RedisCacheRefreshCoordinator::randomToken,
|
||||
observations,
|
||||
ticker);
|
||||
}
|
||||
|
||||
RedisCacheRefreshCoordinator(
|
||||
RedisKeyNamespace namespace,
|
||||
byte[] hmacSecret,
|
||||
RedisAtomicPrimitives primitives,
|
||||
Supplier<String> tokens) {
|
||||
this(
|
||||
namespace,
|
||||
hmacSecret,
|
||||
primitives,
|
||||
tokens,
|
||||
NoOpRedisCapabilityObservationPort.instance(),
|
||||
System::nanoTime);
|
||||
}
|
||||
|
||||
RedisCacheRefreshCoordinator(
|
||||
RedisKeyNamespace namespace,
|
||||
byte[] hmacSecret,
|
||||
RedisAtomicPrimitives primitives,
|
||||
Supplier<String> tokens,
|
||||
RedisCapabilityObservationPort observations,
|
||||
LongSupplier ticker) {
|
||||
this.namespace = refreshNamespace(namespace);
|
||||
Objects.requireNonNull(hmacSecret, "hmacSecret must be non-null");
|
||||
if (hmacSecret.length < 32) {
|
||||
throw new IllegalArgumentException("hmacSecret must contain at least 32 bytes");
|
||||
}
|
||||
this.hmacSecret = hmacSecret.clone();
|
||||
this.primitives = Objects.requireNonNull(primitives, "primitives must be non-null");
|
||||
this.tokens = Objects.requireNonNull(tokens, "tokens must be non-null");
|
||||
this.observer = new RedisCapabilityObserver(observations, ticker);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CacheRefreshClaimAttempt newAttempt() {
|
||||
ensureUsable();
|
||||
return new CacheRefreshClaimAttempt(
|
||||
new CacheRefreshOwnerToken(nextToken()), new CacheRefreshOperationToken(nextToken()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public CacheRefreshClaimOutcome claim(
|
||||
String key, CacheRefreshClaimAttempt attempt, Duration leaseTimeToLive) {
|
||||
return observer.observe(
|
||||
RedisCapabilityObservationEvent.Capability.CACHE,
|
||||
RedisCapabilityObservationEvent.Role.CACHE,
|
||||
RedisCapabilityObservationEvent.Operation.REFRESH_CLAIM,
|
||||
() -> claimOpen(key, attempt, leaseTimeToLive),
|
||||
RedisCacheRefreshCoordinator::classifyClaim);
|
||||
}
|
||||
|
||||
private CacheRefreshClaimOutcome claimOpen(
|
||||
String key, CacheRefreshClaimAttempt attempt, Duration leaseTimeToLive) {
|
||||
ensureUsable();
|
||||
requireUsable(attempt);
|
||||
try {
|
||||
RedisAtomicPrimitives.RefreshClaimResult result =
|
||||
primitives.claimRefreshLease(
|
||||
physicalKey(key),
|
||||
attempt.ownerToken().value(),
|
||||
attempt.operationToken().value(),
|
||||
leaseTimeToLive);
|
||||
return switch (result) {
|
||||
case CLAIMED -> new CacheRefreshClaimOutcome.Claimed(attempt);
|
||||
case ALREADY_OWNED -> new CacheRefreshClaimOutcome.AlreadyOwned(attempt);
|
||||
case CONTENDED -> new CacheRefreshClaimOutcome.Contended();
|
||||
case WRONG_TYPE, INVALID ->
|
||||
throw new RedisProgramCompatibilityException(
|
||||
RedisProgramId.CACHE_REFRESH_CLAIM, result.name());
|
||||
};
|
||||
} catch (RedisCommandFailureException exception) {
|
||||
return exception.certainty() == RedisCommandFailureException.Certainty.NOT_APPLIED
|
||||
? new CacheRefreshClaimOutcome.Unavailable()
|
||||
: new CacheRefreshClaimOutcome.Indeterminate();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public CacheRefreshReleaseOutcome release(String key, CacheRefreshClaimAttempt attempt) {
|
||||
return observer.observe(
|
||||
RedisCapabilityObservationEvent.Capability.CACHE,
|
||||
RedisCapabilityObservationEvent.Role.CACHE,
|
||||
RedisCapabilityObservationEvent.Operation.REFRESH_RELEASE,
|
||||
() -> releaseOpen(key, attempt),
|
||||
RedisCacheRefreshCoordinator::classifyRelease);
|
||||
}
|
||||
|
||||
private CacheRefreshReleaseOutcome releaseOpen(String key, CacheRefreshClaimAttempt attempt) {
|
||||
ensureUsable();
|
||||
requireUsable(attempt);
|
||||
try {
|
||||
RedisAtomicPrimitives.CompareDeleteResult result =
|
||||
primitives.compareAndDelete(physicalKey(key), ownerState(attempt));
|
||||
return switch (result) {
|
||||
case DELETED -> new CacheRefreshReleaseOutcome.Released();
|
||||
case ABSENT -> new CacheRefreshReleaseOutcome.AlreadyReleased();
|
||||
case NOT_OWNER -> new CacheRefreshReleaseOutcome.NotOwner();
|
||||
case WRONG_TYPE, INVALID ->
|
||||
throw new RedisProgramCompatibilityException(
|
||||
RedisProgramId.COMPARE_AND_DELETE, result.name());
|
||||
};
|
||||
} catch (RedisCommandFailureException exception) {
|
||||
return exception.certainty() == RedisCommandFailureException.Certainty.NOT_APPLIED
|
||||
? new CacheRefreshReleaseOutcome.Unavailable()
|
||||
: new CacheRefreshReleaseOutcome.Indeterminate();
|
||||
}
|
||||
}
|
||||
|
||||
private String physicalKey(String semanticKey) {
|
||||
if (semanticKey == null || semanticKey.isBlank()) {
|
||||
throw new IllegalArgumentException("semantic cache key must be non-blank");
|
||||
}
|
||||
RedisKeyDigest digest =
|
||||
RedisKeyDigest.sensitive(
|
||||
namespace.hashKeyVersion(),
|
||||
hmacSecret,
|
||||
List.of(semanticKey.getBytes(StandardCharsets.UTF_8)));
|
||||
return RedisKeyBuilder.build(namespace, digest);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (destroyed.compareAndSet(false, true)) {
|
||||
Arrays.fill(hmacSecret, (byte) 0);
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureUsable() {
|
||||
if (destroyed.get()) {
|
||||
throw new IllegalStateException("Redis cache refresh coordinator is destroyed");
|
||||
}
|
||||
}
|
||||
|
||||
private String nextToken() {
|
||||
String token = Objects.requireNonNull(tokens.get(), "generated token must be non-null");
|
||||
if (!token.matches("[A-Za-z0-9_-]{16,63}")) {
|
||||
throw new IllegalArgumentException(
|
||||
"generated token must contain 16..63 Base64URL-safe characters");
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
private static byte[] ownerState(CacheRefreshClaimAttempt attempt) {
|
||||
return (attempt.ownerToken().value() + "|" + attempt.operationToken().value())
|
||||
.getBytes(StandardCharsets.US_ASCII);
|
||||
}
|
||||
|
||||
private static void requireUsable(CacheRefreshClaimAttempt attempt) {
|
||||
Objects.requireNonNull(attempt, "attempt must be non-null");
|
||||
if (!attempt.usable()) {
|
||||
throw new IllegalArgumentException("Redis refresh coordination requires a usable attempt");
|
||||
}
|
||||
}
|
||||
|
||||
private static RedisKeyNamespace refreshNamespace(RedisKeyNamespace namespace) {
|
||||
Objects.requireNonNull(namespace, "namespace must be non-null");
|
||||
return new RedisKeyNamespace(
|
||||
namespace.application(),
|
||||
namespace.environment(),
|
||||
namespace.capability(),
|
||||
namespace.region(),
|
||||
namespace.hashKeyVersion(),
|
||||
namespace.keyVersion(),
|
||||
"refresh-lease",
|
||||
namespace.maximumKeyBytes());
|
||||
}
|
||||
|
||||
private static RedisAtomicPrimitives productionPrimitives(RedisBinaryCommands commands) {
|
||||
RedisProgramCatalog catalog = RedisProgramCatalog.foundation();
|
||||
return new RedisAtomicPrimitives(catalog, new RedisLuaProgramExecutor(catalog, commands));
|
||||
}
|
||||
|
||||
private static String randomToken() {
|
||||
byte[] random = new byte[16];
|
||||
RANDOM.nextBytes(random);
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(random);
|
||||
}
|
||||
|
||||
private static RedisCapabilityObserver.Classification classifyClaim(
|
||||
CacheRefreshClaimOutcome outcome) {
|
||||
if (outcome instanceof CacheRefreshClaimOutcome.Claimed
|
||||
|| outcome instanceof CacheRefreshClaimOutcome.AlreadyOwned) {
|
||||
return classification(
|
||||
RedisCapabilityObservationEvent.Outcome.SUCCESS,
|
||||
RedisCapabilityObservationEvent.Certainty.DEFINITE);
|
||||
}
|
||||
if (outcome instanceof CacheRefreshClaimOutcome.Contended) {
|
||||
return classification(
|
||||
RedisCapabilityObservationEvent.Outcome.CONTENDED,
|
||||
RedisCapabilityObservationEvent.Certainty.DEFINITE);
|
||||
}
|
||||
if (outcome instanceof CacheRefreshClaimOutcome.Indeterminate) {
|
||||
return classification(
|
||||
RedisCapabilityObservationEvent.Outcome.INDETERMINATE,
|
||||
RedisCapabilityObservationEvent.Certainty.INDETERMINATE);
|
||||
}
|
||||
return classification(
|
||||
RedisCapabilityObservationEvent.Outcome.UNAVAILABLE,
|
||||
RedisCapabilityObservationEvent.Certainty.NOT_APPLIED);
|
||||
}
|
||||
|
||||
private static RedisCapabilityObserver.Classification classifyRelease(
|
||||
CacheRefreshReleaseOutcome outcome) {
|
||||
if (outcome instanceof CacheRefreshReleaseOutcome.Released
|
||||
|| outcome instanceof CacheRefreshReleaseOutcome.AlreadyReleased) {
|
||||
return classification(
|
||||
RedisCapabilityObservationEvent.Outcome.SUCCESS,
|
||||
RedisCapabilityObservationEvent.Certainty.DEFINITE);
|
||||
}
|
||||
if (outcome instanceof CacheRefreshReleaseOutcome.NotOwner) {
|
||||
return classification(
|
||||
RedisCapabilityObservationEvent.Outcome.CONFLICT,
|
||||
RedisCapabilityObservationEvent.Certainty.DEFINITE);
|
||||
}
|
||||
if (outcome instanceof CacheRefreshReleaseOutcome.Indeterminate) {
|
||||
return classification(
|
||||
RedisCapabilityObservationEvent.Outcome.INDETERMINATE,
|
||||
RedisCapabilityObservationEvent.Certainty.INDETERMINATE);
|
||||
}
|
||||
return classification(
|
||||
RedisCapabilityObservationEvent.Outcome.UNAVAILABLE,
|
||||
RedisCapabilityObservationEvent.Certainty.NOT_APPLIED);
|
||||
}
|
||||
|
||||
private static RedisCapabilityObserver.Classification classification(
|
||||
RedisCapabilityObservationEvent.Outcome outcome,
|
||||
RedisCapabilityObservationEvent.Certainty certainty) {
|
||||
return new RedisCapabilityObserver.Classification(outcome, certainty);
|
||||
}
|
||||
}
|
||||
+192
-5
@@ -1,32 +1,94 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyNamespace;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Duration;
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/** Immutable key, TTL and envelope bounds for one semantic string cache region. */
|
||||
final class RedisCacheRegionPolicy {
|
||||
final class RedisCacheRegionPolicy implements AutoCloseable {
|
||||
|
||||
private static final Duration MAXIMUM_TTL = Duration.ofDays(30);
|
||||
private static final Duration COMPATIBILITY_MINIMUM_HARD_TTL = Duration.ofMillis(1);
|
||||
|
||||
private final RedisKeyNamespace namespace;
|
||||
private final byte[] hmacSecret;
|
||||
private final Duration positiveTtl;
|
||||
private final String policyRevision;
|
||||
private final Duration positiveSoftTtl;
|
||||
private final Duration positiveHardTtl;
|
||||
private final Duration negativeTtl;
|
||||
private final double jitterRatio;
|
||||
private final Duration minimumHardTtl;
|
||||
private final int maximumValueBytes;
|
||||
private final AtomicBoolean destroyed = new AtomicBoolean();
|
||||
|
||||
/**
|
||||
* Compatibility constructor for the existing single-positive-TTL settings contract.
|
||||
*
|
||||
* <p>It deliberately disables stale serving and jitter. New region bindings should use the full
|
||||
* constructor so the effective policy revision and soft/hard bounds are explicit.
|
||||
*/
|
||||
RedisCacheRegionPolicy(
|
||||
RedisKeyNamespace namespace,
|
||||
byte[] hmacSecret,
|
||||
Duration positiveTtl,
|
||||
Duration negativeTtl,
|
||||
int maximumValueBytes) {
|
||||
this(
|
||||
namespace,
|
||||
hmacSecret,
|
||||
"single-ttl-compatibility-r1",
|
||||
positiveTtl,
|
||||
positiveTtl,
|
||||
negativeTtl,
|
||||
0.0,
|
||||
COMPATIBILITY_MINIMUM_HARD_TTL,
|
||||
maximumValueBytes);
|
||||
}
|
||||
|
||||
RedisCacheRegionPolicy(
|
||||
RedisKeyNamespace namespace,
|
||||
byte[] hmacSecret,
|
||||
String policyRevision,
|
||||
Duration positiveSoftTtl,
|
||||
Duration positiveHardTtl,
|
||||
Duration negativeTtl,
|
||||
double jitterRatio,
|
||||
Duration minimumHardTtl,
|
||||
int maximumValueBytes) {
|
||||
this.namespace = Objects.requireNonNull(namespace, "namespace must be non-null");
|
||||
Objects.requireNonNull(hmacSecret, "hmacSecret must be non-null");
|
||||
if (hmacSecret.length < 32) {
|
||||
throw new IllegalArgumentException("hmacSecret must contain at least 32 bytes");
|
||||
}
|
||||
this.hmacSecret = hmacSecret.clone();
|
||||
this.positiveTtl = positive(positiveTtl, "positiveTtl");
|
||||
this.policyRevision = policyRevision(policyRevision);
|
||||
this.positiveSoftTtl = positive(positiveSoftTtl, "positiveSoftTtl");
|
||||
this.positiveHardTtl = positive(positiveHardTtl, "positiveHardTtl");
|
||||
this.negativeTtl = positive(negativeTtl, "negativeTtl");
|
||||
if (this.positiveSoftTtl.compareTo(this.positiveHardTtl) > 0) {
|
||||
throw new IllegalArgumentException("positive soft TTL must not exceed positive hard TTL");
|
||||
}
|
||||
if (!Double.isFinite(jitterRatio) || jitterRatio < 0.0 || jitterRatio > 0.5) {
|
||||
throw new IllegalArgumentException("jitter ratio must be finite and in 0.0..0.5");
|
||||
}
|
||||
this.jitterRatio = jitterRatio;
|
||||
if (scale(this.positiveHardTtl, 1.0 + jitterRatio).compareTo(MAXIMUM_TTL) > 0
|
||||
|| scale(this.negativeTtl, 1.0 + jitterRatio).compareTo(MAXIMUM_TTL) > 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"configured hard TTL plus positive jitter must not exceed 30 days");
|
||||
}
|
||||
this.minimumHardTtl = positive(minimumHardTtl, "minimumHardTtl");
|
||||
if (this.minimumHardTtl.compareTo(this.positiveHardTtl) > 0
|
||||
|| this.minimumHardTtl.compareTo(this.negativeTtl) > 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"minimum hard TTL must not exceed positive hard TTL or negative TTL");
|
||||
}
|
||||
if (maximumValueBytes < 1 || maximumValueBytes > 16_777_216) {
|
||||
throw new IllegalArgumentException("maximumValueBytes must be in 1..16777216");
|
||||
}
|
||||
@@ -38,26 +100,151 @@ final class RedisCacheRegionPolicy {
|
||||
}
|
||||
|
||||
byte[] hmacSecret() {
|
||||
ensureUsable();
|
||||
return hmacSecret.clone();
|
||||
}
|
||||
|
||||
String policyRevision() {
|
||||
return policyRevision;
|
||||
}
|
||||
|
||||
Duration positiveSoftTtl() {
|
||||
return positiveSoftTtl;
|
||||
}
|
||||
|
||||
Duration positiveHardTtl() {
|
||||
return positiveHardTtl;
|
||||
}
|
||||
|
||||
/** Existing accessor retained while single-TTL runtime settings migrate to the full policy. */
|
||||
Duration positiveTtl() {
|
||||
return positiveTtl;
|
||||
return positiveHardTtl;
|
||||
}
|
||||
|
||||
Duration negativeTtl() {
|
||||
return negativeTtl;
|
||||
}
|
||||
|
||||
Duration maximumEntryTimeToLive() {
|
||||
Duration maximumConfigured =
|
||||
positiveHardTtl.compareTo(negativeTtl) >= 0 ? positiveHardTtl : negativeTtl;
|
||||
return scale(maximumConfigured, 1.0 + jitterRatio);
|
||||
}
|
||||
|
||||
int maximumValueBytes() {
|
||||
return maximumValueBytes;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (destroyed.compareAndSet(false, true)) {
|
||||
Arrays.fill(hmacSecret, (byte) 0);
|
||||
}
|
||||
}
|
||||
|
||||
PositiveExpiry positiveExpiry(byte[] hmacDerivedPhysicalKey) {
|
||||
double factor = effectiveFactor(hmacDerivedPhysicalKey, "positive", positiveHardTtl);
|
||||
Duration soft = scale(positiveSoftTtl, factor);
|
||||
Duration hard = scale(positiveHardTtl, factor);
|
||||
if (hard.compareTo(minimumHardTtl) < 0) {
|
||||
hard = minimumHardTtl;
|
||||
}
|
||||
if (soft.compareTo(hard) > 0) {
|
||||
soft = hard;
|
||||
}
|
||||
return new PositiveExpiry(soft, hard);
|
||||
}
|
||||
|
||||
Duration negativeTimeToLive(byte[] hmacDerivedPhysicalKey) {
|
||||
double factor = effectiveFactor(hmacDerivedPhysicalKey, "negative", negativeTtl);
|
||||
Duration actual = scale(negativeTtl, factor);
|
||||
return actual.compareTo(minimumHardTtl) < 0 ? minimumHardTtl : actual;
|
||||
}
|
||||
|
||||
private double effectiveFactor(
|
||||
byte[] hmacDerivedPhysicalKey, String expiryKind, Duration configuredHardTtl) {
|
||||
Objects.requireNonNull(hmacDerivedPhysicalKey, "hmacDerivedPhysicalKey must be non-null");
|
||||
if (hmacDerivedPhysicalKey.length == 0) {
|
||||
throw new IllegalArgumentException("hmacDerivedPhysicalKey must not be empty");
|
||||
}
|
||||
double sampledFactor =
|
||||
1.0 + (jitterRatio * symmetricSample(hmacDerivedPhysicalKey, expiryKind));
|
||||
double minimumFactor =
|
||||
((double) minimumHardTtl.toMillis()) / Math.max(1L, configuredHardTtl.toMillis());
|
||||
return Math.max(sampledFactor, minimumFactor);
|
||||
}
|
||||
|
||||
private double symmetricSample(byte[] hmacDerivedPhysicalKey, String expiryKind) {
|
||||
byte[] kind = expiryKind.getBytes(StandardCharsets.UTF_8);
|
||||
byte[] revision = policyRevision.getBytes(StandardCharsets.UTF_8);
|
||||
ByteBuffer canonical =
|
||||
ByteBuffer.allocate(
|
||||
Integer.BYTES
|
||||
+ hmacDerivedPhysicalKey.length
|
||||
+ Integer.BYTES
|
||||
+ revision.length
|
||||
+ Integer.BYTES
|
||||
+ kind.length);
|
||||
canonical
|
||||
.putInt(hmacDerivedPhysicalKey.length)
|
||||
.put(hmacDerivedPhysicalKey)
|
||||
.putInt(revision.length)
|
||||
.put(revision)
|
||||
.putInt(kind.length)
|
||||
.put(kind);
|
||||
long sampleBits = ByteBuffer.wrap(sha256(canonical.array())).getLong() >>> 11;
|
||||
double unitInterval = sampleBits * 0x1.0p-53;
|
||||
return (unitInterval * 2.0) - 1.0;
|
||||
}
|
||||
|
||||
private static Duration scale(Duration configured, double factor) {
|
||||
long configuredMillis = Math.max(1L, configured.toMillis());
|
||||
long actualMillis = Math.max(1L, Math.round(configuredMillis * factor));
|
||||
return Duration.ofMillis(actualMillis);
|
||||
}
|
||||
|
||||
private static Duration positive(Duration value, String field) {
|
||||
Objects.requireNonNull(value, field + " must be non-null");
|
||||
if (value.isZero() || value.isNegative() || value.compareTo(Duration.ofDays(30)) > 0) {
|
||||
if (value.isZero() || value.isNegative() || value.compareTo(MAXIMUM_TTL) > 0) {
|
||||
throw new IllegalArgumentException(field + " must be positive and at most 30 days");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static String policyRevision(String value) {
|
||||
Objects.requireNonNull(value, "policyRevision must be non-null");
|
||||
if (value.isBlank() || value.length() > 128) {
|
||||
throw new IllegalArgumentException("policyRevision must contain 1..128 characters");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static byte[] sha256(byte[] content) {
|
||||
try {
|
||||
return MessageDigest.getInstance("SHA-256").digest(content);
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("SHA-256 unavailable for cache TTL jitter", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureUsable() {
|
||||
if (destroyed.get()) {
|
||||
throw new IllegalStateException("Redis cache region policy is destroyed");
|
||||
}
|
||||
}
|
||||
|
||||
record PositiveExpiry(Duration softTtl, Duration hardTtl) {
|
||||
|
||||
PositiveExpiry {
|
||||
Objects.requireNonNull(softTtl, "softTtl must be non-null");
|
||||
Objects.requireNonNull(hardTtl, "hardTtl must be non-null");
|
||||
if (softTtl.isZero()
|
||||
|| softTtl.isNegative()
|
||||
|| hardTtl.isZero()
|
||||
|| hardTtl.isNegative()
|
||||
|| softTtl.compareTo(hardTtl) > 0) {
|
||||
throw new IllegalArgumentException("positive expiry requires 0 < softTtl <= hardTtl");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import dev.caskeleton.application.cache.AuthoritativeAbsence;
|
||||
import dev.caskeleton.application.cache.CacheInvalidationOutcome;
|
||||
import dev.caskeleton.application.cache.CacheLookup;
|
||||
import dev.caskeleton.application.cache.CacheRecordMetadata;
|
||||
import dev.caskeleton.application.cache.CacheRecordOutcome;
|
||||
import dev.caskeleton.application.cache.CacheRegionPort;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/** Lifecycle-owning composition of the Redis L2 and its optional cache-only local decorator. */
|
||||
final class RedisCacheRegionRuntime implements CacheRegionPort<String, String>, AutoCloseable {
|
||||
|
||||
private final CacheRegionPort<String, String> delegate;
|
||||
private final RedisCacheL2Region l2;
|
||||
private final RedisLocalCacheRegion local;
|
||||
private final AtomicBoolean closed = new AtomicBoolean();
|
||||
|
||||
private RedisCacheRegionRuntime(
|
||||
CacheRegionPort<String, String> delegate,
|
||||
RedisCacheL2Region l2,
|
||||
RedisLocalCacheRegion local) {
|
||||
this.delegate = Objects.requireNonNull(delegate, "delegate must be non-null");
|
||||
this.l2 = Objects.requireNonNull(l2, "l2 must be non-null");
|
||||
this.local = local;
|
||||
}
|
||||
|
||||
static RedisCacheRegionRuntime l2Only(RedisCacheL2Region l2) {
|
||||
return new RedisCacheRegionRuntime(l2, l2, null);
|
||||
}
|
||||
|
||||
static RedisCacheRegionRuntime local(RedisCacheL2Region l2, RedisLocalCacheRegion local) {
|
||||
return new RedisCacheRegionRuntime(
|
||||
Objects.requireNonNull(local, "local must be non-null"), l2, local);
|
||||
}
|
||||
|
||||
Optional<RedisLocalCacheRegion> local() {
|
||||
return Optional.ofNullable(local);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CacheLookup<String> lookup(String key) {
|
||||
ensureOpen();
|
||||
return delegate.lookup(key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CacheRecordOutcome record(String key, String value, CacheRecordMetadata metadata) {
|
||||
ensureOpen();
|
||||
return delegate.record(key, value, metadata);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CacheRecordOutcome recordAbsent(
|
||||
String key, AuthoritativeAbsence reason, CacheRecordMetadata metadata) {
|
||||
ensureOpen();
|
||||
return delegate.recordAbsent(key, reason, metadata);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CacheInvalidationOutcome invalidate(String key) {
|
||||
ensureOpen();
|
||||
return delegate.invalidate(key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CacheInvalidationOutcome invalidateRegion() {
|
||||
ensureOpen();
|
||||
return delegate.invalidateRegion();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (closed.compareAndSet(false, true)) {
|
||||
RuntimeException failure = null;
|
||||
try {
|
||||
if (local != null) {
|
||||
local.close();
|
||||
}
|
||||
} catch (RuntimeException exception) {
|
||||
failure = exception;
|
||||
}
|
||||
if (l2 instanceof AutoCloseable closeable) {
|
||||
try {
|
||||
closeable.close();
|
||||
} catch (Exception exception) {
|
||||
RuntimeException closeFailure =
|
||||
exception instanceof RuntimeException runtimeException
|
||||
? runtimeException
|
||||
: new IllegalStateException("Redis cache L2 close failed", exception);
|
||||
if (failure == null) {
|
||||
failure = closeFailure;
|
||||
} else {
|
||||
failure.addSuppressed(closeFailure);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (failure != null) {
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureOpen() {
|
||||
if (closed.get()) {
|
||||
throw new IllegalStateException("Redis cache region runtime is closed");
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
/** Rejects ambiguous canonical/legacy activation and unapproved legacy production primaries. */
|
||||
final class RedisCanonicalActivationValidator {
|
||||
|
||||
private RedisCanonicalActivationValidator() {}
|
||||
|
||||
static void validate(
|
||||
boolean canonicalActive,
|
||||
boolean legacyMigrationEnabled,
|
||||
boolean legacyCacheEnabled,
|
||||
boolean legacyRateLimitEnabled) {
|
||||
boolean legacyActive = legacyCacheEnabled || legacyRateLimitEnabled;
|
||||
if (canonicalActive && legacyActive) {
|
||||
throw new IllegalStateException(
|
||||
"Canonical and legacy Redis configuration cannot be active simultaneously; no precedence"
|
||||
+ " is defined");
|
||||
}
|
||||
if (legacyActive && !legacyMigrationEnabled) {
|
||||
throw new IllegalStateException(
|
||||
"Legacy standalone Redis activation requires explicit migration input mode");
|
||||
}
|
||||
}
|
||||
}
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.config.RedisProviderSettings;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.security.RedisCredentialMaterialProvider;
|
||||
import dev.caskeleton.application.cache.CacheObservationPort;
|
||||
import dev.caskeleton.application.cache.DisabledCacheObservationPort;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.Arrays;
|
||||
import java.util.Set;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/** Canonical default-region cache composition, isolated to the physical Redis CACHE role. */
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@EnableConfigurationProperties({RedisCanonicalCacheSettings.class, RedisProviderSettings.class})
|
||||
@ConditionalOnProperty(
|
||||
name = "ca-skeleton.capabilities.cache.bindings.default",
|
||||
havingValue = "redis",
|
||||
matchIfMissing = false)
|
||||
public class RedisCanonicalCacheConfig {
|
||||
|
||||
private static final int MAXIMUM_COMMAND_OVERHEAD_BYTES = 4096;
|
||||
|
||||
@Bean(name = "redisCanonicalDefaultCacheRegion", destroyMethod = "close")
|
||||
@ConditionalOnProperty(
|
||||
name = "ca-skeleton.capabilities.cache.bindings.default",
|
||||
havingValue = "redis",
|
||||
matchIfMissing = false)
|
||||
RedisCacheRegionRuntime redisCanonicalDefaultCacheRegion(
|
||||
RedisCanonicalCacheSettings settings,
|
||||
RedisProviderSettings providerProperties,
|
||||
RedisCanonicalRoleRegistry roleRegistry,
|
||||
RedisCredentialMaterialProvider credentialProvider,
|
||||
ObjectProvider<Clock> clockProvider,
|
||||
ObjectProvider<MeterRegistry> meterRegistryProvider,
|
||||
ObjectProvider<RedisCapabilityObservationPort> capabilityObservationsProvider) {
|
||||
settings.validateActive();
|
||||
validateCommandBound(settings, providerProperties.runtime());
|
||||
Clock clock = clockProvider.getIfAvailable(Clock::systemUTC);
|
||||
RedisCapabilityObservationPort capabilityObservations =
|
||||
capabilityObservationsProvider.getIfUnique(NoOpRedisCapabilityObservationPort::instance);
|
||||
RedisRoleCommandRouter router = roleRegistry.router(RedisRole.CACHE);
|
||||
byte[] hmacSecret =
|
||||
RedisHmacMaterialResolver.resolve(
|
||||
settings.keyHmacSecretReference(), credentialProvider, clock, "cache");
|
||||
RedisCacheRegionPolicy policy = null;
|
||||
RedisStringCacheRegion l2 = null;
|
||||
RedisCacheInvalidationMessage.Codec codec = null;
|
||||
try {
|
||||
policy =
|
||||
new RedisCacheRegionPolicy(
|
||||
settings.namespace(),
|
||||
hmacSecret,
|
||||
settings.policyRevision(),
|
||||
settings.positiveSoftTtl(),
|
||||
settings.positiveHardTtl(),
|
||||
settings.negativeTtl(),
|
||||
settings.ttlJitter(),
|
||||
minimumHardTtl(settings),
|
||||
settings.maximumValueBytes());
|
||||
l2 =
|
||||
new RedisStringCacheRegion(
|
||||
policy, router, clock, capabilityObservations, System::nanoTime);
|
||||
policy = null;
|
||||
if (!settings.l1().enabled()) {
|
||||
RedisCacheRegionRuntime runtime = RedisCacheRegionRuntime.l2Only(l2);
|
||||
l2 = null;
|
||||
return runtime;
|
||||
}
|
||||
|
||||
MeterRegistry meterRegistry = meterRegistryProvider.getIfAvailable();
|
||||
CacheObservationPort observations =
|
||||
meterRegistry == null
|
||||
? DisabledCacheObservationPort.instance()
|
||||
: new MicrometerCacheObservationPort(
|
||||
meterRegistry, Set.of(settings.semanticRegion()));
|
||||
String channel = l2.invalidationChannel();
|
||||
codec = new RedisCacheInvalidationMessage.Codec(hmacSecret);
|
||||
RedisLocalCacheRegion local =
|
||||
new RedisLocalCacheRegion(
|
||||
settings.semanticRegion(),
|
||||
l2,
|
||||
settings.l1().policy(),
|
||||
clock,
|
||||
observations,
|
||||
channel,
|
||||
codec,
|
||||
message ->
|
||||
router.publish(
|
||||
channel.getBytes(StandardCharsets.US_ASCII),
|
||||
message.getBytes(StandardCharsets.US_ASCII)));
|
||||
RedisCacheRegionRuntime runtime = RedisCacheRegionRuntime.local(l2, local);
|
||||
l2 = null;
|
||||
codec = null;
|
||||
return runtime;
|
||||
} finally {
|
||||
Arrays.fill(hmacSecret, (byte) 0);
|
||||
if (codec != null) {
|
||||
codec.close();
|
||||
}
|
||||
if (l2 != null) {
|
||||
l2.close();
|
||||
}
|
||||
if (policy != null) {
|
||||
policy.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Bean(name = "redisCanonicalDefaultCacheInvalidationSubscription", destroyMethod = "close")
|
||||
@ConditionalOnProperty(
|
||||
name = "ca-skeleton.capabilities.cache.regions.default.l1.enabled",
|
||||
havingValue = "true",
|
||||
matchIfMissing = false)
|
||||
RedisCacheInvalidationSubscription redisCanonicalDefaultCacheInvalidationSubscription(
|
||||
RedisCanonicalRoleRegistry roleRegistry,
|
||||
@Qualifier("redisCanonicalDefaultCacheRegion") RedisCacheRegionRuntime cacheRegion) {
|
||||
RedisLocalCacheRegion local =
|
||||
cacheRegion
|
||||
.local()
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new IllegalStateException(
|
||||
"Canonical Redis L1 subscription requires the cache-only local decorator"));
|
||||
return RedisCacheInvalidationSubscription.subscribe(
|
||||
roleRegistry.router(RedisRole.CACHE),
|
||||
local.invalidationChannel(),
|
||||
local.invalidationMessageCodec(),
|
||||
local.invalidationSubscriber());
|
||||
}
|
||||
|
||||
private static void validateCommandBound(
|
||||
RedisCanonicalCacheSettings settings, RedisProviderSettings.RuntimeProperties runtime) {
|
||||
long required = (long) settings.maximumValueBytes() + MAXIMUM_COMMAND_OVERHEAD_BYTES;
|
||||
if (required > runtime.maximumCommandBytes()) {
|
||||
throw new IllegalArgumentException(
|
||||
"Canonical Redis cache maximum value bytes exceed the CACHE router command bound");
|
||||
}
|
||||
}
|
||||
|
||||
private static Duration minimumHardTtl(RedisCanonicalCacheSettings settings) {
|
||||
Duration minimum = Duration.ofSeconds(1);
|
||||
if (minimum.compareTo(settings.positiveHardTtl()) > 0
|
||||
|| minimum.compareTo(settings.negativeTtl()) > 0) {
|
||||
return settings.positiveHardTtl().compareTo(settings.negativeTtl()) <= 0
|
||||
? settings.positiveHardTtl()
|
||||
: settings.negativeTtl();
|
||||
}
|
||||
return minimum;
|
||||
}
|
||||
}
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyNamespace;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.security.RedisSecretReference;
|
||||
import java.time.Duration;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.boot.context.properties.bind.ConstructorBinding;
|
||||
|
||||
/**
|
||||
* Canonical policy for the skeleton's default semantic Redis cache region.
|
||||
*
|
||||
* <p>Provider connection and authentication settings intentionally do not exist here. The CACHE
|
||||
* role binding owns the topology router, while this capability policy owns only semantic cache
|
||||
* behavior and a reference to HMAC key material.
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "ca-skeleton.capabilities.cache.regions.default")
|
||||
public record RedisCanonicalCacheSettings(
|
||||
String keyHmacSecretReference,
|
||||
String namespaceApplication,
|
||||
String namespaceEnvironment,
|
||||
String semanticRegion,
|
||||
int hashKeyVersion,
|
||||
int keyVersion,
|
||||
String policyRevision,
|
||||
Duration positiveSoftTtl,
|
||||
Duration positiveHardTtl,
|
||||
Duration negativeTtl,
|
||||
Double ttlJitter,
|
||||
int maximumValueBytes,
|
||||
LocalProperties l1) {
|
||||
|
||||
private static final Duration MAXIMUM_TTL = Duration.ofDays(30);
|
||||
|
||||
@ConstructorBinding
|
||||
public RedisCanonicalCacheSettings {
|
||||
keyHmacSecretReference = keyHmacSecretReference == null ? "" : keyHmacSecretReference.trim();
|
||||
namespaceApplication = defaultText(namespaceApplication, "ca-skeleton");
|
||||
namespaceEnvironment = defaultText(namespaceEnvironment, "local");
|
||||
semanticRegion = defaultText(semanticRegion, "default");
|
||||
hashKeyVersion = hashKeyVersion == 0 ? 1 : hashKeyVersion;
|
||||
keyVersion = keyVersion == 0 ? 1 : keyVersion;
|
||||
policyRevision = defaultText(policyRevision, "canonical-default-r1");
|
||||
positiveHardTtl =
|
||||
positive(positiveHardTtl, Duration.ofMinutes(5), MAXIMUM_TTL, "positiveHardTtl");
|
||||
positiveSoftTtl =
|
||||
positive(
|
||||
positiveSoftTtl,
|
||||
positiveHardTtl.multipliedBy(4).dividedBy(5),
|
||||
MAXIMUM_TTL,
|
||||
"positiveSoftTtl");
|
||||
negativeTtl = positive(negativeTtl, Duration.ofMinutes(1), MAXIMUM_TTL, "negativeTtl");
|
||||
ttlJitter = ttlJitter == null ? 0.10d : ttlJitter;
|
||||
maximumValueBytes = maximumValueBytes == 0 ? 61_440 : maximumValueBytes;
|
||||
l1 = l1 == null ? LocalProperties.defaults() : l1;
|
||||
|
||||
if (positiveSoftTtl.compareTo(positiveHardTtl) > 0) {
|
||||
throw new IllegalArgumentException("positiveSoftTtl must not exceed positiveHardTtl");
|
||||
}
|
||||
if (!Double.isFinite(ttlJitter) || ttlJitter < 0.0d || ttlJitter > 0.5d) {
|
||||
throw new IllegalArgumentException("ttlJitter must be in 0.0..0.5");
|
||||
}
|
||||
if (policyRevision.length() > 128 || policyRevision.chars().anyMatch(Character::isISOControl)) {
|
||||
throw new IllegalArgumentException("policyRevision must contain 1..128 safe characters");
|
||||
}
|
||||
if (maximumValueBytes < 1 || maximumValueBytes > 16_777_216) {
|
||||
throw new IllegalArgumentException("maximumValueBytes must be in 1..16777216");
|
||||
}
|
||||
// Centralizes slug and key-version validation without retaining a duplicate rule set.
|
||||
new RedisKeyNamespace(
|
||||
namespaceApplication,
|
||||
namespaceEnvironment,
|
||||
"cache",
|
||||
semanticRegion,
|
||||
hashKeyVersion,
|
||||
keyVersion,
|
||||
"entry",
|
||||
512);
|
||||
}
|
||||
|
||||
void validateActive() {
|
||||
RedisSecretReference.parse(keyHmacSecretReference);
|
||||
}
|
||||
|
||||
RedisKeyNamespace namespace() {
|
||||
return new RedisKeyNamespace(
|
||||
namespaceApplication,
|
||||
namespaceEnvironment,
|
||||
"cache",
|
||||
semanticRegion,
|
||||
hashKeyVersion,
|
||||
keyVersion,
|
||||
"entry",
|
||||
512);
|
||||
}
|
||||
|
||||
public record LocalProperties(
|
||||
boolean enabled,
|
||||
int maximumEntries,
|
||||
long maximumWeightBytes,
|
||||
long maximumEntryWeightBytes,
|
||||
Duration timeToLive,
|
||||
Duration generationRecheckInterval,
|
||||
int invalidationQueueCapacity) {
|
||||
|
||||
@ConstructorBinding
|
||||
public LocalProperties {
|
||||
maximumEntries = maximumEntries == 0 ? 10_000 : maximumEntries;
|
||||
maximumWeightBytes = maximumWeightBytes == 0 ? 67_108_864L : maximumWeightBytes;
|
||||
maximumEntryWeightBytes = maximumEntryWeightBytes == 0 ? 1_048_576L : maximumEntryWeightBytes;
|
||||
timeToLive = timeToLive == null ? Duration.ofSeconds(30) : timeToLive;
|
||||
generationRecheckInterval =
|
||||
generationRecheckInterval == null ? Duration.ofSeconds(5) : generationRecheckInterval;
|
||||
invalidationQueueCapacity = invalidationQueueCapacity == 0 ? 1024 : invalidationQueueCapacity;
|
||||
policy(
|
||||
maximumEntries,
|
||||
maximumWeightBytes,
|
||||
maximumEntryWeightBytes,
|
||||
timeToLive,
|
||||
generationRecheckInterval,
|
||||
invalidationQueueCapacity);
|
||||
}
|
||||
|
||||
RedisLocalCachePolicy policy() {
|
||||
return policy(
|
||||
maximumEntries,
|
||||
maximumWeightBytes,
|
||||
maximumEntryWeightBytes,
|
||||
timeToLive,
|
||||
generationRecheckInterval,
|
||||
invalidationQueueCapacity);
|
||||
}
|
||||
|
||||
private static LocalProperties defaults() {
|
||||
return new LocalProperties(false, 0, 0, 0, null, null, 0);
|
||||
}
|
||||
|
||||
private static RedisLocalCachePolicy policy(
|
||||
int maximumEntries,
|
||||
long maximumWeightBytes,
|
||||
long maximumEntryWeightBytes,
|
||||
Duration timeToLive,
|
||||
Duration generationRecheckInterval,
|
||||
int invalidationQueueCapacity) {
|
||||
return new RedisLocalCachePolicy(
|
||||
maximumEntries,
|
||||
maximumWeightBytes,
|
||||
maximumEntryWeightBytes,
|
||||
timeToLive,
|
||||
generationRecheckInterval,
|
||||
invalidationQueueCapacity);
|
||||
}
|
||||
}
|
||||
|
||||
private static Duration positive(
|
||||
Duration value, Duration fallback, Duration maximum, String field) {
|
||||
Duration actual = value == null ? fallback : value;
|
||||
if (actual.isZero() || actual.isNegative() || actual.compareTo(maximum) > 0) {
|
||||
throw new IllegalArgumentException(field + " must be positive and bounded");
|
||||
}
|
||||
return actual;
|
||||
}
|
||||
|
||||
private static String defaultText(String value, String fallback) {
|
||||
return value == null || value.isBlank() ? fallback : value.trim();
|
||||
}
|
||||
}
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettingsFactory;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.config.RedisProviderSettings;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.security.RedisCredentialMaterialProvider;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.security.RedisTrustMaterialProvider;
|
||||
import dev.caskeleton.shared.health.RedisHealthSnapshotProvider;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import java.time.Clock;
|
||||
import java.util.EnumMap;
|
||||
import java.util.EnumSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.env.Environment;
|
||||
|
||||
/**
|
||||
* Canonical Redis composition root.
|
||||
*
|
||||
* <p>Provider definitions alone are inert. Only an explicit role binding resolves material and
|
||||
* opens a topology-native client.
|
||||
*/
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@EnableConfigurationProperties(RedisProviderSettings.class)
|
||||
public class RedisCanonicalConfig {
|
||||
|
||||
@Bean
|
||||
RedisCapabilityObservationPort redisCapabilityObservationPort(
|
||||
ObjectProvider<MeterRegistry> meterRegistryProvider) {
|
||||
MeterRegistry registry = meterRegistryProvider.getIfAvailable();
|
||||
RedisCapabilityObservationPort delegate =
|
||||
registry == null
|
||||
? NoOpRedisCapabilityObservationPort.instance()
|
||||
: new MicrometerRedisCapabilityObservationPort(registry);
|
||||
return new SafeRedisCapabilityObservationPort(delegate);
|
||||
}
|
||||
|
||||
@Bean(name = "redisCanonicalRoleRegistry", destroyMethod = "close")
|
||||
RedisCanonicalRoleRegistry redisCanonicalRoleRegistry(
|
||||
RedisProviderSettings properties,
|
||||
Environment environment,
|
||||
ObjectProvider<RedisCredentialMaterialProvider> credentialProvider,
|
||||
ObjectProvider<RedisTrustMaterialProvider> trustProvider,
|
||||
ObjectProvider<Clock> clockProvider,
|
||||
ObjectProvider<RedisRuntimeConnector> connectorProvider,
|
||||
ObjectProvider<RedisSentinelRuntimeConnector> sentinelConnectorProvider,
|
||||
RedisCapabilityObservationPort observations) {
|
||||
Map<RedisRole, Set<RedisHealthSnapshotProvider.Capability>> selectedCapabilities =
|
||||
selectedCapabilities(environment);
|
||||
Map<dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole, RedisDeploymentSettings>
|
||||
active =
|
||||
new RedisDeploymentSettingsFactory()
|
||||
.compileActive(properties, selectedRoles(selectedCapabilities));
|
||||
RedisCanonicalActivationValidator.validate(
|
||||
!active.isEmpty(),
|
||||
properties.legacyMigrationEnabled(),
|
||||
environment.getProperty("app.cache.redis.enabled", Boolean.class, false),
|
||||
environment.getProperty("app.rate-limit.legacy-standalone-enabled", Boolean.class, false));
|
||||
|
||||
RedisProviderSettings.RuntimeProperties runtime = properties.runtime();
|
||||
Clock clock = clockProvider.getIfAvailable(Clock::systemUTC);
|
||||
RedisRuntimeConnector connector =
|
||||
connectorProvider.getIfAvailable(
|
||||
() ->
|
||||
deployment ->
|
||||
connect(
|
||||
deployment,
|
||||
runtime,
|
||||
requiredUnique(credentialProvider, "Redis credential material provider"),
|
||||
requiredUnique(trustProvider, "Redis trust material provider"),
|
||||
clock));
|
||||
RedisSentinelRuntimeConnector sentinelConnector =
|
||||
active.values().stream().anyMatch(RedisDeploymentSettings.Sentinel.class::isInstance)
|
||||
? sentinelConnectorProvider.getIfAvailable(
|
||||
() ->
|
||||
new DefaultRedisSentinelRuntimeConnector(
|
||||
runtime.clientSettings(),
|
||||
runtime.maximumCommandBytes(),
|
||||
requiredUnique(credentialProvider, "Redis credential material provider"),
|
||||
requiredUnique(trustProvider, "Redis trust material provider"),
|
||||
clock))
|
||||
: null;
|
||||
return new RedisCanonicalRoleRegistry(
|
||||
active,
|
||||
runtime.clientSettings(),
|
||||
runtime.maximumInFlightCommands(),
|
||||
runtime.maximumCommandBytes(),
|
||||
runtime.maximumInFlightBytes(),
|
||||
runtime.routeDrainTimeout(),
|
||||
runtime.defaultWriteTtl(),
|
||||
connector::connect,
|
||||
properties.roles(),
|
||||
selectedCapabilities,
|
||||
clock,
|
||||
runtime.semanticProbeMinimumInterval(),
|
||||
runtime.semanticProbeMaximumStaleness(),
|
||||
System::nanoTime,
|
||||
observations,
|
||||
sentinelConnector,
|
||||
runtime.sentinelDiscoveryRefreshPeriod(),
|
||||
BoundedRedisSentinelRefreshWorker::new);
|
||||
}
|
||||
|
||||
private static RedisRoutableCommandRuntime connect(
|
||||
RedisDeploymentSettings deployment,
|
||||
RedisProviderSettings.RuntimeProperties runtime,
|
||||
RedisCredentialMaterialProvider credentialProvider,
|
||||
RedisTrustMaterialProvider trustProvider,
|
||||
Clock clock) {
|
||||
return RedisTopologyCommandRuntime.connect(
|
||||
deployment,
|
||||
runtime.clientSettings(),
|
||||
runtime.maximumCommandBytes(),
|
||||
credentialProvider,
|
||||
trustProvider,
|
||||
clock);
|
||||
}
|
||||
|
||||
private static <T> T requiredUnique(ObjectProvider<T> provider, String capability) {
|
||||
T instance = provider.getIfUnique();
|
||||
if (instance == null) {
|
||||
throw new IllegalStateException(
|
||||
capability + " must have exactly one bean for a canonically bound Redis role");
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public static Map<RedisRole, Set<RedisHealthSnapshotProvider.Capability>> selectedCapabilities(
|
||||
Environment environment) {
|
||||
Map<RedisRole, Set<RedisHealthSnapshotProvider.Capability>> selected =
|
||||
new EnumMap<>(RedisRole.class);
|
||||
EnumSet<RedisHealthSnapshotProvider.Capability> cache =
|
||||
EnumSet.noneOf(RedisHealthSnapshotProvider.Capability.class);
|
||||
if (selected(environment, "ca-skeleton.capabilities.cache.bindings.default", "redis")) {
|
||||
cache.add(RedisHealthSnapshotProvider.Capability.CACHE);
|
||||
}
|
||||
selected.put(RedisRole.CACHE, Set.copyOf(cache));
|
||||
|
||||
EnumSet<RedisHealthSnapshotProvider.Capability> coordination =
|
||||
EnumSet.noneOf(RedisHealthSnapshotProvider.Capability.class);
|
||||
if (selected(environment, "ca-skeleton.capabilities.rate-limit.provider", "redis")) {
|
||||
coordination.add(RedisHealthSnapshotProvider.Capability.RATE_LIMIT);
|
||||
}
|
||||
if (selected(environment, "ca-skeleton.capabilities.idempotency.provider", "redis")) {
|
||||
coordination.add(RedisHealthSnapshotProvider.Capability.IDEMPOTENCY);
|
||||
}
|
||||
if (selected(environment, "ca-skeleton.capabilities.lease.provider", "redis")) {
|
||||
coordination.add(RedisHealthSnapshotProvider.Capability.EFFICIENCY_LEASE);
|
||||
}
|
||||
selected.put(RedisRole.COORDINATION, Set.copyOf(coordination));
|
||||
|
||||
EnumSet<RedisHealthSnapshotProvider.Capability> session =
|
||||
EnumSet.noneOf(RedisHealthSnapshotProvider.Capability.class);
|
||||
if (selected(environment, "ca-skeleton.security.auth-mode", "redis-session")) {
|
||||
session.add(RedisHealthSnapshotProvider.Capability.SESSION);
|
||||
}
|
||||
selected.put(RedisRole.SESSION, Set.copyOf(session));
|
||||
return Map.copyOf(selected);
|
||||
}
|
||||
|
||||
private static Set<RedisRole> selectedRoles(
|
||||
Map<RedisRole, Set<RedisHealthSnapshotProvider.Capability>> capabilities) {
|
||||
EnumSet<RedisRole> roles = EnumSet.noneOf(RedisRole.class);
|
||||
capabilities.forEach(
|
||||
(role, selectedCapabilities) -> {
|
||||
if (!selectedCapabilities.isEmpty()) {
|
||||
roles.add(role);
|
||||
}
|
||||
});
|
||||
return Set.copyOf(roles);
|
||||
}
|
||||
|
||||
private static boolean selected(Environment environment, String property, String expected) {
|
||||
return expected.equalsIgnoreCase(environment.getProperty(property, ""));
|
||||
}
|
||||
}
|
||||
+758
@@ -0,0 +1,758 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRoleBinding;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings;
|
||||
import dev.caskeleton.shared.health.RedisHealthSnapshotProvider;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.LongSupplier;
|
||||
|
||||
/** Owns exactly the command routers selected by canonical Redis role bindings. */
|
||||
final class RedisCanonicalRoleRegistry implements AutoCloseable, RedisHealthSnapshotProvider {
|
||||
|
||||
private static final Duration SENTINEL_CLEANUP_COMPLETION_MARGIN = Duration.ofMillis(100);
|
||||
|
||||
@FunctionalInterface
|
||||
interface RuntimeFactory {
|
||||
|
||||
RedisRoutableCommandRuntime connect(RedisDeploymentSettings deployment);
|
||||
}
|
||||
|
||||
private final Map<RedisRole, RedisRoleCommandRouter> routers;
|
||||
private final Map<RedisRole, RedisSemanticProbeObservationCache> observations;
|
||||
private final Map<RedisRole, RedisRoleBinding> bindings;
|
||||
private final Map<RedisRole, Set<Capability>> capabilities;
|
||||
private final Map<RedisRole, RedisSemanticProbePlan> probePlans;
|
||||
private final Map<RedisRole, RecoveryState> recoveries;
|
||||
private final RedisSemanticReadinessProbe semanticProbe;
|
||||
private final RuntimeFactory runtimeFactory;
|
||||
private final Clock clock;
|
||||
private final Duration probeTimeout;
|
||||
private final Duration drainTimeout;
|
||||
private final int maximumInFlight;
|
||||
private final int maximumCommandBytes;
|
||||
private final long maximumInFlightBytes;
|
||||
private final Duration defaultWriteTtl;
|
||||
private final LongSupplier ticker;
|
||||
private final RedisCapabilityObservationPort observationsPort;
|
||||
private final RedisSentinelFailoverCoordinator failoverCoordinator;
|
||||
private final AtomicBoolean closed = new AtomicBoolean();
|
||||
|
||||
RedisCanonicalRoleRegistry(
|
||||
Map<RedisRole, RedisDeploymentSettings> activeDeployments,
|
||||
RedisClientRuntimeSettings clientSettings,
|
||||
int maximumInFlight,
|
||||
int maximumCommandBytes,
|
||||
long maximumInFlightBytes,
|
||||
Duration drainTimeout,
|
||||
Duration defaultWriteTtl,
|
||||
RuntimeFactory runtimeFactory) {
|
||||
this(
|
||||
activeDeployments,
|
||||
clientSettings,
|
||||
maximumInFlight,
|
||||
maximumCommandBytes,
|
||||
maximumInFlightBytes,
|
||||
drainTimeout,
|
||||
defaultWriteTtl,
|
||||
runtimeFactory,
|
||||
Map.of(),
|
||||
Map.of(),
|
||||
Clock.systemUTC());
|
||||
}
|
||||
|
||||
RedisCanonicalRoleRegistry(
|
||||
Map<RedisRole, RedisDeploymentSettings> activeDeployments,
|
||||
RedisClientRuntimeSettings clientSettings,
|
||||
int maximumInFlight,
|
||||
int maximumCommandBytes,
|
||||
long maximumInFlightBytes,
|
||||
Duration drainTimeout,
|
||||
Duration defaultWriteTtl,
|
||||
RuntimeFactory runtimeFactory,
|
||||
Map<RedisRole, RedisRoleBinding> bindings,
|
||||
Map<RedisRole, Set<Capability>> capabilities,
|
||||
Clock clock) {
|
||||
this(
|
||||
activeDeployments,
|
||||
clientSettings,
|
||||
maximumInFlight,
|
||||
maximumCommandBytes,
|
||||
maximumInFlightBytes,
|
||||
drainTimeout,
|
||||
defaultWriteTtl,
|
||||
runtimeFactory,
|
||||
bindings,
|
||||
capabilities,
|
||||
clock,
|
||||
Duration.ofSeconds(5),
|
||||
Duration.ofSeconds(15),
|
||||
System::nanoTime,
|
||||
NoOpRedisCapabilityObservationPort.instance());
|
||||
}
|
||||
|
||||
RedisCanonicalRoleRegistry(
|
||||
Map<RedisRole, RedisDeploymentSettings> activeDeployments,
|
||||
RedisClientRuntimeSettings clientSettings,
|
||||
int maximumInFlight,
|
||||
int maximumCommandBytes,
|
||||
long maximumInFlightBytes,
|
||||
Duration drainTimeout,
|
||||
Duration defaultWriteTtl,
|
||||
RuntimeFactory runtimeFactory,
|
||||
Map<RedisRole, RedisRoleBinding> bindings,
|
||||
Map<RedisRole, Set<Capability>> capabilities,
|
||||
Clock clock,
|
||||
Duration semanticProbeMinimumInterval,
|
||||
Duration semanticProbeMaximumStaleness,
|
||||
LongSupplier ticker) {
|
||||
this(
|
||||
activeDeployments,
|
||||
clientSettings,
|
||||
maximumInFlight,
|
||||
maximumCommandBytes,
|
||||
maximumInFlightBytes,
|
||||
drainTimeout,
|
||||
defaultWriteTtl,
|
||||
runtimeFactory,
|
||||
bindings,
|
||||
capabilities,
|
||||
clock,
|
||||
semanticProbeMinimumInterval,
|
||||
semanticProbeMaximumStaleness,
|
||||
ticker,
|
||||
NoOpRedisCapabilityObservationPort.instance(),
|
||||
null,
|
||||
Duration.ofSeconds(30),
|
||||
BoundedRedisSentinelRefreshWorker::new);
|
||||
}
|
||||
|
||||
RedisCanonicalRoleRegistry(
|
||||
Map<RedisRole, RedisDeploymentSettings> activeDeployments,
|
||||
RedisClientRuntimeSettings clientSettings,
|
||||
int maximumInFlight,
|
||||
int maximumCommandBytes,
|
||||
long maximumInFlightBytes,
|
||||
Duration drainTimeout,
|
||||
Duration defaultWriteTtl,
|
||||
RuntimeFactory runtimeFactory,
|
||||
Map<RedisRole, RedisRoleBinding> bindings,
|
||||
Map<RedisRole, Set<Capability>> capabilities,
|
||||
Clock clock,
|
||||
Duration semanticProbeMinimumInterval,
|
||||
Duration semanticProbeMaximumStaleness,
|
||||
LongSupplier ticker,
|
||||
RedisCapabilityObservationPort observationsPort) {
|
||||
this(
|
||||
activeDeployments,
|
||||
clientSettings,
|
||||
maximumInFlight,
|
||||
maximumCommandBytes,
|
||||
maximumInFlightBytes,
|
||||
drainTimeout,
|
||||
defaultWriteTtl,
|
||||
runtimeFactory,
|
||||
bindings,
|
||||
capabilities,
|
||||
clock,
|
||||
semanticProbeMinimumInterval,
|
||||
semanticProbeMaximumStaleness,
|
||||
ticker,
|
||||
observationsPort,
|
||||
null,
|
||||
Duration.ofSeconds(30),
|
||||
BoundedRedisSentinelRefreshWorker::new);
|
||||
}
|
||||
|
||||
RedisCanonicalRoleRegistry(
|
||||
Map<RedisRole, RedisDeploymentSettings> activeDeployments,
|
||||
RedisClientRuntimeSettings clientSettings,
|
||||
int maximumInFlight,
|
||||
int maximumCommandBytes,
|
||||
long maximumInFlightBytes,
|
||||
Duration drainTimeout,
|
||||
Duration defaultWriteTtl,
|
||||
RuntimeFactory runtimeFactory,
|
||||
Map<RedisRole, RedisRoleBinding> bindings,
|
||||
Map<RedisRole, Set<Capability>> capabilities,
|
||||
Clock clock,
|
||||
Duration semanticProbeMinimumInterval,
|
||||
Duration semanticProbeMaximumStaleness,
|
||||
LongSupplier ticker,
|
||||
RedisCapabilityObservationPort observationsPort,
|
||||
RedisSentinelRuntimeConnector sentinelConnector,
|
||||
Duration sentinelDiscoveryRefreshPeriod,
|
||||
RedisSentinelFailoverCoordinator.WorkerFactory workerFactory) {
|
||||
Objects.requireNonNull(ticker, "ticker must be non-null");
|
||||
Objects.requireNonNull(activeDeployments, "activeDeployments must be non-null");
|
||||
Objects.requireNonNull(clientSettings, "clientSettings must be non-null");
|
||||
Objects.requireNonNull(runtimeFactory, "runtimeFactory must be non-null");
|
||||
Objects.requireNonNull(bindings, "bindings must be non-null");
|
||||
Map<RedisRole, RedisRoleBinding> activeBindings = new EnumMap<>(RedisRole.class);
|
||||
activeDeployments.forEach(
|
||||
(role, ignored) -> {
|
||||
RedisRoleBinding binding = bindings.get(role);
|
||||
if (binding != null) {
|
||||
activeBindings.put(role, binding);
|
||||
}
|
||||
});
|
||||
this.bindings = Map.copyOf(activeBindings);
|
||||
Map<RedisRole, Set<Capability>> safeCapabilities = new EnumMap<>(RedisRole.class);
|
||||
Objects.requireNonNull(capabilities, "capabilities must be non-null")
|
||||
.forEach((role, values) -> safeCapabilities.put(role, Set.copyOf(values)));
|
||||
this.capabilities = Map.copyOf(safeCapabilities);
|
||||
this.clock = Objects.requireNonNull(clock, "clock must be non-null");
|
||||
this.runtimeFactory = runtimeFactory;
|
||||
this.ticker = ticker;
|
||||
this.observationsPort =
|
||||
new SafeRedisCapabilityObservationPort(
|
||||
Objects.requireNonNull(observationsPort, "observationsPort must be non-null"));
|
||||
this.semanticProbe = RedisSemanticReadinessProbe.system(this.clock);
|
||||
Map<RedisRole, RedisSemanticProbePlan> plans = new EnumMap<>(RedisRole.class);
|
||||
activeBindings.forEach(
|
||||
(role, ignored) ->
|
||||
plans.put(
|
||||
role,
|
||||
RedisSemanticProbePlan.forRole(
|
||||
role, this.capabilities.getOrDefault(role, Set.of()))));
|
||||
this.probePlans = Map.copyOf(plans);
|
||||
this.probeTimeout = clientSettings.commandTimeout();
|
||||
this.drainTimeout = Objects.requireNonNull(drainTimeout, "drainTimeout must be non-null");
|
||||
this.maximumInFlight = maximumInFlight;
|
||||
this.maximumCommandBytes = maximumCommandBytes;
|
||||
this.maximumInFlightBytes = maximumInFlightBytes;
|
||||
this.defaultWriteTtl =
|
||||
Objects.requireNonNull(defaultWriteTtl, "defaultWriteTtl must be non-null");
|
||||
if (drainTimeout.compareTo(clientSettings.overallTimeout().plusMillis(100)) < 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"Redis route drain timeout must include the runtime overall timeout and a 100ms safety"
|
||||
+ " margin");
|
||||
}
|
||||
|
||||
activeDeployments.forEach(RedisCanonicalRoleRegistry::rejectUnsupportedTopology);
|
||||
Map<RedisRole, RedisDeploymentSettings.Sentinel> sentinelDeployments =
|
||||
sentinelDeployments(activeDeployments);
|
||||
if (!sentinelDeployments.isEmpty() && sentinelConnector == null) {
|
||||
throw new IllegalStateException(
|
||||
"Redis Sentinel refresh connector is required for every active Sentinel role");
|
||||
}
|
||||
Map<RedisRole, RedisRoleCommandRouter> created = new EnumMap<>(RedisRole.class);
|
||||
Map<RedisRole, RedisSemanticProbeObservationCache> createdObservations =
|
||||
new EnumMap<>(RedisRole.class);
|
||||
Map<RedisRole, RecoveryState> createdRecoveries = new EnumMap<>(RedisRole.class);
|
||||
AtomicReference<RedisSentinelFailoverCoordinator> coordinatorReference =
|
||||
new AtomicReference<>();
|
||||
RedisSentinelFailoverCoordinator createdCoordinator = null;
|
||||
try {
|
||||
activeDeployments.forEach(
|
||||
(role, deployment) -> {
|
||||
RedisRoleCommandRouter.TopologyFailureListener topologyFailureListener =
|
||||
deployment instanceof RedisDeploymentSettings.Sentinel
|
||||
? (failedRoute, failure) -> {
|
||||
RedisSentinelFailoverCoordinator coordinator = coordinatorReference.get();
|
||||
if (coordinator != null) {
|
||||
coordinator.requestRecovery(role, failedRoute);
|
||||
}
|
||||
}
|
||||
: RedisRoleCommandRouter.TopologyFailureListener.ignore();
|
||||
RedisRoutableCommandRuntime runtime;
|
||||
try {
|
||||
runtime =
|
||||
deployment instanceof RedisDeploymentSettings.Sentinel sentinel
|
||||
? connectSentinel(sentinelConnector, sentinel)
|
||||
: runtimeFactory.connect(deployment);
|
||||
} catch (RedisTemporaryConnectionException temporary) {
|
||||
if (!isOptionalCache(role)) {
|
||||
throw temporary;
|
||||
}
|
||||
installDormant(
|
||||
role,
|
||||
deployment,
|
||||
created,
|
||||
createdObservations,
|
||||
createdRecoveries,
|
||||
semanticProbeMinimumInterval,
|
||||
semanticProbeMaximumStaleness,
|
||||
ticker,
|
||||
topologyFailureListener);
|
||||
return;
|
||||
}
|
||||
RedisRoleCommandRouter router = newRouter(role, runtime, topologyFailureListener);
|
||||
try {
|
||||
RedisSemanticProbePlan plan = probePlans.get(role);
|
||||
if (plan == null) {
|
||||
router.probe(probeTimeout);
|
||||
} else {
|
||||
RedisSemanticReadinessProbe.Result qualification =
|
||||
semanticProbe.probeResult(plan, router);
|
||||
if (qualification.disposition()
|
||||
== RedisSemanticReadinessProbe.Disposition.RETRYABLE_TRANSPORT
|
||||
&& isOptionalCache(role)) {
|
||||
router.close();
|
||||
installDormant(
|
||||
role,
|
||||
deployment,
|
||||
created,
|
||||
createdObservations,
|
||||
createdRecoveries,
|
||||
semanticProbeMinimumInterval,
|
||||
semanticProbeMaximumStaleness,
|
||||
ticker,
|
||||
topologyFailureListener);
|
||||
return;
|
||||
}
|
||||
if (qualification.disposition()
|
||||
!= RedisSemanticReadinessProbe.Disposition.SUCCEEDED) {
|
||||
throw new IllegalStateException(
|
||||
"Redis semantic qualification failed: " + qualification.reason().name());
|
||||
}
|
||||
RedisSemanticProbeObservationCache observation =
|
||||
new RedisSemanticProbeObservationCache(
|
||||
semanticProbeMinimumInterval,
|
||||
semanticProbeMaximumStaleness,
|
||||
this.clock,
|
||||
ticker);
|
||||
observation.seed(qualification.reason());
|
||||
createdObservations.put(role, observation);
|
||||
}
|
||||
created.put(role, router);
|
||||
} catch (RuntimeException exception) {
|
||||
router.close();
|
||||
throw exception;
|
||||
}
|
||||
});
|
||||
if (!sentinelDeployments.isEmpty()) {
|
||||
createdCoordinator =
|
||||
new RedisSentinelFailoverCoordinator(
|
||||
sentinelDeployments,
|
||||
created,
|
||||
sentinelConnector,
|
||||
this::qualifyCandidate,
|
||||
this::observeSentinelInstall,
|
||||
probeTimeout,
|
||||
drainTimeout,
|
||||
sentinelDiscoveryRefreshPeriod,
|
||||
longer(
|
||||
clientSettings.shutdownTimeout().plus(SENTINEL_CLEANUP_COMPLETION_MARGIN),
|
||||
drainTimeout),
|
||||
Objects.requireNonNull(workerFactory, "workerFactory must be non-null"));
|
||||
coordinatorReference.set(createdCoordinator);
|
||||
}
|
||||
} catch (RuntimeException exception) {
|
||||
if (createdCoordinator != null) {
|
||||
createdCoordinator.close();
|
||||
}
|
||||
created.values().forEach(RedisRoleCommandRouter::close);
|
||||
throw exception;
|
||||
}
|
||||
this.routers = Map.copyOf(created);
|
||||
this.observations = Map.copyOf(createdObservations);
|
||||
this.recoveries = Map.copyOf(createdRecoveries);
|
||||
this.failoverCoordinator = createdCoordinator;
|
||||
}
|
||||
|
||||
Set<RedisRole> boundRoles() {
|
||||
return routers.keySet();
|
||||
}
|
||||
|
||||
boolean isClosed() {
|
||||
return closed.get();
|
||||
}
|
||||
|
||||
RedisRoleCommandRouter router(RedisRole role) {
|
||||
RedisRoleCommandRouter router =
|
||||
routers.get(Objects.requireNonNull(role, "role must be non-null"));
|
||||
if (router == null) {
|
||||
throw new IllegalStateException("Redis role is not canonically bound: " + role);
|
||||
}
|
||||
return router;
|
||||
}
|
||||
|
||||
RedisRoleCommandRouter.SwapResult rotate(RedisRole role, RedisRoutableCommandRuntime candidate) {
|
||||
Objects.requireNonNull(candidate, "candidate must be non-null");
|
||||
RedisSemanticProbePlan plan = probePlans.get(role);
|
||||
if (plan == null) {
|
||||
return router(role).swap(candidate, probeTimeout, drainTimeout);
|
||||
}
|
||||
RedisRoleCommandRouter qualificationRouter =
|
||||
new RedisRoleCommandRouter(
|
||||
role,
|
||||
candidate,
|
||||
maximumInFlight,
|
||||
maximumCommandBytes,
|
||||
maximumInFlightBytes,
|
||||
drainTimeout,
|
||||
defaultWriteTtl);
|
||||
Reason qualification = semanticProbe.probe(plan, qualificationRouter);
|
||||
if (qualification != Reason.SEMANTIC_PROBE_SUCCEEDED) {
|
||||
qualificationRouter.close();
|
||||
return RedisRoleCommandRouter.SwapResult.PROBE_FAILED;
|
||||
}
|
||||
RedisRoutableCommandRuntime qualified =
|
||||
qualificationRouter.releaseQualifiedRuntimeForTransfer();
|
||||
RedisRoleCommandRouter.SwapResult result;
|
||||
try {
|
||||
result = router(role).swap(qualified, probeTimeout, drainTimeout);
|
||||
} catch (RuntimeException failure) {
|
||||
closeQuietly(qualified);
|
||||
throw failure;
|
||||
}
|
||||
if (result != RedisRoleCommandRouter.SwapResult.PROBE_FAILED) {
|
||||
observations.get(role).seed(Reason.SEMANTIC_PROBE_SUCCEEDED);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
void probe(RedisRole role) {
|
||||
router(role).probe(probeTimeout);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Snapshot snapshot() {
|
||||
List<RoleHealth> roles = new ArrayList<>(bindings.size());
|
||||
for (RedisRole role : RedisRole.values()) {
|
||||
RedisRoleBinding binding = bindings.get(role);
|
||||
if (binding != null) {
|
||||
roles.add(probeHealth(role, binding));
|
||||
}
|
||||
}
|
||||
return new Snapshot(clock.instant(), roles);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (closed.compareAndSet(false, true)) {
|
||||
if (failoverCoordinator != null) {
|
||||
failoverCoordinator.close();
|
||||
}
|
||||
recoveries.values().forEach(recovery -> recovery.markTerminal(terminalClosed()));
|
||||
routers.values().forEach(RedisRoleCommandRouter::close);
|
||||
}
|
||||
}
|
||||
|
||||
private static void rejectUnsupportedTopology(
|
||||
RedisRole role, RedisDeploymentSettings deployment) {
|
||||
if (role == RedisRole.SESSION && deployment instanceof RedisDeploymentSettings.Cluster) {
|
||||
throw new UnsupportedOperationException(
|
||||
"Redis SESSION role cannot use Cluster until session rotation preserves one hash slot");
|
||||
}
|
||||
}
|
||||
|
||||
private RoleHealth probeHealth(RedisRole role, RedisRoleBinding binding) {
|
||||
RedisRoleCommandRouter router = router(role);
|
||||
RedisSemanticProbeObservationCache observationCache = observations.get(role);
|
||||
RedisSemanticProbeObservationCache.Observation observation;
|
||||
RecoveryState recovery = recoveries.get(role);
|
||||
if (closed.get()) {
|
||||
observation = observationCache.seed(Reason.ROUTE_CLOSED);
|
||||
} else if (recovery != null && !recovery.active()) {
|
||||
observation =
|
||||
recovery.deployment() instanceof RedisDeploymentSettings.Sentinel
|
||||
? observationCache.seed(Reason.COMMAND_UNAVAILABLE)
|
||||
: observationCache.observe(() -> recover(role, recovery).reason());
|
||||
} else if (router.isClosed()) {
|
||||
observation = observationCache.seed(Reason.ROUTE_CLOSED);
|
||||
} else if (router.hadRecentCommandFailure()) {
|
||||
observation = observationCache.seed(Reason.RECENT_COMMAND_FAILURE);
|
||||
} else {
|
||||
observation =
|
||||
observationCache.observe(() -> semanticProbe.probe(probePlans.get(role), router));
|
||||
}
|
||||
if (closed.get() && observation.reason() != Reason.ROUTE_CLOSED) {
|
||||
observation = observationCache.seed(Reason.ROUTE_CLOSED);
|
||||
}
|
||||
Reason reason = observation.reason();
|
||||
State state =
|
||||
switch (reason) {
|
||||
case SEMANTIC_PROBE_SUCCEEDED -> State.AVAILABLE;
|
||||
case COMMAND_SATURATED -> State.OVERLOADED;
|
||||
default -> State.UNAVAILABLE;
|
||||
};
|
||||
RoleHealth health =
|
||||
new RoleHealth(
|
||||
Role.valueOf(role.name()),
|
||||
binding.deploymentId(),
|
||||
binding.required(),
|
||||
EvictionPolicy.valueOf(
|
||||
binding.expectedEviction().trim().replace('-', '_').toUpperCase(Locale.ROOT)),
|
||||
EvictionAttestation.CONFIGURED_EXPECTATION_ONLY,
|
||||
capabilities.getOrDefault(role, Set.of()),
|
||||
state,
|
||||
reason,
|
||||
observation.observedAt(),
|
||||
observation.age().toMillis(),
|
||||
observation.stale());
|
||||
capabilities
|
||||
.getOrDefault(role, Set.of())
|
||||
.forEach(
|
||||
capability ->
|
||||
observationsPort.observe(
|
||||
new RedisCapabilityObservationEvent.ReadinessObserved(
|
||||
RedisCapabilityObservationEvent.Capability.valueOf(capability.name()),
|
||||
RedisCapabilityObservationEvent.Role.valueOf(health.role().name()),
|
||||
health.state(),
|
||||
health.reason(),
|
||||
health.required()
|
||||
? RedisCapabilityObservationEvent.Requirement.REQUIRED
|
||||
: RedisCapabilityObservationEvent.Requirement.OPTIONAL)));
|
||||
return health;
|
||||
}
|
||||
|
||||
private RedisSemanticReadinessProbe.Result recover(RedisRole role, RecoveryState recovery) {
|
||||
if (closed.get()) {
|
||||
return recovery.markTerminal(terminalClosed());
|
||||
}
|
||||
RedisSemanticReadinessProbe.Result terminal = recovery.terminal();
|
||||
if (terminal != null) {
|
||||
return terminal;
|
||||
}
|
||||
RedisRoutableCommandRuntime candidate;
|
||||
try {
|
||||
candidate = runtimeFactory.connect(recovery.deployment());
|
||||
} catch (RedisTemporaryConnectionException temporary) {
|
||||
return retryableUnavailable();
|
||||
} catch (RuntimeException permanent) {
|
||||
return recovery.markTerminal(terminalUnavailable());
|
||||
}
|
||||
if (closed.get()) {
|
||||
closeQuietly(candidate);
|
||||
return recovery.markTerminal(terminalClosed());
|
||||
}
|
||||
RedisRoleCommandRouter qualificationRouter = newRouter(role, candidate);
|
||||
RedisSemanticReadinessProbe.Result qualification =
|
||||
semanticProbe.probeResult(probePlans.get(role), qualificationRouter);
|
||||
if (closed.get()) {
|
||||
qualificationRouter.close();
|
||||
return recovery.markTerminal(terminalClosed());
|
||||
}
|
||||
if (qualification.disposition() == RedisSemanticReadinessProbe.Disposition.TERMINAL_CONTRACT) {
|
||||
qualificationRouter.close();
|
||||
return recovery.markTerminal(qualification);
|
||||
}
|
||||
if (qualification.disposition()
|
||||
== RedisSemanticReadinessProbe.Disposition.RETRYABLE_TRANSPORT) {
|
||||
qualificationRouter.close();
|
||||
return qualification;
|
||||
}
|
||||
RedisRoutableCommandRuntime qualified =
|
||||
qualificationRouter.releaseQualifiedRuntimeForTransfer();
|
||||
if (closed.get()) {
|
||||
closeQuietly(qualified);
|
||||
return recovery.markTerminal(terminalClosed());
|
||||
}
|
||||
RedisRoleCommandRouter.SwapResult swap;
|
||||
try {
|
||||
swap = router(role).swap(qualified, probeTimeout, drainTimeout);
|
||||
} catch (RuntimeException failure) {
|
||||
closeQuietly(qualified);
|
||||
return closed.get() ? recovery.markTerminal(terminalClosed()) : retryableUnavailable();
|
||||
}
|
||||
if (swap == RedisRoleCommandRouter.SwapResult.PROBE_FAILED) {
|
||||
return retryableUnavailable();
|
||||
}
|
||||
if (closed.get() || !recovery.markActive()) {
|
||||
return recovery.markTerminal(terminalClosed());
|
||||
}
|
||||
return qualification;
|
||||
}
|
||||
|
||||
private void installDormant(
|
||||
RedisRole role,
|
||||
RedisDeploymentSettings deployment,
|
||||
Map<RedisRole, RedisRoleCommandRouter> created,
|
||||
Map<RedisRole, RedisSemanticProbeObservationCache> createdObservations,
|
||||
Map<RedisRole, RecoveryState> createdRecoveries,
|
||||
Duration minimumInterval,
|
||||
Duration maximumStaleness,
|
||||
LongSupplier ticker,
|
||||
RedisRoleCommandRouter.TopologyFailureListener topologyFailureListener) {
|
||||
created.put(
|
||||
role,
|
||||
newRouter(
|
||||
role,
|
||||
new RedisDormantCommandRuntime(deployment.deploymentId()),
|
||||
topologyFailureListener));
|
||||
RedisSemanticProbeObservationCache observation =
|
||||
new RedisSemanticProbeObservationCache(minimumInterval, maximumStaleness, clock, ticker);
|
||||
observation.seed(Reason.COMMAND_UNAVAILABLE);
|
||||
createdObservations.put(role, observation);
|
||||
createdRecoveries.put(role, new RecoveryState(deployment));
|
||||
}
|
||||
|
||||
private RedisRoleCommandRouter newRouter(RedisRole role, RedisRoutableCommandRuntime runtime) {
|
||||
return newRouter(role, runtime, RedisRoleCommandRouter.TopologyFailureListener.ignore());
|
||||
}
|
||||
|
||||
private RedisRoleCommandRouter newRouter(
|
||||
RedisRole role,
|
||||
RedisRoutableCommandRuntime runtime,
|
||||
RedisRoleCommandRouter.TopologyFailureListener topologyFailureListener) {
|
||||
return new RedisRoleCommandRouter(
|
||||
role,
|
||||
runtime,
|
||||
maximumInFlight,
|
||||
maximumCommandBytes,
|
||||
maximumInFlightBytes,
|
||||
drainTimeout,
|
||||
defaultWriteTtl,
|
||||
ticker,
|
||||
observationsPort,
|
||||
RedisDrainWaiter.system(),
|
||||
topologyFailureListener);
|
||||
}
|
||||
|
||||
private RedisSentinelFailoverCoordinator.CandidateQualification qualifyCandidate(
|
||||
RedisRole role, RedisRoutableCommandRuntime candidate) {
|
||||
Objects.requireNonNull(candidate, "candidate must be non-null");
|
||||
if (closed.get()) {
|
||||
return RedisSentinelFailoverCoordinator.CandidateQualification.REJECTED;
|
||||
}
|
||||
RedisSemanticProbePlan plan = probePlans.get(role);
|
||||
if (plan == null) {
|
||||
return RedisSentinelFailoverCoordinator.CandidateQualification.ACCEPTED;
|
||||
}
|
||||
RedisRoleCommandRouter qualificationRouter;
|
||||
try {
|
||||
qualificationRouter = newRouter(role, candidate);
|
||||
} catch (RuntimeException failure) {
|
||||
return RedisSentinelFailoverCoordinator.CandidateQualification.REJECTED;
|
||||
}
|
||||
RedisSemanticReadinessProbe.Result qualification;
|
||||
try {
|
||||
qualification = semanticProbe.probeResult(plan, qualificationRouter);
|
||||
} catch (RuntimeException failure) {
|
||||
detachQualificationRouter(qualificationRouter);
|
||||
return RedisSentinelFailoverCoordinator.CandidateQualification.REJECTED;
|
||||
}
|
||||
if (!detachQualificationRouter(qualificationRouter)) {
|
||||
return RedisSentinelFailoverCoordinator.CandidateQualification.REJECTED;
|
||||
}
|
||||
return qualification.disposition() == RedisSemanticReadinessProbe.Disposition.SUCCEEDED
|
||||
&& !closed.get()
|
||||
? RedisSentinelFailoverCoordinator.CandidateQualification.ACCEPTED
|
||||
: RedisSentinelFailoverCoordinator.CandidateQualification.REJECTED;
|
||||
}
|
||||
|
||||
private void observeSentinelInstall(RedisRole role, RedisRoleCommandRouter.SwapResult result) {
|
||||
if (result == RedisRoleCommandRouter.SwapResult.DRAINED
|
||||
|| result == RedisRoleCommandRouter.SwapResult.FORCED_AFTER_TIMEOUT) {
|
||||
RedisSemanticProbeObservationCache observation = observations.get(role);
|
||||
if (observation != null) {
|
||||
observation.seed(Reason.SEMANTIC_PROBE_SUCCEEDED);
|
||||
}
|
||||
RecoveryState recovery = recoveries.get(role);
|
||||
if (recovery != null) {
|
||||
recovery.markActive();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean detachQualificationRouter(RedisRoleCommandRouter qualificationRouter) {
|
||||
try {
|
||||
qualificationRouter.releaseQualifiedRuntimeForTransfer();
|
||||
return true;
|
||||
} catch (RuntimeException failure) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static RedisRoutableCommandRuntime connectSentinel(
|
||||
RedisSentinelRuntimeConnector connector, RedisDeploymentSettings.Sentinel deployment) {
|
||||
RedisSentinelDiscoveredRoute route = connector.discover(deployment);
|
||||
return connector.connect(deployment, route);
|
||||
}
|
||||
|
||||
private static Map<RedisRole, RedisDeploymentSettings.Sentinel> sentinelDeployments(
|
||||
Map<RedisRole, RedisDeploymentSettings> deployments) {
|
||||
EnumMap<RedisRole, RedisDeploymentSettings.Sentinel> sentinels = new EnumMap<>(RedisRole.class);
|
||||
deployments.forEach(
|
||||
(role, deployment) -> {
|
||||
if (deployment instanceof RedisDeploymentSettings.Sentinel sentinel) {
|
||||
sentinels.put(role, sentinel);
|
||||
}
|
||||
});
|
||||
return Map.copyOf(sentinels);
|
||||
}
|
||||
|
||||
private static Duration longer(Duration first, Duration second) {
|
||||
return first.compareTo(second) >= 0 ? first : second;
|
||||
}
|
||||
|
||||
private boolean isOptionalCache(RedisRole role) {
|
||||
RedisRoleBinding binding = bindings.get(role);
|
||||
return role == RedisRole.CACHE && binding != null && !binding.required();
|
||||
}
|
||||
|
||||
private static RedisSemanticReadinessProbe.Result retryableUnavailable() {
|
||||
return new RedisSemanticReadinessProbe.Result(
|
||||
Reason.COMMAND_UNAVAILABLE, RedisSemanticReadinessProbe.Disposition.RETRYABLE_TRANSPORT);
|
||||
}
|
||||
|
||||
private static RedisSemanticReadinessProbe.Result terminalUnavailable() {
|
||||
return new RedisSemanticReadinessProbe.Result(
|
||||
Reason.COMMAND_UNAVAILABLE, RedisSemanticReadinessProbe.Disposition.TERMINAL_CONTRACT);
|
||||
}
|
||||
|
||||
private static RedisSemanticReadinessProbe.Result terminalClosed() {
|
||||
return new RedisSemanticReadinessProbe.Result(
|
||||
Reason.ROUTE_CLOSED, RedisSemanticReadinessProbe.Disposition.TERMINAL_CONTRACT);
|
||||
}
|
||||
|
||||
private static void closeQuietly(RedisRoutableCommandRuntime runtime) {
|
||||
try {
|
||||
runtime.close();
|
||||
} catch (RuntimeException ignored) {
|
||||
// Recovery cleanup cannot expose provider detail through health.
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecoveryState {
|
||||
|
||||
private final RedisDeploymentSettings deployment;
|
||||
private volatile RedisSemanticReadinessProbe.Result terminal;
|
||||
private volatile boolean active;
|
||||
|
||||
private RecoveryState(RedisDeploymentSettings deployment) {
|
||||
this.deployment = deployment;
|
||||
}
|
||||
|
||||
private synchronized RedisDeploymentSettings deployment() {
|
||||
return deployment;
|
||||
}
|
||||
|
||||
private synchronized RedisSemanticReadinessProbe.Result terminal() {
|
||||
return terminal;
|
||||
}
|
||||
|
||||
private synchronized boolean active() {
|
||||
return active;
|
||||
}
|
||||
|
||||
private synchronized RedisSemanticReadinessProbe.Result markTerminal(
|
||||
RedisSemanticReadinessProbe.Result result) {
|
||||
if (!active && terminal == null) {
|
||||
terminal = result;
|
||||
}
|
||||
return terminal == null ? result : terminal;
|
||||
}
|
||||
|
||||
private synchronized boolean markActive() {
|
||||
if (terminal != null) {
|
||||
return false;
|
||||
}
|
||||
active = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import dev.caskeleton.shared.health.RedisHealthSnapshotProvider;
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Closed, identity-free operational facts emitted only inside the Redis adapter leaf. */
|
||||
final class RedisCapabilityObservationEvent {
|
||||
|
||||
static final long MAXIMUM_DURATION_NANOS = Duration.ofMinutes(5).toNanos();
|
||||
static final int MAXIMUM_IN_FLIGHT_COMMANDS = 4096;
|
||||
static final long MAXIMUM_IN_FLIGHT_BYTES = 268_435_456L;
|
||||
|
||||
private RedisCapabilityObservationEvent() {}
|
||||
|
||||
sealed interface Event
|
||||
permits OperationCompleted, AdmissionChanged, ReadinessObserved, LifecycleDrainCompleted {}
|
||||
|
||||
record OperationCompleted(
|
||||
Capability capability,
|
||||
Role role,
|
||||
Operation operation,
|
||||
Outcome outcome,
|
||||
Certainty certainty,
|
||||
long durationNanos)
|
||||
implements Event {
|
||||
|
||||
public OperationCompleted {
|
||||
Objects.requireNonNull(capability, "capability must be non-null");
|
||||
Objects.requireNonNull(role, "role must be non-null");
|
||||
Objects.requireNonNull(operation, "operation must be non-null");
|
||||
Objects.requireNonNull(outcome, "outcome must be non-null");
|
||||
Objects.requireNonNull(certainty, "certainty must be non-null");
|
||||
if (durationNanos < 0 || durationNanos > MAXIMUM_DURATION_NANOS) {
|
||||
throw new IllegalArgumentException("durationNanos must be non-negative and bounded");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
record AdmissionChanged(
|
||||
Role role,
|
||||
AdmissionState admission,
|
||||
InFlightState state,
|
||||
int inFlightCommands,
|
||||
long inFlightBytes)
|
||||
implements Event {
|
||||
|
||||
public AdmissionChanged {
|
||||
Objects.requireNonNull(role, "role must be non-null");
|
||||
Objects.requireNonNull(admission, "admission must be non-null");
|
||||
Objects.requireNonNull(state, "state must be non-null");
|
||||
if (inFlightCommands < 0 || inFlightCommands > MAXIMUM_IN_FLIGHT_COMMANDS) {
|
||||
throw new IllegalArgumentException("inFlightCommands must be non-negative and bounded");
|
||||
}
|
||||
if (inFlightBytes < 0 || inFlightBytes > MAXIMUM_IN_FLIGHT_BYTES) {
|
||||
throw new IllegalArgumentException("inFlightBytes must be non-negative and bounded");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
record ReadinessObserved(
|
||||
Capability capability,
|
||||
Role role,
|
||||
RedisHealthSnapshotProvider.State state,
|
||||
RedisHealthSnapshotProvider.Reason reason,
|
||||
Requirement requirement)
|
||||
implements Event {
|
||||
|
||||
public ReadinessObserved {
|
||||
Objects.requireNonNull(capability, "capability must be non-null");
|
||||
Objects.requireNonNull(role, "role must be non-null");
|
||||
Objects.requireNonNull(state, "state must be non-null");
|
||||
Objects.requireNonNull(reason, "reason must be non-null");
|
||||
Objects.requireNonNull(requirement, "requirement must be non-null");
|
||||
}
|
||||
}
|
||||
|
||||
record LifecycleDrainCompleted(Role role, DrainOutcome drainOutcome) implements Event {
|
||||
|
||||
public LifecycleDrainCompleted {
|
||||
Objects.requireNonNull(role, "role must be non-null");
|
||||
Objects.requireNonNull(drainOutcome, "drainOutcome must be non-null");
|
||||
}
|
||||
}
|
||||
|
||||
enum Capability {
|
||||
CACHE,
|
||||
RATE_LIMIT,
|
||||
IDEMPOTENCY,
|
||||
EFFICIENCY_LEASE,
|
||||
SESSION,
|
||||
RUNTIME
|
||||
}
|
||||
|
||||
enum Role {
|
||||
CACHE,
|
||||
COORDINATION,
|
||||
SESSION
|
||||
}
|
||||
|
||||
enum Operation {
|
||||
LOOKUP,
|
||||
RECORD,
|
||||
INVALIDATE,
|
||||
REFRESH_CLAIM,
|
||||
REFRESH_RELEASE,
|
||||
RATE_EVALUATE,
|
||||
IDEMPOTENCY_CLAIM,
|
||||
IDEMPOTENCY_START,
|
||||
IDEMPOTENCY_RENEW,
|
||||
IDEMPOTENCY_COMPLETE,
|
||||
IDEMPOTENCY_FAIL,
|
||||
IDEMPOTENCY_RELEASE,
|
||||
IDEMPOTENCY_INSPECT,
|
||||
LEASE_ACQUIRE,
|
||||
LEASE_INSPECT,
|
||||
LEASE_RENEW,
|
||||
LEASE_RELEASE,
|
||||
SESSION_CREATE,
|
||||
SESSION_INSPECT,
|
||||
SESSION_SAVE,
|
||||
SESSION_TOUCH,
|
||||
SESSION_REVOKE,
|
||||
SESSION_ROTATE,
|
||||
ROUTE_COMMAND
|
||||
}
|
||||
|
||||
enum Outcome {
|
||||
SUCCESS,
|
||||
HIT,
|
||||
MISS,
|
||||
DENIED,
|
||||
CONTENDED,
|
||||
CONFLICT,
|
||||
INCOMPATIBLE,
|
||||
UNAVAILABLE,
|
||||
OVERLOADED,
|
||||
CLOSED,
|
||||
INDETERMINATE,
|
||||
STALE,
|
||||
SKIPPED,
|
||||
TOMBSTONED,
|
||||
ABSOLUTE_EXPIRED
|
||||
}
|
||||
|
||||
enum Certainty {
|
||||
DEFINITE,
|
||||
NOT_APPLIED,
|
||||
INDETERMINATE
|
||||
}
|
||||
|
||||
enum AdmissionState {
|
||||
ADMITTED,
|
||||
REJECTED_SATURATED,
|
||||
REJECTED_CLOSED,
|
||||
NOT_APPLICABLE
|
||||
}
|
||||
|
||||
enum InFlightState {
|
||||
IDLE,
|
||||
ACTIVE,
|
||||
SATURATED
|
||||
}
|
||||
|
||||
enum Requirement {
|
||||
OPTIONAL,
|
||||
REQUIRED
|
||||
}
|
||||
|
||||
enum DrainOutcome {
|
||||
DRAINED,
|
||||
FORCED_AFTER_TIMEOUT,
|
||||
INTERRUPTED
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
@FunctionalInterface
|
||||
interface RedisCapabilityObservationPort {
|
||||
|
||||
void observe(RedisCapabilityObservationEvent.Event event);
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.LongSupplier;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/** Measures one logical semantic operation without accepting request identity or wire material. */
|
||||
final class RedisCapabilityObserver {
|
||||
|
||||
private static final long UNAVAILABLE_TICK = Long.MIN_VALUE;
|
||||
|
||||
private final RedisCapabilityObservationPort observations;
|
||||
private final LongSupplier ticker;
|
||||
|
||||
RedisCapabilityObserver(RedisCapabilityObservationPort observations, LongSupplier ticker) {
|
||||
this.observations =
|
||||
new SafeRedisCapabilityObservationPort(
|
||||
Objects.requireNonNull(observations, "observations must be non-null"));
|
||||
this.ticker = Objects.requireNonNull(ticker, "ticker must be non-null");
|
||||
}
|
||||
|
||||
static RedisCapabilityObserver disabled() {
|
||||
return new RedisCapabilityObserver(
|
||||
NoOpRedisCapabilityObservationPort.instance(), System::nanoTime);
|
||||
}
|
||||
|
||||
<T> T observe(
|
||||
RedisCapabilityObservationEvent.Capability capability,
|
||||
RedisCapabilityObservationEvent.Role role,
|
||||
RedisCapabilityObservationEvent.Operation operation,
|
||||
Supplier<T> action,
|
||||
Function<T, Classification> classifier) {
|
||||
return observe(
|
||||
capability,
|
||||
role,
|
||||
operation,
|
||||
action,
|
||||
classifier,
|
||||
ignored ->
|
||||
new Classification(
|
||||
RedisCapabilityObservationEvent.Outcome.UNAVAILABLE,
|
||||
RedisCapabilityObservationEvent.Certainty.NOT_APPLIED));
|
||||
}
|
||||
|
||||
<T> T observe(
|
||||
RedisCapabilityObservationEvent.Capability capability,
|
||||
RedisCapabilityObservationEvent.Role role,
|
||||
RedisCapabilityObservationEvent.Operation operation,
|
||||
Supplier<T> action,
|
||||
Function<T, Classification> classifier,
|
||||
Function<RuntimeException, Classification> failureClassifier) {
|
||||
Objects.requireNonNull(action, "action must be non-null");
|
||||
Objects.requireNonNull(classifier, "classifier must be non-null");
|
||||
Objects.requireNonNull(failureClassifier, "failureClassifier must be non-null");
|
||||
long started = safeTick();
|
||||
T result;
|
||||
try {
|
||||
result = action.get();
|
||||
} catch (RuntimeException failure) {
|
||||
Classification failureClassification;
|
||||
try {
|
||||
failureClassification =
|
||||
Objects.requireNonNull(
|
||||
failureClassifier.apply(failure), "failure classification must be non-null");
|
||||
} catch (RuntimeException diagnosticFailure) {
|
||||
throw failure;
|
||||
}
|
||||
completedSafely(capability, role, operation, failureClassification, started);
|
||||
throw failure;
|
||||
}
|
||||
Classification classification;
|
||||
try {
|
||||
classification =
|
||||
Objects.requireNonNull(classifier.apply(result), "classification must be non-null");
|
||||
} catch (RuntimeException diagnosticFailure) {
|
||||
return result;
|
||||
}
|
||||
completedSafely(capability, role, operation, classification, started);
|
||||
return result;
|
||||
}
|
||||
|
||||
private void completedSafely(
|
||||
RedisCapabilityObservationEvent.Capability capability,
|
||||
RedisCapabilityObservationEvent.Role role,
|
||||
RedisCapabilityObservationEvent.Operation operation,
|
||||
Classification classification,
|
||||
long started) {
|
||||
try {
|
||||
long finished = safeTick();
|
||||
long elapsed =
|
||||
started == UNAVAILABLE_TICK || finished == UNAVAILABLE_TICK ? 0L : finished - started;
|
||||
long bounded =
|
||||
Math.min(RedisCapabilityObservationEvent.MAXIMUM_DURATION_NANOS, Math.max(0L, elapsed));
|
||||
observations.observe(
|
||||
new RedisCapabilityObservationEvent.OperationCompleted(
|
||||
capability,
|
||||
role,
|
||||
operation,
|
||||
classification.outcome(),
|
||||
classification.certainty(),
|
||||
bounded));
|
||||
} catch (RuntimeException ignored) {
|
||||
// Diagnostic timing/event construction cannot change the authoritative command result.
|
||||
}
|
||||
}
|
||||
|
||||
private long safeTick() {
|
||||
try {
|
||||
return ticker.getAsLong();
|
||||
} catch (RuntimeException ignored) {
|
||||
return UNAVAILABLE_TICK;
|
||||
}
|
||||
}
|
||||
|
||||
record Classification(
|
||||
RedisCapabilityObservationEvent.Outcome outcome,
|
||||
RedisCapabilityObservationEvent.Certainty certainty) {
|
||||
|
||||
Classification {
|
||||
Objects.requireNonNull(outcome, "outcome must be non-null");
|
||||
Objects.requireNonNull(certainty, "certainty must be non-null");
|
||||
}
|
||||
}
|
||||
}
|
||||
+321
@@ -0,0 +1,321 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* Validated catalog-owned invocation. It is the only program identity carried through command
|
||||
* ports; raw SHA, Lua source and raw key collections never cross those ports.
|
||||
*/
|
||||
final class RedisCatalogProgramInvocation {
|
||||
|
||||
enum ReplyShape {
|
||||
VALUE,
|
||||
READ_ONLY_VALUE,
|
||||
MULTI,
|
||||
READ_ONLY_MULTI
|
||||
}
|
||||
|
||||
private final RedisProgramDescriptor descriptor;
|
||||
private final byte[] exactScript;
|
||||
private final String externalId;
|
||||
private final List<Key> keys;
|
||||
private final List<Argument> arguments;
|
||||
private final ReplyShape replyShape;
|
||||
private final int encodedBytes;
|
||||
private final Supplier<Duration> remainingBudget;
|
||||
|
||||
private RedisCatalogProgramInvocation(
|
||||
RedisProgramCatalog owner,
|
||||
RedisProgramDescriptor descriptor,
|
||||
List<byte[]> keys,
|
||||
List<byte[]> arguments,
|
||||
ReplyShape replyShape,
|
||||
Supplier<Duration> remainingBudget) {
|
||||
Objects.requireNonNull(owner, "owner must be non-null");
|
||||
this.descriptor = Objects.requireNonNull(descriptor, "descriptor must be non-null");
|
||||
this.exactScript = descriptor.scriptBytes();
|
||||
this.externalId = descriptor.id().externalId();
|
||||
if (owner.descriptor(descriptor.id()) != descriptor) {
|
||||
throw new IllegalArgumentException("Redis program descriptor is not owned by this catalog");
|
||||
}
|
||||
Objects.requireNonNull(keys, "keys must be non-null");
|
||||
Objects.requireNonNull(arguments, "arguments must be non-null");
|
||||
if (keys.size() != descriptor.keyCount() || arguments.size() != descriptor.argumentCount()) {
|
||||
throw new IllegalArgumentException("Redis program signature does not match descriptor");
|
||||
}
|
||||
List<Key> safeKeys = new ArrayList<>(keys.size());
|
||||
long bytes = 0;
|
||||
for (byte[] key : keys) {
|
||||
if (key == null || key.length < 1 || key.length > descriptor.maximumKeyBytes()) {
|
||||
throw new IllegalArgumentException("Redis program key is out of bounds");
|
||||
}
|
||||
safeKeys.add(new Key(key));
|
||||
bytes += key.length;
|
||||
}
|
||||
List<Argument> safeArguments = new ArrayList<>(arguments.size());
|
||||
for (byte[] argument : arguments) {
|
||||
if (argument == null
|
||||
|| argument.length < 1
|
||||
|| argument.length > descriptor.maximumArgumentBytes()) {
|
||||
throw new IllegalArgumentException("Redis program argument is out of bounds");
|
||||
}
|
||||
Argument safeArgument = new Argument(argument);
|
||||
safeArguments.add(safeArgument);
|
||||
bytes += safeArgument.encodedLength();
|
||||
}
|
||||
if (bytes > Integer.MAX_VALUE) {
|
||||
throw new IllegalArgumentException("Redis program invocation is too large");
|
||||
}
|
||||
this.keys = List.copyOf(safeKeys);
|
||||
this.arguments = List.copyOf(safeArguments);
|
||||
this.replyShape = Objects.requireNonNull(replyShape, "replyShape must be non-null");
|
||||
this.encodedBytes = (int) bytes;
|
||||
this.remainingBudget = remainingBudget;
|
||||
}
|
||||
|
||||
private RedisCatalogProgramInvocation(RedisSemanticReadinessProbe.AclProbeMaterial material) {
|
||||
this.descriptor = null;
|
||||
this.externalId = "semantic-capability-acl-v1";
|
||||
this.exactScript = RedisSemanticAclProbeCatalog.scriptBytes();
|
||||
List<byte[]> keys = material.copyKeys();
|
||||
Objects.requireNonNull(keys, "keys must be non-null");
|
||||
List<Key> safeKeys = new ArrayList<>(keys.size());
|
||||
long bytes = 0;
|
||||
for (byte[] key : keys) {
|
||||
if (key == null || key.length < 1 || key.length > 512) {
|
||||
throw new IllegalArgumentException("Redis program key is out of bounds");
|
||||
}
|
||||
safeKeys.add(new Key(key));
|
||||
bytes += key.length;
|
||||
}
|
||||
byte[] encodedCapability =
|
||||
Objects.requireNonNull(material.capability(), "capability must be non-null")
|
||||
.name()
|
||||
.getBytes(java.nio.charset.StandardCharsets.US_ASCII);
|
||||
List<Argument> safeArguments = List.of(new Argument(encodedCapability));
|
||||
bytes += encodedCapability.length;
|
||||
if (bytes > Integer.MAX_VALUE) {
|
||||
throw new IllegalArgumentException("Redis program invocation is too large");
|
||||
}
|
||||
this.keys = List.copyOf(safeKeys);
|
||||
this.arguments = List.copyOf(safeArguments);
|
||||
this.replyShape = ReplyShape.READ_ONLY_VALUE;
|
||||
this.encodedBytes = (int) bytes;
|
||||
this.remainingBudget = null;
|
||||
}
|
||||
|
||||
static RedisCatalogProgramInvocation capabilityOwned(
|
||||
RedisProgramCatalog owner, RedisCatalogProgramMaterial material, ReplyShape replyShape) {
|
||||
RedisProgramDescriptor descriptor = owner.descriptor(material.programId());
|
||||
return new RedisCatalogProgramInvocation(
|
||||
owner, descriptor, material.copyKeys(), material.copyArguments(), replyShape, null);
|
||||
}
|
||||
|
||||
static RedisCatalogProgramInvocation primitiveOwned(
|
||||
RedisProgramCatalog owner,
|
||||
RedisProgramDescriptor descriptor,
|
||||
RedisPrimitiveInvocation primitive,
|
||||
ReplyShape replyShape) {
|
||||
if (primitive.descriptor().programId() != descriptor.id()) {
|
||||
throw new IllegalArgumentException("primitive program identity is inconsistent");
|
||||
}
|
||||
return new RedisCatalogProgramInvocation(
|
||||
owner,
|
||||
descriptor,
|
||||
primitiveKeys(primitive),
|
||||
primitiveArguments(descriptor, primitive),
|
||||
replyShape,
|
||||
primitive::remainingDeadline);
|
||||
}
|
||||
|
||||
static RedisCatalogProgramInvocation boundedGetOwned(
|
||||
RedisProgramCatalog owner,
|
||||
RedisProgramDescriptor descriptor,
|
||||
RedisPhysicalKey key,
|
||||
int maximumValueBytes) {
|
||||
if (descriptor.id() != RedisProgramId.BOUNDED_GET_V1) {
|
||||
throw new IllegalArgumentException("bounded GET descriptor is required");
|
||||
}
|
||||
return new RedisCatalogProgramInvocation(
|
||||
owner,
|
||||
descriptor,
|
||||
List.of(RedisPhysicalKey.WireCodec.copy(key)),
|
||||
List.of(
|
||||
Integer.toString(maximumValueBytes)
|
||||
.getBytes(java.nio.charset.StandardCharsets.US_ASCII)),
|
||||
ReplyShape.READ_ONLY_VALUE,
|
||||
null);
|
||||
}
|
||||
|
||||
static RedisCatalogProgramInvocation semanticAclProbe(
|
||||
RedisSemanticReadinessProbe.AclProbeMaterial material) {
|
||||
return new RedisCatalogProgramInvocation(
|
||||
Objects.requireNonNull(material, "semantic ACL material must be non-null"));
|
||||
}
|
||||
|
||||
private static List<byte[]> primitiveKeys(RedisPrimitiveInvocation primitive) {
|
||||
return primitive.keys().stream()
|
||||
.map(RedisPrimitiveKey::physicalKey)
|
||||
.map(RedisPhysicalKey.WireCodec::copy)
|
||||
.toList();
|
||||
}
|
||||
|
||||
private static List<byte[]> primitiveArguments(
|
||||
RedisProgramDescriptor descriptor, RedisPrimitiveInvocation primitive) {
|
||||
if (descriptor.id() == RedisProgramId.BOUNDED_GET_V1) {
|
||||
return List.of(
|
||||
Integer.toString(primitive.descriptor().maximumValueBytes())
|
||||
.getBytes(java.nio.charset.StandardCharsets.US_ASCII));
|
||||
}
|
||||
if (!(primitive.arguments() instanceof RedisPrimitiveInvocation.ProgramArguments arguments)) {
|
||||
throw new IllegalArgumentException("primitive program arguments are not closed");
|
||||
}
|
||||
return arguments.programValues().stream().map(RedisPrimitiveValue::copyEncoded).toList();
|
||||
}
|
||||
|
||||
RedisProgramDescriptor descriptor() {
|
||||
if (descriptor == null) {
|
||||
throw new IllegalStateException("Redis exact program has no manifest descriptor");
|
||||
}
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
RedisProgramId programIdOrNull() {
|
||||
return descriptor == null ? null : descriptor.id();
|
||||
}
|
||||
|
||||
String externalId() {
|
||||
return externalId;
|
||||
}
|
||||
|
||||
private byte[] copyExactScript() {
|
||||
return exactScript.clone();
|
||||
}
|
||||
|
||||
ReplyShape replyShape() {
|
||||
return replyShape;
|
||||
}
|
||||
|
||||
int keyCount() {
|
||||
return keys.size();
|
||||
}
|
||||
|
||||
int argumentCount() {
|
||||
return arguments.size();
|
||||
}
|
||||
|
||||
private byte[] copyArgument(int index) {
|
||||
return arguments.get(index).copyEncoded();
|
||||
}
|
||||
|
||||
int encodedBytes() {
|
||||
return encodedBytes;
|
||||
}
|
||||
|
||||
Duration boundedTimeout(Duration defaultTimeout) {
|
||||
Objects.requireNonNull(defaultTimeout, "defaultTimeout must be non-null");
|
||||
if (remainingBudget == null) {
|
||||
return defaultTimeout;
|
||||
}
|
||||
Duration remaining = remainingBudget.get();
|
||||
return remaining.compareTo(defaultTimeout) < 0 ? remaining : defaultTimeout;
|
||||
}
|
||||
|
||||
private byte[][] copyKeysArray() {
|
||||
byte[][] result = new byte[keys.size()][];
|
||||
for (int index = 0; index < keys.size(); index++) {
|
||||
result[index] = keys.get(index).copyEncoded();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<byte[]> copyKeys() {
|
||||
List<byte[]> result = new ArrayList<>(keys.size());
|
||||
for (Key key : keys) {
|
||||
result.add(key.copyEncoded());
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
private List<byte[]> copyArguments() {
|
||||
List<byte[]> result = new ArrayList<>(arguments.size());
|
||||
for (Argument argument : arguments) {
|
||||
result.add(argument.copyEncoded());
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
String sha1() {
|
||||
return RedisScriptRecovery.sha1(exactScript);
|
||||
}
|
||||
|
||||
private byte[][] copyArgumentsArray() {
|
||||
byte[][] result = new byte[arguments.size()][];
|
||||
for (int index = 0; index < arguments.size(); index++) {
|
||||
result[index] = arguments.get(index).copyEncoded();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static final class Argument {
|
||||
private final byte[] encoded;
|
||||
|
||||
private Argument(byte[] encoded) {
|
||||
this.encoded = encoded.clone();
|
||||
}
|
||||
|
||||
private int encodedLength() {
|
||||
return encoded.length;
|
||||
}
|
||||
|
||||
private byte[] copyEncoded() {
|
||||
return encoded.clone();
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Key {
|
||||
private final byte[] encoded;
|
||||
|
||||
private Key(byte[] encoded) {
|
||||
this.encoded = encoded.clone();
|
||||
}
|
||||
|
||||
private byte[] copyEncoded() {
|
||||
return encoded.clone();
|
||||
}
|
||||
}
|
||||
|
||||
/** Sole terminal wire unwrap; every byte is derived from an already validated invocation. */
|
||||
static final class WireCodec {
|
||||
|
||||
private WireCodec() {}
|
||||
|
||||
static byte[] exactScript(RedisCatalogProgramInvocation invocation) {
|
||||
return invocation.copyExactScript();
|
||||
}
|
||||
|
||||
static byte[] argument(RedisCatalogProgramInvocation invocation, int index) {
|
||||
return invocation.copyArgument(index);
|
||||
}
|
||||
|
||||
static byte[][] keysArray(RedisCatalogProgramInvocation invocation) {
|
||||
return invocation.copyKeysArray();
|
||||
}
|
||||
|
||||
static byte[][] argumentsArray(RedisCatalogProgramInvocation invocation) {
|
||||
return invocation.copyArgumentsArray();
|
||||
}
|
||||
|
||||
static List<byte[]> keys(RedisCatalogProgramInvocation invocation) {
|
||||
return invocation.copyKeys();
|
||||
}
|
||||
|
||||
static List<byte[]> arguments(RedisCatalogProgramInvocation invocation) {
|
||||
return invocation.copyArguments();
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Closed capability-owned material consumed only while constructing a catalog invocation.
|
||||
*
|
||||
* <p>Every permitted implementation has a private constructor in its semantic owner. No command
|
||||
* executor receives this raw material and no arbitrary package peer can implement the contract.
|
||||
*/
|
||||
sealed interface RedisCatalogProgramMaterial
|
||||
permits RedisAtomicPrimitives.ProgramMaterial,
|
||||
RedisEdgeRateLimitProvider.ProgramInvocation,
|
||||
RedisEfficiencyLeaseProvider.ProgramInvocation,
|
||||
RedisEfficiencyLeaseHandle.ProgramInvocation,
|
||||
RedisIdempotencyStoreProvider.ProgramInvocation,
|
||||
RedisLuaVersionedSessionStore.ProgramInvocation,
|
||||
RedisSemanticReadinessProbe.ProgramInvocation {
|
||||
|
||||
RedisProgramId programId();
|
||||
|
||||
RedisCatalogProgramInvocation.ReplyShape replyShape();
|
||||
|
||||
List<byte[]> copyKeys();
|
||||
|
||||
List<byte[]> copyArguments();
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** Bounded defensive reply from one catalog invocation. */
|
||||
final class RedisCatalogProgramReply {
|
||||
|
||||
private final byte[] value;
|
||||
private final List<byte[]> fields;
|
||||
|
||||
private RedisCatalogProgramReply(byte[] value, List<byte[]> fields) {
|
||||
this.value = value == null ? null : value.clone();
|
||||
this.fields = defensive(fields);
|
||||
}
|
||||
|
||||
static RedisCatalogProgramReply value(byte[] value) {
|
||||
return new RedisCatalogProgramReply(value, List.of());
|
||||
}
|
||||
|
||||
static RedisCatalogProgramReply multi(List<byte[]> fields) {
|
||||
return new RedisCatalogProgramReply(null, fields);
|
||||
}
|
||||
|
||||
byte[] copyValue() {
|
||||
return value == null ? null : value.clone();
|
||||
}
|
||||
|
||||
List<byte[]> copyFields() {
|
||||
return defensive(fields);
|
||||
}
|
||||
|
||||
private static List<byte[]> defensive(List<byte[]> fields) {
|
||||
if (fields == null || fields.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<byte[]> safe = new ArrayList<>(fields.size());
|
||||
for (byte[] field : fields) {
|
||||
safe.add(field == null ? null : field.clone());
|
||||
}
|
||||
return List.copyOf(safe);
|
||||
}
|
||||
}
|
||||
+22
-3
@@ -1,5 +1,7 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/** Adapter-internal transport failure with explicit overload and mutation certainty. */
|
||||
final class RedisCommandFailureException extends RuntimeException {
|
||||
|
||||
@@ -7,11 +9,18 @@ final class RedisCommandFailureException extends RuntimeException {
|
||||
|
||||
private final Kind kind;
|
||||
private final Certainty certainty;
|
||||
private final RecoveryHint recoveryHint;
|
||||
|
||||
RedisCommandFailureException(Kind kind, Certainty certainty, String message, Throwable cause) {
|
||||
this(kind, certainty, RecoveryHint.NONE, message, cause);
|
||||
}
|
||||
|
||||
RedisCommandFailureException(
|
||||
Kind kind, Certainty certainty, RecoveryHint recoveryHint, String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
this.kind = kind;
|
||||
this.certainty = certainty;
|
||||
this.kind = Objects.requireNonNull(kind, "kind must be non-null");
|
||||
this.certainty = Objects.requireNonNull(certainty, "certainty must be non-null");
|
||||
this.recoveryHint = Objects.requireNonNull(recoveryHint, "recoveryHint must be non-null");
|
||||
}
|
||||
|
||||
Kind kind() {
|
||||
@@ -22,13 +31,23 @@ final class RedisCommandFailureException extends RuntimeException {
|
||||
return certainty;
|
||||
}
|
||||
|
||||
RecoveryHint recoveryHint() {
|
||||
return recoveryHint;
|
||||
}
|
||||
|
||||
enum Kind {
|
||||
UNAVAILABLE,
|
||||
OVERLOADED
|
||||
OVERLOADED,
|
||||
ACL_DENIED
|
||||
}
|
||||
|
||||
enum Certainty {
|
||||
NOT_APPLIED,
|
||||
INDETERMINATE
|
||||
}
|
||||
|
||||
enum RecoveryHint {
|
||||
NONE,
|
||||
REDISCOVER_SENTINEL
|
||||
}
|
||||
}
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Adapter-internal immutable connection/admission profile for one dedicated Redis role. */
|
||||
record RedisConnectionProfile(
|
||||
String host,
|
||||
int port,
|
||||
String password,
|
||||
Duration commandTimeout,
|
||||
Duration legacyTtl,
|
||||
int maximumReadableValueBytes,
|
||||
int maximumCommandBytes,
|
||||
int maximumQueuedCommands,
|
||||
int maximumInFlightBytes) {
|
||||
|
||||
private static final int LEGACY_COMMAND_OVERHEAD_BYTES = 4_096;
|
||||
|
||||
RedisConnectionProfile {
|
||||
Objects.requireNonNull(host, "host must be non-null");
|
||||
Objects.requireNonNull(password, "password must be non-null");
|
||||
Objects.requireNonNull(commandTimeout, "commandTimeout must be non-null");
|
||||
Objects.requireNonNull(legacyTtl, "legacyTtl must be non-null");
|
||||
if (maximumReadableValueBytes < 1 || maximumCommandBytes < 1) {
|
||||
throw new IllegalArgumentException("Redis byte bounds must be positive");
|
||||
}
|
||||
}
|
||||
|
||||
static RedisConnectionProfile cache(RedisRuntimeSettings settings) {
|
||||
Objects.requireNonNull(settings, "settings must be non-null");
|
||||
return new RedisConnectionProfile(
|
||||
settings.host(),
|
||||
settings.port(),
|
||||
settings.password(),
|
||||
settings.commandTimeout(),
|
||||
settings.positiveTtl(),
|
||||
settings.maximumReadableValueBytes(),
|
||||
settings.maximumCommandBytes(),
|
||||
settings.maximumQueuedCommands(),
|
||||
settings.maximumInFlightBytes());
|
||||
}
|
||||
|
||||
static RedisConnectionProfile rateLimit(RedisLegacyStandaloneSettings settings) {
|
||||
Objects.requireNonNull(settings, "settings must be non-null");
|
||||
return new RedisConnectionProfile(
|
||||
settings.host(),
|
||||
settings.port(),
|
||||
settings.password(),
|
||||
settings.commandTimeout(),
|
||||
Duration.ofSeconds(1),
|
||||
settings.maximumCommandBytes() - LEGACY_COMMAND_OVERHEAD_BYTES,
|
||||
settings.maximumCommandBytes(),
|
||||
settings.maximumQueuedCommands(),
|
||||
settings.maximumInFlightBytes());
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Signed exact counter helpers; increment is atomic with initial TTL. */
|
||||
final class RedisCounterPrimitives {
|
||||
|
||||
private final RedisPrimitiveCatalog catalog;
|
||||
private final RedisPrimitiveExecutor executor;
|
||||
|
||||
RedisCounterPrimitives(RedisPrimitiveCatalog catalog, RedisPrimitiveCommands commands) {
|
||||
this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null");
|
||||
this.executor = new RedisPrimitiveExecutor(catalog, commands);
|
||||
}
|
||||
|
||||
RedisPrimitiveKey key(String slot, String identity) {
|
||||
return catalog.keyFactory(RedisPrimitiveId.COUNTER_READ).key(slot, identity);
|
||||
}
|
||||
|
||||
RedisPrimitiveReply read(RedisPrimitiveKey key) {
|
||||
return executor.execute(
|
||||
RedisPrimitiveId.COUNTER_READ, List.of(key), RedisPrimitiveInvocation.NoArguments.INSTANCE);
|
||||
}
|
||||
|
||||
RedisCounterResult increment(
|
||||
RedisPrimitiveKey key, long delta, long minimum, long maximum, Duration initialTimeToLive) {
|
||||
try {
|
||||
return RedisCounterResult.from(
|
||||
executor.execute(
|
||||
RedisPrimitiveId.COUNTER_INCREMENT_INITIAL_TTL,
|
||||
List.of(key),
|
||||
new RedisPrimitiveInvocation.CounterArguments(
|
||||
delta, minimum, maximum, initialTimeToLive)));
|
||||
} catch (RedisCommandFailureException failure) {
|
||||
return RedisCounterResult.failed(failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import java.util.OptionalLong;
|
||||
|
||||
/**
|
||||
* Exact signed counter outcome; the resulting value is never reinterpreted as an affected count.
|
||||
*/
|
||||
record RedisCounterResult(
|
||||
Status status, Certainty certainty, OptionalLong value, String diagnosticCode) {
|
||||
|
||||
enum Status {
|
||||
UPDATED,
|
||||
LIMIT_EXCEEDED,
|
||||
OVERFLOW,
|
||||
MISSING_TTL,
|
||||
MALFORMED_VALUE,
|
||||
WRONG_TYPE,
|
||||
INVALID,
|
||||
UNKNOWN
|
||||
}
|
||||
|
||||
enum Certainty {
|
||||
APPLIED,
|
||||
NOT_APPLIED,
|
||||
INDETERMINATE
|
||||
}
|
||||
|
||||
RedisCounterResult {
|
||||
if (status == null || certainty == null || value == null) {
|
||||
throw new IllegalArgumentException("counter result is invalid");
|
||||
}
|
||||
diagnosticCode = diagnosticCode == null ? "" : diagnosticCode;
|
||||
}
|
||||
|
||||
static RedisCounterResult from(RedisPrimitiveReply reply) {
|
||||
Status status =
|
||||
switch (reply.status()) {
|
||||
case UPDATED -> Status.UPDATED;
|
||||
case LIMIT_EXCEEDED -> Status.LIMIT_EXCEEDED;
|
||||
case OVERFLOW -> Status.OVERFLOW;
|
||||
case MISSING_TTL -> Status.MISSING_TTL;
|
||||
case MALFORMED_VALUE -> Status.MALFORMED_VALUE;
|
||||
case WRONG_TYPE -> Status.WRONG_TYPE;
|
||||
case INVALID, TTL_APPLY_FAILED -> Status.INVALID;
|
||||
default -> Status.UNKNOWN;
|
||||
};
|
||||
return new RedisCounterResult(
|
||||
status,
|
||||
status == Status.UPDATED ? Certainty.APPLIED : Certainty.NOT_APPLIED,
|
||||
reply.signedNumber(),
|
||||
reply.diagnosticCode());
|
||||
}
|
||||
|
||||
static RedisCounterResult failed(RedisCommandFailureException failure) {
|
||||
return new RedisCounterResult(
|
||||
Status.UNKNOWN,
|
||||
failure.certainty() == RedisCommandFailureException.Certainty.INDETERMINATE
|
||||
? Certainty.INDETERMINATE
|
||||
: Certainty.NOT_APPLIED,
|
||||
OptionalLong.empty(),
|
||||
"");
|
||||
}
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.security.RedisRotatableRuntime;
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/** Lifecycle-safe Redis deployment runtime with no public native command surface. */
|
||||
final class RedisDeploymentRuntime implements RedisRotatableRuntime {
|
||||
|
||||
public enum Topology {
|
||||
STANDALONE,
|
||||
SENTINEL,
|
||||
CLUSTER
|
||||
}
|
||||
|
||||
public record Timeouts(
|
||||
Duration connect, Duration acquire, Duration command, Duration overall, Duration shutdown) {
|
||||
|
||||
public Timeouts {
|
||||
Objects.requireNonNull(connect, "connect must be non-null");
|
||||
Objects.requireNonNull(acquire, "acquire must be non-null");
|
||||
Objects.requireNonNull(command, "command must be non-null");
|
||||
Objects.requireNonNull(overall, "overall must be non-null");
|
||||
Objects.requireNonNull(shutdown, "shutdown must be non-null");
|
||||
}
|
||||
}
|
||||
|
||||
private final String deploymentId;
|
||||
private final Topology topology;
|
||||
private final Timeouts timeouts;
|
||||
private final RedisNativeClientHandle nativeClient;
|
||||
private final RedisLettuceUris credentialOwner;
|
||||
private final AtomicBoolean closed = new AtomicBoolean();
|
||||
|
||||
RedisDeploymentRuntime(
|
||||
String deploymentId,
|
||||
Topology topology,
|
||||
RedisClientRuntimeSettings settings,
|
||||
RedisNativeClientHandle nativeClient,
|
||||
RedisLettuceUris credentialOwner) {
|
||||
this.deploymentId = Objects.requireNonNull(deploymentId, "deploymentId must be non-null");
|
||||
this.topology = Objects.requireNonNull(topology, "topology must be non-null");
|
||||
Objects.requireNonNull(settings, "settings must be non-null");
|
||||
this.timeouts =
|
||||
new Timeouts(
|
||||
settings.connectTimeout(),
|
||||
settings.acquireTimeout(),
|
||||
settings.commandTimeout(),
|
||||
settings.overallTimeout(),
|
||||
settings.shutdownTimeout());
|
||||
this.nativeClient = Objects.requireNonNull(nativeClient, "nativeClient must be non-null");
|
||||
this.credentialOwner =
|
||||
Objects.requireNonNull(credentialOwner, "credentialOwner must be non-null");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String deploymentId() {
|
||||
return deploymentId;
|
||||
}
|
||||
|
||||
public Topology topology() {
|
||||
return topology;
|
||||
}
|
||||
|
||||
public Timeouts timeouts() {
|
||||
return timeouts;
|
||||
}
|
||||
|
||||
public boolean isClosed() {
|
||||
return closed.get();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (closed.compareAndSet(false, true)) {
|
||||
try {
|
||||
nativeClient.close(timeouts.shutdown());
|
||||
} finally {
|
||||
credentialOwner.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.security.RedisSslOptionsFactory;
|
||||
import io.lettuce.core.SslOptions;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Creates one topology-native runtime only for an explicitly bound Redis role. */
|
||||
final class RedisDeploymentRuntimeFactory {
|
||||
|
||||
private final RedisLettuceUriFactory uriFactory;
|
||||
private final RedisSslOptionsFactory sslOptionsFactory;
|
||||
private final RedisLettuceClientOptionsFactory optionsFactory;
|
||||
private final RedisNativeClientFactory nativeClientFactory;
|
||||
|
||||
RedisDeploymentRuntimeFactory(
|
||||
RedisLettuceUriFactory uriFactory, RedisSslOptionsFactory sslOptionsFactory) {
|
||||
this(
|
||||
uriFactory,
|
||||
sslOptionsFactory,
|
||||
new RedisLettuceClientOptionsFactory(),
|
||||
new LettuceRedisNativeClientFactory());
|
||||
}
|
||||
|
||||
RedisDeploymentRuntimeFactory(
|
||||
RedisLettuceUriFactory uriFactory,
|
||||
RedisSslOptionsFactory sslOptionsFactory,
|
||||
RedisLettuceClientOptionsFactory optionsFactory,
|
||||
RedisNativeClientFactory nativeClientFactory) {
|
||||
this.uriFactory = Objects.requireNonNull(uriFactory, "uriFactory must be non-null");
|
||||
this.sslOptionsFactory =
|
||||
Objects.requireNonNull(sslOptionsFactory, "sslOptionsFactory must be non-null");
|
||||
this.optionsFactory = Objects.requireNonNull(optionsFactory, "optionsFactory must be non-null");
|
||||
this.nativeClientFactory =
|
||||
Objects.requireNonNull(nativeClientFactory, "nativeClientFactory must be non-null");
|
||||
}
|
||||
|
||||
Optional<RedisDeploymentRuntime> createIfBound(
|
||||
RedisRole role,
|
||||
Map<RedisRole, RedisDeploymentSettings> activeDeployments,
|
||||
RedisClientRuntimeSettings clientSettings) {
|
||||
Objects.requireNonNull(role, "role must be non-null");
|
||||
Objects.requireNonNull(activeDeployments, "activeDeployments must be non-null");
|
||||
RedisDeploymentSettings deployment = activeDeployments.get(role);
|
||||
if (deployment == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(create(deployment, clientSettings));
|
||||
}
|
||||
|
||||
RedisDeploymentRuntime create(
|
||||
RedisDeploymentSettings deployment, RedisClientRuntimeSettings clientSettings) {
|
||||
Objects.requireNonNull(deployment, "deployment must be non-null");
|
||||
Objects.requireNonNull(clientSettings, "clientSettings must be non-null");
|
||||
if (deployment instanceof RedisDeploymentSettings.Sentinel) {
|
||||
throw new UnsupportedOperationException(
|
||||
"Redis Sentinel separate discovery and data trust is unsupported by one Lettuce SSL"
|
||||
+ " context");
|
||||
}
|
||||
SslOptions sslOptions =
|
||||
sslOptionsFactory.create(deployment.dataTls(), clientSettings.tlsHandshakeTimeout());
|
||||
RedisLettuceUris uris = uriFactory.create(deployment, clientSettings);
|
||||
try {
|
||||
return switch (uris) {
|
||||
case RedisLettuceUris.Standalone standalone ->
|
||||
runtime(
|
||||
deployment,
|
||||
RedisDeploymentRuntime.Topology.STANDALONE,
|
||||
clientSettings,
|
||||
nativeClientFactory.openStandalone(
|
||||
standalone.dataUri(),
|
||||
optionsFactory.clientOptions(clientSettings, sslOptions),
|
||||
clientSettings),
|
||||
uris);
|
||||
case RedisLettuceUris.Cluster cluster ->
|
||||
runtime(
|
||||
deployment,
|
||||
RedisDeploymentRuntime.Topology.CLUSTER,
|
||||
clientSettings,
|
||||
nativeClientFactory.openCluster(
|
||||
cluster.seedUris(),
|
||||
optionsFactory.clusterClientOptions(clientSettings, sslOptions),
|
||||
clientSettings),
|
||||
uris);
|
||||
case RedisLettuceUris.SentinelDiscovery ignored ->
|
||||
throw new IllegalStateException("Redis Sentinel fail-closed guard was bypassed");
|
||||
case RedisLettuceUris.SentinelData ignored ->
|
||||
throw new IllegalStateException("Redis Sentinel fail-closed guard was bypassed");
|
||||
};
|
||||
} catch (RuntimeException exception) {
|
||||
uris.close();
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
|
||||
private static RedisDeploymentRuntime runtime(
|
||||
RedisDeploymentSettings deployment,
|
||||
RedisDeploymentRuntime.Topology topology,
|
||||
RedisClientRuntimeSettings settings,
|
||||
RedisNativeClientHandle client,
|
||||
RedisLettuceUris credentialOwner) {
|
||||
try {
|
||||
return new RedisDeploymentRuntime(
|
||||
deployment.deploymentId(), topology, settings, client, credentialOwner);
|
||||
} catch (RuntimeException exception) {
|
||||
try {
|
||||
client.close(settings.shutdownTimeout());
|
||||
} finally {
|
||||
credentialOwner.close();
|
||||
}
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Non-owning unavailable route used while an optional CACHE deployment awaits recovery. */
|
||||
final class RedisDormantCommandRuntime implements RedisRoutableCommandRuntime {
|
||||
|
||||
private final String deploymentId;
|
||||
|
||||
RedisDormantCommandRuntime(String deploymentId) {
|
||||
this.deploymentId = Objects.requireNonNull(deploymentId, "deploymentId must be non-null");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void probe(Duration timeout) {
|
||||
throw unavailable();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String deploymentId() {
|
||||
return deploymentId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] get(RedisPhysicalKey key) {
|
||||
throw unavailable();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void set(RedisPhysicalKey key, RedisBinaryValue value, Duration timeToLive) {
|
||||
throw unavailable();
|
||||
}
|
||||
|
||||
@Override
|
||||
public long delete(RedisPhysicalKey key) {
|
||||
throw unavailable();
|
||||
}
|
||||
|
||||
@Override
|
||||
public RedisPrimitiveReply execute(RedisPrimitiveInvocation invocation) {
|
||||
throw unavailable();
|
||||
}
|
||||
|
||||
@Override
|
||||
public RedisCatalogProgramReply executeCatalogProgram(RedisCatalogProgramInvocation invocation) {
|
||||
throw unavailable();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String loadCatalogProgram(RedisCatalogProgramInvocation invocation) {
|
||||
throw unavailable();
|
||||
}
|
||||
|
||||
@Override
|
||||
public long publish(byte[] channel, byte[] message) {
|
||||
throw unavailable();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Subscription subscribe(byte[] channel, Listener listener) {
|
||||
Objects.requireNonNull(listener, "listener must be non-null");
|
||||
return () -> {};
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {}
|
||||
|
||||
private static RedisCommandFailureException unavailable() {
|
||||
return new RedisCommandFailureException(
|
||||
RedisCommandFailureException.Kind.UNAVAILABLE,
|
||||
RedisCommandFailureException.Certainty.NOT_APPLIED,
|
||||
"Redis optional role is temporarily unavailable",
|
||||
null);
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.IntSupplier;
|
||||
|
||||
@FunctionalInterface
|
||||
interface RedisDrainWaiter {
|
||||
|
||||
Result await(IntSupplier inFlight, Object monitor, Duration timeout);
|
||||
|
||||
static RedisDrainWaiter system() {
|
||||
return (inFlight, monitor, timeout) -> {
|
||||
Objects.requireNonNull(inFlight, "inFlight must be non-null");
|
||||
Objects.requireNonNull(monitor, "monitor must be non-null");
|
||||
Objects.requireNonNull(timeout, "timeout must be non-null");
|
||||
long deadline = saturatedAdd(System.nanoTime(), timeout.toNanos());
|
||||
synchronized (monitor) {
|
||||
while (inFlight.getAsInt() > 0) {
|
||||
long remaining = deadline - System.nanoTime();
|
||||
if (remaining <= 0) {
|
||||
return Result.TIMED_OUT;
|
||||
}
|
||||
try {
|
||||
TimeUnit.NANOSECONDS.timedWait(monitor, remaining);
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
return Result.INTERRUPTED;
|
||||
}
|
||||
}
|
||||
return Result.DRAINED;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static long saturatedAdd(long left, long right) {
|
||||
try {
|
||||
return Math.addExact(left, right);
|
||||
} catch (ArithmeticException ignored) {
|
||||
return Long.MAX_VALUE;
|
||||
}
|
||||
}
|
||||
|
||||
enum Result {
|
||||
DRAINED,
|
||||
TIMED_OUT,
|
||||
INTERRUPTED
|
||||
}
|
||||
}
|
||||
+553
@@ -0,0 +1,553 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyBuilder;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyDigest;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyNamespace;
|
||||
import dev.caskeleton.shared.ratelimit.EdgeRateLimitPort;
|
||||
import dev.caskeleton.shared.ratelimit.RateLimitAlgorithm;
|
||||
import dev.caskeleton.shared.ratelimit.RateLimitDecision;
|
||||
import dev.caskeleton.shared.ratelimit.RateLimitOutcome;
|
||||
import dev.caskeleton.shared.ratelimit.RateLimitPolicy;
|
||||
import dev.caskeleton.shared.ratelimit.RateLimitRequest;
|
||||
import dev.caskeleton.shared.ratelimit.RateParameters;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.locks.ReentrantReadWriteLock;
|
||||
import java.util.function.LongSupplier;
|
||||
|
||||
/** Provider-neutral edge-rate port backed by one exact Redis Lua program per policy evaluation. */
|
||||
final class RedisEdgeRateLimitProvider implements EdgeRateLimitPort, AutoCloseable {
|
||||
|
||||
private static final int PROGRAM_SCHEMA_VERSION = 2;
|
||||
private static final long SCALE = 1_000_000L;
|
||||
private static final long MAXIMUM_LIMIT = 1_000_000_000L;
|
||||
private static final Duration MAXIMUM_WINDOW = Duration.ofDays(1);
|
||||
private static final Duration MAXIMUM_GRACE = Duration.ofDays(1);
|
||||
private static final Duration MAXIMUM_CLOCK_REGRESSION = Duration.ofHours(1);
|
||||
private static final int MAXIMUM_KEY_BYTES = 512;
|
||||
|
||||
private final Map<String, RateLimitPolicy> policies;
|
||||
private final RedisProgramCatalog catalog;
|
||||
private final RedisRateProgramExecutor executor;
|
||||
private final String application;
|
||||
private final String environment;
|
||||
private final int hashKeyVersion;
|
||||
private final int keyVersion;
|
||||
private final byte[] hmacSecret;
|
||||
private final Clock clock;
|
||||
private final Duration failureRetryAfter;
|
||||
private final Duration minimumCallerBudget;
|
||||
private final RedisCapabilityObserver observer;
|
||||
private final ReentrantReadWriteLock lifecycle = new ReentrantReadWriteLock();
|
||||
private boolean closed;
|
||||
|
||||
RedisEdgeRateLimitProvider(
|
||||
Map<String, RateLimitPolicy> policies,
|
||||
RedisProgramCatalog catalog,
|
||||
RedisRateProgramExecutor executor,
|
||||
String application,
|
||||
String environment,
|
||||
int hashKeyVersion,
|
||||
int keyVersion,
|
||||
byte[] hmacSecret,
|
||||
Clock clock,
|
||||
Duration failureRetryAfter) {
|
||||
this(
|
||||
policies,
|
||||
catalog,
|
||||
executor,
|
||||
application,
|
||||
environment,
|
||||
hashKeyVersion,
|
||||
keyVersion,
|
||||
hmacSecret,
|
||||
clock,
|
||||
failureRetryAfter,
|
||||
Duration.ZERO,
|
||||
NoOpRedisCapabilityObservationPort.instance(),
|
||||
System::nanoTime);
|
||||
}
|
||||
|
||||
RedisEdgeRateLimitProvider(
|
||||
Map<String, RateLimitPolicy> policies,
|
||||
RedisProgramCatalog catalog,
|
||||
RedisRateProgramExecutor executor,
|
||||
String application,
|
||||
String environment,
|
||||
int hashKeyVersion,
|
||||
int keyVersion,
|
||||
byte[] hmacSecret,
|
||||
Clock clock,
|
||||
Duration failureRetryAfter,
|
||||
Duration minimumCallerBudget) {
|
||||
this(
|
||||
policies,
|
||||
catalog,
|
||||
executor,
|
||||
application,
|
||||
environment,
|
||||
hashKeyVersion,
|
||||
keyVersion,
|
||||
hmacSecret,
|
||||
clock,
|
||||
failureRetryAfter,
|
||||
minimumCallerBudget,
|
||||
NoOpRedisCapabilityObservationPort.instance(),
|
||||
System::nanoTime);
|
||||
}
|
||||
|
||||
RedisEdgeRateLimitProvider(
|
||||
Map<String, RateLimitPolicy> policies,
|
||||
RedisProgramCatalog catalog,
|
||||
RedisRateProgramExecutor executor,
|
||||
String application,
|
||||
String environment,
|
||||
int hashKeyVersion,
|
||||
int keyVersion,
|
||||
byte[] hmacSecret,
|
||||
Clock clock,
|
||||
Duration failureRetryAfter,
|
||||
Duration minimumCallerBudget,
|
||||
RedisCapabilityObservationPort observations,
|
||||
LongSupplier ticker) {
|
||||
this.policies = Map.copyOf(Objects.requireNonNull(policies, "policies must be non-null"));
|
||||
if (this.policies.isEmpty()) {
|
||||
throw new IllegalArgumentException("Redis rate-limit provider requires at least one policy");
|
||||
}
|
||||
this.policies.forEach(
|
||||
(id, policy) -> {
|
||||
Objects.requireNonNull(policy, "rate-limit policy must be non-null");
|
||||
if (!id.equals(policy.policyId())) {
|
||||
throw new IllegalArgumentException("rate-limit policy map key must match policyId");
|
||||
}
|
||||
validateProviderBounds(policy);
|
||||
});
|
||||
this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null");
|
||||
this.executor = Objects.requireNonNull(executor, "executor must be non-null");
|
||||
this.application = Objects.requireNonNull(application, "application must be non-null");
|
||||
this.environment = Objects.requireNonNull(environment, "environment must be non-null");
|
||||
this.hashKeyVersion = hashKeyVersion;
|
||||
this.keyVersion = keyVersion;
|
||||
this.hmacSecret =
|
||||
Arrays.copyOf(
|
||||
Objects.requireNonNull(hmacSecret, "hmacSecret must be non-null"), hmacSecret.length);
|
||||
if (this.hmacSecret.length < 32) {
|
||||
throw new IllegalArgumentException(
|
||||
"rate-limit key HMAC secret must contain at least 32 bytes");
|
||||
}
|
||||
this.clock = Objects.requireNonNull(clock, "clock must be non-null");
|
||||
this.failureRetryAfter =
|
||||
Objects.requireNonNull(failureRetryAfter, "failureRetryAfter must be non-null");
|
||||
if (failureRetryAfter.isZero()
|
||||
|| failureRetryAfter.isNegative()
|
||||
|| failureRetryAfter.compareTo(Duration.ofDays(30)) > 0) {
|
||||
throw new IllegalArgumentException("failureRetryAfter must be positive and bounded");
|
||||
}
|
||||
this.minimumCallerBudget =
|
||||
Objects.requireNonNull(minimumCallerBudget, "minimumCallerBudget must be non-null");
|
||||
if (minimumCallerBudget.isNegative()
|
||||
|| minimumCallerBudget.compareTo(Duration.ofSeconds(30)) > 0) {
|
||||
throw new IllegalArgumentException("minimumCallerBudget must be non-negative and bounded");
|
||||
}
|
||||
this.observer = new RedisCapabilityObserver(observations, ticker);
|
||||
RateLimitPolicy first = this.policies.values().iterator().next();
|
||||
namespace(first, "state");
|
||||
}
|
||||
|
||||
@Override
|
||||
public RateLimitOutcome evaluate(RateLimitRequest request) {
|
||||
return observer.observe(
|
||||
RedisCapabilityObservationEvent.Capability.RATE_LIMIT,
|
||||
RedisCapabilityObservationEvent.Role.COORDINATION,
|
||||
RedisCapabilityObservationEvent.Operation.RATE_EVALUATE,
|
||||
() -> evaluateWithLifecycle(request),
|
||||
RedisEdgeRateLimitProvider::classify);
|
||||
}
|
||||
|
||||
private RateLimitOutcome evaluateWithLifecycle(RateLimitRequest request) {
|
||||
lifecycle.readLock().lock();
|
||||
try {
|
||||
if (closed) {
|
||||
throw new IllegalStateException("Redis rate-limit provider is closed");
|
||||
}
|
||||
return evaluateOpen(request);
|
||||
} finally {
|
||||
lifecycle.readLock().unlock();
|
||||
}
|
||||
}
|
||||
|
||||
private RateLimitOutcome evaluateOpen(RateLimitRequest request) {
|
||||
Objects.requireNonNull(request, "request must be non-null");
|
||||
RateLimitPolicy policy = policies.get(request.policyId());
|
||||
if (policy == null) {
|
||||
return incompatible(
|
||||
request.policyId(), RateLimitOutcome.IncompatibleCategory.STATE_INCOMPATIBLE);
|
||||
}
|
||||
if (request.cost() > policy.maximumCost()) {
|
||||
throw new IllegalArgumentException("rate-limit request cost exceeds policy maximumCost");
|
||||
}
|
||||
Instant now = clock.instant();
|
||||
if (!request.callerDeadline().isAfter(now)
|
||||
|| Duration.between(now, request.callerDeadline()).compareTo(minimumCallerBudget) < 0) {
|
||||
return unavailable(
|
||||
policy.policyId(), RateLimitOutcome.UnavailableCategory.NO_MUTATION_CONFIRMED);
|
||||
}
|
||||
|
||||
ProgramInvocation invocation = invocation(policy, request);
|
||||
RedisRateProgramReply reply;
|
||||
try {
|
||||
reply = execute(invocation);
|
||||
} catch (RedisProgramCompatibilityException exception) {
|
||||
return incompatible(
|
||||
policy.policyId(), RateLimitOutcome.IncompatibleCategory.REPLY_INCOMPATIBLE);
|
||||
} catch (RedisCommandFailureException exception) {
|
||||
if (!canReplayIndeterminate(policy, request, exception)) {
|
||||
return mapCommandFailure(policy.policyId(), exception);
|
||||
}
|
||||
try {
|
||||
reply = execute(invocation);
|
||||
} catch (RedisProgramCompatibilityException retryException) {
|
||||
return incompatible(
|
||||
policy.policyId(), RateLimitOutcome.IncompatibleCategory.REPLY_INCOMPATIBLE);
|
||||
} catch (RedisCommandFailureException retryException) {
|
||||
return mapCommandFailure(policy.policyId(), retryException);
|
||||
}
|
||||
}
|
||||
return mapReply(policy, reply);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
lifecycle.writeLock().lock();
|
||||
try {
|
||||
if (!closed) {
|
||||
Arrays.fill(hmacSecret, (byte) 0);
|
||||
closed = true;
|
||||
}
|
||||
} finally {
|
||||
lifecycle.writeLock().unlock();
|
||||
}
|
||||
}
|
||||
|
||||
boolean destroyed() {
|
||||
lifecycle.readLock().lock();
|
||||
try {
|
||||
if (!closed) {
|
||||
return false;
|
||||
}
|
||||
for (byte value : hmacSecret) {
|
||||
if (value != 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} finally {
|
||||
lifecycle.readLock().unlock();
|
||||
}
|
||||
}
|
||||
|
||||
private RedisRateProgramReply execute(ProgramInvocation invocation) {
|
||||
return executor.execute(catalog.capabilityInvocation(invocation));
|
||||
}
|
||||
|
||||
private boolean canReplayIndeterminate(
|
||||
RateLimitPolicy policy, RateLimitRequest request, RedisCommandFailureException exception) {
|
||||
if (exception.certainty() != RedisCommandFailureException.Certainty.INDETERMINATE
|
||||
|| !policy.evaluationDedupPolicy().enabled()
|
||||
|| request.evaluationId().isEmpty()
|
||||
|| minimumCallerBudget.isZero()) {
|
||||
return false;
|
||||
}
|
||||
Instant now = clock.instant();
|
||||
return request.callerDeadline().isAfter(now)
|
||||
&& Duration.between(now, request.callerDeadline()).compareTo(minimumCallerBudget) >= 0;
|
||||
}
|
||||
|
||||
private RateLimitOutcome mapReply(RateLimitPolicy policy, RedisRateProgramReply reply) {
|
||||
Objects.requireNonNull(reply, "rate program reply must be non-null");
|
||||
return switch (reply.status()) {
|
||||
case ALLOWED -> evaluated(policy, reply, RedisRateProgramDecision.ALLOWED);
|
||||
case DENIED -> evaluated(policy, reply, RedisRateProgramDecision.DENIED);
|
||||
case DEDUP_REPLAY -> evaluated(policy, reply, reply.decision());
|
||||
case CLOCK_UNSAFE ->
|
||||
unavailable(policy.policyId(), RateLimitOutcome.UnavailableCategory.CLOCK_UNSAFE);
|
||||
case STATE_INCOMPATIBLE ->
|
||||
incompatible(policy.policyId(), RateLimitOutcome.IncompatibleCategory.STATE_INCOMPATIBLE);
|
||||
case INVALID ->
|
||||
incompatible(
|
||||
policy.policyId(), RateLimitOutcome.IncompatibleCategory.PROGRAM_INCOMPATIBLE);
|
||||
};
|
||||
}
|
||||
|
||||
private RateLimitOutcome evaluated(
|
||||
RateLimitPolicy policy, RedisRateProgramReply reply, RedisRateProgramDecision decision) {
|
||||
if (decision == RedisRateProgramDecision.NONE) {
|
||||
return incompatible(
|
||||
policy.policyId(), RateLimitOutcome.IncompatibleCategory.REPLY_INCOMPATIBLE);
|
||||
}
|
||||
boolean allowed = decision == RedisRateProgramDecision.ALLOWED;
|
||||
long expectedLimit = limit(policy);
|
||||
if (reply.limit() != expectedLimit
|
||||
|| reply.remaining() > reply.limit()
|
||||
|| reply.effectiveNowMillis() < reply.serverNowMillis()
|
||||
|| (allowed && reply.retryAfterMillis() != 0)
|
||||
|| (!allowed && reply.retryAfterMillis() < 1)
|
||||
|| reply.resetAtMillis() < reply.effectiveNowMillis()) {
|
||||
return incompatible(
|
||||
policy.policyId(), RateLimitOutcome.IncompatibleCategory.REPLY_INCOMPATIBLE);
|
||||
}
|
||||
RateLimitDecision.DecisionCertainty certainty =
|
||||
policy.algorithm() == RateLimitAlgorithm.SLIDING_COUNTER
|
||||
? RateLimitDecision.DecisionCertainty.APPROXIMATE_ALGORITHM
|
||||
: RateLimitDecision.DecisionCertainty.CERTAIN;
|
||||
try {
|
||||
return new RateLimitOutcome.Evaluated(
|
||||
new RateLimitDecision(
|
||||
allowed,
|
||||
reply.limit(),
|
||||
reply.remaining(),
|
||||
Duration.ofMillis(reply.retryAfterMillis()),
|
||||
Instant.ofEpochMilli(reply.resetAtMillis()),
|
||||
policy.policyId(),
|
||||
policy.policyRevision(),
|
||||
RateLimitDecision.DecisionSource.GLOBAL_REDIS,
|
||||
certainty));
|
||||
} catch (RuntimeException exception) {
|
||||
return incompatible(
|
||||
policy.policyId(), RateLimitOutcome.IncompatibleCategory.REPLY_INCOMPATIBLE);
|
||||
}
|
||||
}
|
||||
|
||||
private RateLimitOutcome mapCommandFailure(
|
||||
String policyId, RedisCommandFailureException exception) {
|
||||
if (exception.certainty() == RedisCommandFailureException.Certainty.INDETERMINATE) {
|
||||
return new RateLimitOutcome.Indeterminate(policyId, failureRetryAfter);
|
||||
}
|
||||
RateLimitOutcome.UnavailableCategory category =
|
||||
exception.kind() == RedisCommandFailureException.Kind.OVERLOADED
|
||||
? RateLimitOutcome.UnavailableCategory.ADMISSION_REJECTED
|
||||
: RateLimitOutcome.UnavailableCategory.UNAVAILABLE_BEFORE_SEND;
|
||||
return unavailable(policyId, category);
|
||||
}
|
||||
|
||||
private RateLimitOutcome unavailable(
|
||||
String policyId, RateLimitOutcome.UnavailableCategory category) {
|
||||
return new RateLimitOutcome.Unavailable(policyId, failureRetryAfter, category);
|
||||
}
|
||||
|
||||
private static RateLimitOutcome incompatible(
|
||||
String policyId, RateLimitOutcome.IncompatibleCategory category) {
|
||||
return new RateLimitOutcome.Incompatible(policyId, category);
|
||||
}
|
||||
|
||||
private static RedisCapabilityObserver.Classification classify(RateLimitOutcome outcome) {
|
||||
if (outcome instanceof RateLimitOutcome.Evaluated evaluated) {
|
||||
return classification(
|
||||
evaluated.decision().allowed()
|
||||
? RedisCapabilityObservationEvent.Outcome.SUCCESS
|
||||
: RedisCapabilityObservationEvent.Outcome.DENIED,
|
||||
RedisCapabilityObservationEvent.Certainty.DEFINITE);
|
||||
}
|
||||
if (outcome instanceof RateLimitOutcome.Indeterminate) {
|
||||
return classification(
|
||||
RedisCapabilityObservationEvent.Outcome.INDETERMINATE,
|
||||
RedisCapabilityObservationEvent.Certainty.INDETERMINATE);
|
||||
}
|
||||
if (outcome instanceof RateLimitOutcome.Incompatible) {
|
||||
return classification(
|
||||
RedisCapabilityObservationEvent.Outcome.INCOMPATIBLE,
|
||||
RedisCapabilityObservationEvent.Certainty.DEFINITE);
|
||||
}
|
||||
RateLimitOutcome.Unavailable unavailable = (RateLimitOutcome.Unavailable) outcome;
|
||||
return classification(
|
||||
unavailable.category() == RateLimitOutcome.UnavailableCategory.ADMISSION_REJECTED
|
||||
? RedisCapabilityObservationEvent.Outcome.OVERLOADED
|
||||
: RedisCapabilityObservationEvent.Outcome.UNAVAILABLE,
|
||||
RedisCapabilityObservationEvent.Certainty.NOT_APPLIED);
|
||||
}
|
||||
|
||||
private static RedisCapabilityObserver.Classification classification(
|
||||
RedisCapabilityObservationEvent.Outcome outcome,
|
||||
RedisCapabilityObservationEvent.Certainty certainty) {
|
||||
return new RedisCapabilityObserver.Classification(outcome, certainty);
|
||||
}
|
||||
|
||||
private ProgramInvocation invocation(RateLimitPolicy policy, RateLimitRequest request) {
|
||||
String algorithm = algorithmId(policy.algorithm());
|
||||
RedisKeyDigest digest =
|
||||
RedisKeyDigest.sensitive(
|
||||
hashKeyVersion,
|
||||
hmacSecret,
|
||||
List.of(
|
||||
utf8(policy.policyId()),
|
||||
utf8(policy.policyRevision()),
|
||||
utf8(algorithm),
|
||||
utf8(request.subjectDigest())));
|
||||
List<byte[]> keys =
|
||||
List.of(
|
||||
physicalKey(policy, digest, "state"),
|
||||
physicalKey(policy, digest, "dedup"),
|
||||
physicalKey(policy, digest, "dedup-order"));
|
||||
String evaluationId =
|
||||
policy.evaluationDedupPolicy().enabled() && !request.evaluationId().isEmpty()
|
||||
? request.evaluationId()
|
||||
: "-";
|
||||
List<byte[]> arguments =
|
||||
switch (policy.parameters()) {
|
||||
case RateParameters.FixedWindow fixed ->
|
||||
commonArguments(policy, request.cost(), fixed.limit(), fixed.window(), evaluationId);
|
||||
case RateParameters.SlidingCounter sliding ->
|
||||
commonArguments(
|
||||
policy, request.cost(), sliding.limit(), sliding.window(), evaluationId);
|
||||
case RateParameters.TokenBucket token ->
|
||||
List.of(
|
||||
ascii(PROGRAM_SCHEMA_VERSION),
|
||||
utf8(policy.policyRevision()),
|
||||
ascii(Math.multiplyExact(token.capacity(), SCALE)),
|
||||
ascii(Math.multiplyExact(token.refillTokens(), SCALE)),
|
||||
ascii(token.refillPeriod().toMillis()),
|
||||
ascii(Math.multiplyExact(request.cost(), SCALE)),
|
||||
ascii(policy.cleanupGrace().toMillis()),
|
||||
ascii(policy.maximumClockRegression().toMillis()),
|
||||
utf8(evaluationId),
|
||||
ascii(policy.evaluationDedupPolicy().timeToLive().toMillis()),
|
||||
ascii(policy.evaluationDedupPolicy().maximumEntries()),
|
||||
ascii(policy.evaluationDedupPolicy().maximumStoredBytes()));
|
||||
};
|
||||
RedisProgramId programId =
|
||||
switch (policy.algorithm()) {
|
||||
case FIXED_WINDOW -> RedisProgramId.RATE_FIXED_WINDOW_V2;
|
||||
case SLIDING_COUNTER -> RedisProgramId.RATE_SLIDING_COUNTER_V2;
|
||||
case TOKEN_BUCKET -> RedisProgramId.RATE_TOKEN_BUCKET_V2;
|
||||
};
|
||||
return new ProgramInvocation(programId, keys, arguments);
|
||||
}
|
||||
|
||||
private static List<byte[]> commonArguments(
|
||||
RateLimitPolicy policy, long cost, long limit, Duration window, String evaluationId) {
|
||||
return List.of(
|
||||
ascii(PROGRAM_SCHEMA_VERSION),
|
||||
utf8(policy.policyRevision()),
|
||||
ascii(limit),
|
||||
ascii(cost),
|
||||
ascii(window.toMillis()),
|
||||
ascii(policy.cleanupGrace().toMillis()),
|
||||
ascii(policy.maximumClockRegression().toMillis()),
|
||||
utf8(evaluationId),
|
||||
ascii(policy.evaluationDedupPolicy().timeToLive().toMillis()),
|
||||
ascii(policy.evaluationDedupPolicy().maximumEntries()),
|
||||
ascii(policy.evaluationDedupPolicy().maximumStoredBytes()));
|
||||
}
|
||||
|
||||
private byte[] physicalKey(RateLimitPolicy policy, RedisKeyDigest digest, String kind) {
|
||||
return utf8(RedisKeyBuilder.build(namespace(policy, kind), digest));
|
||||
}
|
||||
|
||||
private RedisKeyNamespace namespace(RateLimitPolicy policy, String kind) {
|
||||
return new RedisKeyNamespace(
|
||||
application,
|
||||
environment,
|
||||
"rate",
|
||||
policy.policyId(),
|
||||
hashKeyVersion,
|
||||
keyVersion,
|
||||
kind,
|
||||
MAXIMUM_KEY_BYTES);
|
||||
}
|
||||
|
||||
private static void validateProviderBounds(RateLimitPolicy policy) {
|
||||
if (policy.cleanupGrace().compareTo(MAXIMUM_GRACE) > 0
|
||||
|| policy.maximumClockRegression().compareTo(MAXIMUM_CLOCK_REGRESSION) > 0) {
|
||||
throw new IllegalArgumentException("rate-limit grace or clock regression exceeds v2 bounds");
|
||||
}
|
||||
switch (policy.parameters()) {
|
||||
case RateParameters.FixedWindow fixed -> {
|
||||
boundedLimitAndWindow(fixed.limit(), fixed.window());
|
||||
}
|
||||
case RateParameters.SlidingCounter sliding -> {
|
||||
boundedLimitAndWindow(sliding.limit(), sliding.window());
|
||||
}
|
||||
case RateParameters.TokenBucket token -> {
|
||||
if (token.capacity() > MAXIMUM_LIMIT
|
||||
|| token.refillPeriod().compareTo(MAXIMUM_WINDOW) > 0) {
|
||||
throw new IllegalArgumentException("token-bucket policy exceeds v2 program bounds");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void boundedLimitAndWindow(long limit, Duration window) {
|
||||
if (limit > MAXIMUM_LIMIT || window.compareTo(MAXIMUM_WINDOW) > 0) {
|
||||
throw new IllegalArgumentException("rate-limit policy exceeds v2 program bounds");
|
||||
}
|
||||
}
|
||||
|
||||
private static long limit(RateLimitPolicy policy) {
|
||||
return switch (policy.parameters()) {
|
||||
case RateParameters.FixedWindow fixed -> fixed.limit();
|
||||
case RateParameters.SlidingCounter sliding -> sliding.limit();
|
||||
case RateParameters.TokenBucket token -> token.capacity();
|
||||
};
|
||||
}
|
||||
|
||||
private static String algorithmId(RateLimitAlgorithm algorithm) {
|
||||
return switch (algorithm) {
|
||||
case FIXED_WINDOW -> "fixed-window";
|
||||
case SLIDING_COUNTER -> "sliding-window-counter";
|
||||
case TOKEN_BUCKET -> "token-bucket";
|
||||
};
|
||||
}
|
||||
|
||||
private static byte[] ascii(long value) {
|
||||
return Long.toString(value).getBytes(StandardCharsets.US_ASCII);
|
||||
}
|
||||
|
||||
private static byte[] utf8(String value) {
|
||||
return value.getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
static final class ProgramInvocation implements RedisCatalogProgramMaterial {
|
||||
|
||||
private final RedisProgramId programId;
|
||||
private final List<byte[]> keys;
|
||||
private final List<byte[]> arguments;
|
||||
|
||||
private ProgramInvocation(RedisProgramId programId, List<byte[]> keys, List<byte[]> arguments) {
|
||||
this.programId = Objects.requireNonNull(programId, "programId must be non-null");
|
||||
this.keys =
|
||||
Objects.requireNonNull(keys, "keys must be non-null").stream()
|
||||
.map(byte[]::clone)
|
||||
.toList();
|
||||
this.arguments =
|
||||
Objects.requireNonNull(arguments, "arguments must be non-null").stream()
|
||||
.map(byte[]::clone)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public RedisProgramId programId() {
|
||||
return programId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RedisCatalogProgramInvocation.ReplyShape replyShape() {
|
||||
return RedisCatalogProgramInvocation.ReplyShape.MULTI;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<byte[]> copyKeys() {
|
||||
return keys.stream().map(byte[]::clone).toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<byte[]> copyArguments() {
|
||||
return arguments.stream().map(byte[]::clone).toList();
|
||||
}
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.security.RedisCredentialMaterialProvider;
|
||||
import dev.caskeleton.application.lease.DistributedLeasePort;
|
||||
import java.time.Clock;
|
||||
import java.util.Arrays;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/** Canonical COORDINATION-role composition for the owner-safe Redis efficiency lease. */
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@EnableConfigurationProperties(RedisLeaseSettings.class)
|
||||
@ConditionalOnProperty(
|
||||
name = "ca-skeleton.capabilities.lease.provider",
|
||||
havingValue = "redis",
|
||||
matchIfMissing = false)
|
||||
public class RedisEfficiencyLeaseConfig {
|
||||
|
||||
@Bean(name = "distributedLeasePort", destroyMethod = "close")
|
||||
@ConditionalOnProperty(
|
||||
name = "ca-skeleton.capabilities.lease.provider",
|
||||
havingValue = "redis",
|
||||
matchIfMissing = false)
|
||||
DistributedLeasePort distributedLeasePort(
|
||||
RedisLeaseSettings settings,
|
||||
RedisCanonicalRoleRegistry roleRegistry,
|
||||
RedisCredentialMaterialProvider credentialProvider,
|
||||
ObjectProvider<Clock> clockProvider,
|
||||
ObjectProvider<RedisCapabilityObservationPort> observationsProvider) {
|
||||
settings.validateActive();
|
||||
Clock clock = clockProvider.getIfAvailable(Clock::systemUTC);
|
||||
RedisCapabilityObservationPort observations =
|
||||
observationsProvider.getIfUnique(NoOpRedisCapabilityObservationPort::instance);
|
||||
byte[] hmacSecret =
|
||||
RedisHmacMaterialResolver.resolve(
|
||||
settings.keyHmacSecretReference(), credentialProvider, clock, "efficiency-lease");
|
||||
try {
|
||||
return RedisEfficiencyLeaseProvider.create(
|
||||
settings.namespaceApplication(),
|
||||
settings.namespaceEnvironment(),
|
||||
settings.hashKeyVersion(),
|
||||
settings.keyVersion(),
|
||||
hmacSecret,
|
||||
roleRegistry.router(RedisRole.COORDINATION),
|
||||
clock,
|
||||
settings.driftBudget(),
|
||||
observations);
|
||||
} finally {
|
||||
Arrays.fill(hmacSecret, (byte) 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
+394
@@ -0,0 +1,394 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import dev.caskeleton.application.lease.LeaseAttempt;
|
||||
import dev.caskeleton.application.lease.LeaseHandle;
|
||||
import dev.caskeleton.application.lease.LeaseReleaseOutcome;
|
||||
import dev.caskeleton.application.lease.LeaseRenewOutcome;
|
||||
import dev.caskeleton.application.lease.LeaseState;
|
||||
import dev.caskeleton.application.lease.LeaseUnavailableCategory;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.LongSupplier;
|
||||
|
||||
/** Thread-safe local validity handle for one Redis efficiency lease. */
|
||||
final class RedisEfficiencyLeaseHandle implements LeaseHandle {
|
||||
|
||||
private static final int PROGRAM_SCHEMA_VERSION = 1;
|
||||
private static final Duration MAXIMUM_LEASE = Duration.ofHours(24);
|
||||
|
||||
private final byte[] key;
|
||||
private final LeaseAttempt attempt;
|
||||
private final RedisLeaseProgramExecutor programs;
|
||||
private final RedisLeaseLifecycle lifecycle;
|
||||
private final LongSupplier nanoTime;
|
||||
private final long driftNanos;
|
||||
private final Instant acquiredAt;
|
||||
private final AtomicLong validityDeadlineNanos = new AtomicLong();
|
||||
private final AtomicReference<Instant> observedServerExpiry;
|
||||
private final AtomicReference<LeaseState> state = new AtomicReference<>(LeaseState.ACTIVE);
|
||||
private final Object mutationMonitor = new Object();
|
||||
private final RedisCapabilityObserver observer;
|
||||
|
||||
RedisEfficiencyLeaseHandle(
|
||||
byte[] key,
|
||||
LeaseAttempt attempt,
|
||||
RedisLeaseProgramExecutor programs,
|
||||
RedisLeaseLifecycle lifecycle,
|
||||
LongSupplier nanoTime,
|
||||
Duration driftBudget,
|
||||
Instant acquiredAt,
|
||||
RedisLeaseProgramReply reply,
|
||||
long commandStartedNanos,
|
||||
long commandFinishedNanos,
|
||||
RedisCapabilityObserver observer) {
|
||||
this.key = Objects.requireNonNull(key, "key must be non-null").clone();
|
||||
this.attempt = Objects.requireNonNull(attempt, "attempt must be non-null");
|
||||
this.programs = Objects.requireNonNull(programs, "programs must be non-null");
|
||||
this.lifecycle = Objects.requireNonNull(lifecycle, "lifecycle must be non-null");
|
||||
this.nanoTime = Objects.requireNonNull(nanoTime, "nanoTime must be non-null");
|
||||
this.driftNanos = Objects.requireNonNull(driftBudget, "driftBudget must be non-null").toNanos();
|
||||
this.acquiredAt = Objects.requireNonNull(acquiredAt, "acquiredAt must be non-null");
|
||||
this.observedServerExpiry =
|
||||
new AtomicReference<>(Instant.ofEpochMilli(reply.serverExpiryMillis()));
|
||||
this.observer = Objects.requireNonNull(observer, "observer must be non-null");
|
||||
updateValidity(reply.remainingMillis(), commandStartedNanos, commandFinishedNanos);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String ownerToken() {
|
||||
return attempt.ownerToken();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String operationId() {
|
||||
return attempt.operationId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Instant acquiredAt() {
|
||||
return acquiredAt;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Duration remainingValidity() {
|
||||
if (state.get() != LeaseState.ACTIVE) {
|
||||
return Duration.ZERO;
|
||||
}
|
||||
long remaining = validityDeadlineNanos.get() - nanoTime.getAsLong();
|
||||
if (remaining <= 0) {
|
||||
state.compareAndSet(LeaseState.ACTIVE, LeaseState.LOST);
|
||||
return Duration.ZERO;
|
||||
}
|
||||
return Duration.ofNanos(remaining);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Instant observedServerExpiry() {
|
||||
return observedServerExpiry.get();
|
||||
}
|
||||
|
||||
@Override
|
||||
public LeaseState state() {
|
||||
remainingValidity();
|
||||
return state.get();
|
||||
}
|
||||
|
||||
@Override
|
||||
public LeaseRenewOutcome renew(Duration leaseTtl) {
|
||||
Objects.requireNonNull(leaseTtl, "leaseTtl must be non-null");
|
||||
if (leaseTtl.isZero()
|
||||
|| leaseTtl.isNegative()
|
||||
|| leaseTtl.compareTo(MAXIMUM_LEASE) > 0
|
||||
|| !Duration.ofMillis(leaseTtl.toMillis()).equals(leaseTtl)) {
|
||||
throw new IllegalArgumentException("leaseTtl must be positive, bounded, whole milliseconds");
|
||||
}
|
||||
return observer.observe(
|
||||
RedisCapabilityObservationEvent.Capability.EFFICIENCY_LEASE,
|
||||
RedisCapabilityObservationEvent.Role.COORDINATION,
|
||||
RedisCapabilityObservationEvent.Operation.LEASE_RENEW,
|
||||
() -> renewOpen(leaseTtl),
|
||||
RedisEfficiencyLeaseHandle::classifyRenew);
|
||||
}
|
||||
|
||||
private LeaseRenewOutcome renewOpen(Duration leaseTtl) {
|
||||
synchronized (mutationMonitor) {
|
||||
LeaseState current = state();
|
||||
if (current == LeaseState.RELEASED || current == LeaseState.LOST) {
|
||||
return new LeaseRenewOutcome.Absent();
|
||||
}
|
||||
if (current == LeaseState.UNKNOWN) {
|
||||
return new LeaseRenewOutcome.Indeterminate(attempt.operationId());
|
||||
}
|
||||
long started = nanoTime.getAsLong();
|
||||
RedisLeaseProgramReply reply;
|
||||
try {
|
||||
reply =
|
||||
lifecycle.withOpen(
|
||||
() ->
|
||||
programs.execute(
|
||||
new ProgramInvocation(
|
||||
RedisProgramId.LEASE_RENEW_V1,
|
||||
key,
|
||||
List.of(
|
||||
ascii(PROGRAM_SCHEMA_VERSION),
|
||||
ascii(attempt.ownerToken()),
|
||||
ascii(attempt.operationId()),
|
||||
ascii(leaseTtl.toMillis())))));
|
||||
} catch (RedisCommandFailureException failure) {
|
||||
state.set(LeaseState.UNKNOWN);
|
||||
return failure.certainty() == RedisCommandFailureException.Certainty.INDETERMINATE
|
||||
? new LeaseRenewOutcome.Indeterminate(attempt.operationId())
|
||||
: new LeaseRenewOutcome.Unavailable(category(failure));
|
||||
} catch (RedisProgramCompatibilityException | IllegalArgumentException failure) {
|
||||
state.set(LeaseState.UNKNOWN);
|
||||
return new LeaseRenewOutcome.Unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
|
||||
} catch (IllegalStateException failure) {
|
||||
state.set(LeaseState.UNKNOWN);
|
||||
return new LeaseRenewOutcome.Unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
|
||||
}
|
||||
long finished = nanoTime.getAsLong();
|
||||
return mapRenew(reply, started, finished);
|
||||
}
|
||||
}
|
||||
|
||||
private LeaseRenewOutcome mapRenew(RedisLeaseProgramReply reply, long started, long finished) {
|
||||
return switch (reply.status()) {
|
||||
case "RENEWED" -> {
|
||||
if (!validLiveReply(reply)) {
|
||||
state.set(LeaseState.UNKNOWN);
|
||||
yield new LeaseRenewOutcome.Unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
|
||||
}
|
||||
observedServerExpiry.set(Instant.ofEpochMilli(reply.serverExpiryMillis()));
|
||||
if (!updateValidity(reply.remainingMillis(), started, finished)) {
|
||||
yield new LeaseRenewOutcome.Unavailable(LeaseUnavailableCategory.DEADLINE_EXPIRED);
|
||||
}
|
||||
yield new LeaseRenewOutcome.Renewed(remainingValidity());
|
||||
}
|
||||
case "ABSENT" -> {
|
||||
state.set(LeaseState.LOST);
|
||||
yield new LeaseRenewOutcome.Absent();
|
||||
}
|
||||
case "NOT_OWNER", "OWNER_OPERATION_CONFLICT" -> {
|
||||
state.set(LeaseState.LOST);
|
||||
yield new LeaseRenewOutcome.NotOwner();
|
||||
}
|
||||
case "STATE_INCOMPATIBLE", "INVALID" -> {
|
||||
state.set(LeaseState.UNKNOWN);
|
||||
yield new LeaseRenewOutcome.Unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
|
||||
}
|
||||
default -> {
|
||||
state.set(LeaseState.UNKNOWN);
|
||||
yield new LeaseRenewOutcome.Unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public LeaseReleaseOutcome release() {
|
||||
return observer.observe(
|
||||
RedisCapabilityObservationEvent.Capability.EFFICIENCY_LEASE,
|
||||
RedisCapabilityObservationEvent.Role.COORDINATION,
|
||||
RedisCapabilityObservationEvent.Operation.LEASE_RELEASE,
|
||||
this::releaseOpen,
|
||||
RedisEfficiencyLeaseHandle::classifyRelease);
|
||||
}
|
||||
|
||||
private LeaseReleaseOutcome releaseOpen() {
|
||||
synchronized (mutationMonitor) {
|
||||
if (state.get() == LeaseState.RELEASED) {
|
||||
return new LeaseReleaseOutcome.AlreadyAbsent();
|
||||
}
|
||||
RedisLeaseProgramReply reply;
|
||||
try {
|
||||
reply =
|
||||
lifecycle.withOpen(
|
||||
() ->
|
||||
programs.execute(
|
||||
new ProgramInvocation(
|
||||
RedisProgramId.LEASE_RELEASE_V1,
|
||||
key,
|
||||
List.of(
|
||||
ascii(PROGRAM_SCHEMA_VERSION),
|
||||
ascii(attempt.ownerToken()),
|
||||
ascii(attempt.operationId())))));
|
||||
} catch (RedisCommandFailureException failure) {
|
||||
state.set(LeaseState.UNKNOWN);
|
||||
return failure.certainty() == RedisCommandFailureException.Certainty.INDETERMINATE
|
||||
? new LeaseReleaseOutcome.Indeterminate(attempt.operationId())
|
||||
: new LeaseReleaseOutcome.Unavailable(category(failure));
|
||||
} catch (RedisProgramCompatibilityException | IllegalArgumentException failure) {
|
||||
state.set(LeaseState.UNKNOWN);
|
||||
return new LeaseReleaseOutcome.Unavailable(
|
||||
LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
|
||||
} catch (IllegalStateException failure) {
|
||||
state.set(LeaseState.UNKNOWN);
|
||||
return new LeaseReleaseOutcome.Unavailable(
|
||||
LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
|
||||
}
|
||||
return mapRelease(reply);
|
||||
}
|
||||
}
|
||||
|
||||
private LeaseReleaseOutcome mapRelease(RedisLeaseProgramReply reply) {
|
||||
return switch (reply.status()) {
|
||||
case "RELEASED" -> {
|
||||
state.set(LeaseState.RELEASED);
|
||||
yield new LeaseReleaseOutcome.Released();
|
||||
}
|
||||
case "ALREADY_ABSENT" -> {
|
||||
state.set(LeaseState.RELEASED);
|
||||
yield new LeaseReleaseOutcome.AlreadyAbsent();
|
||||
}
|
||||
case "NOT_OWNER", "OWNER_OPERATION_CONFLICT" -> {
|
||||
state.set(LeaseState.LOST);
|
||||
yield new LeaseReleaseOutcome.NotOwner();
|
||||
}
|
||||
case "STATE_INCOMPATIBLE", "INVALID" -> {
|
||||
state.set(LeaseState.UNKNOWN);
|
||||
yield new LeaseReleaseOutcome.Unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
|
||||
}
|
||||
default -> {
|
||||
state.set(LeaseState.UNKNOWN);
|
||||
yield new LeaseReleaseOutcome.Unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private boolean updateValidity(long remainingMillis, long started, long finished) {
|
||||
long commandElapsed = Math.max(0L, finished - started);
|
||||
long rawValidity;
|
||||
try {
|
||||
rawValidity = Math.multiplyExact(remainingMillis, 1_000_000L);
|
||||
} catch (ArithmeticException failure) {
|
||||
state.set(LeaseState.UNKNOWN);
|
||||
return false;
|
||||
}
|
||||
long effective = rawValidity - commandElapsed - driftNanos;
|
||||
if (effective <= 0) {
|
||||
validityDeadlineNanos.set(finished);
|
||||
state.set(LeaseState.LOST);
|
||||
return false;
|
||||
}
|
||||
validityDeadlineNanos.set(saturatedAdd(finished, effective));
|
||||
state.set(LeaseState.ACTIVE);
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean validLiveReply(RedisLeaseProgramReply reply) {
|
||||
return reply.remainingMillis() > 0
|
||||
&& reply.remainingMillis() <= MAXIMUM_LEASE.toMillis()
|
||||
&& reply.stateRevision() > 0
|
||||
&& reply.serverExpiryMillis() >= reply.serverNowMillis()
|
||||
&& reply.serverExpiryMillis() - reply.serverNowMillis() == reply.remainingMillis()
|
||||
&& attempt.operationId().equals(reply.operationId());
|
||||
}
|
||||
|
||||
private static LeaseUnavailableCategory category(RedisCommandFailureException failure) {
|
||||
return failure.kind() == RedisCommandFailureException.Kind.OVERLOADED
|
||||
? LeaseUnavailableCategory.ADMISSION_REJECTED
|
||||
: LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND;
|
||||
}
|
||||
|
||||
private static long saturatedAdd(long left, long right) {
|
||||
try {
|
||||
return Math.addExact(left, right);
|
||||
} catch (ArithmeticException failure) {
|
||||
return Long.MAX_VALUE;
|
||||
}
|
||||
}
|
||||
|
||||
private static RedisCapabilityObserver.Classification classifyRenew(LeaseRenewOutcome outcome) {
|
||||
if (outcome instanceof LeaseRenewOutcome.Renewed) {
|
||||
return definite(RedisCapabilityObservationEvent.Outcome.SUCCESS);
|
||||
}
|
||||
if (outcome instanceof LeaseRenewOutcome.NotOwner) {
|
||||
return definite(RedisCapabilityObservationEvent.Outcome.CONFLICT);
|
||||
}
|
||||
if (outcome instanceof LeaseRenewOutcome.Absent) {
|
||||
return definite(RedisCapabilityObservationEvent.Outcome.MISS);
|
||||
}
|
||||
if (outcome instanceof LeaseRenewOutcome.Indeterminate) {
|
||||
return indeterminate();
|
||||
}
|
||||
return unavailable();
|
||||
}
|
||||
|
||||
private static RedisCapabilityObserver.Classification classifyRelease(
|
||||
LeaseReleaseOutcome outcome) {
|
||||
if (outcome instanceof LeaseReleaseOutcome.Released
|
||||
|| outcome instanceof LeaseReleaseOutcome.AlreadyAbsent) {
|
||||
return definite(RedisCapabilityObservationEvent.Outcome.SUCCESS);
|
||||
}
|
||||
if (outcome instanceof LeaseReleaseOutcome.NotOwner) {
|
||||
return definite(RedisCapabilityObservationEvent.Outcome.CONFLICT);
|
||||
}
|
||||
if (outcome instanceof LeaseReleaseOutcome.Indeterminate) {
|
||||
return indeterminate();
|
||||
}
|
||||
return unavailable();
|
||||
}
|
||||
|
||||
private static RedisCapabilityObserver.Classification definite(
|
||||
RedisCapabilityObservationEvent.Outcome outcome) {
|
||||
return new RedisCapabilityObserver.Classification(
|
||||
outcome, RedisCapabilityObservationEvent.Certainty.DEFINITE);
|
||||
}
|
||||
|
||||
private static RedisCapabilityObserver.Classification indeterminate() {
|
||||
return new RedisCapabilityObserver.Classification(
|
||||
RedisCapabilityObservationEvent.Outcome.INDETERMINATE,
|
||||
RedisCapabilityObservationEvent.Certainty.INDETERMINATE);
|
||||
}
|
||||
|
||||
private static RedisCapabilityObserver.Classification unavailable() {
|
||||
return new RedisCapabilityObserver.Classification(
|
||||
RedisCapabilityObservationEvent.Outcome.UNAVAILABLE,
|
||||
RedisCapabilityObservationEvent.Certainty.NOT_APPLIED);
|
||||
}
|
||||
|
||||
private static byte[] ascii(long value) {
|
||||
return Long.toString(value).getBytes(StandardCharsets.US_ASCII);
|
||||
}
|
||||
|
||||
private static byte[] ascii(String value) {
|
||||
return value.getBytes(StandardCharsets.US_ASCII);
|
||||
}
|
||||
|
||||
static final class ProgramInvocation implements RedisCatalogProgramMaterial {
|
||||
|
||||
private final RedisProgramId programId;
|
||||
private final byte[] key;
|
||||
private final List<byte[]> arguments;
|
||||
|
||||
private ProgramInvocation(RedisProgramId programId, byte[] key, List<byte[]> arguments) {
|
||||
this.programId = Objects.requireNonNull(programId, "programId must be non-null");
|
||||
this.key = Objects.requireNonNull(key, "key must be non-null").clone();
|
||||
this.arguments = arguments.stream().map(byte[]::clone).toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public RedisProgramId programId() {
|
||||
return programId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RedisCatalogProgramInvocation.ReplyShape replyShape() {
|
||||
return RedisCatalogProgramInvocation.ReplyShape.MULTI;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<byte[]> copyKeys() {
|
||||
return List.of(key.clone());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<byte[]> copyArguments() {
|
||||
return arguments.stream().map(byte[]::clone).toList();
|
||||
}
|
||||
}
|
||||
}
|
||||
+503
@@ -0,0 +1,503 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import dev.caskeleton.application.lease.DistributedLeasePort;
|
||||
import dev.caskeleton.application.lease.LeaseAcquireOutcome;
|
||||
import dev.caskeleton.application.lease.LeaseAttempt;
|
||||
import dev.caskeleton.application.lease.LeaseInspectionOutcome;
|
||||
import dev.caskeleton.application.lease.LeaseInspectionRequest;
|
||||
import dev.caskeleton.application.lease.LeaseRequest;
|
||||
import dev.caskeleton.application.lease.LeaseUnavailableCategory;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
import java.util.function.LongSupplier;
|
||||
|
||||
/**
|
||||
* Redis owner-safe efficiency lease provider.
|
||||
*
|
||||
* <p>This provider has no fencing token and must not authorize correctness-sensitive writes.
|
||||
*/
|
||||
final class RedisEfficiencyLeaseProvider implements DistributedLeasePort, AutoCloseable {
|
||||
|
||||
private static final int PROGRAM_SCHEMA_VERSION = 1;
|
||||
private static final Duration MAXIMUM_RETRY_AFTER = Duration.ofMinutes(5);
|
||||
|
||||
private final RedisLeaseKeyFactory keys;
|
||||
private final RedisLeaseProgramExecutor programs;
|
||||
private final RedisLeaseTokenGenerator tokens;
|
||||
private final Clock clock;
|
||||
private final LongSupplier nanoTime;
|
||||
private final Duration driftBudget;
|
||||
private final RedisLeaseWaitStrategy waitStrategy;
|
||||
private final RedisLeaseLifecycle lifecycle = new RedisLeaseLifecycle();
|
||||
private final RedisCapabilityObserver observer;
|
||||
|
||||
RedisEfficiencyLeaseProvider(
|
||||
RedisLeaseKeyFactory keys,
|
||||
RedisLeaseProgramExecutor programs,
|
||||
RedisLeaseTokenGenerator tokens,
|
||||
Clock clock,
|
||||
LongSupplier nanoTime,
|
||||
Duration driftBudget,
|
||||
RedisLeaseWaitStrategy waitStrategy) {
|
||||
this(
|
||||
keys,
|
||||
programs,
|
||||
tokens,
|
||||
clock,
|
||||
nanoTime,
|
||||
driftBudget,
|
||||
waitStrategy,
|
||||
NoOpRedisCapabilityObservationPort.instance());
|
||||
}
|
||||
|
||||
RedisEfficiencyLeaseProvider(
|
||||
RedisLeaseKeyFactory keys,
|
||||
RedisLeaseProgramExecutor programs,
|
||||
RedisLeaseTokenGenerator tokens,
|
||||
Clock clock,
|
||||
LongSupplier nanoTime,
|
||||
Duration driftBudget,
|
||||
RedisLeaseWaitStrategy waitStrategy,
|
||||
RedisCapabilityObservationPort observations) {
|
||||
this.keys = Objects.requireNonNull(keys, "keys must be non-null");
|
||||
this.programs = Objects.requireNonNull(programs, "programs must be non-null");
|
||||
this.tokens = Objects.requireNonNull(tokens, "tokens must be non-null");
|
||||
this.clock = Objects.requireNonNull(clock, "clock must be non-null");
|
||||
this.nanoTime = Objects.requireNonNull(nanoTime, "nanoTime must be non-null");
|
||||
this.driftBudget = Objects.requireNonNull(driftBudget, "driftBudget must be non-null");
|
||||
if (driftBudget.isNegative()
|
||||
|| driftBudget.compareTo(Duration.ofSeconds(5)) > 0
|
||||
|| !Duration.ofMillis(driftBudget.toMillis()).equals(driftBudget)) {
|
||||
throw new IllegalArgumentException("driftBudget must be non-negative, bounded milliseconds");
|
||||
}
|
||||
this.waitStrategy = Objects.requireNonNull(waitStrategy, "waitStrategy must be non-null");
|
||||
this.observer = new RedisCapabilityObserver(observations, nanoTime);
|
||||
}
|
||||
|
||||
static RedisEfficiencyLeaseProvider create(
|
||||
String application,
|
||||
String environment,
|
||||
int hashKeyVersion,
|
||||
int keyVersion,
|
||||
byte[] hmacSecret,
|
||||
RedisStructuredCommands commands,
|
||||
Clock clock,
|
||||
Duration driftBudget) {
|
||||
return create(
|
||||
application,
|
||||
environment,
|
||||
hashKeyVersion,
|
||||
keyVersion,
|
||||
hmacSecret,
|
||||
commands,
|
||||
clock,
|
||||
driftBudget,
|
||||
NoOpRedisCapabilityObservationPort.instance());
|
||||
}
|
||||
|
||||
static RedisEfficiencyLeaseProvider create(
|
||||
String application,
|
||||
String environment,
|
||||
int hashKeyVersion,
|
||||
int keyVersion,
|
||||
byte[] hmacSecret,
|
||||
RedisStructuredCommands commands,
|
||||
Clock clock,
|
||||
Duration driftBudget,
|
||||
RedisCapabilityObservationPort observations) {
|
||||
RedisProgramCatalog catalog = RedisProgramCatalog.efficiencyLease();
|
||||
return new RedisEfficiencyLeaseProvider(
|
||||
new RedisLeaseKeyFactory(application, environment, hashKeyVersion, keyVersion, hmacSecret),
|
||||
new RedisLeaseProgramExecutor(catalog, commands),
|
||||
new RedisLeaseTokenGenerator(new SecureRandom()),
|
||||
clock,
|
||||
System::nanoTime,
|
||||
driftBudget,
|
||||
RedisLeaseWaitStrategy.parking(),
|
||||
observations);
|
||||
}
|
||||
|
||||
@Override
|
||||
public LeaseAttempt newAttempt(String operationId) {
|
||||
return lifecycle.withOpen(() -> new LeaseAttempt(tokens.next(), operationId));
|
||||
}
|
||||
|
||||
@Override
|
||||
public LeaseAcquireOutcome tryAcquire(LeaseRequest request) {
|
||||
return observer.observe(
|
||||
RedisCapabilityObservationEvent.Capability.EFFICIENCY_LEASE,
|
||||
RedisCapabilityObservationEvent.Role.COORDINATION,
|
||||
RedisCapabilityObservationEvent.Operation.LEASE_ACQUIRE,
|
||||
() -> tryAcquireOpen(request),
|
||||
RedisEfficiencyLeaseProvider::classifyAcquire);
|
||||
}
|
||||
|
||||
private LeaseAcquireOutcome tryAcquireOpen(LeaseRequest request) {
|
||||
Objects.requireNonNull(request, "request must be non-null");
|
||||
byte[] key;
|
||||
try {
|
||||
key = lifecycle.withOpen(() -> keys.physicalKey(request.purpose(), request.resourceDigest()));
|
||||
} catch (IllegalStateException failure) {
|
||||
return unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
|
||||
}
|
||||
long waitStarted = nanoTime.getAsLong();
|
||||
int backoffAttempt = 0;
|
||||
while (true) {
|
||||
long commandStarted = nanoTime.getAsLong();
|
||||
RedisLeaseProgramReply reply;
|
||||
try {
|
||||
reply =
|
||||
lifecycle.withOpen(
|
||||
() ->
|
||||
programs.execute(
|
||||
new ProgramInvocation(
|
||||
RedisProgramId.LEASE_ACQUIRE_V1,
|
||||
key,
|
||||
List.of(
|
||||
ascii(PROGRAM_SCHEMA_VERSION),
|
||||
ascii(request.attempt().ownerToken()),
|
||||
ascii(request.attempt().operationId()),
|
||||
ascii(request.leaseTtl().toMillis())))));
|
||||
} catch (RedisCommandFailureException failure) {
|
||||
return mapAcquireFailure(request, failure);
|
||||
} catch (RedisProgramCompatibilityException | IllegalArgumentException failure) {
|
||||
return unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
|
||||
} catch (IllegalStateException failure) {
|
||||
return unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
|
||||
}
|
||||
long commandFinished = nanoTime.getAsLong();
|
||||
LeaseAcquireOutcome mapped = mapAcquire(request, key, reply, commandStarted, commandFinished);
|
||||
long elapsedWaitNanos = Math.max(0L, commandFinished - waitStarted);
|
||||
if (!(mapped instanceof LeaseAcquireOutcome.Contended contended)
|
||||
|| request.waitTimeout().isZero()
|
||||
|| elapsedWaitNanos >= request.waitTimeout().toNanos()) {
|
||||
return mapped;
|
||||
}
|
||||
long remainingWaitNanos = request.waitTimeout().toNanos() - elapsedWaitNanos;
|
||||
Duration pause =
|
||||
pause(
|
||||
contended.retryAfter(),
|
||||
Duration.ofNanos(Math.max(0L, remainingWaitNanos)),
|
||||
backoffAttempt++);
|
||||
if (pause.isZero()) {
|
||||
return mapped;
|
||||
}
|
||||
try {
|
||||
waitStrategy.await(pause);
|
||||
} catch (RedisLeaseWaitInterruptedException interrupted) {
|
||||
return unavailable(LeaseUnavailableCategory.DEADLINE_EXPIRED);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private LeaseAcquireOutcome mapAcquire(
|
||||
LeaseRequest request,
|
||||
byte[] key,
|
||||
RedisLeaseProgramReply reply,
|
||||
long commandStarted,
|
||||
long commandFinished) {
|
||||
return switch (reply.status()) {
|
||||
case "ACQUIRED" -> {
|
||||
if (!validOwnedReply(request.attempt(), request.leaseTtl(), reply)) {
|
||||
yield unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
|
||||
}
|
||||
RedisEfficiencyLeaseHandle handle =
|
||||
handle(request.attempt(), key, reply, commandStarted, commandFinished);
|
||||
if (handle.state() != dev.caskeleton.application.lease.LeaseState.ACTIVE) {
|
||||
handle.release();
|
||||
yield unavailable(LeaseUnavailableCategory.DEADLINE_EXPIRED);
|
||||
}
|
||||
yield new LeaseAcquireOutcome.Acquired(handle);
|
||||
}
|
||||
case "REPLAYED_SAME_OPERATION" -> {
|
||||
if (!validOwnedReply(request.attempt(), Duration.ofHours(24), reply)) {
|
||||
yield unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
|
||||
}
|
||||
RedisEfficiencyLeaseHandle handle =
|
||||
handle(request.attempt(), key, reply, commandStarted, commandFinished);
|
||||
if (handle.state() != dev.caskeleton.application.lease.LeaseState.ACTIVE) {
|
||||
handle.release();
|
||||
yield unavailable(LeaseUnavailableCategory.DEADLINE_EXPIRED);
|
||||
}
|
||||
yield new LeaseAcquireOutcome.ReplayedSameOperation(handle);
|
||||
}
|
||||
case "CONTENDED" -> {
|
||||
if (!validLiveReply(reply)) {
|
||||
yield unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
|
||||
}
|
||||
yield new LeaseAcquireOutcome.Contended(
|
||||
Duration.ofMillis(Math.min(reply.remainingMillis(), MAXIMUM_RETRY_AFTER.toMillis())));
|
||||
}
|
||||
case "OWNER_OPERATION_CONFLICT" ->
|
||||
validLiveReply(reply)
|
||||
? new LeaseAcquireOutcome.OwnerOperationConflict()
|
||||
: unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
|
||||
case "STATE_INCOMPATIBLE", "INVALID" ->
|
||||
unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
|
||||
default -> unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public LeaseInspectionOutcome inspect(LeaseInspectionRequest request) {
|
||||
return observer.observe(
|
||||
RedisCapabilityObservationEvent.Capability.EFFICIENCY_LEASE,
|
||||
RedisCapabilityObservationEvent.Role.COORDINATION,
|
||||
RedisCapabilityObservationEvent.Operation.LEASE_INSPECT,
|
||||
() -> inspectOpen(request),
|
||||
RedisEfficiencyLeaseProvider::classifyInspection);
|
||||
}
|
||||
|
||||
private LeaseInspectionOutcome inspectOpen(LeaseInspectionRequest request) {
|
||||
Objects.requireNonNull(request, "request must be non-null");
|
||||
byte[] key;
|
||||
try {
|
||||
key = lifecycle.withOpen(() -> keys.physicalKey(request.purpose(), request.resourceDigest()));
|
||||
} catch (IllegalStateException failure) {
|
||||
return new LeaseInspectionOutcome.Unavailable(
|
||||
LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
|
||||
}
|
||||
long started = nanoTime.getAsLong();
|
||||
RedisLeaseProgramReply reply;
|
||||
try {
|
||||
reply =
|
||||
lifecycle.withOpen(
|
||||
() ->
|
||||
programs.execute(
|
||||
new ProgramInvocation(
|
||||
RedisProgramId.LEASE_INSPECT_V1,
|
||||
key,
|
||||
List.of(
|
||||
ascii(PROGRAM_SCHEMA_VERSION),
|
||||
ascii(request.attempt().ownerToken()),
|
||||
ascii(request.attempt().operationId())))));
|
||||
} catch (RedisCommandFailureException failure) {
|
||||
return failure.certainty() == RedisCommandFailureException.Certainty.INDETERMINATE
|
||||
? new LeaseInspectionOutcome.Indeterminate(request.attempt().operationId())
|
||||
: new LeaseInspectionOutcome.Unavailable(category(failure));
|
||||
} catch (RedisProgramCompatibilityException | IllegalArgumentException failure) {
|
||||
return new LeaseInspectionOutcome.Unavailable(
|
||||
LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
|
||||
} catch (IllegalStateException failure) {
|
||||
return new LeaseInspectionOutcome.Unavailable(
|
||||
LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
|
||||
}
|
||||
long finished = nanoTime.getAsLong();
|
||||
return mapInspection(request, key, reply, started, finished);
|
||||
}
|
||||
|
||||
private LeaseInspectionOutcome mapInspection(
|
||||
LeaseInspectionRequest request,
|
||||
byte[] key,
|
||||
RedisLeaseProgramReply reply,
|
||||
long started,
|
||||
long finished) {
|
||||
return switch (reply.status()) {
|
||||
case "OWNED" -> {
|
||||
if (!validOwnedReply(request.attempt(), Duration.ofHours(24), reply)) {
|
||||
yield new LeaseInspectionOutcome.Unavailable(
|
||||
LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
|
||||
}
|
||||
RedisEfficiencyLeaseHandle handle =
|
||||
handle(request.attempt(), key, reply, started, finished);
|
||||
yield handle.state() == dev.caskeleton.application.lease.LeaseState.ACTIVE
|
||||
? new LeaseInspectionOutcome.Owned(handle)
|
||||
: new LeaseInspectionOutcome.Unavailable(LeaseUnavailableCategory.DEADLINE_EXPIRED);
|
||||
}
|
||||
case "ABSENT" -> new LeaseInspectionOutcome.Absent();
|
||||
case "NOT_OWNER" -> new LeaseInspectionOutcome.NotOwner();
|
||||
case "OWNER_OPERATION_CONFLICT" -> new LeaseInspectionOutcome.OwnerOperationConflict();
|
||||
case "STATE_INCOMPATIBLE", "INVALID" ->
|
||||
new LeaseInspectionOutcome.Unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
|
||||
default ->
|
||||
new LeaseInspectionOutcome.Unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
|
||||
};
|
||||
}
|
||||
|
||||
private RedisEfficiencyLeaseHandle handle(
|
||||
LeaseAttempt attempt,
|
||||
byte[] key,
|
||||
RedisLeaseProgramReply reply,
|
||||
long commandStarted,
|
||||
long commandFinished) {
|
||||
return new RedisEfficiencyLeaseHandle(
|
||||
key,
|
||||
attempt,
|
||||
programs,
|
||||
lifecycle,
|
||||
nanoTime,
|
||||
driftBudget,
|
||||
clock.instant(),
|
||||
reply,
|
||||
commandStarted,
|
||||
commandFinished,
|
||||
observer);
|
||||
}
|
||||
|
||||
private static boolean validOwnedReply(
|
||||
LeaseAttempt attempt, Duration maximumTtl, RedisLeaseProgramReply reply) {
|
||||
return reply.remainingMillis() > 0
|
||||
&& reply.remainingMillis() <= maximumTtl.toMillis()
|
||||
&& reply.stateRevision() > 0
|
||||
&& reply.serverExpiryMillis() >= reply.serverNowMillis()
|
||||
&& reply.serverExpiryMillis() - reply.serverNowMillis() == reply.remainingMillis()
|
||||
&& attempt.operationId().equals(reply.operationId());
|
||||
}
|
||||
|
||||
private static boolean validLiveReply(RedisLeaseProgramReply reply) {
|
||||
return reply.remainingMillis() > 0
|
||||
&& reply.remainingMillis() <= Duration.ofHours(24).toMillis()
|
||||
&& reply.stateRevision() > 0
|
||||
&& reply.serverExpiryMillis() >= reply.serverNowMillis()
|
||||
&& reply.serverExpiryMillis() - reply.serverNowMillis() == reply.remainingMillis();
|
||||
}
|
||||
|
||||
private static Duration pause(Duration contention, Duration remainingWait, int backoffAttempt) {
|
||||
if (remainingWait.isZero()) {
|
||||
return Duration.ZERO;
|
||||
}
|
||||
int shift = Math.min(backoffAttempt, 7);
|
||||
long capMillis = Math.min(250L, 2L << shift);
|
||||
long jitterMillis = ThreadLocalRandom.current().nextLong(1L, capMillis + 1L);
|
||||
long millis =
|
||||
Math.min(
|
||||
jitterMillis,
|
||||
Math.min(Math.max(1L, contention.toMillis()), Math.max(0L, remainingWait.toMillis())));
|
||||
return millis < 1 ? Duration.ZERO : Duration.ofMillis(millis);
|
||||
}
|
||||
|
||||
private static LeaseAcquireOutcome mapAcquireFailure(
|
||||
LeaseRequest request, RedisCommandFailureException failure) {
|
||||
if (failure.certainty() == RedisCommandFailureException.Certainty.INDETERMINATE) {
|
||||
return new LeaseAcquireOutcome.Indeterminate(request.attempt().operationId());
|
||||
}
|
||||
if (failure.kind() == RedisCommandFailureException.Kind.OVERLOADED) {
|
||||
return new LeaseAcquireOutcome.Overloaded();
|
||||
}
|
||||
return unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
|
||||
}
|
||||
|
||||
private static LeaseAcquireOutcome unavailable(LeaseUnavailableCategory category) {
|
||||
return new LeaseAcquireOutcome.Unavailable(category);
|
||||
}
|
||||
|
||||
private static LeaseUnavailableCategory category(RedisCommandFailureException failure) {
|
||||
return failure.kind() == RedisCommandFailureException.Kind.OVERLOADED
|
||||
? LeaseUnavailableCategory.ADMISSION_REJECTED
|
||||
: LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND;
|
||||
}
|
||||
|
||||
private static RedisCapabilityObserver.Classification classifyAcquire(
|
||||
LeaseAcquireOutcome outcome) {
|
||||
if (outcome instanceof LeaseAcquireOutcome.Acquired
|
||||
|| outcome instanceof LeaseAcquireOutcome.ReplayedSameOperation) {
|
||||
return definite(RedisCapabilityObservationEvent.Outcome.SUCCESS);
|
||||
}
|
||||
if (outcome instanceof LeaseAcquireOutcome.Contended) {
|
||||
return definite(RedisCapabilityObservationEvent.Outcome.CONTENDED);
|
||||
}
|
||||
if (outcome instanceof LeaseAcquireOutcome.OwnerOperationConflict) {
|
||||
return definite(RedisCapabilityObservationEvent.Outcome.CONFLICT);
|
||||
}
|
||||
if (outcome instanceof LeaseAcquireOutcome.Overloaded) {
|
||||
return new RedisCapabilityObserver.Classification(
|
||||
RedisCapabilityObservationEvent.Outcome.OVERLOADED,
|
||||
RedisCapabilityObservationEvent.Certainty.NOT_APPLIED);
|
||||
}
|
||||
if (outcome instanceof LeaseAcquireOutcome.Indeterminate) {
|
||||
return indeterminate();
|
||||
}
|
||||
return unavailable();
|
||||
}
|
||||
|
||||
private static RedisCapabilityObserver.Classification classifyInspection(
|
||||
LeaseInspectionOutcome outcome) {
|
||||
if (outcome instanceof LeaseInspectionOutcome.Owned) {
|
||||
return definite(RedisCapabilityObservationEvent.Outcome.SUCCESS);
|
||||
}
|
||||
if (outcome instanceof LeaseInspectionOutcome.Absent) {
|
||||
return definite(RedisCapabilityObservationEvent.Outcome.MISS);
|
||||
}
|
||||
if (outcome instanceof LeaseInspectionOutcome.NotOwner
|
||||
|| outcome instanceof LeaseInspectionOutcome.OwnerOperationConflict) {
|
||||
return definite(RedisCapabilityObservationEvent.Outcome.CONFLICT);
|
||||
}
|
||||
if (outcome instanceof LeaseInspectionOutcome.Indeterminate) {
|
||||
return indeterminate();
|
||||
}
|
||||
return unavailable();
|
||||
}
|
||||
|
||||
private static RedisCapabilityObserver.Classification definite(
|
||||
RedisCapabilityObservationEvent.Outcome outcome) {
|
||||
return new RedisCapabilityObserver.Classification(
|
||||
outcome, RedisCapabilityObservationEvent.Certainty.DEFINITE);
|
||||
}
|
||||
|
||||
private static RedisCapabilityObserver.Classification indeterminate() {
|
||||
return new RedisCapabilityObserver.Classification(
|
||||
RedisCapabilityObservationEvent.Outcome.INDETERMINATE,
|
||||
RedisCapabilityObservationEvent.Certainty.INDETERMINATE);
|
||||
}
|
||||
|
||||
private static RedisCapabilityObserver.Classification unavailable() {
|
||||
return new RedisCapabilityObserver.Classification(
|
||||
RedisCapabilityObservationEvent.Outcome.UNAVAILABLE,
|
||||
RedisCapabilityObservationEvent.Certainty.NOT_APPLIED);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
lifecycle.close(keys::close);
|
||||
}
|
||||
|
||||
boolean destroyed() {
|
||||
return lifecycle.closed() && keys.destroyed();
|
||||
}
|
||||
|
||||
private static byte[] ascii(long value) {
|
||||
return Long.toString(value).getBytes(StandardCharsets.US_ASCII);
|
||||
}
|
||||
|
||||
private static byte[] ascii(String value) {
|
||||
return value.getBytes(StandardCharsets.US_ASCII);
|
||||
}
|
||||
|
||||
static final class ProgramInvocation implements RedisCatalogProgramMaterial {
|
||||
|
||||
private final RedisProgramId programId;
|
||||
private final byte[] key;
|
||||
private final List<byte[]> arguments;
|
||||
|
||||
private ProgramInvocation(RedisProgramId programId, byte[] key, List<byte[]> arguments) {
|
||||
this.programId = Objects.requireNonNull(programId, "programId must be non-null");
|
||||
this.key = Objects.requireNonNull(key, "key must be non-null").clone();
|
||||
this.arguments = arguments.stream().map(byte[]::clone).toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public RedisProgramId programId() {
|
||||
return programId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RedisCatalogProgramInvocation.ReplyShape replyShape() {
|
||||
return RedisCatalogProgramInvocation.ReplyShape.MULTI;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<byte[]> copyKeys() {
|
||||
return List.of(key.clone());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<byte[]> copyArguments() {
|
||||
return arguments.stream().map(byte[]::clone).toList();
|
||||
}
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
/** Bounded WGS84 coordinate whose text form is always redacted. */
|
||||
record RedisGeoCoordinate(double longitude, double latitude) {
|
||||
|
||||
RedisGeoCoordinate {
|
||||
if (!Double.isFinite(longitude)
|
||||
|| !Double.isFinite(latitude)
|
||||
|| longitude < -180
|
||||
|| longitude > 180
|
||||
|| latitude < -85.05112878
|
||||
|| latitude > 85.05112878) {
|
||||
throw new IllegalArgumentException("geo coordinate exceeds descriptor bounds");
|
||||
}
|
||||
if (canonical(longitude).length() > 20 || canonical(latitude).length() > 20) {
|
||||
throw new IllegalArgumentException("geo coordinate exceeds canonical encoding bounds");
|
||||
}
|
||||
}
|
||||
|
||||
String canonicalLongitude() {
|
||||
return canonical(longitude);
|
||||
}
|
||||
|
||||
String canonicalLatitude() {
|
||||
return canonical(latitude);
|
||||
}
|
||||
|
||||
private static String canonical(double value) {
|
||||
if (value == 0) {
|
||||
return "0";
|
||||
}
|
||||
return java.math.BigDecimal.valueOf(value).stripTrailingZeros().toPlainString();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "RedisGeoCoordinate[redacted]";
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Privacy-sensitive bounded GEO helpers; coordinates are never returned or stringified. */
|
||||
final class RedisGeoPrimitives {
|
||||
|
||||
private final RedisPrimitiveCatalog catalog;
|
||||
private final RedisPrimitiveExecutor executor;
|
||||
private final RedisPrimitiveDescriptor admission;
|
||||
|
||||
RedisGeoPrimitives(RedisPrimitiveCatalog catalog, RedisPrimitiveCommands commands) {
|
||||
this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null");
|
||||
this.executor = new RedisPrimitiveExecutor(catalog, commands);
|
||||
this.admission = catalog.descriptor(RedisPrimitiveId.GEO_ADD);
|
||||
}
|
||||
|
||||
RedisPrimitiveKey key(String slot, String identity) {
|
||||
return catalog.keyFactory(RedisPrimitiveId.GEO_ADD).key(slot, identity);
|
||||
}
|
||||
|
||||
RedisPrimitiveValue member(String member) {
|
||||
return RedisPrimitiveValue.utf8(member, admission.maximumMemberBytes());
|
||||
}
|
||||
|
||||
RedisPrimitiveMutationResult admitOrUpdate(
|
||||
RedisPrimitiveKey key,
|
||||
RedisPrimitiveValue member,
|
||||
RedisGeoCoordinate coordinate,
|
||||
Duration initialTimeToLive) {
|
||||
return executor.mutate(
|
||||
RedisPrimitiveId.GEO_ADD,
|
||||
List.of(key),
|
||||
new RedisPrimitiveInvocation.GeoAdmissionArguments(
|
||||
member,
|
||||
coordinate,
|
||||
RedisPrimitiveLimit.of(admission.maximumElements(), admission),
|
||||
initialTimeToLive));
|
||||
}
|
||||
|
||||
RedisPrimitiveReply search(
|
||||
RedisPrimitiveKey key,
|
||||
RedisGeoCoordinate center,
|
||||
double radiusMeters,
|
||||
int count,
|
||||
RedisPrimitiveInvocation.GeoArguments.Sort sort) {
|
||||
RedisPrimitiveDescriptor descriptor = catalog.descriptor(RedisPrimitiveId.GEO_SEARCH);
|
||||
return executor.execute(
|
||||
RedisPrimitiveId.GEO_SEARCH,
|
||||
List.of(key),
|
||||
new RedisPrimitiveInvocation.GeoArguments(
|
||||
center,
|
||||
RedisPrimitiveInvocation.GeoArguments.Shape.RADIUS,
|
||||
radiusMeters,
|
||||
0,
|
||||
RedisPrimitiveLimit.of(count, descriptor),
|
||||
sort));
|
||||
}
|
||||
|
||||
RedisPrimitiveReply searchBox(
|
||||
RedisPrimitiveKey key,
|
||||
RedisGeoCoordinate center,
|
||||
double widthMeters,
|
||||
double heightMeters,
|
||||
int count,
|
||||
RedisPrimitiveInvocation.GeoArguments.Sort sort) {
|
||||
RedisPrimitiveDescriptor descriptor = catalog.descriptor(RedisPrimitiveId.GEO_SEARCH);
|
||||
return executor.execute(
|
||||
RedisPrimitiveId.GEO_SEARCH,
|
||||
List.of(key),
|
||||
new RedisPrimitiveInvocation.GeoArguments(
|
||||
center,
|
||||
RedisPrimitiveInvocation.GeoArguments.Shape.BOX,
|
||||
widthMeters,
|
||||
heightMeters,
|
||||
RedisPrimitiveLimit.of(count, descriptor),
|
||||
sort));
|
||||
}
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Bounded hash helpers. Field growth is admitted atomically against descriptor capacity. */
|
||||
final class RedisHashPrimitives {
|
||||
|
||||
private final RedisPrimitiveCatalog catalog;
|
||||
private final RedisPrimitiveExecutor executor;
|
||||
private final RedisPrimitiveDescriptor putDescriptor;
|
||||
|
||||
RedisHashPrimitives(RedisPrimitiveCatalog catalog, RedisPrimitiveCommands commands) {
|
||||
this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null");
|
||||
this.executor = new RedisPrimitiveExecutor(catalog, commands);
|
||||
this.putDescriptor = catalog.descriptor(RedisPrimitiveId.HASH_SET_FIELDS);
|
||||
}
|
||||
|
||||
RedisPrimitiveKey key(String slot, String identity) {
|
||||
return catalog.keyFactory(RedisPrimitiveId.HASH_GET).key(slot, identity);
|
||||
}
|
||||
|
||||
RedisPrimitiveValue field(String field) {
|
||||
return RedisPrimitiveValue.utf8(field, putDescriptor.maximumFieldBytes());
|
||||
}
|
||||
|
||||
RedisPrimitiveValue value(String value) {
|
||||
return RedisPrimitiveValue.utf8(value, putDescriptor.maximumValueBytes());
|
||||
}
|
||||
|
||||
RedisPrimitiveMutationResult put(
|
||||
RedisPrimitiveKey key,
|
||||
RedisPrimitiveValue field,
|
||||
RedisPrimitiveValue value,
|
||||
Duration initialTimeToLive) {
|
||||
return executor.mutate(
|
||||
RedisPrimitiveId.HASH_SET_FIELDS,
|
||||
List.of(key),
|
||||
new RedisPrimitiveInvocation.HashAdmissionArguments(
|
||||
field,
|
||||
value,
|
||||
RedisPrimitiveLimit.of(putDescriptor.maximumElements(), putDescriptor),
|
||||
initialTimeToLive));
|
||||
}
|
||||
|
||||
RedisPrimitiveReply get(RedisPrimitiveKey key, RedisPrimitiveValue field) {
|
||||
return executor.execute(
|
||||
RedisPrimitiveId.HASH_GET,
|
||||
List.of(key),
|
||||
new RedisPrimitiveInvocation.BinaryArguments(List.of(field)));
|
||||
}
|
||||
|
||||
RedisPrimitiveReply multiGet(RedisPrimitiveKey key, List<RedisPrimitiveValue> fields) {
|
||||
RedisPrimitiveDescriptor descriptor = catalog.descriptor(RedisPrimitiveId.HASH_MGET);
|
||||
RedisPrimitiveLimit.of(fields.size(), descriptor);
|
||||
return executor.execute(
|
||||
RedisPrimitiveId.HASH_MGET,
|
||||
List.of(key),
|
||||
new RedisPrimitiveInvocation.BinaryArguments(fields));
|
||||
}
|
||||
|
||||
RedisPrimitiveMutationResult delete(RedisPrimitiveKey key, List<RedisPrimitiveValue> fields) {
|
||||
RedisPrimitiveDescriptor descriptor = catalog.descriptor(RedisPrimitiveId.HASH_DELETE_FIELDS);
|
||||
RedisPrimitiveLimit.of(fields.size(), descriptor);
|
||||
return executor.mutate(
|
||||
RedisPrimitiveId.HASH_DELETE_FIELDS,
|
||||
List.of(key),
|
||||
new RedisPrimitiveInvocation.BinaryArguments(fields));
|
||||
}
|
||||
|
||||
RedisPrimitiveScanOutcome<RedisPrimitiveHashEntry> scan(
|
||||
RedisPrimitiveKey key, RedisPrimitiveCursor cursor, long routeEpoch) {
|
||||
RedisPrimitiveDescriptor descriptor = catalog.descriptor(RedisPrimitiveId.HASH_SCAN_PAGE);
|
||||
cursor.validateFor(catalog, descriptor, key, routeEpoch);
|
||||
return RedisPrimitiveScanOutcome.from(
|
||||
executor.execute(
|
||||
RedisPrimitiveId.HASH_SCAN_PAGE,
|
||||
List.of(key),
|
||||
new RedisPrimitiveInvocation.ScanPageArguments(
|
||||
cursor, descriptor.maximumElements(), descriptor.maximumResultBytes())),
|
||||
RedisPrimitiveHashEntry.class);
|
||||
}
|
||||
|
||||
RedisPrimitiveMutationResult compareRevision(
|
||||
RedisPrimitiveKey key,
|
||||
RedisPrimitiveInvocation.HashRevisionArguments.ExpectedKind expectedKind,
|
||||
String expectedRevision,
|
||||
String nextRevision,
|
||||
RedisPrimitiveValue value,
|
||||
Duration initialTimeToLive) {
|
||||
return executor.mutate(
|
||||
RedisPrimitiveId.HASH_REVISION_CAS,
|
||||
List.of(key),
|
||||
new RedisPrimitiveInvocation.HashRevisionArguments(
|
||||
expectedKind, expectedRevision, nextRevision, value, initialTimeToLive));
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.security.RedisCredentialMaterialProvider;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.security.RedisSecretReference;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.security.VersionedRedisCredentialMaterial;
|
||||
import java.time.Clock;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
|
||||
/** Resolves bounded Base64 HMAC material without retaining a raw configuration secret. */
|
||||
final class RedisHmacMaterialResolver {
|
||||
|
||||
private RedisHmacMaterialResolver() {}
|
||||
|
||||
static byte[] resolve(
|
||||
String reference,
|
||||
RedisCredentialMaterialProvider credentialProvider,
|
||||
Clock clock,
|
||||
String capability) {
|
||||
try (VersionedRedisCredentialMaterial material =
|
||||
credentialProvider.resolve(RedisSecretReference.parse(reference))) {
|
||||
if (material.isExpiredAt(clock.instant())) {
|
||||
throw failure(capability);
|
||||
}
|
||||
byte[] decoded =
|
||||
material.useSecret(
|
||||
chars -> {
|
||||
byte[] encoded = new byte[chars.length];
|
||||
try {
|
||||
for (int index = 0; index < chars.length; index++) {
|
||||
if (chars[index] > 0x7f) {
|
||||
throw failure(capability);
|
||||
}
|
||||
encoded[index] = (byte) chars[index];
|
||||
}
|
||||
return Base64.getDecoder().decode(encoded);
|
||||
} finally {
|
||||
Arrays.fill(encoded, (byte) 0);
|
||||
}
|
||||
});
|
||||
if (decoded.length < 32 || decoded.length > 4096) {
|
||||
Arrays.fill(decoded, (byte) 0);
|
||||
throw failure(capability);
|
||||
}
|
||||
return decoded;
|
||||
} catch (RuntimeException ignored) {
|
||||
throw failure(capability);
|
||||
}
|
||||
}
|
||||
|
||||
private static IllegalStateException failure(String capability) {
|
||||
return new IllegalStateException("Redis " + capability + " HMAC material resolution failed");
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Approximate HLL helpers forbidden for billing, authorization, quota, audit and security. */
|
||||
final class RedisHyperLogLogPrimitives {
|
||||
|
||||
private final RedisPrimitiveCatalog catalog;
|
||||
private final RedisPrimitiveExecutor executor;
|
||||
private final RedisPrimitiveDescriptor addDescriptor;
|
||||
|
||||
RedisHyperLogLogPrimitives(RedisPrimitiveCatalog catalog, RedisPrimitiveCommands commands) {
|
||||
this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null");
|
||||
this.executor = new RedisPrimitiveExecutor(catalog, commands);
|
||||
this.addDescriptor = catalog.descriptor(RedisPrimitiveId.HLL_ADD);
|
||||
}
|
||||
|
||||
RedisPrimitiveKey key(String slot, String identity) {
|
||||
return catalog.keyFactory(RedisPrimitiveId.HLL_ADD).key(slot, identity);
|
||||
}
|
||||
|
||||
RedisPrimitiveValue element(String value) {
|
||||
return RedisPrimitiveValue.utf8(value, addDescriptor.maximumValueBytes());
|
||||
}
|
||||
|
||||
RedisPrimitiveMutationResult add(RedisPrimitiveKey key, List<RedisPrimitiveValue> elements) {
|
||||
RedisPrimitiveLimit.of(elements.size(), addDescriptor);
|
||||
return executor.mutate(
|
||||
RedisPrimitiveId.HLL_ADD,
|
||||
List.of(key),
|
||||
new RedisPrimitiveInvocation.BinaryArguments(elements));
|
||||
}
|
||||
|
||||
RedisPrimitiveReply count(RedisPrimitiveKey key) {
|
||||
return executor.execute(
|
||||
RedisPrimitiveId.HLL_COUNT, List.of(key), RedisPrimitiveInvocation.NoArguments.INSTANCE);
|
||||
}
|
||||
|
||||
RedisPrimitiveMutationResult merge(
|
||||
RedisPrimitiveKey destination, List<RedisPrimitiveKey> sources) {
|
||||
RedisPrimitiveDescriptor descriptor = catalog.descriptor(RedisPrimitiveId.HLL_MERGE_SAME_SLOT);
|
||||
if (sources.isEmpty() || sources.size() > descriptor.maximumKeys() - 1) {
|
||||
throw new IllegalArgumentException("HLL merge fan-in exceeds descriptor bounds");
|
||||
}
|
||||
ArrayList<RedisPrimitiveKey> keys = new ArrayList<>();
|
||||
keys.add(destination);
|
||||
keys.addAll(sources);
|
||||
descriptor.validateKeys(keys);
|
||||
return executor.mutate(
|
||||
RedisPrimitiveId.HLL_MERGE_SAME_SLOT, keys, RedisPrimitiveInvocation.NoArguments.INSTANCE);
|
||||
}
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.security.RedisCredentialMaterialProvider;
|
||||
import dev.caskeleton.application.idempotency.IdempotencyExecutorV2;
|
||||
import dev.caskeleton.application.idempotency.IdempotencyStorePortV2;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Clock;
|
||||
import java.util.Arrays;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/** Canonical COORDINATION-role composition for Redis request-replay idempotency V2. */
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@EnableConfigurationProperties(RedisIdempotencySettings.class)
|
||||
@ConditionalOnProperty(
|
||||
name = "ca-skeleton.capabilities.idempotency.provider",
|
||||
havingValue = "redis",
|
||||
matchIfMissing = false)
|
||||
public class RedisIdempotencyConfig {
|
||||
|
||||
@Bean(name = "redisIdempotencyStoreV2", destroyMethod = "close")
|
||||
@ConditionalOnProperty(
|
||||
name = "ca-skeleton.capabilities.idempotency.provider",
|
||||
havingValue = "redis",
|
||||
matchIfMissing = false)
|
||||
IdempotencyStorePortV2 redisIdempotencyStoreV2(
|
||||
RedisIdempotencySettings settings,
|
||||
RedisCanonicalRoleRegistry roleRegistry,
|
||||
RedisCredentialMaterialProvider credentialProvider,
|
||||
ObjectProvider<Clock> clockProvider,
|
||||
ObjectProvider<RedisCapabilityObservationPort> observationsProvider) {
|
||||
settings.validateActive();
|
||||
Clock clock = clockProvider.getIfAvailable(Clock::systemUTC);
|
||||
RedisCapabilityObservationPort observations =
|
||||
observationsProvider.getIfUnique(NoOpRedisCapabilityObservationPort::instance);
|
||||
byte[] hmacSecret =
|
||||
RedisHmacMaterialResolver.resolve(
|
||||
settings.keyHmacSecretReference(), credentialProvider, clock, "idempotency");
|
||||
RedisProgramCatalog catalog = RedisProgramCatalog.idempotencyV2();
|
||||
try {
|
||||
return new RedisIdempotencyStoreProvider(
|
||||
new RedisIdempotencyKeyFactory(
|
||||
settings.namespaceApplication(),
|
||||
settings.namespaceEnvironment(),
|
||||
settings.hashKeyVersion(),
|
||||
settings.keyVersion(),
|
||||
hmacSecret),
|
||||
new RedisIdempotencyProgramExecutor(catalog, roleRegistry.router(RedisRole.COORDINATION)),
|
||||
new RedisIdempotencyRecordCodec(),
|
||||
new RedisIdempotencyTokenGenerator(new SecureRandom()),
|
||||
observations,
|
||||
System::nanoTime);
|
||||
} finally {
|
||||
Arrays.fill(hmacSecret, (byte) 0);
|
||||
}
|
||||
}
|
||||
|
||||
@Bean(name = "idempotencyExecutorV2")
|
||||
@ConditionalOnProperty(
|
||||
name = "ca-skeleton.capabilities.idempotency.provider",
|
||||
havingValue = "redis",
|
||||
matchIfMissing = false)
|
||||
IdempotencyExecutorV2 idempotencyExecutorV2(
|
||||
IdempotencyStorePortV2 store, RedisIdempotencySettings settings) {
|
||||
return new IdempotencyExecutorV2(
|
||||
store,
|
||||
settings.processingLease(),
|
||||
settings.replayTtl(),
|
||||
settings.failureRetention(),
|
||||
settings.responseCodecId(),
|
||||
settings.policyRevision());
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyBuilder;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyDigest;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyNamespace;
|
||||
import dev.caskeleton.application.idempotency.IdempotencyScope;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/** HMAC-pseudonymizes every request-replay scope dimension into one Cluster-safe record key. */
|
||||
final class RedisIdempotencyKeyFactory implements AutoCloseable {
|
||||
|
||||
private final RedisKeyNamespace namespace;
|
||||
private final byte[] hmacSecret;
|
||||
private final AtomicBoolean closed = new AtomicBoolean();
|
||||
|
||||
RedisIdempotencyKeyFactory(
|
||||
String application,
|
||||
String environment,
|
||||
int hashKeyVersion,
|
||||
int keyVersion,
|
||||
byte[] hmacSecret) {
|
||||
this.namespace =
|
||||
new RedisKeyNamespace(
|
||||
application,
|
||||
environment,
|
||||
"idempotency",
|
||||
"request",
|
||||
hashKeyVersion,
|
||||
keyVersion,
|
||||
"record",
|
||||
512);
|
||||
this.hmacSecret =
|
||||
Arrays.copyOf(
|
||||
Objects.requireNonNull(hmacSecret, "hmacSecret must be non-null"), hmacSecret.length);
|
||||
if (this.hmacSecret.length < 32) {
|
||||
throw new IllegalArgumentException(
|
||||
"idempotency scope HMAC secret requires at least 32 bytes");
|
||||
}
|
||||
}
|
||||
|
||||
byte[] physicalKey(IdempotencyScope scope) {
|
||||
if (closed.get()) {
|
||||
throw new IllegalStateException("idempotency key material is closed");
|
||||
}
|
||||
Objects.requireNonNull(scope, "scope must be non-null");
|
||||
RedisKeyDigest digest =
|
||||
RedisKeyDigest.sensitive(
|
||||
namespace.hashKeyVersion(),
|
||||
hmacSecret,
|
||||
List.of(
|
||||
utf8(scope.tenant() == null ? "-" : scope.tenant()),
|
||||
utf8(scope.principal()),
|
||||
utf8(scope.idempotencyKey()),
|
||||
utf8(scope.useCaseName())));
|
||||
return utf8(RedisKeyBuilder.build(namespace, digest));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (closed.compareAndSet(false, true)) {
|
||||
Arrays.fill(hmacSecret, (byte) 0);
|
||||
}
|
||||
}
|
||||
|
||||
boolean destroyed() {
|
||||
return closed.get()
|
||||
&& java.util.stream.IntStream.range(0, hmacSecret.length)
|
||||
.allMatch(index -> hmacSecret[index] == 0);
|
||||
}
|
||||
|
||||
private static byte[] utf8(String value) {
|
||||
return value.getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import java.util.concurrent.locks.ReentrantReadWriteLock;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/** Prevents request-replay commands from racing provider close and HMAC destruction. */
|
||||
final class RedisIdempotencyLifecycle {
|
||||
|
||||
private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
|
||||
private boolean closed;
|
||||
|
||||
<T> T withOpen(Supplier<T> operation) {
|
||||
lock.readLock().lock();
|
||||
try {
|
||||
if (closed) {
|
||||
throw new IllegalStateException("Redis idempotency provider is closed");
|
||||
}
|
||||
return operation.get();
|
||||
} finally {
|
||||
lock.readLock().unlock();
|
||||
}
|
||||
}
|
||||
|
||||
void close(Runnable destroy) {
|
||||
lock.writeLock().lock();
|
||||
try {
|
||||
if (!closed) {
|
||||
destroy.run();
|
||||
closed = true;
|
||||
}
|
||||
} finally {
|
||||
lock.writeLock().unlock();
|
||||
}
|
||||
}
|
||||
|
||||
boolean closed() {
|
||||
lock.readLock().lock();
|
||||
try {
|
||||
return closed;
|
||||
} finally {
|
||||
lock.readLock().unlock();
|
||||
}
|
||||
}
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Executes and fail-closed parses the fixed six-field request-replay program protocol. */
|
||||
final class RedisIdempotencyProgramExecutor {
|
||||
|
||||
private static final long MAXIMUM_EXACT_LUA_INTEGER = 9_007_199_254_740_991L;
|
||||
|
||||
private final RedisProgramCatalog catalog;
|
||||
private final RedisStructuredCommands commands;
|
||||
|
||||
RedisIdempotencyProgramExecutor(RedisProgramCatalog catalog, RedisStructuredCommands commands) {
|
||||
this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null");
|
||||
this.commands = Objects.requireNonNull(commands, "commands must be non-null");
|
||||
}
|
||||
|
||||
RedisIdempotencyProgramReply execute(RedisIdempotencyStoreProvider.ProgramInvocation material) {
|
||||
RedisProgramId id = material.programId();
|
||||
RedisProgramDescriptor descriptor = catalog.descriptor(id);
|
||||
List<byte[]> result =
|
||||
RedisScriptRecovery.evalMulti(commands, catalog.capabilityInvocation(material));
|
||||
if (result == null || result.size() != 6) {
|
||||
throw incompatible(id);
|
||||
}
|
||||
for (byte[] field : result) {
|
||||
if (field == null || field.length > descriptor.maximumReplyFieldBytes()) {
|
||||
throw incompatible(id);
|
||||
}
|
||||
}
|
||||
String status = ascii(result.get(0), id);
|
||||
if (!descriptor.statuses().contains(status)) {
|
||||
throw new RedisProgramCompatibilityException(id, status);
|
||||
}
|
||||
return new RedisIdempotencyProgramReply(
|
||||
status,
|
||||
unsigned(result.get(1), id),
|
||||
unsigned(result.get(2), id),
|
||||
ascii(result.get(3), id),
|
||||
ascii(result.get(4), id),
|
||||
ascii(result.get(5), id));
|
||||
}
|
||||
|
||||
private static String ascii(byte[] value, RedisProgramId id) {
|
||||
for (byte character : value) {
|
||||
if (character < 0x20 || character > 0x7e) {
|
||||
throw incompatible(id);
|
||||
}
|
||||
}
|
||||
return new String(value, StandardCharsets.US_ASCII);
|
||||
}
|
||||
|
||||
private static long unsigned(byte[] value, RedisProgramId id) {
|
||||
String encoded = ascii(value, id);
|
||||
if (!encoded.matches("0|[1-9][0-9]{0,15}")) {
|
||||
throw incompatible(id);
|
||||
}
|
||||
try {
|
||||
long parsed = Long.parseLong(encoded);
|
||||
if (parsed > MAXIMUM_EXACT_LUA_INTEGER) {
|
||||
throw incompatible(id);
|
||||
}
|
||||
return parsed;
|
||||
} catch (NumberFormatException exception) {
|
||||
throw incompatible(id);
|
||||
}
|
||||
}
|
||||
|
||||
private static RedisProgramCompatibilityException incompatible(RedisProgramId id) {
|
||||
return new RedisProgramCompatibilityException(id, "<malformed-idempotency-reply>");
|
||||
}
|
||||
}
|
||||
+10
@@ -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) {}
|
||||
+111
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+77
@@ -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();
|
||||
}
|
||||
}
|
||||
+568
@@ -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();
|
||||
}
|
||||
}
|
||||
+25
@@ -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);
|
||||
}
|
||||
}
|
||||
+27
@@ -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();
|
||||
}
|
||||
}
|
||||
+82
@@ -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);
|
||||
}
|
||||
}
|
||||
+44
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+81
@@ -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>");
|
||||
}
|
||||
}
|
||||
+10
@@ -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) {}
|
||||
+64
@@ -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();
|
||||
}
|
||||
}
|
||||
+21
@@ -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);
|
||||
}
|
||||
}
|
||||
+7
@@ -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;
|
||||
}
|
||||
+20
@@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+87
@@ -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();
|
||||
}
|
||||
}
|
||||
+68
@@ -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();
|
||||
}
|
||||
}
|
||||
+213
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+119
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+56
@@ -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))));
|
||||
}
|
||||
}
|
||||
+51
@@ -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;
|
||||
}
|
||||
}
|
||||
+526
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
@@ -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);
|
||||
}
|
||||
}
|
||||
+4
-45
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+621
@@ -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);
|
||||
}
|
||||
}
|
||||
+17
@@ -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);
|
||||
}
|
||||
+11
@@ -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);
|
||||
}
|
||||
+12
@@ -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();
|
||||
}
|
||||
+73
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+204
@@ -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";
|
||||
};
|
||||
}
|
||||
}
|
||||
+7
@@ -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);
|
||||
}
|
||||
+85
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+123
@@ -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);
|
||||
}
|
||||
}
|
||||
+30
@@ -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());
|
||||
}
|
||||
}
|
||||
+46
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
@@ -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;
|
||||
}
|
||||
}
|
||||
+771
@@ -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);
|
||||
}
|
||||
}
|
||||
+74
@@ -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]";
|
||||
}
|
||||
}
|
||||
+37
@@ -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
Reference in New Issue
Block a user