chore: initialize from backend template 0a6dd0e

This commit is contained in:
DongHyeonka
2026-08-13 20:31:02 +09:00
commit e64e701fe5
3223 changed files with 388401 additions and 0 deletions
+143
View File
@@ -0,0 +1,143 @@
# adapter:outbound:cache-redis — cache and Redis adapter
## Registered identity
- Module ID: `adapter-outbound-cache-redis`
- Gradle path: `:adapter:outbound:cache-redis`
- Focused test (derived from Gradle path): `./gradlew :adapter:outbound:cache-redis:test --console=plain`
- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0.
- Registry SSOT: `src/config/architecture/modules.json`.
Package root: `dev.caskeleton.adapter.outbound.cache`.
## Responsibility
- 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.
- Host the general-purpose Redis SDK under `…cache.redis.sdk` (see below). The SDK is a separate
concern from the semantic cache ports and must not be reached from `application-core`.
## Redis SDK (`…cache.redis.sdk`)
The Redis wrapper and typed API described in
`docs/superpowers/specs/2026-08-07-redis-wrapper-typed-api-design.md` lives inside this leaf. Its
design models the SDK as twelve Gradle modules; this repository's 19-leaf fail-closed registry
outranks that layout, so each designed module is a package instead. Delivery status and the full
adaptation rationale are in
`docs/superpowers/plans/2026-08-07-redis-wrapper-typed-api-status.md`.
- `sdk.api..` is the public contract. It must never import Spring, Lettuce, Micrometer, or any SDK
implementation package; Reactor is confined to `sdk.api.reactive`.
- `sdk.lettuce..` implements the contract; `sdk.config` owns properties, the capability probe, and
the permit authority.
- `src/main/resources/redis-sdk/redis-command-policy.yml` is the command policy SSOT. A command that
is not classified there is refused, so adding a command means editing that file, not the code.
- `sdk.lettuce.operations` implements the typed operations. Everything there goes through
`RedisCommandGateway`, the only seam that reaches Lettuce, and every call is admitted by
`CommandPolicyGuard` before it runs. Never call the driver from an operation directly.
- `sdk.cluster` owns client-side slot arithmetic and cluster observation. It depends on `sdk.api`
only and must never import Lettuce: the slot is computed before a command is built, which is what
lets `CommandPolicyGuard` refuse a cross-slot request instead of learning about it from a server
redirect.
- `sdk.programmability` owns transactions, registered Lua scripts, and deployed function calls. A
script body is never accepted at call time: `EVAL` is blocked in the catalog and only `EVALSHA` of
a `RedisScriptRegistry` digest is reachable. `FUNCTION LOAD` is admin-plane, never application.
- Transactions are `WATCH`/`MULTI`/`EXEC` and **never** roll back. `TransactionResult` reports only
"executed" or "a watched key changed, so nothing ran", and no type in this package offers a word
that suggests otherwise. A queued command returns a `QueuedReply` that throws when read before the
commit, because inside the window the server has answered `+QUEUED` and nothing else. Queued
commands pass the same `CommandPolicyGuard` admission as ordinary ones — a transaction is not a
way around the guard — and the window is closed on every exit path, including a callback that
threw, because a connection abandoned in `MULTI` state silently queues the next caller's command.
The queue is write-only on purpose: a read inside the window cannot be branched on, so the reads a
transaction depends on belong before it, under `WATCH`.
- `sdk.raw` is the approved raw command gateway. A command is reachable only when the catalog marks
it `RAW_ONLY` *and* the deployment registered an `ApprovedRawCommand` for it; keys are parsed back
out of the arguments and namespace-checked before anything is sent. Never add a method here that
takes a command name as a string.
- `sdk.admin` is the read-only diagnostic plane. It takes its own gateway (admin ACL account, own
connection), refuses any command the catalog does not classify `ADMIN_ONLY` and read-only, and
projects replies so a slow log or client listing never carries arguments, peer addresses, or
connection names.
- `sdk.extensions.*` holds the Redis 8 modules (JSON, Search, Time Series, Probabilistic). Every
bean is created through `ifSupported(...)` — the capability probe decides, the catalog minimum is
only a pre-filter — and all of them build commands through `ExtensionCommandRunner` so the guard
sees their keys. Search is the exception that proves it: an index is not a key, so its name is
namespaced by `LettuceRedisSearchOperations` itself.
- `RedisSdkModuleBoundaryTest` enforces the package graph, driver containment, the absence of any
arbitrary string command surface, and the list of designed-but-unimplemented modules. Update
`NOT_YET_IMPLEMENTED_MODULES` when a module lands.
- Decisions that must not be changed without revisiting the design: no unbounded `entries`,
`members`, `rangeAll`, or `keys`; no optional R2 permit or budget; no arbitrary command string
overload; no automatic retry of a non-idempotent write after a timeout; no real key in a metric or
trace tag.
## Boundaries
- Allowed dependency edges come only from the module's
`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.
## Tests
Focused tests use fakes for contract, key, catalog, and typed-facade behavior. R1/R2 promotion
requires a separate real Redis service lane; it may never be silently skipped when selected.
`redisTopologyTest` is the only real-server lane. It is opt-in and fail-closed in seven ways: the
lane must be one of `standalone`, `sentinel`, `cluster`, `tls`; the endpoint properties must be
present (`sentinel` additionally needs `redis.topology.master`, `tls` needs
`redis.topology.trust-material`); a test class carrying the lane's tag must exist; a run that
executes zero tests fails; the classes the lane exists to run must actually have run; the executed
count must reach the lane's declared floor; and a skipped test fails the run rather than counting
as executed. `tls` is a lane, not a deployment mode — its shape is standalone and the task maps it
so, because what it qualifies is the transport.
## Composition
`RedisSdkAutoConfiguration` is the only place Redis settings and Redis runtime come into existence,
and it exists only while `app.redis.enabled` (env `APP_REDIS_ENABLED`) is true. It builds the
client, the connection owner and the health contributors; `RedisCapabilityConfig` in `app-bootstrap`
composes the semantic ports on top, one per role selector. "Redis is on" therefore means the
capabilities that need Redis exist, not merely that Redis is reachable.
Every capability renders its keys under the one namespace from `app.redis.namespace`
`{environment}:{service}:{domain}` — through `CapabilityKeyspace`. Never give a capability its own
prefix tokens: four capabilities each joining two free-form strings produced four different key
shapes, and the deployment's ACL pattern matched none of them.
Lanes authenticate as different accounts. `RedisConnectionKind.credentialRole()` maps the lane to a
`RedisCredentialRole`, and the topology factory builds one client per *configured* role — so a
single-account deployment still gets exactly one client and one event loop. The `SCRIPT` lane is
the reason it exists: `SCRIPT LOAD` and `EVALSHA` belong to the advanced account, so the account
that reads a cache entry cannot execute a script.
A Cluster transaction runs on one node, so `RedisTransactionRunner` derives a routing key from the
watched keys (or an explicit `RedisSlotTag`) and pins the `TRANSACTION` lane to the node that owns
that slot. Routed leases are never pooled: a pooled connection is pinned to the previous caller's
node.
`RedisSdkSettings`
must never carry a class-level `@ConfigurationProperties`: the bootstrap's application-wide
`@ConfigurationPropertiesScan` would then register it in every deployment, so a service that runs
no Redis would bind Redis configuration. `RedisOptionalityContractTest` in `app-bootstrap` enforces
that. Role selectors (cache binding, session auth-mode, idempotency/lease/rate-limit provider)
choose which capabilities compose; none of them activates Redis, and selecting one while the global
switch is off is refused by `RedisActivationValidator`.
+381
View File
@@ -0,0 +1,381 @@
# adapter:outbound:cache-redis — 설계 결정 참조
캐시/Redis 기술 capability 아웃바운드 모듈. 패키지 루트:
`dev.caskeleton.adapter.outbound.cache`. `application-core`의 provider-neutral cache contract를
구현할 수 있는 경계와 Redis physical key/atomic-program 기반을 소유한다.
허용/금지 의존 정책은 `src/config/architecture/modules.json`
`adapter-outbound-cache-redis` 항목이 SSOT다. 상세 목표와 미구현 단계는
`docs/superpowers/specs/2026-07-26-redis-production-capability-design.md`에 있다.
## 현재 readiness
readiness는 서로 다른 세 가지 질문이며 하나로 합치면 안 된다. "코드가 있다"는 "Spring이
조립한다"가 아니고, 그 둘 다 "실서버에서 증명됐다"가 아니다. 이 표를 한 축으로 읽으면 아직
존재하지 않는 wiring을 제공 기능으로 오독하게 된다.
| 축 | 뜻 | 증거 |
| --- | --- | --- |
| **API 구현** | 타입·정책·contract test가 존재한다 | `:adapter:outbound:cache-redis:test` |
| **Spring composition 구현** | `APP_REDIS_ENABLED=true`에서 실제 bean이 조립된다 | `RedisSdkAutoConfigurationTest` |
| **실서버 qualification** | 지원 topology·버전에서 실제 서버로 증명됐다 | `redisTopologyTest` lane evidence |
| Capability | API 구현 | Spring composition 구현 | 실서버 qualification |
| --- | --- | --- | --- |
| Redis SDK typed API (`…cache.redis.sdk`) | 있음 | settings bind + validate 까지만 | 없음 |
| Topology client / connection lifecycle | 없음 | 없음 | 없음 |
| cache / session / idempotency / rate limit / lease semantic port | 없음 | 없음 | 없음 |
| role-aware health·readiness contributor | 없음 | 없음 | 없음 |
즉 현재 `APP_REDIS_ENABLED=true`가 하는 일은 `RedisSdkSettings`를 bind하고 cross-field 규칙을
fail-fast로 검증하는 것까지다. client, connection, gateway, semantic adapter, health contributor는
아직 조립되지 않는다. 남은 단계와 순서는
`docs/superpowers/plans/2026-08-10-redis-optionality-and-composition.md`에 있다.
readiness registry에도 `selected` card가 없으므로 Redis R2 release claim은 없다. 아래 절들은
이전 세대 semantic adapter 세트의 설계 결정을 기록한 것이며, 그 코드는 현재 이 leaf에 없다.
복구 범위는 위 plan의 Phase E가 소유한다.
모듈은 Lettuce connection lifecycle,
finite command timeout, reconnect replay 차단, finite request queue/admission, positive/negative
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.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
`application-core``CacheRegionPort<K,V>`는 다음을 분리한다.
- fresh/stale positive hit;
- authoritative negative hit;
- normal absent/expired/invalidated miss;
- incompatible schema;
- unavailable/overloaded와 operation certainty;
- recorded/conditional/degraded/indeterminate mutation;
- invalidated/already-absent/degraded/indeterminate invalidation.
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를 만든다.
```text
ca:<app>:<env>:<capability>:<region>:hv<hashVersion>:kv<keyVersion>:{<slot>}:<digest>:<kind>
```
민감한 사용자/tenant/composite 값은 raw key에 넣지 않는다. length-prefixed canonical bytes를
HMAC-SHA-256으로 digest한다. random opaque identifier는 SHA-256을 사용할 수 있다. builder는 slug,
version, 정확히 하나인 hash tag와 전체 UTF-8 byte bound를 검증한다.
## Atomic program foundation
`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를 사용한다.
이 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`일 때만 catalog script를 `SCRIPT LOAD`한다. 반환 digest가 예상 identity와
같은지 확인한 뒤 `EVALSHA`를 한 번만 재시도한다. signature/argument bounds는
client 호출 전에 다시 검증하고 descriptor catalog membership 및 반환 status membership을
확인한다. 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`을 명시해야 한다. 이 선택을
명시함으로써 Spring configuration 처리 순서에 따라 managed/custom client 선택이 달라지지 않는다.
Managed runtime은 reconnect 시 pending command를 replay하지 않고, disconnected command를
pre-send 거부하며, request queue와 동시 outstanding command를 같은 finite bound로 제한한다.
`RedisStringCacheRegion``CacheRegionPort<String,String>` bean으로 제공되며 다음 결과를
구분한다.
- positive hit, authoritative negative hit, normal miss;
- unknown/corrupt/retired envelope와 fail-fast future envelope;
- read unavailable/overloaded와 mutation not-applied/indeterminate;
- invalidated와 already absent.
opaque source revision에는 대소 비교 의미가 없으므로
`ONLY_IF_SOURCE_REVISION_NEWER`는 임의 lexical comparison을 하지 않고
`NOT_RECORDED_PROVIDER_POLICY`를 반환한다.
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`이다. 최대 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`이고, 설정 검증은
두 번째 항이 첫 번째 항을 넘지 않게 해 최대 약 2배 population으로 제한한다.
read는 raw `GET`을 사용하지 않는다. 고정 Lua read가 `GETRANGE(0, maximum-envelope-bytes)`
Redis가 wire에 내보내는 bulk reply 자체를 `maximum-envelope-bytes + 1` 이하로 자르고, 초과하면
작은 오류 응답으로 바꾼다. 따라서 다른 writer가 같은 물리 키를 오염시켜도 전체 대용량 value를
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`는 호환성을 위해 남아 있다. 이
경로는 `Optional.empty()`로 miss와 backend failure를 합친다. managed runtime을 사용할 때
legacy `put`에도 positive TTL을 적용하지만, 사용자 제공 legacy client의 TTL은 보장할 수 없으므로
새 semantic cache port 구현의 기준으로 사용하지 않는다.
## Verification
이 leaf가 실제로 가진 task는 `test`, `check`, `redisTopologyTest` 세 개다.
```bash
cd src
./gradlew :adapter:outbound:cache-redis:test --console=plain
./gradlew :application-core:check :adapter:outbound:cache-redis:check --console=plain
```
Topology lane은 opt-in이며 fail-closed다. mode는 `standalone`, `sentinel`, `cluster`만 허용하고,
알 수 없는 mode·endpoint 누락·해당 lane tag를 가진 test class 부재·실행 test 0건은 모두 실패다.
(이전에는 오타 mode가 tag를 아무것도 매칭하지 못해 test 0건으로 `BUILD SUCCESSFUL`이 났다.)
```bash
./gradlew :adapter:outbound:cache-redis:redisTopologyTest \
-Predis.topology.host=127.0.0.1 -Predis.topology.port=6379 \
-Predis.topology.mode=standalone --console=plain
# sentinel lane은 -Predis.topology.master=<master-name> 을 추가로 요구한다.
```
@@ -0,0 +1,202 @@
// Redis SDK leaf — see docs/superpowers/specs/2026-08-07-redis-wrapper-typed-api-design.md.
//
// The design models the SDK as separate Gradle modules. This repository's fail-closed 19-leaf
// registry outranks that layout, so the module boundaries are packages under
// dev.caskeleton.adapter.outbound.cache.redis.sdk and RedisSdkModuleBoundaryTest enforces them.
dependencies {
// Registered edges the semantic port adapters need. The SDK itself imports nothing from them
// today (0 imports across main source) — the semantic cache/session/idempotency/rate-limit
// adapters that did were removed and are restored by Phase E of
// docs/superpowers/plans/2026-08-10-redis-optionality-and-composition.md. They stay declared
// because that restoration is the module's stated responsibility, not because anything here
// compiles against them.
implementation project(':application-core')
implementation project(':shared-contract')
implementation project(':adapter:outbound:support')
implementation 'org.springframework.boot:spring-boot-autoconfigure'
// The role-aware health contributors are HealthIndicators; the readiness probe is the only
// place the required/optional Redis taxonomy can actually be enforced.
implementation 'org.springframework.boot:spring-boot-health'
// Boot's Health type carries Jackson annotations. Without the annotations on the compile
// classpath javac emits an 'unknown enum constant' warning, and this build is -Werror. Runtime
// does not need it from here — the app already has Jackson — so compileOnly is the honest scope.
compileOnly 'com.fasterxml.jackson.core:jackson-annotations'
implementation 'io.lettuce:lettuce-core'
// Reactor is in the public signature of sdk.api.reactive, and it was reaching this module only
// transitively through lettuce-core. A driver upgrade that stopped exposing it would have
// broken compilation of the SDK's own published API, so it is declared directly.
implementation 'io.projectreactor:reactor-core'
implementation 'org.slf4j:slf4j-api'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
// Deliberately absent:
// org.springframework.data:spring-data-redis — the SDK owns its own typed API and command
// policy on purpose; routing through Spring Data would reintroduce the untyped, unguarded
// command surface the catalog exists to prevent. Zero imports.
// io.micrometer:micrometer-core — observation leaves this leaf as RedisObservation through a
// Consumer sink; binding it to a meter registry belongs to the composition root, not here.
// Zero imports.
}
tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' }
// The topology lane is opt-in and fail-closed. The default unit task excludes it, and selecting it
// without an endpoint is an error rather than a skip: a topology test that silently passes because
// it never connected is worse than not having one.
tasks.named('test') {
useJUnitPlatform {
excludeTags 'redis-topology'
}
}
// Lane selection is derived from the declared mode rather than chosen by hand. A promotion test is
// meaningless without sentinels and a cross-slot test is meaningless without a cluster, but writing
// that as a runtime assumption would turn "the lane was never started" into a green skip. Selecting
// by tag keeps the lane fail-closed: what a mode cannot prove is not selected, and what is selected
// must pass.
//
// The mode is an allowlist, not free text. Deriving the tag from an arbitrary property produced the
// worst possible result for a qualification lane: `-Predis.topology.mode=TYPO` built the tag
// `lane-typo`, matched nothing, ran zero tests and exited 0. A release gate that reports success
// for a lane it never ran is worse than no gate, so an unknown mode is an error and a run that
// executed no test is a failure.
// `tls` is a lane, not a deployment mode. Its shape is standalone; what it qualifies is the
// transport, which no other lane carries a single command over. It was reachable only by hand —
// point LiveRedisCompositionTest at the TLS compose with an ad-hoc init script — which is another
// way of saying the release gate did not cover TLS at all.
def REDIS_TOPOLOGY_MODES = ['standalone', 'sentinel', 'cluster', 'tls'] as Set
def REDIS_TOPOLOGY_DEPLOYMENT_MODE = ['standalone': 'standalone', 'sentinel': 'sentinel',
'cluster': 'cluster', 'tls': 'standalone']
// The classes each lane exists to run, and the floor below which its coverage has shrunk. Both are
// declarations rather than observations: a lane that lost a class to a rename, or lost half its
// cases to a filter, otherwise still reports success.
def REDIS_TOPOLOGY_REQUIRED_CLASSES = [
'standalone': ['LiveRedisCompositionTest', 'LiveRedisSemanticPortsTest',
'RedisTopologyContractTest', 'LiveRedisGuardrailTest'],
'sentinel' : ['LiveRedisCompositionTest', 'LiveRedisSentinelPromotionTest',
'RedisTopologyContractTest'],
'cluster' : ['LiveRedisCompositionTest', 'LiveRedisClusterTest',
'LiveRedisClusterTransactionTest', 'LiveRedisSemanticPortsTest'],
'tls' : ['LiveRedisTlsTest'],
]
def REDIS_TOPOLOGY_MINIMUM_TESTS = ['standalone': 20, 'sentinel': 20, 'cluster': 24, 'tls': 4]
tasks.register('redisTopologyTest', Test) {
description = 'Runs the Redis SDK contracts against a real topology declared in infra/redis-sdk.'
group = 'verification'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
// Never up to date. This task's result depends on a server outside the build, so Gradle's
// inputs say nothing about whether it would still pass: re-running it against a lane that was
// restarted, reconfigured, or promoted reports the previous run's verdict as the current one.
// That is the same silent-pass failure mode the fail-closed endpoint check exists to prevent.
outputs.upToDateWhen { false }
def declaredMode = (project.findProperty('redis.topology.mode') ?: 'unset').toString().toLowerCase()
useJUnitPlatform {
includeTags "redis-topology & lane-${declaredMode}".toString()
}
// A filter that matches nothing is a configuration mistake, never a pass.
failOnNoDiscoveredTests = true
['redis.topology.host', 'redis.topology.port',
'redis.topology.master', 'redis.topology.username', 'redis.topology.password',
'redis.topology.trust-material']
.each { key ->
if (project.hasProperty(key)) {
systemProperty key, project.property(key)
}
}
// The lane name and the deployment mode are different things, and only the TLS lane makes that
// visible: its shape is standalone, so the tests must see `standalone` while the tag filter and
// the required properties come from the lane. Passing the lane name through as the mode would
// fail RedisDeploymentMode.valueOf on a value that is not a topology.
systemProperty 'redis.topology.mode', REDIS_TOPOLOGY_DEPLOYMENT_MODE.getOrDefault(declaredMode, declaredMode)
systemProperty 'redis.topology.tls', (declaredMode == 'tls').toString()
// Executed, not merely reported. `afterTest` fires for a skipped test too, so counting every
// callback meant a lane whose tests all skipped could still satisfy the "ran something" check —
// the exact green-for-nothing this gate exists to prevent, one level further in.
def executed = new java.util.concurrent.atomic.AtomicInteger()
def skipped = new java.util.concurrent.atomic.AtomicInteger()
def classes = java.util.Collections.synchronizedSet(new java.util.LinkedHashSet<String>())
afterTest { descriptor, result ->
if (result.resultType == org.gradle.api.tasks.testing.TestResult.ResultType.SKIPPED) {
skipped.incrementAndGet()
} else {
executed.incrementAndGet()
classes.add(descriptor.className.tokenize('.').last())
}
}
doFirst {
if (!REDIS_TOPOLOGY_MODES.contains(declaredMode)) {
throw new GradleException(
"redisTopologyTest was selected with redis.topology.mode='${declaredMode}'; " +
'the supported modes are ' + REDIS_TOPOLOGY_MODES.sort().join(', ') +
'. An unrecognised mode selects no test and would otherwise report success.')
}
def required = ['redis.topology.host', 'redis.topology.port']
if (declaredMode == 'sentinel') {
required += 'redis.topology.master'
}
if (declaredMode == 'tls') {
// Without the trust material the client would have to disable verification to connect,
// and a TLS lane that trusts anything qualifies nothing.
required += 'redis.topology.trust-material'
}
def missing = required.findAll { !project.hasProperty(it) }
if (!missing.isEmpty()) {
throw new GradleException(
'redisTopologyTest was selected without ' + missing.join(', ') +
'; start a lane from infra/redis-sdk and pass -P<key>=<value>.')
}
// The lane's tag must actually exist in the compiled suite. failOnNoDiscoveredTests catches
// an empty run, but this names the cause — a renamed or deleted lane class — instead of
// leaving an operator to guess whether the filter or the server is at fault.
def laneTag = "lane-${declaredMode}"
def tagged = sourceSets.test.allJava.matching { include '**/*.java' }.files.any { file ->
def text = file.text
text.contains('@Tag("redis-topology")') && text.contains("@Tag(\"${laneTag}\")")
}
if (!tagged) {
throw new GradleException(
"redisTopologyTest found no test class tagged 'redis-topology' and " +
"'${laneTag}'. The ${declaredMode} lane has no coverage to run, so a green " +
'result would prove nothing.')
}
}
doLast {
if (executed.get() < 1) {
throw new GradleException(
"redisTopologyTest completed without executing a single test for the " +
"${declaredMode} lane. A qualification lane that runs nothing must not report " +
'success.')
}
// What a lane must cover, named rather than counted by accident. A tag filter matching one
// trivial class satisfied "ran something" while the class the lane exists for had been
// renamed out of the filter, and nothing said so.
def required = REDIS_TOPOLOGY_REQUIRED_CLASSES[declaredMode]
def absent = required.findAll { !classes.contains(it) }
if (!absent.isEmpty()) {
throw new GradleException(
"redisTopologyTest ran the ${declaredMode} lane without ${absent.join(', ')}. " +
'These classes are what the lane qualifies; a run that skipped them proves ' +
'less than the lane claims.')
}
def floor = REDIS_TOPOLOGY_MINIMUM_TESTS[declaredMode]
if (executed.get() < floor) {
throw new GradleException(
"redisTopologyTest executed ${executed.get()} tests for the ${declaredMode} " +
"lane, below the declared floor of ${floor}. Coverage that silently shrank is " +
'a gate that silently weakened.')
}
if (skipped.get() > 0) {
throw new GradleException(
"redisTopologyTest skipped ${skipped.get()} test(s) on the ${declaredMode} " +
'lane. A qualification lane has no conditional coverage: what it cannot prove ' +
'must not be selected, and what is selected must run.')
}
logger.lifecycle("redisTopologyTest: ${declaredMode} lane executed ${executed.get()} tests.")
}
}
@@ -0,0 +1,166 @@
# 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=compileClasspath,testCompileClasspath,testRuntimeClasspath
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
com.github.spotbugs:spotbugs-annotations:4.8.6=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.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_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.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.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath
commons-beanutils:commons-beanutils:1.11.0=checkstyle
commons-collections:commons-collections:3.2.2=checkstyle
commons-io:commons-io:2.21.0=spotbugs
commons-logging:commons-logging:1.3.5=compileClasspath,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.17.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-codec-base:4.2.17.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-codec-dns:4.2.17.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-common:4.2.17.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-handler:4.2.17.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-resolver-dns:4.2.17.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-resolver:4.2.17.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-transport-native-unix-common:4.2.17.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-transport:4.2.17.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
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.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-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-core:2.25.2=spotbugs
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=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.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.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.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.junit:junit-bom:6.1.0=spotbugs
org.mockito:mockito-core:5.20.0=mockitoAgent,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.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.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.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-configuration-processor:4.0.0=annotationProcessor
org.springframework.boot:spring-boot-health:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
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.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
empty=
@@ -0,0 +1,213 @@
package dev.caskeleton.adapter.outbound.cache.redis.cache;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Objects;
/**
* What a cache entry actually stores, beside the value.
*
* <p>A cache that stores only the value cannot answer the questions a correct cache-aside needs:
* whether the entry is still fresh, whether it is merely stale but usable, which source revision it
* came from, and whether the schema is one this deployment can still read. All four live here, so
* every one of them is a field the reader checks rather than an assumption it makes.
*
* <p>Soft and hard expiry are separate and absolute. Soft is when the entry stops being fresh and a
* background refresh should happen; hard is when it stops being usable at all. Keeping both as
* instants — rather than as a TTL the writer computed — means a reader can tell the difference
* without knowing when the entry was written, which matters because the physical Redis TTL is set
* from the hard expiry and nothing else.
*
* <p>The framing is a fixed pipe-delimited header followed by the payload. Deliberately not JSON: a
* cache read is on the hot path, the fields are all bounded scalars, and a parser that can only
* fail one way is easier to reason about than one that can fail many.
*
* <p>A class rather than a record because the payload is a byte array, and the repository's
* static-analysis contract forbids array record components — the same reason {@code RedisEnvelope}
* in the SDK is a class.
*/
final class CacheEnvelope {
/** The layout this deployment writes. */
static final int CURRENT_SCHEMA_VERSION = 1;
private static final char SEPARATOR = '|';
private final int schemaVersion;
private final String sourceRevision;
private final long generation;
private final Instant softExpiresAt;
private final Instant hardExpiresAt;
private final String absence;
private final byte[] payload;
CacheEnvelope(
int schemaVersion,
String sourceRevision,
long generation,
Instant softExpiresAt,
Instant hardExpiresAt,
String absence,
byte[] payload) {
Objects.requireNonNull(sourceRevision, "source revision must be non-null");
Objects.requireNonNull(softExpiresAt, "soft expiry must be non-null");
Objects.requireNonNull(hardExpiresAt, "hard expiry must be non-null");
Objects.requireNonNull(absence, "absence must be non-null");
Objects.requireNonNull(payload, "payload must be non-null");
if (sourceRevision.indexOf(SEPARATOR) >= 0 || absence.indexOf(SEPARATOR) >= 0) {
// A separator inside a field would shift every field after it, and the reader would decode a
// different entry than the writer wrote — silently, because the result still parses.
throw new IllegalArgumentException("an envelope field must not contain the separator");
}
if (softExpiresAt.isAfter(hardExpiresAt)) {
throw new IllegalArgumentException(
"the soft expiry must not be after the hard expiry: an entry cannot stop being fresh"
+ " after it has stopped being usable");
}
this.schemaVersion = schemaVersion;
this.sourceRevision = sourceRevision;
this.generation = generation;
this.softExpiresAt = softExpiresAt;
this.hardExpiresAt = hardExpiresAt;
this.absence = absence;
this.payload = payload.clone();
}
int schemaVersion() {
return schemaVersion;
}
String sourceRevision() {
return sourceRevision;
}
long generation() {
return generation;
}
Instant softExpiresAt() {
return softExpiresAt;
}
Instant hardExpiresAt() {
return hardExpiresAt;
}
String absence() {
return absence;
}
byte[] payload() {
return payload.clone();
}
byte[] encode() {
String header =
schemaVersion
+ "|"
+ sourceRevision
+ "|"
+ generation
+ "|"
+ softExpiresAt.toEpochMilli()
+ "|"
+ hardExpiresAt.toEpochMilli()
+ "|"
+ absence
+ "|";
byte[] head = header.getBytes(StandardCharsets.UTF_8);
byte[] encoded = new byte[head.length + payload.length];
System.arraycopy(head, 0, encoded, 0, head.length);
System.arraycopy(payload, 0, encoded, head.length, payload.length);
return encoded;
}
/**
* Decodes stored bytes.
*
* @param stored the stored entry
* @return the decoded envelope
* @throws CacheEnvelopeException when the bytes are not an envelope this deployment can read
*/
static CacheEnvelope decode(byte[] stored) {
if (stored == null || stored.length == 0) {
throw new CacheEnvelopeException(CacheEnvelopeException.Category.CORRUPT, "empty entry");
}
int fields = 0;
int cursor = 0;
int[] boundaries = new int[6];
while (cursor < stored.length && fields < 6) {
if (stored[cursor] == (byte) SEPARATOR) {
boundaries[fields++] = cursor;
}
cursor++;
}
if (fields < 6) {
throw new CacheEnvelopeException(
CacheEnvelopeException.Category.UNKNOWN, "the entry does not carry an envelope header");
}
String header = new String(stored, 0, boundaries[5], StandardCharsets.UTF_8);
String[] parts = header.split("\\|", -1);
int version;
try {
version = Integer.parseInt(parts[0]);
} catch (NumberFormatException failure) {
throw new CacheEnvelopeException(
CacheEnvelopeException.Category.UNKNOWN, "the schema version is not a number");
}
if (version > CURRENT_SCHEMA_VERSION) {
// Written by a newer deployment. Reading it with this layout would decode the wrong fields,
// and treating it as a miss would let this instance overwrite a newer writer's entry.
throw new CacheEnvelopeException(
CacheEnvelopeException.Category.FUTURE, "schema version " + version);
}
if (version < CURRENT_SCHEMA_VERSION) {
throw new CacheEnvelopeException(
CacheEnvelopeException.Category.RETIRED, "schema version " + version);
}
try {
byte[] payload = new byte[stored.length - boundaries[5] - 1];
System.arraycopy(stored, boundaries[5] + 1, payload, 0, payload.length);
return new CacheEnvelope(
version,
parts[1],
Long.parseLong(parts[2]),
Instant.ofEpochMilli(Long.parseLong(parts[3])),
Instant.ofEpochMilli(Long.parseLong(parts[4])),
parts[5],
payload);
} catch (RuntimeException failure) {
throw new CacheEnvelopeException(
CacheEnvelopeException.Category.CORRUPT, "the envelope header could not be read");
}
}
/** A stored entry this deployment must not treat as an ordinary miss. */
static final class CacheEnvelopeException extends RuntimeException {
private static final long serialVersionUID = 1L;
/** Why the entry could not be read. */
enum Category {
/** Written by a newer schema than this deployment knows. */
FUTURE,
/** Written by a schema this deployment has retired. */
RETIRED,
/** Not an envelope at all. */
UNKNOWN,
/** An envelope whose header does not parse. */
CORRUPT
}
private final transient Category category;
CacheEnvelopeException(Category category, String reason) {
super(reason);
this.category = category;
}
Category category() {
return category;
}
}
}
@@ -0,0 +1,428 @@
package dev.caskeleton.adapter.outbound.cache.redis.cache;
import dev.caskeleton.adapter.outbound.cache.redis.keyspace.CapabilityKeyspace;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.key.RedisNamespace;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisConnectionKind;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisLease;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisRuntimeOwner;
import dev.caskeleton.application.cache.AuthoritativeAbsence;
import dev.caskeleton.application.cache.CacheInvalidationOutcome;
import dev.caskeleton.application.cache.CacheLookup;
import dev.caskeleton.application.cache.CacheObservationToken;
import dev.caskeleton.application.cache.CacheRecordIntent;
import dev.caskeleton.application.cache.CacheRecordMetadata;
import dev.caskeleton.application.cache.CacheRecordOutcome;
import dev.caskeleton.application.cache.CacheRegionPort;
import dev.caskeleton.application.cache.CacheWriteCondition;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.HexFormat;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
/**
* The semantic cache region, on Redis.
*
* <p>The only port in this leaf that may degrade rather than fail. A cache exists to make things
* faster, so an unreachable cache means slower, not broken — every failure path returns a miss or
* {@code DEGRADED_UNAVAILABLE} and the caller falls back to the source. That licence is specific to
* this port and must never be copied to session, idempotency, rate limit, or lease, where the same
* behaviour would mean serving without the guarantee the caller asked for.
*
* <p>Three things this adapter refuses to treat as an ordinary miss, because each of them means the
* opposite of "nothing is cached":
*
* <ul>
* <li>An entry written by a <em>newer</em> schema. Reporting a miss would let this older instance
* overwrite a newer writer's entry, and the two would fight for the key.
* <li>A corrupt or unrecognised entry. It is evidence of a bug or a foreign writer, and
* swallowing it hides both.
* <li>An authoritative absence. "The source says this does not exist" is a cached fact, not the
* absence of one, and collapsing it into a miss defeats the negative caching it exists for.
* </ul>
*
* <p>The physical TTL equals the hard expiry, always. A cache whose Redis TTL outlives its own
* notion of usability accumulates entries nothing will ever read; one whose TTL is shorter throws
* away entries that are still valid.
*
* @param <K> the semantic key type
* @param <V> the cached value type
*/
public final class RedisCacheRegionAdapter<K, V> implements CacheRegionPort<K, V> {
private final RedisRuntimeOwner owner;
private final CacheKeys keys;
private final Function<K, String> keyDigest;
private final Function<V, byte[]> encoder;
private final Function<byte[], V> decoder;
private final Clock clock;
private final Duration softTtl;
private final Duration hardTtl;
private final Duration negativeTtl;
private final Duration commandTimeout;
/**
* Creates the adapter.
*
* @param owner the Redis runtime owner leases come from
* @param keys renders the private physical keys this region owns
* @param keyDigest turns a semantic key into the opaque digest that reaches Redis
* @param encoder encodes a value
* @param decoder decodes a value
* @param clock the clock expiries are measured against
* @param softTtl how long an entry stays fresh
* @param hardTtl how long an entry stays usable
* @param negativeTtl how long an authoritative absence is cached
* @param commandTimeout the ceiling on one cache operation
*/
public RedisCacheRegionAdapter(
RedisRuntimeOwner owner,
CacheKeys keys,
Function<K, String> keyDigest,
Function<V, byte[]> encoder,
Function<byte[], V> decoder,
Clock clock,
Duration softTtl,
Duration hardTtl,
Duration negativeTtl,
Duration commandTimeout) {
this.owner = Objects.requireNonNull(owner, "runtime owner must be non-null");
this.keys = Objects.requireNonNull(keys, "keys must be non-null");
this.keyDigest = Objects.requireNonNull(keyDigest, "key digest must be non-null");
this.encoder = Objects.requireNonNull(encoder, "encoder must be non-null");
this.decoder = Objects.requireNonNull(decoder, "decoder must be non-null");
this.clock = Objects.requireNonNull(clock, "clock must be non-null");
this.softTtl = Objects.requireNonNull(softTtl, "soft TTL must be non-null");
this.hardTtl = Objects.requireNonNull(hardTtl, "hard TTL must be non-null");
this.negativeTtl = Objects.requireNonNull(negativeTtl, "negative TTL must be non-null");
this.commandTimeout =
Objects.requireNonNull(commandTimeout, "command timeout must be non-null");
if (softTtl.compareTo(hardTtl) > 0) {
throw new IllegalArgumentException(
"the soft TTL must not exceed the hard TTL: an entry cannot stop being fresh after it has"
+ " stopped being usable");
}
}
@Override
public CacheLookup<V> lookup(K key) {
Objects.requireNonNull(key, "key must be non-null");
Instant now = clock.instant();
try (RedisLease lease = owner.borrow(RedisConnectionKind.REGULAR)) {
resolveGeneration(lease);
byte[] stored =
lease
.gateway()
.get(keys.entryKey(keyDigest.apply(key)))
.toCompletableFuture()
.get(commandTimeout.toNanos(), TimeUnit.NANOSECONDS);
if (stored == null) {
return new CacheLookup.Miss<>(
CacheLookup.MissReason.ABSENT, CacheWriteCondition.unavailable());
}
return interpret(stored, now);
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
return unavailable();
} catch (Exception failure) {
// Degraded, not broken. The caller loads from the source and carries on.
return unavailable();
}
}
private CacheLookup<V> interpret(byte[] stored, Instant now) {
CacheEnvelope envelope;
try {
envelope = CacheEnvelope.decode(stored);
} catch (CacheEnvelope.CacheEnvelopeException failure) {
CacheLookup.SchemaCategory category =
switch (failure.category()) {
case FUTURE -> CacheLookup.SchemaCategory.FUTURE_VERSION;
case RETIRED -> CacheLookup.SchemaCategory.RETIRED_VERSION;
case UNKNOWN -> CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE;
case CORRUPT -> CacheLookup.SchemaCategory.CORRUPT_ENVELOPE;
};
// A future schema is quarantined and reloaded — overwriting it would start a fight with the
// newer writer. Everything else fails fast, because a corrupt or foreign entry is evidence
// of a defect and reloading over it hides the defect.
return new CacheLookup.IncompatibleSchema<V>(
category,
category == CacheLookup.SchemaCategory.FUTURE_VERSION
? CacheLookup.SchemaPolicy.QUARANTINE_AND_RELOAD
: CacheLookup.SchemaPolicy.FAIL_FAST);
}
if (envelope.generation() != keys.generation()) {
// Written before the region was invalidated. The entry is still physically there and will
// expire on its own; semantically it no longer exists.
return new CacheLookup.Miss<>(
CacheLookup.MissReason.INVALIDATED, CacheWriteCondition.unavailable());
}
if (!envelope.hardExpiresAt().isAfter(now)) {
return new CacheLookup.Miss<>(
CacheLookup.MissReason.EXPIRED, CacheWriteCondition.unavailable());
}
if (!envelope.absence().isEmpty()) {
return new CacheLookup.NegativeHit<>(
AuthoritativeAbsence.valueOf(envelope.absence()), envelope.hardExpiresAt());
}
return new CacheLookup.Hit<>(
decoder.apply(envelope.payload()),
envelope.softExpiresAt().isAfter(now)
? CacheLookup.Freshness.FRESH
: CacheLookup.Freshness.STALE,
envelope.sourceRevision(),
envelope.softExpiresAt(),
envelope.hardExpiresAt(),
observationOf(stored),
// The condition is the observation: recording only if the entry has not changed since is
// what stops a slow source load from overwriting a newer one.
new CacheWriteCondition(observationOf(stored).value()));
}
@Override
public CacheRecordOutcome record(K key, V value, CacheRecordMetadata metadata) {
Objects.requireNonNull(value, "value must be non-null");
return write(key, encoder.apply(value), "", metadata, hardTtl);
}
@Override
public CacheRecordOutcome recordAbsent(
K key, AuthoritativeAbsence reason, CacheRecordMetadata metadata) {
Objects.requireNonNull(reason, "reason must be non-null");
// A negative entry gets its own, shorter lifetime. Caching "does not exist" for as long as a
// real value would keep a resource invisible long after it was created.
return write(key, new byte[0], reason.name(), metadata, negativeTtl);
}
private CacheRecordOutcome write(
K key, byte[] payload, String absence, CacheRecordMetadata metadata, Duration ttl) {
Objects.requireNonNull(key, "key must be non-null");
Objects.requireNonNull(metadata, "metadata must be non-null");
Instant now = clock.instant();
Duration effectiveSoft = softTtl.compareTo(ttl) > 0 ? ttl : softTtl;
byte[] physicalKey = keys.entryKey(keyDigest.apply(key));
try (RedisLease lease = owner.borrow(RedisConnectionKind.REGULAR)) {
resolveGeneration(lease);
if (metadata.intent() == CacheRecordIntent.ONLY_IF_OBSERVED) {
// Conditional replacement. Without it, a source load that started before a concurrent
// write finishes after it, and the older value wins.
byte[] current =
lease
.gateway()
.get(physicalKey)
.toCompletableFuture()
.get(commandTimeout.toNanos(), TimeUnit.NANOSECONDS);
if (current == null || !observationOf(current).equals(metadata.observedToken())) {
return CacheRecordOutcome.NOT_RECORDED_CONDITION;
}
}
CacheEnvelope envelope =
new CacheEnvelope(
CacheEnvelope.CURRENT_SCHEMA_VERSION,
metadata.sourceRevision(),
keys.generation(),
now.plus(effectiveSoft),
now.plus(ttl),
absence,
payload);
Boolean applied =
lease
.gateway()
.set(
physicalKey,
envelope.encode(),
presenceOf(metadata.intent()),
// The physical TTL is the hard expiry and nothing else.
new dev.caskeleton.adapter.outbound.cache.redis.sdk.api.operations.Expiration
.After(ttl))
.toCompletableFuture()
.get(commandTimeout.toNanos(), TimeUnit.NANOSECONDS);
return Boolean.TRUE.equals(applied)
? CacheRecordOutcome.RECORDED
: CacheRecordOutcome.NOT_RECORDED_CONDITION;
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
return CacheRecordOutcome.DEGRADED_UNAVAILABLE;
} catch (Exception failure) {
return CacheRecordOutcome.DEGRADED_UNAVAILABLE;
}
}
private static dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.operations.WritePresence
presenceOf(CacheRecordIntent intent) {
return intent == CacheRecordIntent.ONLY_IF_ABSENT
? dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.operations.WritePresence.IF_ABSENT
: dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.operations.WritePresence.ALWAYS;
}
@Override
public CacheInvalidationOutcome invalidate(K key) {
Objects.requireNonNull(key, "key must be non-null");
try (RedisLease lease = owner.borrow(RedisConnectionKind.REGULAR)) {
byte[] removed =
lease
.gateway()
.getAndDelete(keys.entryKey(keyDigest.apply(key)))
.toCompletableFuture()
.get(commandTimeout.toNanos(), TimeUnit.NANOSECONDS);
return removed == null
? CacheInvalidationOutcome.ALREADY_ABSENT
: CacheInvalidationOutcome.INVALIDATED;
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
return CacheInvalidationOutcome.DEGRADED_UNAVAILABLE;
} catch (Exception failure) {
return CacheInvalidationOutcome.DEGRADED_UNAVAILABLE;
}
}
@Override
public CacheInvalidationOutcome invalidateRegion() {
// A generation bump, not a key scan. Scanning a keyspace to delete a region is O(keyspace) on
// a server that is answering everything else at the same time, and the SDK blocks KEYS for
// exactly that reason. Bumping the generation makes every older entry fail the generation
// check on read and expire on its own schedule.
try (RedisLease lease = owner.borrow(RedisConnectionKind.REGULAR)) {
Long generation =
lease
.gateway()
.incrementBy(keys.generationKey(), 1)
.toCompletableFuture()
.get(commandTimeout.toNanos(), TimeUnit.NANOSECONDS);
keys.observeGeneration(generation);
return CacheInvalidationOutcome.INVALIDATED;
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
return CacheInvalidationOutcome.DEGRADED_UNAVAILABLE;
} catch (Exception failure) {
return CacheInvalidationOutcome.DEGRADED_UNAVAILABLE;
}
}
/**
* Reads the region generation from the server the first time it is needed.
*
* <p>The generation is the server's, not this process's. A local default would make the first
* region invalidation a no-op — the counter starts absent, {@code INCRBY} returns 1, and an entry
* written under a locally assumed 1 would still match. Resolving it with a zero increment reads
* the current value and creates the counter at 0 if it is absent, which is both idempotent and
* the same operation on every instance.
*/
private void resolveGeneration(RedisLease lease) throws Exception {
if (keys.resolved()) {
return;
}
Long current =
lease
.gateway()
.incrementBy(keys.generationKey(), 0)
.toCompletableFuture()
.get(commandTimeout.toNanos(), TimeUnit.NANOSECONDS);
keys.observeGeneration(current == null ? 0L : current);
}
private CacheLookup<V> unavailable() {
// NOT_APPLIED: a lookup that failed changed nothing, so the caller can load from the source
// without wondering whether a write is still in flight.
return new CacheLookup.Unavailable<V>(
CacheLookup.UnavailabilityReason.UNAVAILABLE, CacheLookup.OperationCertainty.NOT_APPLIED);
}
/**
* Derives the observation token from the stored bytes.
*
* <p>A content digest rather than a server revision: Redis has no per-key version, and a digest
* answers the only question the token is used for — "is this still exactly what I read?".
*/
private static CacheObservationToken observationOf(byte[] stored) {
try {
byte[] digest = java.security.MessageDigest.getInstance("SHA-256").digest(stored);
return new CacheObservationToken(HexFormat.of().formatHex(digest, 0, 16));
} catch (java.security.NoSuchAlgorithmException impossible) {
throw new IllegalStateException("SHA-256 must be available", impossible);
}
}
/** The private physical keys this region owns. */
public static final class CacheKeys {
private final CapabilityKeyspace keyspace;
private final String region;
private volatile long generation;
private volatile boolean resolved;
/**
* Creates the key renderer.
*
* @param namespace the deployment namespace every capability shares
* @param region the semantic region name
* @param keyVersion the physical key layout version
*/
public CacheKeys(RedisNamespace namespace, String region, int keyVersion) {
this.keyspace = new CapabilityKeyspace(namespace, "cache", keyVersion);
this.region = Objects.requireNonNull(region, "region must be non-null");
}
/**
* Renders the entry key for a digested semantic key.
*
* @param digest the caller-supplied key digest
* @return the physical key
*/
public byte[] entryKey(String digest) {
// Only the digest. A cache keyspace is one of the easiest places to leak an identifier,
// because it is dumped, scanned and sampled by tooling that has nothing to do with the app.
return keyspace.key(region, digest);
}
/**
* Renders the region generation counter's key.
*
* @return the physical key
*/
public byte[] generationKey() {
return keyspace.key(region, "generation");
}
/**
* Returns the generation entries are currently written under.
*
* @return the generation
*/
public long generation() {
return generation;
}
/**
* Records a generation observed from the server.
*
* @param observed the observed generation
*/
public void observeGeneration(long observed) {
// The server is the authority, in both directions. Taking only increases would leave an
// instance that had bumped its own copy permanently ahead of a region that was reset.
generation = observed;
resolved = true;
}
/**
* Reports whether the generation has been read from the server.
*
* @return {@code true} once resolved
*/
public boolean resolved() {
return resolved;
}
}
}
@@ -0,0 +1,306 @@
package dev.caskeleton.adapter.outbound.cache.redis.idempotency;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.operations.RedisCommandGateway;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.atomic.AtomicReference;
/**
* The idempotency record's state machine, one atomic program per transition.
*
* <p>Each program re-reads the record, checks the owner <em>and</em> the state revision, and only
* then mutates. Both halves are necessary. The owner alone would let a holder whose lease expired —
* and whose claim was taken over — write over the new holder's work. The revision alone would let a
* different owner at the same revision do it. Together they are an optimistic compare-and-set, and
* every successful transition bumps the revision so a stale handle can never be reused.
*
* <p>The record is a hash, not a string, because the transitions touch different fields and a
* read-modify-write of a serialized blob would reintroduce exactly the race the programs remove.
*
* <p>Nothing here interprets the stored response. It is an opaque payload the application encoded;
* this adapter stores and returns bytes, so a codec change is a concern of whoever wrote them.
*/
public final class IdempotencyScripts {
/**
* Claim: create, replay, take over an expired lease, or report why not.
*
* <p>The fingerprint is compared before anything else. Two different requests that hash to the
* same idempotency scope are a client error, and treating the second as a replay of the first
* would return somebody else's response.
*/
private static final String CLAIM =
"""
local state = redis.call('HGET', KEYS[1], 'state')
local nowMillis = tonumber(ARGV[6])
if state == false then
redis.call('HSET', KEYS[1],
'state', 'CLAIMED', 'owner', ARGV[1], 'attempt', 1, 'rev', 1,
'op', ARGV[2], 'fp', ARGV[3], 'codec', ARGV[4], 'policy', ARGV[5],
'leaseUntil', nowMillis + tonumber(ARGV[7]))
redis.call('PEXPIRE', KEYS[1], ARGV[8])
return {'ACQUIRED', 1, 1, ARGV[1], '', tostring(nowMillis + tonumber(ARGV[7]))}
end
local fingerprint = redis.call('HGET', KEYS[1], 'fp')
if fingerprint ~= ARGV[3] then
return {'FINGERPRINT_MISMATCH', 0, 0, '', '', ''}
end
local owner = redis.call('HGET', KEYS[1], 'owner')
local op = redis.call('HGET', KEYS[1], 'op')
local attempt = tonumber(redis.call('HGET', KEYS[1], 'attempt'))
local rev = tonumber(redis.call('HGET', KEYS[1], 'rev'))
if state == 'COMPLETED' then
return {'COMPLETED_REPLAY', attempt, rev, owner, redis.call('HGET', KEYS[1], 'resp'),
tostring(redis.call('PTTL', KEYS[1]))}
end
if state == 'ABANDONED' then
return {'RECOVERY_REQUIRED', attempt, rev, owner, '', ''}
end
if owner == ARGV[1] then
if op ~= ARGV[2] then
return {'OWNER_OPERATION_CONFLICT', attempt, rev, owner, '', ''}
end
-- Same owner, same operation: a retry whose first reply was lost.
return {'REPLAYED_ACQUIRE', attempt, rev, owner, '',
redis.call('HGET', KEYS[1], 'leaseUntil')}
end
local leaseUntil = tonumber(redis.call('HGET', KEYS[1], 'leaseUntil'))
if state == 'FAILED_RETRYABLE' or (leaseUntil ~= nil and leaseUntil <= nowMillis) then
-- The previous holder's processing lease expired, or they marked the attempt retryable.
-- Taking over bumps the attempt so the new holder can tell it is not the first.
redis.call('HSET', KEYS[1],
'state', 'CLAIMED', 'owner', ARGV[1], 'attempt', attempt + 1, 'rev', rev + 1,
'op', ARGV[2], 'leaseUntil', nowMillis + tonumber(ARGV[7]))
redis.call('PEXPIRE', KEYS[1], ARGV[8])
return {'TAKEN_OVER', attempt + 1, rev + 1, ARGV[1], '',
tostring(nowMillis + tonumber(ARGV[7]))}
end
return {'IN_PROGRESS', attempt, rev, owner, '', tostring(leaseUntil - nowMillis)}
""";
/** A generic owner+revision compare-and-set transition. */
private static final String TRANSITION =
"""
local state = redis.call('HGET', KEYS[1], 'state')
if state == false then
return {'ABSENT', 0, 0, '', '', ''}
end
local owner = redis.call('HGET', KEYS[1], 'owner')
local rev = tonumber(redis.call('HGET', KEYS[1], 'rev'))
local attempt = tonumber(redis.call('HGET', KEYS[1], 'attempt'))
local op = redis.call('HGET', KEYS[1], 'op')
if owner ~= ARGV[1] then
return {'NOT_OWNER', attempt, rev, owner, '', ''}
end
if op ~= ARGV[3] then
return {'OPERATION_CONFLICT', attempt, rev, owner, '', ''}
end
if state == ARGV[5] then
-- Already in the target state, under the same owner and the same operation: a retry whose
-- first reply was lost, not a second transition. This is checked BEFORE the revision,
-- deliberately. The caller's handle necessarily carries the pre-transition revision — they
-- never received the reply that would have replaced it — so a revision check first would
-- turn every lost reply into NOT_OWNER and make the idempotent call non-idempotent. The
-- owner and operation already prove the record was moved by this caller and nobody else.
return {'ALREADY', attempt, rev, owner, redis.call('HGET', KEYS[1], 'resp') or '', ''}
end
if rev ~= tonumber(ARGV[2]) then
-- Stale handle: somebody moved the record on after this owner read it, and the target
-- state is not where they left it.
return {'NOT_OWNER', attempt, rev, owner, '', ''}
end
if state ~= ARGV[4] then
return {'WRONG_STATE', attempt, rev, owner, state, ''}
end
redis.call('HSET', KEYS[1], 'state', ARGV[5], 'rev', rev + 1)
if ARGV[6] ~= '' then
redis.call('HSET', KEYS[1], 'resp', ARGV[6])
end
if ARGV[7] ~= '' then
redis.call('HSET', KEYS[1], 'leaseUntil', ARGV[7])
end
if ARGV[8] ~= '' then
redis.call('PEXPIRE', KEYS[1], ARGV[8])
end
return {'APPLIED', attempt, rev + 1, owner, '', ''}
""";
/** Release before execution: only from CLAIMED, and only by the owner that holds it. */
private static final String RELEASE =
"""
local state = redis.call('HGET', KEYS[1], 'state')
if state == false then
return {'ABSENT', 0, 0, '', '', ''}
end
local owner = redis.call('HGET', KEYS[1], 'owner')
local rev = tonumber(redis.call('HGET', KEYS[1], 'rev'))
local attempt = tonumber(redis.call('HGET', KEYS[1], 'attempt'))
local op = redis.call('HGET', KEYS[1], 'op')
if owner ~= ARGV[1] then
return {'NOT_OWNER', attempt, rev, owner, '', ''}
end
if op ~= ARGV[3] then
return {'OPERATION_CONFLICT', attempt, rev, owner, '', ''}
end
if state ~= 'CLAIMED' then
return {'WRONG_STATE', attempt, rev, owner, state, ''}
end
redis.call('DEL', KEYS[1])
return {'APPLIED', attempt, rev, owner, '', ''}
""";
/** Inspect: read the record without touching it. */
private static final String INSPECT =
"""
local state = redis.call('HGET', KEYS[1], 'state')
if state == false then
return {'ABSENT', 0, 0, '', '', ''}
end
local fingerprint = redis.call('HGET', KEYS[1], 'fp')
if fingerprint ~= ARGV[2] then
return {'FINGERPRINT_MISMATCH', 0, 0, '', '', ''}
end
local owner = redis.call('HGET', KEYS[1], 'owner')
local rev = tonumber(redis.call('HGET', KEYS[1], 'rev'))
local attempt = tonumber(redis.call('HGET', KEYS[1], 'attempt'))
local op = redis.call('HGET', KEYS[1], 'op')
local mine = 'OTHER'
if owner == ARGV[1] then
if op == ARGV[3] then
mine = 'MINE'
else
mine = 'OPERATION_CONFLICT'
end
end
return {state, attempt, rev, owner,
redis.call('HGET', KEYS[1], 'resp') or '',
mine .. '|' .. tostring(redis.call('PTTL', KEYS[1]))}
""";
private final Map<String, AtomicReference<String>> digests = new LinkedHashMap<>();
CompletionStage<Reply> claim(RedisCommandGateway gateway, byte[] key, List<String> arguments) {
return run(gateway, "claim", CLAIM, key, arguments);
}
CompletionStage<Reply> transition(
RedisCommandGateway gateway, byte[] key, List<String> arguments) {
return run(gateway, "transition", TRANSITION, key, arguments);
}
CompletionStage<Reply> release(RedisCommandGateway gateway, byte[] key, List<String> arguments) {
return run(gateway, "release", RELEASE, key, arguments);
}
CompletionStage<Reply> inspect(RedisCommandGateway gateway, byte[] key, List<String> arguments) {
return run(gateway, "inspect", INSPECT, key, arguments);
}
private CompletionStage<Reply> run(
RedisCommandGateway gateway, String name, String source, byte[] key, List<String> arguments) {
AtomicReference<String> cache =
digests.computeIfAbsent(name, unused -> new AtomicReference<>());
List<byte[]> encoded = new ArrayList<>(arguments.size());
for (String argument : arguments) {
encoded.add(argument.getBytes(StandardCharsets.UTF_8));
}
return digest(gateway, source, cache)
.thenCompose(digest -> gateway.evaluateRegisteredForList(digest, key, encoded))
.handle(
(reply, failure) ->
failure == null
? CompletableFuture.completedFuture(reply)
: reload(gateway, source, cache, key, encoded, failure))
.thenCompose(stage -> stage)
.thenApply(IdempotencyScripts::replyOf);
}
private CompletionStage<List<Object>> reload(
RedisCommandGateway gateway,
String source,
AtomicReference<String> cache,
byte[] key,
List<byte[]> arguments,
Throwable failure) {
if (!scriptMissing(failure)) {
return CompletableFuture.failedFuture(failure);
}
cache.set(null);
return digest(gateway, source, cache)
.thenCompose(digest -> gateway.evaluateRegisteredForList(digest, key, arguments));
}
private static CompletionStage<String> digest(
RedisCommandGateway gateway, String source, AtomicReference<String> cache) {
String cached = cache.get();
if (cached != null) {
return CompletableFuture.completedFuture(cached);
}
return gateway
.loadScript(source.getBytes(StandardCharsets.UTF_8))
.thenApply(
loaded -> {
cache.set(loaded);
return loaded;
});
}
private static boolean scriptMissing(Throwable failure) {
Throwable cause = failure;
while ((cause instanceof CompletionException || cause instanceof ExecutionException)
&& cause.getCause() != null) {
cause = cause.getCause();
}
String message = cause.getMessage();
return message != null && message.strip().toUpperCase(Locale.ROOT).startsWith("NOSCRIPT");
}
private static Reply replyOf(List<Object> reply) {
if (reply == null || reply.size() < 6) {
throw new IllegalStateException("the idempotency program answered with an unexpected shape");
}
return new Reply(
text(reply.get(0)),
number(reply.get(1)),
number(reply.get(2)),
text(reply.get(3)),
text(reply.get(4)),
text(reply.get(5)));
}
private static long number(Object value) {
if (value instanceof Number n) {
return n.longValue();
}
String text = text(value);
return text.isBlank() ? 0L : Long.parseLong(text.strip());
}
private static String text(Object value) {
if (value instanceof byte[] bytes) {
return new String(bytes, StandardCharsets.UTF_8);
}
return value == null ? "" : String.valueOf(value);
}
/**
* One program's answer.
*
* @param status the transition verdict
* @param attempt the record's attempt counter
* @param revision the record's state revision after the call
* @param owner the stored owner token
* @param payload the stored response, when the verdict carries one
* @param detail verdict-specific detail: a lease deadline, a TTL, or the observed state
*/
record Reply(
String status, long attempt, long revision, String owner, String payload, String detail) {}
}
@@ -0,0 +1,529 @@
package dev.caskeleton.adapter.outbound.cache.redis.idempotency;
import dev.caskeleton.adapter.outbound.cache.redis.keyspace.CapabilityKeyspace;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.key.RedisNamespace;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisConnectionKind;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisLease;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisRuntimeOwner;
import dev.caskeleton.application.idempotency.StoredResponse;
import dev.caskeleton.application.idempotency.v2.IdempotencyClaimAttempt;
import dev.caskeleton.application.idempotency.v2.IdempotencyClaimOutcome;
import dev.caskeleton.application.idempotency.v2.IdempotencyClaimRequest;
import dev.caskeleton.application.idempotency.v2.IdempotencyCompleteOutcome;
import dev.caskeleton.application.idempotency.v2.IdempotencyFailOutcome;
import dev.caskeleton.application.idempotency.v2.IdempotencyFailureDisposition;
import dev.caskeleton.application.idempotency.v2.IdempotencyInspection;
import dev.caskeleton.application.idempotency.v2.IdempotencyInspectionOutcome;
import dev.caskeleton.application.idempotency.v2.IdempotencyInspectionRequest;
import dev.caskeleton.application.idempotency.v2.IdempotencyMutationResult;
import dev.caskeleton.application.idempotency.v2.IdempotencyOwner;
import dev.caskeleton.application.idempotency.v2.IdempotencyReleaseOutcome;
import dev.caskeleton.application.idempotency.v2.IdempotencyRenewOutcome;
import dev.caskeleton.application.idempotency.v2.IdempotencyScopeDigest;
import dev.caskeleton.application.idempotency.v2.IdempotencyStartOutcome;
import dev.caskeleton.application.idempotency.v2.IdempotencyStorePortV2;
import dev.caskeleton.application.transaction.OperationId;
import java.security.SecureRandom;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
/**
* The owner-safe request-replay store, on Redis.
*
* <p>What this exists to prevent is a duplicate side effect: the same request arriving twice — a
* client retry, a proxy retry, a lost response — must not charge a card twice. That is only
* achievable if the claim, the execution marker, and the stored result are one linear state machine
* with an owner, and if every transition proves ownership atomically at the server. Anything less
* and two workers can both believe they hold the operation.
*
* <p>Every failure whose outcome is unknown is reported as {@code INDETERMINATE} rather than as a
* failure. The difference is the whole point: a caller told "failed" retries and duplicates the
* effect, while a caller told "indeterminate" inspects with the same attempt and discovers what
* actually happened. This adapter never converts an ambiguous write into a clean answer.
*
* <p>{@code application-core} sees {@link IdempotencyStorePortV2}. No Redis type, key, or script
* digest crosses this boundary.
*/
public final class RedisIdempotencyStoreAdapter implements IdempotencyStorePortV2 {
private static final SecureRandom RANDOM = new SecureRandom();
private final RedisRuntimeOwner owner;
private final IdempotencyKeys keys;
private final IdempotencyScripts scripts;
private final Clock clock;
private final Duration commandTimeout;
/**
* Creates the adapter.
*
* @param owner the Redis runtime owner leases come from
* @param keys renders the private physical keys this adapter owns
* @param scripts the owner-checked atomic transitions
* @param clock the clock lease deadlines are measured against
* @param commandTimeout the ceiling on one transition
*/
public RedisIdempotencyStoreAdapter(
RedisRuntimeOwner owner,
IdempotencyKeys keys,
IdempotencyScripts scripts,
Clock clock,
Duration commandTimeout) {
this.owner = Objects.requireNonNull(owner, "runtime owner must be non-null");
this.keys = Objects.requireNonNull(keys, "keys must be non-null");
this.scripts = Objects.requireNonNull(scripts, "scripts must be non-null");
this.clock = Objects.requireNonNull(clock, "clock must be non-null");
this.commandTimeout =
Objects.requireNonNull(commandTimeout, "command timeout must be non-null");
}
@Override
public IdempotencyClaimAttempt newClaimAttempt(OperationId operationId) {
// The contract requires 64 lowercase hex characters — 32 bytes of entropy. That is not
// arbitrary: the owner token is the only thing standing between a caller and completing
// somebody else's operation, so it has to be unguessable rather than merely unique.
byte[] entropy = new byte[32];
RANDOM.nextBytes(entropy);
StringBuilder token = new StringBuilder(64);
for (byte value : entropy) {
token.append(Character.forDigit((value >> 4) & 0xF, 16));
token.append(Character.forDigit(value & 0xF, 16));
}
return new IdempotencyClaimAttempt(token.toString(), operationId);
}
@Override
public IdempotencyClaimOutcome claim(IdempotencyClaimRequest request) {
Objects.requireNonNull(request, "request must be non-null");
Instant now = clock.instant();
try (RedisLease lease = owner.borrow(RedisConnectionKind.SCRIPT)) {
IdempotencyScripts.Reply reply =
scripts
.claim(
lease.gateway(),
keys.recordKey(request.scope()),
List.of(
request.claimAttempt().ownerToken(),
request.claimAttempt().operationId().value(),
request.requestFingerprint().hex(),
request.responseCodecId(),
Integer.toString(request.policyRevision()),
Long.toString(now.toEpochMilli()),
Long.toString(request.processingLeaseTtl().toMillis()),
Long.toString(request.replayTtl().toMillis())))
.toCompletableFuture()
.get(commandTimeout.toNanos(), TimeUnit.NANOSECONDS);
return claimOutcomeOf(request, reply, now);
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
return new IdempotencyClaimOutcome.Indeterminate(request.claimAttempt().operationId());
} catch (Exception failure) {
return new IdempotencyClaimOutcome.Indeterminate(request.claimAttempt().operationId());
}
}
private IdempotencyClaimOutcome claimOutcomeOf(
IdempotencyClaimRequest request, IdempotencyScripts.Reply reply, Instant now) {
return switch (reply.status()) {
case "ACQUIRED" ->
new IdempotencyClaimOutcome.Acquired(
ownerOf(request, reply), leaseUntil(reply, now, request));
case "REPLAYED_ACQUIRE" ->
new IdempotencyClaimOutcome.ReplayedAcquire(
ownerOf(request, reply), leaseUntil(reply, now, request));
case "TAKEN_OVER" ->
new IdempotencyClaimOutcome.TakenOverClaimed(
ownerOf(request, reply), leaseUntil(reply, now, request));
case "COMPLETED_REPLAY" ->
new IdempotencyClaimOutcome.CompletedReplay(
new StoredResponse(reply.payload()),
now.plusMillis(Math.max(1, parseLong(reply.detail()))));
case "IN_PROGRESS" ->
new IdempotencyClaimOutcome.InProgress(
Duration.ofMillis(Math.max(1, parseLong(reply.detail()))), reply.attempt());
case "RECOVERY_REQUIRED" -> new IdempotencyClaimOutcome.RecoveryRequired(reply.attempt());
case "FINGERPRINT_MISMATCH" -> new IdempotencyClaimOutcome.FingerprintMismatch();
case "OWNER_OPERATION_CONFLICT" -> new IdempotencyClaimOutcome.OwnerOperationConflict();
default -> new IdempotencyClaimOutcome.Unavailable();
};
}
@Override
public IdempotencyMutationResult<IdempotencyStartOutcome> markExecutionStarted(
IdempotencyOwner ownerHandle, OperationId operationId) {
return transition(
ownerHandle,
operationId,
"CLAIMED",
"EXECUTING",
"",
"",
"",
IdempotencyStartOutcome.STARTED,
IdempotencyStartOutcome.ALREADY_STARTED_SAME_OPERATION,
IdempotencyStartOutcome.ABSENT,
IdempotencyStartOutcome.NOT_OWNER,
IdempotencyStartOutcome.NOT_CLAIMED,
IdempotencyStartOutcome.OPERATION_CONFLICT,
IdempotencyStartOutcome.INDETERMINATE,
IdempotencyStartOutcome::carriesOwner);
}
@Override
public IdempotencyMutationResult<IdempotencyRenewOutcome> renew(
IdempotencyOwner ownerHandle, Duration processingLeaseTtl, OperationId operationId) {
Objects.requireNonNull(processingLeaseTtl, "processing lease TTL must be non-null");
long leaseUntil = clock.instant().plus(processingLeaseTtl).toEpochMilli();
return transition(
ownerHandle,
operationId,
"EXECUTING",
"EXECUTING",
"",
Long.toString(leaseUntil),
"",
IdempotencyRenewOutcome.RENEWED,
IdempotencyRenewOutcome.ALREADY_RENEWED_SAME_OPERATION,
IdempotencyRenewOutcome.ABSENT,
IdempotencyRenewOutcome.NOT_OWNER,
IdempotencyRenewOutcome.NOT_IN_PROGRESS,
IdempotencyRenewOutcome.OPERATION_CONFLICT,
IdempotencyRenewOutcome.INDETERMINATE,
IdempotencyRenewOutcome::carriesOwner);
}
@Override
public IdempotencyCompleteOutcome complete(
IdempotencyOwner ownerHandle,
StoredResponse response,
Duration replayTtl,
OperationId operationId) {
Objects.requireNonNull(response, "response must be non-null");
Objects.requireNonNull(replayTtl, "replay TTL must be non-null");
try (RedisLease lease = owner.borrow(RedisConnectionKind.SCRIPT)) {
IdempotencyScripts.Reply reply =
scripts
.transition(
lease.gateway(),
keys.recordKey(ownerHandle.scope()),
List.of(
ownerHandle.ownerToken(),
Long.toString(ownerHandle.stateRevision()),
operationId.value(),
"EXECUTING",
"COMPLETED",
response.payload(),
"",
Long.toString(replayTtl.toMillis())))
.toCompletableFuture()
.get(commandTimeout.toNanos(), TimeUnit.NANOSECONDS);
return switch (reply.status()) {
case "APPLIED" -> IdempotencyCompleteOutcome.COMPLETED;
case "ALREADY" ->
// The same owner completing twice. Whether it is the same result decides whether this
// is an idempotent repeat or a contradiction the caller has to see.
reply.payload().equals(response.payload())
? IdempotencyCompleteOutcome.ALREADY_COMPLETED_SAME_RESULT
: IdempotencyCompleteOutcome.RESPONSE_CONFLICT;
case "ABSENT" -> IdempotencyCompleteOutcome.ABSENT;
case "NOT_OWNER" -> IdempotencyCompleteOutcome.NOT_OWNER;
case "OPERATION_CONFLICT" -> IdempotencyCompleteOutcome.OPERATION_CONFLICT;
case "WRONG_STATE" -> IdempotencyCompleteOutcome.NOT_IN_PROGRESS;
default -> IdempotencyCompleteOutcome.UNAVAILABLE;
};
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
return IdempotencyCompleteOutcome.INDETERMINATE;
} catch (Exception failure) {
// The effect may have been recorded. A caller told INDETERMINATE inspects; one told
// UNAVAILABLE might retry the whole operation and duplicate it.
return IdempotencyCompleteOutcome.INDETERMINATE;
}
}
@Override
public IdempotencyFailOutcome markFailed(
IdempotencyOwner ownerHandle,
IdempotencyFailureDisposition disposition,
Duration retention,
OperationId operationId) {
Objects.requireNonNull(disposition, "disposition must be non-null");
Objects.requireNonNull(retention, "retention must be non-null");
// The disposition is the caller's judgement about whether the effect happened, and it decides
// whether anybody may retry. NO_EFFECT_RETRYABLE releases the operation for another attempt;
// EFFECT_UNKNOWN_ABANDONED does not, because retrying an unknown effect is how it happens
// twice.
String target =
disposition == IdempotencyFailureDisposition.NO_EFFECT_RETRYABLE
? "FAILED_RETRYABLE"
: "ABANDONED";
try (RedisLease lease = owner.borrow(RedisConnectionKind.SCRIPT)) {
IdempotencyScripts.Reply reply =
scripts
.transition(
lease.gateway(),
keys.recordKey(ownerHandle.scope()),
List.of(
ownerHandle.ownerToken(),
Long.toString(ownerHandle.stateRevision()),
operationId.value(),
"EXECUTING",
target,
"",
"",
Long.toString(retention.toMillis())))
.toCompletableFuture()
.get(commandTimeout.toNanos(), TimeUnit.NANOSECONDS);
return switch (reply.status()) {
case "APPLIED" ->
disposition == IdempotencyFailureDisposition.NO_EFFECT_RETRYABLE
? IdempotencyFailOutcome.MARKED_RETRYABLE
: IdempotencyFailOutcome.MARKED_ABANDONED;
case "ALREADY" -> IdempotencyFailOutcome.ALREADY_MARKED_SAME_OPERATION;
case "ABSENT" -> IdempotencyFailOutcome.ABSENT;
case "NOT_OWNER" -> IdempotencyFailOutcome.NOT_OWNER;
case "OPERATION_CONFLICT" -> IdempotencyFailOutcome.OPERATION_CONFLICT;
case "WRONG_STATE" -> IdempotencyFailOutcome.NOT_IN_PROGRESS;
default -> IdempotencyFailOutcome.UNAVAILABLE;
};
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
return IdempotencyFailOutcome.INDETERMINATE;
} catch (Exception failure) {
return IdempotencyFailOutcome.INDETERMINATE;
}
}
@Override
public IdempotencyReleaseOutcome releaseBeforeExecution(
IdempotencyOwner ownerHandle, OperationId operationId) {
try (RedisLease lease = owner.borrow(RedisConnectionKind.SCRIPT)) {
IdempotencyScripts.Reply reply =
scripts
.release(
lease.gateway(),
keys.recordKey(ownerHandle.scope()),
List.of(
ownerHandle.ownerToken(),
Long.toString(ownerHandle.stateRevision()),
operationId.value()))
.toCompletableFuture()
.get(commandTimeout.toNanos(), TimeUnit.NANOSECONDS);
return switch (reply.status()) {
case "APPLIED" -> IdempotencyReleaseOutcome.RELEASED_BEFORE_EXECUTION;
case "ABSENT" -> IdempotencyReleaseOutcome.ALREADY_RELEASED_SAME_OPERATION;
case "NOT_OWNER" -> IdempotencyReleaseOutcome.NOT_OWNER;
case "OPERATION_CONFLICT" -> IdempotencyReleaseOutcome.OPERATION_CONFLICT;
case "WRONG_STATE" -> IdempotencyReleaseOutcome.EXECUTION_ALREADY_STARTED;
default -> IdempotencyReleaseOutcome.UNAVAILABLE;
};
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
return IdempotencyReleaseOutcome.INDETERMINATE;
} catch (Exception failure) {
return IdempotencyReleaseOutcome.INDETERMINATE;
}
}
@Override
public IdempotencyInspection inspect(IdempotencyInspectionRequest request) {
Objects.requireNonNull(request, "request must be non-null");
Instant now = clock.instant();
try (RedisLease lease = owner.borrow(RedisConnectionKind.SCRIPT)) {
IdempotencyScripts.Reply reply =
scripts
.inspect(
lease.gateway(),
keys.recordKey(request.scope()),
List.of(
request.claimAttempt().ownerToken(),
request.requestFingerprint().hex(),
request.claimAttempt().operationId().value()))
.toCompletableFuture()
.get(commandTimeout.toNanos(), TimeUnit.NANOSECONDS);
return inspectionOf(request, reply, now);
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
return IdempotencyInspection.outcome(IdempotencyInspectionOutcome.UNAVAILABLE);
} catch (Exception failure) {
return IdempotencyInspection.outcome(IdempotencyInspectionOutcome.UNAVAILABLE);
}
}
private IdempotencyInspection inspectionOf(
IdempotencyInspectionRequest request, IdempotencyScripts.Reply reply, Instant now) {
if ("ABSENT".equals(reply.status())) {
return IdempotencyInspection.outcome(IdempotencyInspectionOutcome.ABSENT);
}
if ("FINGERPRINT_MISMATCH".equals(reply.status())) {
return IdempotencyInspection.outcome(IdempotencyInspectionOutcome.FINGERPRINT_MISMATCH);
}
String[] detail = reply.detail().split("\\|", 2);
String ownership = detail[0];
long ttlMillis = detail.length > 1 ? parseLong(detail[1]) : 0;
if ("OPERATION_CONFLICT".equals(ownership)) {
return IdempotencyInspection.outcome(IdempotencyInspectionOutcome.OPERATION_CONFLICT);
}
IdempotencyOwner handle =
new IdempotencyOwner(
request.scope(),
reply.owner(),
reply.attempt(),
reply.revision(),
request.claimAttempt().operationId());
return switch (reply.status()) {
case "COMPLETED" ->
new IdempotencyInspection(
IdempotencyInspectionOutcome.COMPLETED_REPLAY,
Optional.empty(),
Optional.empty(),
Optional.of(new StoredResponse(reply.payload())),
Optional.of(now.plusMillis(Math.max(1, ttlMillis))));
case "FAILED_RETRYABLE" ->
IdempotencyInspection.outcome(IdempotencyInspectionOutcome.FAILED_RETRYABLE);
case "ABANDONED" -> IdempotencyInspection.outcome(IdempotencyInspectionOutcome.ABANDONED);
case "CLAIMED" ->
"MINE".equals(ownership)
? new IdempotencyInspection(
IdempotencyInspectionOutcome.CLAIMED_SAME_OPERATION,
Optional.of(handle),
Optional.of(now.plusMillis(Math.max(1, ttlMillis))),
Optional.empty(),
Optional.empty())
: IdempotencyInspection.outcome(IdempotencyInspectionOutcome.IN_PROGRESS_OTHER);
case "EXECUTING" ->
"MINE".equals(ownership)
? new IdempotencyInspection(
IdempotencyInspectionOutcome.EXECUTING_SAME_OPERATION,
Optional.of(handle),
Optional.of(now.plusMillis(Math.max(1, ttlMillis))),
Optional.empty(),
Optional.empty())
: IdempotencyInspection.outcome(IdempotencyInspectionOutcome.IN_PROGRESS_OTHER);
default -> IdempotencyInspection.outcome(IdempotencyInspectionOutcome.UNAVAILABLE);
};
}
private <O extends Enum<O>> IdempotencyMutationResult<O> transition(
IdempotencyOwner ownerHandle,
OperationId operationId,
String fromState,
String toState,
String payload,
String leaseUntil,
String ttlMillis,
O applied,
O already,
O absent,
O notOwner,
O wrongState,
O operationConflict,
O indeterminate,
java.util.function.Predicate<O> carriesOwner) {
try (RedisLease lease = owner.borrow(RedisConnectionKind.SCRIPT)) {
IdempotencyScripts.Reply reply =
scripts
.transition(
lease.gateway(),
keys.recordKey(ownerHandle.scope()),
List.of(
ownerHandle.ownerToken(),
Long.toString(ownerHandle.stateRevision()),
operationId.value(),
fromState,
toState,
payload,
leaseUntil,
ttlMillis))
.toCompletableFuture()
.get(commandTimeout.toNanos(), TimeUnit.NANOSECONDS);
O outcome =
switch (reply.status()) {
case "APPLIED" -> applied;
case "ALREADY" -> already;
case "ABSENT" -> absent;
case "NOT_OWNER" -> notOwner;
case "OPERATION_CONFLICT" -> operationConflict;
case "WRONG_STATE" -> wrongState;
default -> indeterminate;
};
return new IdempotencyMutationResult<>(
outcome,
carriesOwner.test(outcome)
? new IdempotencyOwner(
ownerHandle.scope(),
ownerHandle.ownerToken(),
reply.attempt(),
reply.revision(),
ownerHandle.claimOperationId())
: null,
carriesOwner);
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
return new IdempotencyMutationResult<>(indeterminate, null, carriesOwner);
} catch (Exception failure) {
return new IdempotencyMutationResult<>(indeterminate, null, carriesOwner);
}
}
private static IdempotencyOwner ownerOf(
IdempotencyClaimRequest request, IdempotencyScripts.Reply reply) {
return new IdempotencyOwner(
request.scope(),
request.claimAttempt().ownerToken(),
reply.attempt(),
reply.revision(),
request.claimAttempt().operationId());
}
private static Instant leaseUntil(
IdempotencyScripts.Reply reply, Instant now, IdempotencyClaimRequest request) {
long millis = parseLong(reply.detail());
return millis > 0 ? Instant.ofEpochMilli(millis) : now.plus(request.processingLeaseTtl());
}
private static long parseLong(String value) {
try {
return value == null || value.isBlank() ? 0L : Long.parseLong(value.strip());
} catch (NumberFormatException failure) {
return 0L;
}
}
/** The private physical keys this adapter owns. */
public static final class IdempotencyKeys {
private final CapabilityKeyspace keyspace;
/**
* Creates the key renderer.
*
* @param namespace the deployment namespace every capability shares
* @param keyVersion the physical key layout version
*/
public IdempotencyKeys(RedisNamespace namespace, int keyVersion) {
this.keyspace = new CapabilityKeyspace(namespace, "idem", keyVersion);
}
/**
* Renders the record key for a scope.
*
* @param scope the caller-supplied scope digest
* @return the physical key
*/
public byte[] recordKey(IdempotencyScopeDigest scope) {
// Only the digest reaches Redis. The idempotency key a client sent — often a request id, a
// user id, or worse — never appears in the keyspace, a slow log, or a metric.
return keyspace.key("d" + scope.keyDigestVersion(), scope.operationCode(), scope.digest());
}
}
}
@@ -0,0 +1,106 @@
package dev.caskeleton.adapter.outbound.cache.redis.keyspace;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.key.RedisKeyRules;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.key.RedisNamespace;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
/**
* The one place a semantic capability's physical keys are rendered.
*
* <p>Every capability used to own a renderer that took two free-form strings — an "application" and
* an "environment" token — and joined them in its own order. The result was a keyspace whose shape
* nothing declared: the cache wrote {@code ca-skeleton:prod:cache:…}, the SDK's own typed keys
* wrote {@code prod:ca-skeleton:shared:…}, and the ACL pattern that was supposed to fence the
* deployment in matched one of them. An account restricted to {@code ~prod:*} could not touch a
* single cache entry, and nothing said so until a real server refused the write.
*
* <p>So the prefix comes from {@link RedisNamespace}, the same type the SDK's key renderer and the
* raw gateway's namespace check use, and the capability name and key version follow it:
*
* <pre>{@code
* {environment}:{service}:{domain}:{capability}:v{version}:{segments…}
* }</pre>
*
* <p>Environment first is not cosmetic either. It is the token an ACL pattern is most likely to
* fence on, and a prefix that starts with it lets one pattern cover a whole deployment without also
* covering the same service in another environment.
*/
public final class CapabilityKeyspace {
private static final char SEPARATOR = ':';
private final String prefix;
/**
* Creates a keyspace.
*
* @param namespace the deployment's namespace
* @param capability the capability token, for example {@code cache} or {@code ratelimit}
* @param keyVersion the physical key layout version
*/
public CapabilityKeyspace(RedisNamespace namespace, String capability, int keyVersion) {
Objects.requireNonNull(namespace, "namespace must be non-null");
RedisKeyRules.requireToken("capability", capability);
if (keyVersion < 1) {
throw new IllegalArgumentException("the key version must be positive");
}
this.prefix = namespace.prefix() + SEPARATOR + capability + SEPARATOR + "v" + keyVersion;
}
/**
* Renders a key under this capability.
*
* <p>Every segment but the last must be free of the separator. That is where the ambiguity lives:
* with a separator inside a segment that has successors, {@code ("a:b", "c")} and {@code ("a",
* "b:c")} render one key, so two subjects share a rate limit or two operations share an
* idempotency record with nothing to show for it. A separator in the <em>final</em> segment
* cannot shift anything, because there is nothing after it — and the final segment is precisely
* the caller-supplied digest, which by contract carries its own hash-version prefix, {@code
* hv1:…}. Each capability renders a fixed number of segments, so no two shapes can meet in the
* middle either.
*
* @param segments the capability-specific key parts, in order, digest last
* @return the physical key
* @throws IllegalArgumentException when a segment is blank, or a non-final one carries the
* separator
*/
public byte[] key(String... segments) {
Objects.requireNonNull(segments, "segments must be non-null");
if (segments.length == 0) {
throw new IllegalArgumentException("a key needs at least one segment below the capability");
}
StringBuilder rendered = new StringBuilder(prefix.length() + 32).append(prefix);
for (int index = 0; index < segments.length; index++) {
rendered
.append(SEPARATOR)
.append(requireSegment(segments[index], index == segments.length - 1));
}
return rendered.toString().getBytes(StandardCharsets.UTF_8);
}
/**
* Returns the prefix every key of this capability starts with.
*
* @return the rendered prefix, without a trailing separator
*/
public String prefix() {
return prefix;
}
private static String requireSegment(String segment, boolean last) {
Objects.requireNonNull(segment, "a key segment must be non-null");
if (segment.isBlank()) {
throw new IllegalArgumentException("a key segment must not be blank");
}
if (!last && segment.indexOf(SEPARATOR) >= 0) {
throw new IllegalArgumentException(
"only the final key segment may contain the separator '"
+ SEPARATOR
+ "', because a separator in any earlier one lets two different inputs render the"
+ " same key: "
+ segment);
}
return segment;
}
}
@@ -0,0 +1,203 @@
package dev.caskeleton.adapter.outbound.cache.redis.lease;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.operations.RedisCommandGateway;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.atomic.AtomicReference;
/**
* The four owner-checked lease programs.
*
* <p>Every one of them compares the stored owner before it mutates, inside the same server
* execution. That is the entire safety property of this adapter: a renew or release that reads the
* owner and then writes would let a holder whose lease expired in between extend or delete a lease
* that now belongs to somebody else. "Check then act" is not a lease.
*
* <p>The stored value is {@code ownerToken:operationId}. Both, because the same owner retrying a
* different operation is a different claim — a caller that re-acquires under a new operation id has
* lost the old one's guarantee and must be told, rather than silently inheriting it.
*/
public final class LeaseScripts {
/** Acquire: set if absent, and report the existing holder when present. */
private static final String ACQUIRE =
"""
local existing = redis.call('GET', KEYS[1])
if existing == false then
redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2])
return {1, redis.call('PTTL', KEYS[1]), ''}
end
if existing == ARGV[1] then
-- The same owner and the same operation. This is a retry of a call whose reply was lost,
-- not a second claim, so it is answered with the lease rather than with contention.
return {2, redis.call('PTTL', KEYS[1]), existing}
end
return {0, redis.call('PTTL', KEYS[1]), existing}
""";
/** Renew: extend only while this exact owner and operation still hold it. */
private static final String RENEW =
"""
local existing = redis.call('GET', KEYS[1])
if existing == false then
return {0, 0, ''}
end
if existing ~= ARGV[1] then
return {-1, redis.call('PTTL', KEYS[1]), existing}
end
redis.call('PEXPIRE', KEYS[1], ARGV[2])
return {1, redis.call('PTTL', KEYS[1]), existing}
""";
/** Release: delete only while this exact owner and operation still hold it. */
private static final String RELEASE =
"""
local existing = redis.call('GET', KEYS[1])
if existing == false then
return {0, 0, ''}
end
if existing ~= ARGV[1] then
return {-1, redis.call('PTTL', KEYS[1]), existing}
end
redis.call('DEL', KEYS[1])
return {1, 0, existing}
""";
/** Inspect: read without mutating, so a caller can ask without taking. */
private static final String INSPECT =
"""
local existing = redis.call('GET', KEYS[1])
if existing == false then
return {0, 0, ''}
end
if existing ~= ARGV[1] then
return {-1, redis.call('PTTL', KEYS[1]), existing}
end
return {1, redis.call('PTTL', KEYS[1]), existing}
""";
private final AtomicReference<String> acquireDigest = new AtomicReference<>();
private final AtomicReference<String> renewDigest = new AtomicReference<>();
private final AtomicReference<String> releaseDigest = new AtomicReference<>();
private final AtomicReference<String> inspectDigest = new AtomicReference<>();
CompletionStage<Reply> acquire(
RedisCommandGateway gateway, byte[] key, String ownership, long ttlMillis) {
return run(gateway, ACQUIRE, acquireDigest, key, args(ownership, ttlMillis));
}
CompletionStage<Reply> renew(
RedisCommandGateway gateway, byte[] key, String ownership, long ttlMillis) {
return run(gateway, RENEW, renewDigest, key, args(ownership, ttlMillis));
}
CompletionStage<Reply> release(RedisCommandGateway gateway, byte[] key, String ownership) {
return run(gateway, RELEASE, releaseDigest, key, args(ownership, 0));
}
CompletionStage<Reply> inspect(RedisCommandGateway gateway, byte[] key, String ownership) {
return run(gateway, INSPECT, inspectDigest, key, args(ownership, 0));
}
private CompletionStage<Reply> run(
RedisCommandGateway gateway,
String source,
AtomicReference<String> cache,
byte[] key,
List<byte[]> arguments) {
return digest(gateway, source, cache)
.thenCompose(digest -> gateway.evaluateRegisteredForList(digest, key, arguments))
.handle(
(reply, failure) ->
failure == null
? CompletableFuture.completedFuture(reply)
: reload(gateway, source, cache, key, arguments, failure))
.thenCompose(stage -> stage)
.thenApply(LeaseScripts::replyOf);
}
private CompletionStage<List<Object>> reload(
RedisCommandGateway gateway,
String source,
AtomicReference<String> cache,
byte[] key,
List<byte[]> arguments,
Throwable failure) {
if (!scriptMissing(failure)) {
return CompletableFuture.failedFuture(failure);
}
cache.set(null);
return digest(gateway, source, cache)
.thenCompose(digest -> gateway.evaluateRegisteredForList(digest, key, arguments));
}
private static CompletionStage<String> digest(
RedisCommandGateway gateway, String source, AtomicReference<String> cache) {
String cached = cache.get();
if (cached != null) {
return CompletableFuture.completedFuture(cached);
}
return gateway
.loadScript(source.getBytes(StandardCharsets.UTF_8))
.thenApply(
loaded -> {
cache.set(loaded);
return loaded;
});
}
private static boolean scriptMissing(Throwable failure) {
Throwable cause = failure;
while ((cause instanceof CompletionException || cause instanceof ExecutionException)
&& cause.getCause() != null) {
cause = cause.getCause();
}
String message = cause.getMessage();
return message != null && message.strip().toUpperCase(Locale.ROOT).startsWith("NOSCRIPT");
}
private static Reply replyOf(List<Object> reply) {
if (reply == null || reply.size() < 3) {
throw new IllegalStateException("the lease program answered with an unexpected shape");
}
return new Reply(asLong(reply.get(0)), asLong(reply.get(1)), asText(reply.get(2)));
}
private static long asLong(Object value) {
if (value instanceof Number number) {
return number.longValue();
}
if (value instanceof byte[] bytes) {
return Long.parseLong(new String(bytes, StandardCharsets.UTF_8).strip());
}
throw new IllegalStateException("the lease program answered with an unexpected value type");
}
private static String asText(Object value) {
if (value instanceof byte[] bytes) {
return new String(bytes, StandardCharsets.UTF_8);
}
return value == null ? "" : String.valueOf(value);
}
private static List<byte[]> args(String ownership, long ttlMillis) {
return List.of(
ownership.getBytes(StandardCharsets.UTF_8),
Long.toString(ttlMillis).getBytes(StandardCharsets.UTF_8));
}
/**
* One program's answer.
*
* @param status {@code 1} applied, {@code 2} replay of the same claim, {@code 0} absent, {@code
* -1} held by somebody else
* @param remainingMillis the server's remaining TTL, diagnostic only
* @param holder the stored ownership string, empty when absent
*/
record Reply(long status, long remainingMillis, String holder) {}
}
@@ -0,0 +1,403 @@
package dev.caskeleton.adapter.outbound.cache.redis.lease;
import dev.caskeleton.adapter.outbound.cache.redis.keyspace.CapabilityKeyspace;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.key.RedisNamespace;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisConnectionKind;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisLease;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisRuntimeOwner;
import dev.caskeleton.application.lease.DistributedLeasePort;
import dev.caskeleton.application.lease.LeaseAcquireOutcome;
import dev.caskeleton.application.lease.LeaseAttempt;
import dev.caskeleton.application.lease.LeaseHandle;
import dev.caskeleton.application.lease.LeaseInspectionOutcome;
import dev.caskeleton.application.lease.LeaseInspectionRequest;
import dev.caskeleton.application.lease.LeaseReleaseOutcome;
import dev.caskeleton.application.lease.LeaseRenewOutcome;
import dev.caskeleton.application.lease.LeaseRequest;
import dev.caskeleton.application.lease.LeaseState;
import java.security.SecureRandom;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import java.util.function.LongSupplier;
/**
* The efficiency lease, on Redis.
*
* <p>Efficiency only, and the name is the contract. This lease reduces duplicate work — two workers
* that would otherwise rebuild the same cache entry — and it is <em>not</em> safe to use as the
* sole authority for a domain invariant. There is no fencing token, so a holder that is paused past
* its expiry cannot be stopped from acting; anything correctness-sensitive needs a conditional
* write at the point of effect, not a lock in front of it. Saying so in the type name is the only
* durable way to keep the next caller from reaching for it as a mutex.
*
* <p>Validity is measured locally, from a monotonic clock, and never from the server's TTL. The
* server's expiry is diagnostic: by the time the reply crosses the network it is already stale by
* an unknown amount, and a holder that trusted it would believe it had time it does not. So the
* budget starts when the request was sent, not when the reply arrived, and it is deliberately
* pessimistic by exactly the round trip.
*/
public final class RedisDistributedLeaseAdapter implements DistributedLeasePort {
private static final SecureRandom RANDOM = new SecureRandom();
private final RedisRuntimeOwner owner;
private final LeaseKeys keys;
private final LeaseScripts scripts;
private final Clock clock;
private final LongSupplier nanoTime;
private final Duration commandTimeout;
private final Duration contentionRetryAfter;
private final Duration driftBudget;
/**
* Creates the adapter.
*
* @param owner the Redis runtime owner leases come from
* @param keys renders the private physical keys this adapter owns
* @param scripts the owner-checked atomic programs
* @param clock the wall clock, used only for reporting instants
* @param nanoTime the monotonic source the validity budget is measured on
* @param commandTimeout the ceiling on one lease operation
* @param contentionRetryAfter what a contended acquire tells the caller to wait
* @param driftBudget how much shorter than the server's TTL this holder considers its lease valid
*/
public RedisDistributedLeaseAdapter(
RedisRuntimeOwner owner,
LeaseKeys keys,
LeaseScripts scripts,
Clock clock,
LongSupplier nanoTime,
Duration commandTimeout,
Duration contentionRetryAfter,
Duration driftBudget) {
this.owner = Objects.requireNonNull(owner, "runtime owner must be non-null");
this.keys = Objects.requireNonNull(keys, "keys must be non-null");
this.scripts = Objects.requireNonNull(scripts, "scripts must be non-null");
this.clock = Objects.requireNonNull(clock, "clock must be non-null");
this.nanoTime = Objects.requireNonNull(nanoTime, "monotonic source must be non-null");
this.commandTimeout =
Objects.requireNonNull(commandTimeout, "command timeout must be non-null");
this.contentionRetryAfter =
Objects.requireNonNull(contentionRetryAfter, "contention retry-after must be non-null");
this.driftBudget = Objects.requireNonNull(driftBudget, "drift budget must be non-null");
if (driftBudget.isNegative()) {
throw new IllegalArgumentException("the drift budget must not be negative");
}
}
/**
* Returns how long this holder considers the lease valid locally.
*
* <p>Shorter than the TTL the server was given, by the configured drift budget. The two clocks
* are not the same clock: if this process's monotonic source runs slower than the server's, a
* holder that measured the full TTL locally would still believe it held the lease after the
* server had already expired it and handed it to somebody else. Spending the difference is the
* entire reason the budget exists.
*/
private Duration localValidityOf(Duration leaseTtl) {
if (leaseTtl.compareTo(driftBudget) <= 0) {
// Not a runtime condition to degrade through: a lease shorter than the deployment's own
// clock-drift allowance could never be safely held for any length of time, so granting one
// would be granting something known to be invalid.
throw new IllegalArgumentException(
"a lease TTL of "
+ leaseTtl
+ " is not longer than the configured clock-drift budget of "
+ driftBudget
+ ", so no part of it could be safely relied on locally");
}
return leaseTtl.minus(driftBudget);
}
@Override
public LeaseAttempt newAttempt(String operationId) {
byte[] entropy = new byte[24];
RANDOM.nextBytes(entropy);
// The owner token is unguessable on purpose. It is the only thing standing between a caller and
// releasing somebody else's lease, so a predictable token would make every owner check
// decorative.
return new LeaseAttempt(
Base64.getUrlEncoder().withoutPadding().encodeToString(entropy), operationId);
}
@Override
public LeaseAcquireOutcome tryAcquire(LeaseRequest request) {
Objects.requireNonNull(request, "request must be non-null");
String ownership = ownershipOf(request.attempt());
long startedAt = nanoTime.getAsLong();
try (RedisLease lease = owner.borrow(RedisConnectionKind.SCRIPT)) {
LeaseScripts.Reply reply =
scripts
.acquire(
lease.gateway(),
keys.leaseKey(request.purpose(), request.resourceDigest()),
ownership,
request.leaseTtl().toMillis())
.toCompletableFuture()
.get(commandTimeout.toNanos(), TimeUnit.NANOSECONDS);
return switch ((int) reply.status()) {
case 1 -> new LeaseAcquireOutcome.Acquired(handle(request, ownership, startedAt));
case 2 ->
// The same owner and operation already hold it. This is a retry whose first reply was
// lost, not a second acquisition, and answering "contended" would make a caller back
// off from a lease it already owns.
new LeaseAcquireOutcome.ReplayedSameOperation(handle(request, ownership, startedAt));
default -> contendedOrConflicting(request, reply);
};
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
return new LeaseAcquireOutcome.Indeterminate(request.attempt().operationId());
} catch (Exception failure) {
// An acquire whose outcome is unknown may have taken the lease. Reporting it as a clean
// failure would let the caller retry under a new attempt and hold it twice; Indeterminate
// tells them to inspect with the same attempt instead.
return new LeaseAcquireOutcome.Indeterminate(request.attempt().operationId());
}
}
private LeaseAcquireOutcome contendedOrConflicting(
LeaseRequest request, LeaseScripts.Reply reply) {
String holder = reply.holder();
String ownerToken = request.attempt().ownerToken();
if (holder.startsWith(ownerToken + ":")) {
// Same owner, different operation. The caller has moved on to another unit of work while
// still holding the lease for the previous one; inheriting it silently would attribute the
// old operation's guarantee to the new one.
return new LeaseAcquireOutcome.OwnerOperationConflict();
}
return new LeaseAcquireOutcome.Contended(
reply.remainingMillis() > 0
? Duration.ofMillis(reply.remainingMillis())
: contentionRetryAfter);
}
@Override
public LeaseInspectionOutcome inspect(LeaseInspectionRequest request) {
Objects.requireNonNull(request, "request must be non-null");
String ownership = ownershipOf(request.attempt());
long startedAt = nanoTime.getAsLong();
try (RedisLease lease = owner.borrow(RedisConnectionKind.SCRIPT)) {
LeaseScripts.Reply reply =
scripts
.inspect(
lease.gateway(),
keys.leaseKey(request.purpose(), request.resourceDigest()),
ownership)
.toCompletableFuture()
.get(commandTimeout.toNanos(), TimeUnit.NANOSECONDS);
if (reply.status() == 1) {
return new LeaseInspectionOutcome.Owned(
new RedisLeaseHandle(
request.purpose(),
request.resourceDigest(),
request.attempt(),
ownership,
Duration.ofMillis(Math.max(0, reply.remainingMillis())),
startedAt));
}
if (reply.status() == 0) {
return new LeaseInspectionOutcome.Absent();
}
return reply.holder().startsWith(request.attempt().ownerToken() + ":")
? new LeaseInspectionOutcome.OwnerOperationConflict()
: new LeaseInspectionOutcome.NotOwner();
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
return new LeaseInspectionOutcome.Indeterminate(request.attempt().operationId());
} catch (Exception failure) {
return new LeaseInspectionOutcome.Indeterminate(request.attempt().operationId());
}
}
private RedisLeaseHandle handle(LeaseRequest request, String ownership, long startedAt) {
return new RedisLeaseHandle(
request.purpose(),
request.resourceDigest(),
request.attempt(),
ownership,
localValidityOf(request.leaseTtl()),
startedAt);
}
private static String ownershipOf(LeaseAttempt attempt) {
return attempt.ownerToken() + ":" + attempt.operationId();
}
/** A held lease, whose validity is its own to track. */
private final class RedisLeaseHandle implements LeaseHandle {
private final String purpose;
private final String resourceDigest;
private final LeaseAttempt attempt;
private final String ownership;
private final Instant acquiredAt;
private volatile Duration grantedValidity;
private volatile long grantedAtNanos;
private volatile LeaseState state = LeaseState.ACTIVE;
private RedisLeaseHandle(
String purpose,
String resourceDigest,
LeaseAttempt attempt,
String ownership,
Duration grantedValidity,
long startedAtNanos) {
this.purpose = purpose;
this.resourceDigest = resourceDigest;
this.attempt = attempt;
this.ownership = ownership;
this.grantedValidity = grantedValidity;
// Measured from when the request left, not from when the reply arrived. The server started
// counting at the former, so a budget anchored on the latter is longer than the lease.
this.grantedAtNanos = startedAtNanos;
this.acquiredAt = clock.instant();
}
@Override
public String ownerToken() {
return attempt.ownerToken();
}
@Override
public String operationId() {
return attempt.operationId();
}
@Override
public Instant acquiredAt() {
return acquiredAt;
}
@Override
public Duration remainingValidity() {
Duration elapsed = Duration.ofNanos(nanoTime.getAsLong() - grantedAtNanos);
Duration remaining = grantedValidity.minus(elapsed);
return remaining.isNegative() ? Duration.ZERO : remaining;
}
@Override
public Instant observedServerExpiry() {
return acquiredAt.plus(grantedValidity);
}
@Override
public LeaseState state() {
if (state == LeaseState.ACTIVE && remainingValidity().isZero()) {
// The budget ran out without a successful renew. The server may or may not still hold it;
// what is certain is that this holder can no longer claim it does.
return LeaseState.LOST;
}
return state;
}
@Override
public LeaseRenewOutcome renew(Duration leaseTtl) {
Objects.requireNonNull(leaseTtl, "leaseTtl must be non-null");
long startedAt = nanoTime.getAsLong();
try (RedisLease lease = owner.borrow(RedisConnectionKind.SCRIPT)) {
LeaseScripts.Reply reply =
scripts
.renew(
lease.gateway(),
keys.leaseKey(purpose, resourceDigest),
ownership,
leaseTtl.toMillis())
.toCompletableFuture()
.get(commandTimeout.toNanos(), TimeUnit.NANOSECONDS);
if (reply.status() == 1) {
// The renewed budget is shortened by the drift allowance for the same reason the first
// one was: a renew does not make the two clocks agree.
grantedValidity = localValidityOf(leaseTtl);
grantedAtNanos = startedAt;
return new LeaseRenewOutcome.Renewed(remainingValidity());
}
state = LeaseState.LOST;
return reply.status() == 0
? new LeaseRenewOutcome.Absent()
: new LeaseRenewOutcome.NotOwner();
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
state = LeaseState.UNKNOWN;
return new LeaseRenewOutcome.Indeterminate(attempt.operationId());
} catch (Exception failure) {
// A renew whose outcome is unknown must not extend the local budget. Leaving the handle in
// UNKNOWN is what keeps a caller from acting on validity it cannot demonstrate.
state = LeaseState.UNKNOWN;
return new LeaseRenewOutcome.Indeterminate(attempt.operationId());
}
}
@Override
public LeaseReleaseOutcome release() {
try (RedisLease lease = owner.borrow(RedisConnectionKind.SCRIPT)) {
LeaseScripts.Reply reply =
scripts
.release(lease.gateway(), keys.leaseKey(purpose, resourceDigest), ownership)
.toCompletableFuture()
.get(commandTimeout.toNanos(), TimeUnit.NANOSECONDS);
if (reply.status() == 1) {
state = LeaseState.RELEASED;
return new LeaseReleaseOutcome.Released();
}
if (reply.status() == 0) {
state = LeaseState.RELEASED;
return new LeaseReleaseOutcome.AlreadyAbsent();
}
state = LeaseState.LOST;
return new LeaseReleaseOutcome.NotOwner();
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
state = LeaseState.UNKNOWN;
return new LeaseReleaseOutcome.Indeterminate(attempt.operationId());
} catch (Exception failure) {
state = LeaseState.UNKNOWN;
return new LeaseReleaseOutcome.Indeterminate(attempt.operationId());
}
}
}
/** The private physical keys this adapter owns. */
public static final class LeaseKeys {
private final CapabilityKeyspace keyspace;
/**
* Creates the key renderer.
*
* @param namespace the deployment namespace every capability shares
* @param keyVersion the physical key layout version
*/
public LeaseKeys(RedisNamespace namespace, int keyVersion) {
this.keyspace = new CapabilityKeyspace(namespace, "lease", keyVersion);
}
/**
* Renders the lease key for a resource.
*
* @param purpose the lease purpose
* @param resourceDigest the caller-supplied resource digest
* @return the physical key
*/
public byte[] leaseKey(String purpose, String resourceDigest) {
// The resource appears only as the digest the caller already produced, so the keyspace never
// carries the identifier of whatever is being coordinated on.
return keyspace.key(purpose, resourceDigest);
}
}
/** The guarantee this port provides, stated where a caller will see it. */
public static dev.caskeleton.application.lease.LeaseGuarantee guarantee() {
return dev.caskeleton.application.lease.LeaseGuarantee.EFFICIENCY_ONLY;
}
}
@@ -0,0 +1,49 @@
package dev.caskeleton.adapter.outbound.cache.redis.ratelimit;
import dev.caskeleton.adapter.outbound.cache.redis.keyspace.CapabilityKeyspace;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.key.RedisNamespace;
import dev.caskeleton.shared.ratelimit.RateLimitPolicy;
import java.util.Objects;
/**
* The physical keys this adapter owns, and nothing else does.
*
* <p>Two properties matter and neither is cosmetic.
*
* <p>The key carries the policy <em>revision</em>. Changing a limit from 100/minute to 10/minute
* while the old counters are still in the keyspace would let a subject that had already spent 50
* under the old policy continue against a budget of 10 — or, depending on which way the change
* went, hand them a fresh allowance. A revision in the key means a policy change starts new
* counters, which is the only interpretation that is correct in both directions.
*
* <p>The subject appears only as a digest, and only as one the caller already produced. This
* adapter never sees an IP address, a user id, or a token: the pseudonymisation happens at the
* edge, before the port is called, so a Redis keyspace dump — or a slow log, or a metric label —
* cannot re-identify anybody.
*/
public final class RateLimitKeys {
private final CapabilityKeyspace keyspace;
/**
* Creates the key renderer.
*
* @param namespace the deployment namespace every capability shares
* @param keyVersion the physical key layout version
*/
public RateLimitKeys(RedisNamespace namespace, int keyVersion) {
this.keyspace = new CapabilityKeyspace(namespace, "ratelimit", keyVersion);
}
/**
* Renders the counter key for a subject under a policy.
*
* @param policy the policy being evaluated
* @param subjectDigest the caller-supplied subject digest
* @return the physical key
*/
public byte[] counterKey(RateLimitPolicy policy, String subjectDigest) {
Objects.requireNonNull(policy, "policy must be non-null");
return keyspace.key(policy.policyId(), policy.policyRevision(), subjectDigest);
}
}
@@ -0,0 +1,280 @@
package dev.caskeleton.adapter.outbound.cache.redis.ratelimit;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.operations.RedisCommandGateway;
import dev.caskeleton.shared.ratelimit.RateLimitPolicy;
import dev.caskeleton.shared.ratelimit.RateParameters;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.atomic.AtomicReference;
/**
* The atomic programs that make one rate-limit decision one round trip.
*
* <p>Every algorithm here reads state, decides, mutates, and sets an expiry inside a single server
* execution. Splitting that into commands is not a performance question: two concurrent requests
* that both read "49 used of 50" would both be allowed, and the limit would be exceeded by exactly
* the concurrency. The whole decision has to be indivisible or it is not a limit.
*
* <p>Time comes from the caller, not from the server's {@code TIME}. Two reasons: a script that
* calls {@code TIME} is non-deterministic, and the decision has to be measured against the clock
* the caller's deadline is measured against. The caller's clock going backwards is handled by the
* policy's clock-regression bound rather than by trusting it blindly.
*
* <p>Loaded once and called by digest. A {@code NOSCRIPT} means the server rejected the call before
* running anything, so reloading and retrying once is safe — it is not a retry of an ambiguous
* mutation.
*/
public final class RateLimitScripts {
/**
* Fixed window: one counter per window, expiring with it.
*
* <p>Returns {@code {allowed, remaining, resetAfterMillis}}. The expiry is set from the window
* rather than refreshed per hit, so a subject cannot hold a counter alive indefinitely.
*/
private static final String FIXED_WINDOW =
"""
local limit = tonumber(ARGV[1])
local windowMillis = tonumber(ARGV[2])
local cost = tonumber(ARGV[3])
local nowMillis = tonumber(ARGV[4])
local windowStart = nowMillis - (nowMillis % windowMillis)
local resetAfter = (windowStart + windowMillis) - nowMillis
local bucket = tostring(windowStart)
local current = tonumber(redis.call('HGET', KEYS[1], bucket)) or 0
if current + cost > limit then
return {0, limit - current, resetAfter}
end
redis.call('HSET', KEYS[1], bucket, current + cost)
redis.call('PEXPIRE', KEYS[1], windowMillis * 2)
return {1, limit - (current + cost), resetAfter}
""";
/**
* Sliding counter: the current window plus a weighted share of the previous one.
*
* <p>Approximate by construction, and the port says so. An exact sliding window needs one sorted
* set entry per request, which costs memory proportional to the traffic it is limiting — the
* failure mode of an exact limiter is that it becomes the outage.
*/
private static final String SLIDING_COUNTER =
"""
local limit = tonumber(ARGV[1])
local windowMillis = tonumber(ARGV[2])
local cost = tonumber(ARGV[3])
local nowMillis = tonumber(ARGV[4])
local windowStart = nowMillis - (nowMillis % windowMillis)
local elapsed = nowMillis - windowStart
local resetAfter = windowMillis - elapsed
local current = tonumber(redis.call('HGET', KEYS[1], tostring(windowStart))) or 0
local previous = tonumber(redis.call('HGET', KEYS[1], tostring(windowStart - windowMillis))) or 0
local weight = (windowMillis - elapsed) / windowMillis
local estimated = current + math.floor(previous * weight)
if estimated + cost > limit then
return {0, math.max(0, limit - estimated), resetAfter}
end
redis.call('HSET', KEYS[1], tostring(windowStart), current + cost)
redis.call('HDEL', KEYS[1], tostring(windowStart - (windowMillis * 2)))
redis.call('PEXPIRE', KEYS[1], windowMillis * 3)
return {1, math.max(0, limit - (estimated + cost)), resetAfter}
""";
/**
* Token bucket: refill by elapsed time, then spend.
*
* <p>The stored timestamp is advanced by whole refill periods only. Advancing it to "now" would
* discard the fraction of a period that had already accrued, so a caller polling faster than the
* refill period would never accumulate a token.
*/
private static final String TOKEN_BUCKET =
"""
local capacity = tonumber(ARGV[1])
local refillTokens = tonumber(ARGV[2])
local refillPeriodMillis = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])
local nowMillis = tonumber(ARGV[5])
local state = redis.call('HMGET', KEYS[1], 'tokens', 'updatedAt')
local tokens = tonumber(state[1])
local updatedAt = tonumber(state[2])
if tokens == nil or updatedAt == nil then
tokens = capacity
updatedAt = nowMillis
end
if updatedAt > nowMillis then
-- The caller's clock went backwards. Refilling on a negative elapsed time would remove
-- tokens; holding the state still is the conservative reading.
updatedAt = nowMillis
end
local periods = math.floor((nowMillis - updatedAt) / refillPeriodMillis)
if periods > 0 then
tokens = math.min(capacity, tokens + (periods * refillTokens))
updatedAt = updatedAt + (periods * refillPeriodMillis)
end
local resetAfter = refillPeriodMillis - ((nowMillis - updatedAt) % refillPeriodMillis)
if tokens < cost then
redis.call('HSET', KEYS[1], 'tokens', tokens, 'updatedAt', updatedAt)
redis.call('PEXPIRE', KEYS[1], refillPeriodMillis * (capacity / math.max(1, refillTokens)) + refillPeriodMillis)
return {0, math.floor(tokens), resetAfter}
end
tokens = tokens - cost
redis.call('HSET', KEYS[1], 'tokens', tokens, 'updatedAt', updatedAt)
redis.call('PEXPIRE', KEYS[1], refillPeriodMillis * (capacity / math.max(1, refillTokens)) + refillPeriodMillis)
return {1, math.floor(tokens), resetAfter}
""";
private final AtomicReference<String> fixedWindowDigest = new AtomicReference<>();
private final AtomicReference<String> slidingCounterDigest = new AtomicReference<>();
private final AtomicReference<String> tokenBucketDigest = new AtomicReference<>();
/**
* Evaluates one request atomically.
*
* @param gateway the driver seam of a borrowed lease
* @param key the rendered counter key
* @param policy the policy to apply
* @param cost the request's cost
* @param now the caller's clock reading
* @return the evaluation
*/
public CompletionStage<Evaluation> evaluate(
RedisCommandGateway gateway, byte[] key, RateLimitPolicy policy, long cost, Instant now) {
Objects.requireNonNull(gateway, "gateway must be non-null");
Objects.requireNonNull(policy, "policy must be non-null");
Objects.requireNonNull(now, "now must be non-null");
long nowMillis = now.toEpochMilli();
return switch (policy.parameters()) {
case RateParameters.FixedWindow window ->
run(
gateway,
FIXED_WINDOW,
fixedWindowDigest,
key,
arguments(window.limit(), window.window().toMillis(), cost, nowMillis));
case RateParameters.SlidingCounter sliding ->
run(
gateway,
SLIDING_COUNTER,
slidingCounterDigest,
key,
arguments(sliding.limit(), sliding.window().toMillis(), cost, nowMillis));
case RateParameters.TokenBucket bucket ->
run(
gateway,
TOKEN_BUCKET,
tokenBucketDigest,
key,
arguments(
bucket.capacity(),
bucket.refillTokens(),
bucket.refillPeriod().toMillis(),
cost,
nowMillis));
default ->
CompletableFuture.failedFuture(
new IllegalStateException("unsupported rate parameters: " + policy.parameters()));
};
}
private CompletionStage<Evaluation> run(
RedisCommandGateway gateway,
String source,
AtomicReference<String> cache,
byte[] key,
List<byte[]> arguments) {
return digest(gateway, source, cache)
.thenCompose(digest -> gateway.evaluateRegisteredForList(digest, key, arguments))
.handle(
(reply, failure) ->
failure == null
? CompletableFuture.completedFuture(reply)
: reload(gateway, source, cache, key, arguments, failure))
.thenCompose(stage -> stage)
.thenApply(RateLimitScripts::evaluationOf);
}
private CompletionStage<List<Object>> reload(
RedisCommandGateway gateway,
String source,
AtomicReference<String> cache,
byte[] key,
List<byte[]> arguments,
Throwable failure) {
if (!scriptMissing(failure)) {
return CompletableFuture.failedFuture(failure);
}
cache.set(null);
return digest(gateway, source, cache)
.thenCompose(digest -> gateway.evaluateRegisteredForList(digest, key, arguments));
}
private static CompletionStage<String> digest(
RedisCommandGateway gateway, String source, AtomicReference<String> cache) {
String cached = cache.get();
if (cached != null) {
return CompletableFuture.completedFuture(cached);
}
return gateway
.loadScript(source.getBytes(StandardCharsets.UTF_8))
.thenApply(
loaded -> {
cache.set(loaded);
return loaded;
});
}
private static boolean scriptMissing(Throwable failure) {
Throwable cause = failure;
while ((cause instanceof CompletionException || cause instanceof ExecutionException)
&& cause.getCause() != null) {
cause = cause.getCause();
}
String message = cause.getMessage();
return message != null && message.strip().toUpperCase(Locale.ROOT).startsWith("NOSCRIPT");
}
private static Evaluation evaluationOf(List<Object> reply) {
if (reply == null || reply.size() < 3) {
throw new IllegalStateException(
"the rate limit program answered with "
+ (reply == null ? "nothing" : reply.size())
+ " values; three were expected");
}
return new Evaluation(asLong(reply.get(0)) == 1L, asLong(reply.get(1)), asLong(reply.get(2)));
}
private static long asLong(Object value) {
if (value instanceof Number number) {
return number.longValue();
}
if (value instanceof byte[] bytes) {
return Long.parseLong(new String(bytes, StandardCharsets.UTF_8).strip());
}
throw new IllegalStateException(
"the rate limit program answered with an unexpected value type: "
+ (value == null ? "null" : value.getClass().getName()));
}
private static List<byte[]> arguments(long... values) {
return java.util.Arrays.stream(values)
.mapToObj(value -> Long.toString(value).getBytes(StandardCharsets.UTF_8))
.toList();
}
/**
* One evaluation's result.
*
* @param allowed whether the request may proceed
* @param remaining the remaining budget after this request
* @param resetAfterMillis how long until the budget changes
*/
public record Evaluation(boolean allowed, long remaining, long resetAfterMillis) {}
}
@@ -0,0 +1,187 @@
package dev.caskeleton.adapter.outbound.cache.redis.ratelimit;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.error.RedisOperationException;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisConnectionKind;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisLease;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisRuntimeOwner;
import dev.caskeleton.shared.ratelimit.EdgeRateLimitPort;
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.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
/**
* The provider-neutral edge rate limit, on Redis.
*
* <p>This is the seam the review asks for and the previous generation lost: {@code
* application-core} and {@code shared-contract} see {@link EdgeRateLimitPort}, never a Redis key, a
* connection, a Lua digest, or an SDK type. Everything Redis-shaped stops here.
*
* <p>Fail-closed, without exception. A rate limit exists to bound what reaches the system behind
* it; a limiter that allows traffic when its store is unreachable removes the bound at exactly the
* moment it matters, so every failure path returns {@link RateLimitOutcome.Unavailable} and the
* caller decides. That is why this adapter never has a local fallback counter — an in-process count
* during a Redis outage is not a global limit, it is N times the limit.
*
* <p>The decision is one round trip. Reading the counter and then writing it would let two
* concurrent requests each see the same remaining budget and both be allowed, so the whole decision
* — read, evaluate, increment, expire — is a registered script the server runs atomically.
*/
public final class RedisEdgeRateLimitAdapter implements EdgeRateLimitPort {
private final RedisRuntimeOwner owner;
private final RateLimitKeys keys;
private final Map<String, RateLimitPolicy> policies;
private final RateLimitScripts scripts;
private final Clock clock;
private final Duration commandTimeout;
private final Duration failureRetryAfter;
/**
* Creates the adapter.
*
* @param owner the Redis runtime owner leases come from
* @param keys renders the private physical keys this adapter owns
* @param policies the configured policies, by identifier
* @param scripts the registered atomic programs
* @param clock the clock the decision windows are measured against
* @param commandTimeout the ceiling on one evaluation
* @param failureRetryAfter what an unavailable outcome tells the caller to wait
*/
public RedisEdgeRateLimitAdapter(
RedisRuntimeOwner owner,
RateLimitKeys keys,
Map<String, RateLimitPolicy> policies,
RateLimitScripts scripts,
Clock clock,
Duration commandTimeout,
Duration failureRetryAfter) {
this.owner = Objects.requireNonNull(owner, "runtime owner must be non-null");
this.keys = Objects.requireNonNull(keys, "keys must be non-null");
this.policies = Map.copyOf(Objects.requireNonNull(policies, "policies must be non-null"));
this.scripts = Objects.requireNonNull(scripts, "scripts must be non-null");
this.clock = Objects.requireNonNull(clock, "clock must be non-null");
this.commandTimeout =
Objects.requireNonNull(commandTimeout, "command timeout must be non-null");
this.failureRetryAfter =
Objects.requireNonNull(failureRetryAfter, "failure retry-after must be non-null");
}
@Override
public RateLimitOutcome evaluate(RateLimitRequest request) {
Objects.requireNonNull(request, "request must be non-null");
RateLimitPolicy policy = policies.get(request.policyId());
if (policy == null) {
// An unknown policy is a deployment error, not a traffic condition. Treating it as "allowed"
// would silently disable a limit somebody configured a caller to rely on.
return new RateLimitOutcome.Incompatible(
request.policyId(), RateLimitOutcome.IncompatibleCategory.STATE_INCOMPATIBLE);
}
if (request.cost() > policy.maximumCost()) {
return new RateLimitOutcome.Incompatible(
request.policyId(), RateLimitOutcome.IncompatibleCategory.STATE_INCOMPATIBLE);
}
Instant now = clock.instant();
if (!request.callerDeadline().isAfter(now)) {
// The caller has already run out of time. Spending their remaining budget on a round trip
// whose answer arrives after they gave up is worse than telling them now.
return new RateLimitOutcome.Unavailable(
request.policyId(),
failureRetryAfter,
RateLimitOutcome.UnavailableCategory.ADMISSION_REJECTED);
}
try (RedisLease lease = owner.borrow(RedisConnectionKind.SCRIPT)) {
RateLimitScripts.Evaluation evaluation =
scripts
.evaluate(
lease.gateway(),
keys.counterKey(policy, request.subjectDigest()),
policy,
request.cost(),
now)
.toCompletableFuture()
.get(commandTimeout.toNanos(), TimeUnit.NANOSECONDS);
return new RateLimitOutcome.Evaluated(decisionOf(policy, evaluation, now));
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
return unavailable(policy, RateLimitOutcome.UnavailableCategory.NO_MUTATION_CONFIRMED);
} catch (RedisOperationException failure) {
// The distinction that matters: a command the guard refused never reached Redis and consumed
// no budget, while a command that may have run has consumed one the caller will never know
// about. Reporting them the same way would make a retry either free or double-charged.
return unavailable(
policy,
failure.metadata().ambiguousExecution()
? RateLimitOutcome.UnavailableCategory.NO_MUTATION_CONFIRMED
: RateLimitOutcome.UnavailableCategory.UNAVAILABLE_BEFORE_SEND);
} catch (Exception failure) {
return unavailable(policy, RateLimitOutcome.UnavailableCategory.NO_MUTATION_CONFIRMED);
}
}
private RateLimitOutcome unavailable(
RateLimitPolicy policy, RateLimitOutcome.UnavailableCategory category) {
return new RateLimitOutcome.Unavailable(policy.policyId(), failureRetryAfter, category);
}
private RateLimitDecision decisionOf(
RateLimitPolicy policy, RateLimitScripts.Evaluation evaluation, Instant now) {
long limit = limitOf(policy.parameters());
long remaining = Math.max(0, Math.min(limit, evaluation.remaining()));
Instant resetAt = now.plusMillis(Math.max(0, evaluation.resetAfterMillis()));
Duration retryAfter =
evaluation.allowed()
? Duration.ZERO
// A denied decision must carry a positive wait, and the window may already have
// elapsed by a millisecond by the time we get here.
: Duration.ofMillis(Math.max(1, evaluation.resetAfterMillis()));
return new RateLimitDecision(
evaluation.allowed(),
limit,
remaining,
retryAfter,
resetAt,
policy.policyId(),
policy.policyRevision(),
RateLimitDecision.DecisionSource.GLOBAL_REDIS,
certaintyOf(policy.parameters()));
}
private static long limitOf(RateParameters parameters) {
return switch (parameters) {
case RateParameters.FixedWindow window -> window.limit();
case RateParameters.SlidingCounter sliding -> sliding.limit();
case RateParameters.TokenBucket bucket -> bucket.capacity();
default -> throw new IllegalStateException("unsupported rate parameters: " + parameters);
};
}
private static RateLimitDecision.DecisionCertainty certaintyOf(RateParameters parameters) {
// A sliding counter interpolates across two fixed windows. That is a deliberate trade — exact
// sliding windows cost a sorted set per subject — but the caller is told, because "approximate"
// and "certain" are different things to build a billing or abuse decision on.
return parameters instanceof RateParameters.SlidingCounter
? RateLimitDecision.DecisionCertainty.APPROXIMATE_ALGORITHM
: RateLimitDecision.DecisionCertainty.CERTAIN;
}
/** The policies this adapter serves, for composition-time validation. */
public List<String> policyIds() {
return List.copyOf(policies.keySet());
}
}
@@ -0,0 +1,26 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.admin;
import java.time.Duration;
import java.util.Objects;
/**
* One connected client, projected.
*
* <p>Neither the peer address nor the connection name is carried. Both routinely encode tenant or
* deployment identity, and this plane's replies end up in dashboards; the counters below are what
* an operator actually needs to find a leaking pool or a stuck consumer.
*
* @param id the server-assigned connection id
* @param age how long the connection has existed
* @param idle how long it has been idle
* @param lastCommandFamily the container name of the last command it ran
*/
public record ClientSummary(long id, Duration age, Duration idle, String lastCommandFamily) {
/** Canonical constructor. */
public ClientSummary {
Objects.requireNonNull(age, "age must be non-null");
Objects.requireNonNull(idle, "idle must be non-null");
Objects.requireNonNull(lastCommandFamily, "last command family must be non-null");
}
}
@@ -0,0 +1,337 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.admin;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.command.CommandId;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.command.CommandSupport;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.command.OperationBudget;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.key.QualifiedRedisKey;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.command.CommandRequest;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.command.RedisCommandCatalog;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.command.RedisCommandPolicy;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.command.SyncRedisCommandExecutor;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.operations.RedisCommandGateway;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.operations.RedisOperationContext;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
/**
* The admin plane, on its own connection and its own ACL account.
*
* <p>It takes its own gateway for the same reason the blocking operations do: a diagnostic that
* walks the keyspace or serializes a large {@code INFO} must not compete with request traffic, and
* the account it authenticates with should be able to read diagnostics and nothing else. Binding
* that to a separate gateway instance is how the separation is expressed structurally instead of
* being left to a deployment note.
*
* <p>Every command is checked against the catalog before it is built: not classified {@code
* ADMIN_ONLY}, or not read-only, and it does not get sent. That check is what keeps a future
* addition to this class from quietly becoming a write.
*/
public final class LettuceRedisAdminOperations implements RedisAdminOperations {
private static final String FAMILY = "ADMIN";
private static final int MAX_PROJECTED = 1_000;
private final RedisCommandCatalog catalog;
private final RedisCommandGateway adminGateway;
private final RedisOperationContext context;
private final SyncRedisCommandExecutor executor;
private final Duration timeout;
/**
* Creates the admin plane.
*
* @param catalog the command policy catalog
* @param adminGateway the driver seam, bound to the admin account's own connection
* @param context the shared rendering and budget rules
* @param executor the guarded blocking executor
* @param timeout the bound on one diagnostic
*/
public LettuceRedisAdminOperations(
RedisCommandCatalog catalog,
RedisCommandGateway adminGateway,
RedisOperationContext context,
SyncRedisCommandExecutor executor,
Duration timeout) {
this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null");
this.adminGateway = Objects.requireNonNull(adminGateway, "admin gateway must be non-null");
this.context = Objects.requireNonNull(context, "operation context must be non-null");
this.executor = Objects.requireNonNull(executor, "executor must be non-null");
this.timeout = Objects.requireNonNull(timeout, "timeout must be non-null");
if (timeout.isZero() || timeout.isNegative()) {
throw new IllegalArgumentException("the admin timeout must be positive");
}
}
@Override
public Map<String, String> serverInfo(String section) {
Objects.requireNonNull(section, "section must be non-null");
return fields(text(run(CommandId.parse("INFO"), List.of(), utf8(section))));
}
@Override
public long databaseSize() {
return number(run(CommandId.parse("DBSIZE"), List.of()));
}
@Override
public long memoryUsage(QualifiedRedisKey key) {
Objects.requireNonNull(key, "key must be non-null");
byte[] rendered = context.renderKey(key);
List<Object> reply = run(CommandId.parse("MEMORY USAGE"), List.of(key), rendered);
return reply.isEmpty() || reply.get(0) == null ? -1L : number(reply);
}
@Override
public List<SlowLogEntry> slowLog(int count) {
requireBounded(count, "a slow log read");
List<Object> reply =
run(CommandId.parse("SLOWLOG GET"), List.of(), utf8(Integer.toString(count)));
List<SlowLogEntry> entries = new ArrayList<>();
for (Object element : reply) {
List<Object> row = nested(element);
if (row.size() < 4) {
continue;
}
entries.add(
new SlowLogEntry(
(Long) row.get(0),
Instant.ofEpochSecond((Long) row.get(1)),
Duration.ofNanos(Duration.ofMillis((Long) row.get(2)).toNanos() / 1_000L),
family(nested(row.get(3)))));
}
return List.copyOf(entries);
}
@Override
public Map<String, Duration> latencyLatest() {
List<Object> reply = run(CommandId.parse("LATENCY LATEST"), List.of());
Map<String, Duration> latest = new LinkedHashMap<>();
for (Object element : reply) {
List<Object> row = nested(element);
if (row.size() < 3) {
continue;
}
latest.put(text(List.of(row.get(0))), Duration.ofMillis((Long) row.get(2)));
}
return Map.copyOf(latest);
}
@Override
public List<ClientSummary> clients(int limit) {
requireBounded(limit, "a client projection");
String listing = text(run(CommandId.parse("CLIENT LIST"), List.of()));
List<ClientSummary> clients = new ArrayList<>();
for (String line : listing.lines().toList()) {
if (line.isBlank() || clients.size() == limit) {
break;
}
Map<String, String> attributes = attributes(line);
clients.add(
new ClientSummary(
Long.parseLong(attributes.getOrDefault("id", "0")),
Duration.ofSeconds(Long.parseLong(attributes.getOrDefault("age", "0"))),
Duration.ofSeconds(Long.parseLong(attributes.getOrDefault("idle", "0"))),
attributes.getOrDefault("cmd", "unknown").toUpperCase(Locale.ROOT)));
}
return List.copyOf(clients);
}
@Override
public Map<String, String> clusterInfo() {
return fields(text(run(CommandId.parse("CLUSTER INFO"), List.of())));
}
/**
* The only configuration parameters this plane will read.
*
* <p>Chosen for what an operator diagnosing a Redis problem actually needs — memory ceiling and
* eviction, persistence, replication durability, connection lifetime, topology — and nothing
* else. Adding a parameter is an edit here, which is the point: the set is reviewable, whereas a
* glob is not.
*/
private static final List<String> DIAGNOSTIC_PARAMETERS =
List.of(
"maxmemory",
"maxmemory-policy",
"maxmemory-samples",
"appendonly",
"appendfsync",
"save",
"min-replicas-to-write",
"min-replicas-max-lag",
"timeout",
"tcp-keepalive",
"databases",
"cluster-enabled",
"cluster-require-full-coverage",
"lazyfree-lazy-eviction",
"lazyfree-lazy-expire",
"notify-keyspace-events",
"slowlog-log-slower-than",
"slowlog-max-len");
/** Substrings that mark a parameter as carrying credential material. */
private static final List<String> SECRET_MARKERS =
List.of("pass", "auth", "secret", "key-file", "keyfile", "user");
/** Replacement for a value that must never leave the server. */
static final String REDACTED = "[redacted]";
@Override
public Map<String, String> configuration() {
List<byte[]> arguments =
DIAGNOSTIC_PARAMETERS.stream().map(LettuceRedisAdminOperations::utf8).toList();
List<Object> reply = run(CommandId.parse("CONFIG GET"), List.of(), arguments);
Map<String, String> parameters = new LinkedHashMap<>();
for (int index = 0; index + 1 < reply.size(); index += 2) {
String name = text(List.of(reply.get(index)));
// Filtered again on the way out. The request already named only allowlisted parameters, but
// a server-side alias or a future glob-expanding change must not be able to widen the
// projection, and a parameter that slipped through must not carry its value with it.
if (!DIAGNOSTIC_PARAMETERS.contains(name)) {
continue;
}
String value = text(List.of(reply.get(index + 1)));
parameters.put(name, isSecretShaped(name) ? REDACTED : value);
}
return Map.copyOf(parameters);
}
private static boolean isSecretShaped(String parameterName) {
String lower = parameterName.toLowerCase(Locale.ROOT);
return SECRET_MARKERS.stream().anyMatch(lower::contains);
}
@Override
public Optional<String> aclDryRun(String username, CommandId commandId) {
Objects.requireNonNull(username, "username must be non-null");
Objects.requireNonNull(commandId, "command id must be non-null");
List<byte[]> arguments = new ArrayList<>();
arguments.add(utf8(username));
arguments.add(utf8(commandId.family()));
commandId.subcommand().map(LettuceRedisAdminOperations::utf8).ifPresent(arguments::add);
String answer = text(run(CommandId.parse("ACL DRYRUN"), List.of(), arguments));
return "OK".equals(answer) ? Optional.empty() : Optional.of(answer);
}
private List<Object> run(CommandId commandId, List<QualifiedRedisKey> keys, byte[]... arguments) {
return run(commandId, keys, List.of(arguments));
}
private List<Object> run(
CommandId commandId, List<QualifiedRedisKey> keys, List<byte[]> arguments) {
RedisCommandPolicy policy = catalog.require(commandId);
if (policy.support() != CommandSupport.ADMIN_ONLY || !policy.readOnly()) {
throw context.reject(
FAMILY, true, "the admin plane only sends read-only diagnostics the catalog approved");
}
long requestBytes = 1L;
for (byte[] argument : arguments) {
requestBytes += argument.length;
}
OperationBudget budget =
new OperationBudget(
Math.max(1, keys.size()),
requestBytes,
context.limits().maxReplyBytesPerElement(),
timeout);
return executor.execute(
new CommandRequest<>(
commandId,
keys,
requestBytes,
0L,
Optional.empty(),
Optional.empty(),
Optional.of(budget),
Optional.empty(),
() -> adminGateway.sendAdminDiagnostic(commandId, arguments)));
}
private void requireBounded(int count, String description) {
if (count < 1) {
throw context.reject(FAMILY, true, description + " must declare a positive bound");
}
if (count > MAX_PROJECTED) {
throw context.reject(
FAMILY, true, description + " may not exceed " + MAX_PROJECTED + " entries");
}
}
private static String family(List<Object> commandWords) {
return commandWords.isEmpty()
? "UNKNOWN"
: text(List.of(commandWords.get(0))).toUpperCase(Locale.ROOT);
}
private static Map<String, String> fields(String body) {
Map<String, String> parsed = new LinkedHashMap<>();
for (String line : body.lines().toList()) {
String trimmed = line.strip();
int separator = trimmed.indexOf(':');
if (trimmed.isEmpty() || trimmed.startsWith("#") || separator < 1) {
continue;
}
parsed.put(trimmed.substring(0, separator), trimmed.substring(separator + 1));
}
return Map.copyOf(parsed);
}
private static Map<String, String> attributes(String line) {
Map<String, String> parsed = new LinkedHashMap<>();
// Parsed by hand rather than by splitting: a CLIENT LIST line is space-separated key=value
// pairs, and the values can themselves contain characters a naive split would mangle.
String remainder = line.strip();
while (!remainder.isEmpty()) {
int space = remainder.indexOf(' ');
String pair = space < 0 ? remainder : remainder.substring(0, space);
remainder = space < 0 ? "" : remainder.substring(space + 1);
int separator = pair.indexOf('=');
if (separator > 0) {
parsed.put(pair.substring(0, separator), pair.substring(separator + 1));
}
}
return parsed;
}
private static long number(List<Object> reply) {
Object first = reply.isEmpty() ? null : reply.get(0);
if (first instanceof Long value) {
return value;
}
if (first instanceof byte[] bytes) {
return Long.parseLong(new String(bytes, StandardCharsets.UTF_8).strip());
}
throw new IllegalStateException("the diagnostic did not answer with a number");
}
private static String text(List<Object> reply) {
Object first = reply.isEmpty() ? null : reply.get(0);
if (first instanceof byte[] bytes) {
return new String(bytes, StandardCharsets.UTF_8);
}
return first == null ? "" : String.valueOf(first);
}
@SuppressWarnings("unchecked")
private static List<Object> nested(Object element) {
return element instanceof List ? (List<Object>) element : List.of();
}
private static byte[] utf8(String text) {
return text.getBytes(StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,103 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.admin;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.command.CommandId;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.key.QualifiedRedisKey;
import java.time.Duration;
import java.util.List;
import java.util.Map;
/**
* Read-only server diagnostics, separated from the application request path.
*
* <p>Everything here is read-only by construction: the command policy catalog classifies each of
* these {@code ADMIN_ONLY} and read-only, and the implementation refuses to send anything that is
* not. The destructive counterparts an operator might reach for — {@code FLUSHDB}, {@code
* FLUSHALL}, {@code SHUTDOWN}, {@code DEBUG}, {@code CONFIG SET}, {@code CLIENT KILL}, {@code ACL
* SETUSER}, {@code SLOWLOG RESET}, {@code LATENCY RESET} — are all {@code BLOCKED} in the catalog
* and have no method here or anywhere else in the SDK.
*
* <p>The plane is expected to run on its own connection factory and its own ACL account. That is
* not something this interface can enforce, which is exactly why the catalog blocks the dangerous
* commands outright rather than trusting the deployment to have separated the credentials.
*/
public interface RedisAdminOperations {
/**
* Reads one {@code INFO} section.
*
* @param section the section name, for example {@code memory} or {@code replication}
* @return the section's fields
*/
Map<String, String> serverInfo(String section);
/**
* Reads the key count of the current database.
*
* @return the key count
*/
long databaseSize();
/**
* Reads the memory one key occupies.
*
* @param key the key, which is namespace-checked like any other
* @return the size in bytes, or {@code -1} when the key is absent
*/
long memoryUsage(QualifiedRedisKey key);
/**
* Reads the most recent slow log entries.
*
* @param count the strictly positive bound on returned entries
* @return the entries, newest first
*/
List<SlowLogEntry> slowLog(int count);
/**
* Reads the latest latency spike per monitored event.
*
* @return the latest spike per event name
*/
Map<String, Duration> latencyLatest();
/**
* Reads a bounded projection of the connected clients.
*
* @param limit the strictly positive bound on returned clients
* @return the projected clients
*/
List<ClientSummary> clients(int limit);
/**
* Reads the cluster state.
*
* @return the {@code CLUSTER INFO} fields
*/
Map<String, String> clusterInfo();
/**
* Reads the fixed diagnostic configuration projection.
*
* <p>There is deliberately no pattern parameter. {@code CONFIG GET} with a caller-supplied glob
* is an arbitrary read of the server's configuration: {@code *} returns everything the account
* can see, including {@code requirepass}, {@code masterauth}, {@code masteruser} and the TLS key
* passwords. An admin plane whose whole purpose is bounded, payload-free diagnostics cannot own a
* method that returns whatever the caller asks for, so the parameter set is fixed here and
* anything outside it is unreachable.
*
* @return the allowlisted diagnostic parameters, with any secret-shaped value redacted
*/
Map<String, String> configuration();
/**
* Asks the server whether a user would be allowed to run a command.
*
* <p>This is how an ACL account is verified against what the SDK actually sends, rather than
* against what someone believed it sends.
*
* @param username the ACL user
* @param commandId the command to test
* @return empty when the command would be allowed, otherwise the server's refusal reason
*/
java.util.Optional<String> aclDryRun(String username, CommandId commandId);
}
@@ -0,0 +1,28 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.admin;
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
/**
* One entry of the server's slow log, projected.
*
* <p>The arguments the slow command was called with are deliberately not carried. A slow log entry
* is read by an operator and usually ends up in a dashboard or a ticket, and the arguments of a
* slow command are exactly the caller data — keys, member names, payload fragments — that must
* never leave through an operational channel. The command family is enough to find the call site.
*
* @param id the server-assigned entry id
* @param at when the command ran
* @param took how long the server spent executing it
* @param commandFamily the container command name, without arguments
*/
public record SlowLogEntry(long id, Instant at, Duration took, String commandFamily) {
/** Canonical constructor. */
public SlowLogEntry {
Objects.requireNonNull(at, "timestamp must be non-null");
Objects.requireNonNull(took, "duration must be non-null");
Objects.requireNonNull(commandFamily, "command family must be non-null");
}
}
@@ -0,0 +1,108 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.reactive.ReactiveRedisBatchOperations;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.reactive.ReactiveRedisBitFieldOperations;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.reactive.ReactiveRedisBitmapOperations;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.reactive.ReactiveRedisGeoOperations;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.reactive.ReactiveRedisHashOperations;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.reactive.ReactiveRedisHyperLogLogOperations;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.reactive.ReactiveRedisKeyOperations;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.reactive.ReactiveRedisListOperations;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.reactive.ReactiveRedisSetOperations;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.reactive.ReactiveRedisSortedSetOperations;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.reactive.ReactiveRedisStreamOperations;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.reactive.ReactiveRedisValueOperations;
/**
* Reactive entry point to the typed Redis API.
*
* <p>Mirrors {@link RedisOperations} method for method. The two are separate types on purpose: a
* single generic asynchronous abstraction would push {@code CompletionStage} into every call site
* and make the blocking cost of a synchronous caller invisible.
*/
public interface ReactiveRedisOperations {
/**
* Returns reactive string operations.
*
* @return the string operations
*/
ReactiveRedisValueOperations values();
/**
* Returns reactive hash operations.
*
* @return the hash operations
*/
ReactiveRedisHashOperations hashes();
/**
* Returns reactive list operations.
*
* @return the list operations
*/
ReactiveRedisListOperations lists();
/**
* Returns reactive set operations.
*
* @return the set operations
*/
ReactiveRedisSetOperations sets();
/**
* Returns reactive sorted set operations.
*
* @return the sorted set operations
*/
ReactiveRedisSortedSetOperations sortedSets();
/**
* Returns reactive bitmap operations.
*
* @return the bitmap operations
*/
ReactiveRedisBitmapOperations bitmaps();
/**
* Returns reactive bitfield operations.
*
* @return the bitfield operations
*/
ReactiveRedisBitFieldOperations bitFields();
/**
* Returns reactive HyperLogLog operations.
*
* @return the HyperLogLog operations
*/
ReactiveRedisHyperLogLogOperations hyperLogLogs();
/**
* Returns reactive geospatial operations.
*
* @return the geospatial operations
*/
ReactiveRedisGeoOperations geo();
/**
* Returns reactive stream operations.
*
* @return the stream operations
*/
ReactiveRedisStreamOperations streams();
/**
* Returns reactive key and expiry operations.
*
* @return the key operations
*/
ReactiveRedisKeyOperations keys();
/**
* Returns reactive batch operations.
*
* @return the batch operations
*/
ReactiveRedisBatchOperations batches();
}
@@ -0,0 +1,119 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api;
import java.util.Collection;
import java.util.EnumSet;
import java.util.Objects;
import java.util.Set;
/** Immutable probe result describing what the bound server actually supports. */
public final class RedisCapabilities {
private final RedisVersion serverVersion;
private final RedisDeploymentMode deploymentMode;
private final Set<RedisCapability> available;
private RedisCapabilities(
RedisVersion serverVersion,
RedisDeploymentMode deploymentMode,
Set<RedisCapability> available) {
this.serverVersion = serverVersion;
this.deploymentMode = deploymentMode;
this.available = available;
}
/**
* Creates a capability snapshot.
*
* @param serverVersion probed server version
* @param deploymentMode probed deployment mode
* @param available capabilities the probe proved present
* @return the immutable snapshot
*/
public static RedisCapabilities of(
RedisVersion serverVersion,
RedisDeploymentMode deploymentMode,
Collection<RedisCapability> available) {
Objects.requireNonNull(serverVersion, "server version must be non-null");
Objects.requireNonNull(deploymentMode, "deployment mode must be non-null");
Objects.requireNonNull(available, "available capabilities must be non-null");
if (!serverVersion.isAtLeast(RedisVersion.MINIMUM_SUPPORTED)) {
throw new IllegalArgumentException("Redis SDK requires server version 7.2.0 or later");
}
EnumSet<RedisCapability> capabilities = EnumSet.noneOf(RedisCapability.class);
for (RedisCapability capability : available) {
Objects.requireNonNull(capability, "capability must be non-null");
if (!capability.possibleOn(serverVersion)) {
throw new IllegalArgumentException(
"Capability " + capability + " cannot exist on server " + serverVersion);
}
capabilities.add(capability);
}
return new RedisCapabilities(serverVersion, deploymentMode, Set.copyOf(capabilities));
}
/**
* Returns the probed server version.
*
* @return the server version
*/
public RedisVersion serverVersion() {
return serverVersion;
}
/**
* Returns the probed deployment mode.
*
* @return the deployment mode
*/
public RedisDeploymentMode deploymentMode() {
return deploymentMode;
}
/**
* Reports whether the probe proved the capability present.
*
* @param capability the capability to check
* @return {@code true} when the capability is available
*/
public boolean has(RedisCapability capability) {
return available.contains(Objects.requireNonNull(capability, "capability must be non-null"));
}
/**
* Reports whether the server satisfies a command's declared minimum version.
*
* @param minimumVersion the command minimum version
* @return {@code true} when the server is new enough
*/
public boolean satisfies(RedisVersion minimumVersion) {
return serverVersion.isAtLeast(
Objects.requireNonNull(minimumVersion, "minimum version must be non-null"));
}
/**
* Returns every proven capability.
*
* @return an unmodifiable capability set
*/
public Set<RedisCapability> available() {
return available;
}
@Override
public boolean equals(Object other) {
return other instanceof RedisCapabilities capabilities
&& serverVersion.equals(capabilities.serverVersion)
&& deploymentMode == capabilities.deploymentMode
&& available.equals(capabilities.available);
}
@Override
public int hashCode() {
return Objects.hash(serverVersion, deploymentMode, available);
}
@Override
public String toString() {
return "RedisCapabilities[" + serverVersion + ", " + deploymentMode + ", " + available + "]";
}
}
@@ -0,0 +1,72 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api;
import java.util.Objects;
/**
* Version-gated server capability.
*
* <p>A capability is never assumed from the advertised server version alone for extensions: the
* minimum version is a fast pre-filter and the probe is the authority.
*/
public enum RedisCapability {
/** Sharded Pub/Sub ({@code SPUBLISH}/{@code SSUBSCRIBE}). */
SHARDED_PUBSUB(7, 0, false),
/** Redis Functions ({@code FUNCTION LOAD}/{@code FCALL}). */
FUNCTIONS(7, 0, false),
/** Hash field expiration ({@code HEXPIRE}/{@code HPERSIST}/{@code HTTL}). */
HASH_FIELD_EXPIRATION(7, 4, false),
/** Combined hash read/write plus field expiration ({@code HGETEX}/{@code HSETEX}). */
HASH_FIELD_EXPIRATION_COMBINED(8, 0, false),
/** Stream acknowledge-and-delete ({@code XACKDEL}/{@code XDELEX}). */
STREAM_ACKNOWLEDGE_DELETE(8, 2, false),
/** Stream negative acknowledge ({@code XNACK}). */
STREAM_NEGATIVE_ACKNOWLEDGE(8, 8, false),
/** JSON document commands. */
JSON(8, 0, true),
/** Query engine index, search, aggregation, and vector query commands. */
SEARCH(8, 0, true),
/** Time series commands. */
TIME_SERIES(8, 0, true),
/** Probabilistic structures: Bloom, Cuckoo, Count-Min Sketch, Top-K, t-digest. */
PROBABILISTIC(8, 0, true);
private final int minimumMajor;
private final int minimumMinor;
private final boolean extension;
RedisCapability(int minimumMajor, int minimumMinor, boolean extension) {
this.minimumMajor = minimumMajor;
this.minimumMinor = minimumMinor;
this.extension = extension;
}
/**
* Returns the lowest server version that can carry the capability.
*
* @return the minimum version
*/
public RedisVersion minimumVersion() {
return new RedisVersion(minimumMajor, minimumMinor, 0);
}
/**
* Reports whether the capability belongs to an independently deployable extension module rather
* than the classic core.
*
* @return {@code true} for extension capabilities
*/
public boolean extension() {
return extension;
}
/**
* Reports whether the server version alone can rule the capability out.
*
* @param serverVersion the probed server version
* @return {@code true} when the version is new enough for the capability to be possible
*/
public boolean possibleOn(RedisVersion serverVersion) {
Objects.requireNonNull(serverVersion, "server version must be non-null");
return serverVersion.isAtLeast(minimumVersion());
}
}
@@ -0,0 +1,43 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api;
/** Redis deployment topology the SDK is bound to. */
public enum RedisDeploymentMode {
/** Single primary, optional replicas, any database index. */
STANDALONE,
/** Sentinel-managed primary with failover promotion semantics. */
SENTINEL,
/** Cluster with slot ownership; database 0 only and same-slot multi-key operations. */
CLUSTER;
/**
* Reports whether the mode constrains multi-key operations to a single hash slot.
*
* @return {@code true} for {@link #CLUSTER}
*/
public boolean requiresSameSlot() {
return this == CLUSTER;
}
/**
* Reports whether the mode promotes a replica to primary without the client being asked.
*
* <p>This is the property that makes an acknowledged write losable: a primary that has been
* superseded keeps accepting writes until it finds out, and those writes are discarded when it
* resyncs. Standalone is excluded because it has no promotion — a standalone primary that fails
* is simply down, which is visible.
*
* @return {@code true} for {@link #SENTINEL} and {@link #CLUSTER}
*/
public boolean replicated() {
return this != STANDALONE;
}
/**
* Reports whether the mode allows a database index other than {@code 0}.
*
* @return {@code true} for every non-cluster mode
*/
public boolean allowsNonZeroDatabase() {
return this != CLUSTER;
}
}
@@ -0,0 +1,109 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.operations.RedisBatchOperations;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.operations.RedisBitFieldOperations;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.operations.RedisBitmapOperations;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.operations.RedisGeoOperations;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.operations.RedisHashOperations;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.operations.RedisHyperLogLogOperations;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.operations.RedisKeyOperations;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.operations.RedisListOperations;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.operations.RedisSetOperations;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.operations.RedisSortedSetOperations;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.operations.RedisStreamOperations;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.operations.RedisValueOperations;
/**
* Synchronous entry point to the typed Redis API.
*
* <p>Blocking, transactional, Pub/Sub, admin, raw, and extension surfaces are deliberately not
* reachable from here. Each of those needs a different connection, a different ACL account, or a
* different deployment decision, and folding them into one facade is how those separations get
* lost.
*/
public interface RedisOperations {
/**
* Returns string operations.
*
* @return the string operations
*/
RedisValueOperations values();
/**
* Returns hash operations.
*
* @return the hash operations
*/
RedisHashOperations hashes();
/**
* Returns list operations.
*
* @return the list operations
*/
RedisListOperations lists();
/**
* Returns set operations.
*
* @return the set operations
*/
RedisSetOperations sets();
/**
* Returns sorted set operations.
*
* @return the sorted set operations
*/
RedisSortedSetOperations sortedSets();
/**
* Returns bitmap operations.
*
* @return the bitmap operations
*/
RedisBitmapOperations bitmaps();
/**
* Returns bitfield operations.
*
* @return the bitfield operations
*/
RedisBitFieldOperations bitFields();
/**
* Returns HyperLogLog operations.
*
* @return the HyperLogLog operations
*/
RedisHyperLogLogOperations hyperLogLogs();
/**
* Returns geospatial operations.
*
* @return the geospatial operations
*/
RedisGeoOperations geo();
/**
* Returns stream operations.
*
* @return the stream operations
*/
RedisStreamOperations streams();
/**
* Returns key and expiry operations.
*
* @return the key operations
*/
RedisKeyOperations keys();
/**
* Returns batch operations.
*
* @return the batch operations
*/
RedisBatchOperations batches();
}
@@ -0,0 +1,84 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api;
import java.util.Objects;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Strict {@code major.minor.patch} Redis server version with natural ordering.
*
* <p>The SDK feature baseline is Redis 7.2. Version gating never guesses: a capability is either
* proven by this value or by an explicit server probe.
*/
public record RedisVersion(int major, int minor, int patch) implements Comparable<RedisVersion> {
private static final Pattern SEMANTIC = Pattern.compile("^(\\d{1,4})\\.(\\d{1,4})\\.(\\d{1,4})$");
/** Lowest server version the SDK supports at all. */
public static final RedisVersion MINIMUM_SUPPORTED = new RedisVersion(7, 2, 0);
public RedisVersion {
if (major < 0 || minor < 0 || patch < 0) {
throw new IllegalArgumentException("Redis version components must not be negative");
}
}
/**
* Parses a strict {@code major.minor.patch} version.
*
* @param text version text, for example {@code 8.2.1}
* @return the parsed version
* @throws IllegalArgumentException when the text is not a strict three-component version
*/
public static RedisVersion parse(String text) {
Objects.requireNonNull(text, "Redis version text must be non-null");
Matcher matcher = SEMANTIC.matcher(text.strip());
if (!matcher.matches()) {
throw new IllegalArgumentException("Redis version must be major.minor.patch");
}
return new RedisVersion(
Integer.parseInt(matcher.group(1)),
Integer.parseInt(matcher.group(2)),
Integer.parseInt(matcher.group(3)));
}
/**
* Parses a {@code major.minor} profile such as a policy file minimum version.
*
* @param text version text, for example {@code 7.4}
* @return the parsed version with patch {@code 0}
*/
public static RedisVersion parseProfile(String text) {
Objects.requireNonNull(text, "Redis version profile text must be non-null");
String stripped = text.strip();
return stripped.chars().filter(character -> character == '.').count() == 1
? parse(stripped + ".0")
: parse(stripped);
}
/**
* Reports whether this version is at least {@code other}.
*
* @param other the required minimum
* @return {@code true} when this version satisfies the minimum
*/
public boolean isAtLeast(RedisVersion other) {
return compareTo(Objects.requireNonNull(other, "compared version must be non-null")) >= 0;
}
@Override
public int compareTo(RedisVersion other) {
Objects.requireNonNull(other, "compared version must be non-null");
int majorOrder = Integer.compare(major, other.major);
if (majorOrder != 0) {
return majorOrder;
}
int minorOrder = Integer.compare(minor, other.minor);
return minorOrder != 0 ? minorOrder : Integer.compare(patch, other.patch);
}
@Override
public String toString() {
return major + "." + minor + "." + patch;
}
}
@@ -0,0 +1,40 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.codec;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.error.RedisSerializationException;
/**
* Binary codec for one Redis value, field, or member type.
*
* <p>Java native serialization is never an implementation option. Every codec has a stable
* identifier so a decoding failure can be attributed without logging the payload.
*
* @param <T> the decoded type
*/
public interface RedisCodec<T> {
/**
* Returns the stable codec identifier used in telemetry and schema evolution records.
*
* @return the codec id
*/
String id();
/**
* Encodes a value.
*
* @param value the value to encode
* @return the encoded bytes
* @throws RedisSerializationException when the value cannot be encoded
*/
byte[] encode(T value);
/**
* Decodes bytes previously produced by {@link #encode(Object)}.
*
* @param bytes the stored bytes
* @return the decoded value
* @throws RedisSerializationException when the bytes are corrupt, truncated, or of an unknown
* schema version
*/
T decode(byte[] bytes);
}
@@ -0,0 +1,124 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.codec;
import java.time.Instant;
import java.util.Arrays;
import java.util.Objects;
/**
* Versioned wrapper stored around every object payload.
*
* <p>Carrying schema and version with the bytes is what makes a rolling schema change safe: a
* reader can recognize a future version and fail loudly instead of silently mis-decoding it.
*
* <p>This is a value class rather than a record because the payload is a byte array; the repository
* static-analysis contract forbids array record components, and value semantics are provided here
* explicitly with defensive copies.
*/
public final class RedisEnvelope {
/** The alphabet a stored schema identifier may use. */
private static final java.util.regex.Pattern SCHEMA_IDENTIFIER =
java.util.regex.Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,127}");
private final String schema;
private final int version;
private final Instant createdAt;
private final byte[] payload;
/**
* Creates an envelope.
*
* @param schema the stable schema identifier
* @param version the schema version of the payload
* @param createdAt the write timestamp
* @param payload the opaque payload bytes
*/
public RedisEnvelope(String schema, int version, Instant createdAt, byte[] payload) {
Objects.requireNonNull(schema, "schema must be non-null");
Objects.requireNonNull(createdAt, "createdAt must be non-null");
Objects.requireNonNull(payload, "payload must be non-null");
if (schema.isBlank()) {
throw new IllegalArgumentException("schema must not be blank");
}
if (!SCHEMA_IDENTIFIER.matcher(schema).matches()) {
// A schema id is written into the stored framing and compared on read. Constraining it to a
// conservative identifier alphabet means the framing writer never has to render a quote, a
// backslash, or a control character in that position, and a stored envelope can never carry
// a schema name that changes how the rest of the document parses.
throw new IllegalArgumentException(
"schema '"
+ schema
+ "' must match "
+ SCHEMA_IDENTIFIER.pattern()
+ " (letters, digits, '.', '_' and '-', 1..128 characters)");
}
if (version < 1) {
throw new IllegalArgumentException("schema version must be positive");
}
this.schema = schema;
this.version = version;
this.createdAt = createdAt;
this.payload = payload.clone();
}
/**
* Returns the stable schema identifier.
*
* @return the schema id
*/
public String schema() {
return schema;
}
/**
* Returns the schema version of the payload.
*
* @return the schema version
*/
public int version() {
return version;
}
/**
* Returns the write timestamp.
*
* @return the instant the envelope was written
*/
public Instant createdAt() {
return createdAt;
}
/**
* Returns a copy of the opaque payload bytes.
*
* @return the payload
*/
public byte[] payload() {
return payload.clone();
}
@Override
public boolean equals(Object other) {
return other instanceof RedisEnvelope envelope
&& schema.equals(envelope.schema)
&& version == envelope.version
&& createdAt.equals(envelope.createdAt)
&& Arrays.equals(payload, envelope.payload);
}
@Override
public int hashCode() {
return Objects.hash(schema, version, createdAt, Arrays.hashCode(payload));
}
@Override
public String toString() {
return "RedisEnvelope[schema="
+ schema
+ ", version="
+ version
+ ", bytes="
+ payload.length
+ "]";
}
}
@@ -0,0 +1,55 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.codec;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.error.RedisSerializationException;
/**
* Encodes and decodes the payload carried inside a {@link RedisEnvelope}.
*
* <p>The envelope owns schema identity, version, and framing. The payload codec owns only the
* object representation, and it is told which version it is reading so a compatible reader can be
* deployed before a writer changes.
*
* @param <T> the decoded type
*/
public interface RedisPayloadCodec<T> {
/**
* Returns the schema identifier this codec reads and writes.
*
* @return the stable schema id
*/
String schema();
/**
* Returns the schema version this codec writes.
*
* @return the current write version
*/
int writeVersion();
/**
* Reports whether this codec can read a stored version.
*
* @param version the stored schema version
* @return {@code true} when the version is readable
*/
boolean canRead(int version);
/**
* Encodes the payload.
*
* @param value the value to encode
* @return the payload bytes
*/
byte[] encodePayload(T value);
/**
* Decodes the payload.
*
* @param payload the stored payload bytes
* @param version the stored schema version
* @return the decoded value
* @throws RedisSerializationException when the payload cannot be decoded
*/
T decodePayload(byte[] payload, int version);
}
@@ -0,0 +1,19 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.command;
/**
* Capability token proving that an R2 operation was explicitly approved.
*
* <p>A permit is not a convenience flag. Application code may implement this interface, but a
* self-made instance never passes {@link RedisPermitVerifier}: only the configured {@link
* RedisPolicyAuthority} issues instances with valid provenance. The final enforcement boundary
* remains the Redis ACL account, which a permit never widens.
*/
public interface AdvancedOperationPermit {
/**
* Returns the approved policy name.
*
* @return the policy name this permit was issued for
*/
String policyName();
}
@@ -0,0 +1,17 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.command;
/** ACL account family a command must execute under. */
public enum CommandAccess {
/** Ordinary application account holding only R1 typed commands. */
APPLICATION,
/** Application account extended with explicitly approved R2 commands. */
APPLICATION_ADVANCED,
/** Dedicated raw gateway account restricted to registered commands and namespaces. */
RAW_GATEWAY,
/** Read-only diagnostics account used by the admin plane. */
ADMIN_READONLY,
/** Extension account restricted to one extension command family. */
EXTENSION,
/** No account may run the command through this SDK. */
NONE
}
@@ -0,0 +1,85 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.command;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import java.util.regex.Pattern;
/**
* Canonical identity of a Redis command or container subcommand.
*
* <p>The identity is always upper case so a policy file, a server metadata reply, and an SDK call
* site cannot disagree because of casing.
*/
public record CommandId(String command, Optional<String> subcommand) {
private static final Pattern TOKEN = Pattern.compile("^[A-Z][A-Z0-9._-]{0,31}$");
private static final Pattern SEPARATOR = Pattern.compile("[ |]+");
public CommandId {
Objects.requireNonNull(command, "command must be non-null");
Objects.requireNonNull(subcommand, "subcommand must be non-null");
command = command.strip().toUpperCase(Locale.ROOT);
if (!TOKEN.matcher(command).matches()) {
throw new IllegalArgumentException("command name is not a valid Redis command token");
}
subcommand = subcommand.map(value -> value.strip().toUpperCase(Locale.ROOT));
if (subcommand.isPresent() && !TOKEN.matcher(subcommand.get()).matches()) {
throw new IllegalArgumentException("subcommand name is not a valid Redis command token");
}
}
/**
* Creates a top-level command identity.
*
* @param command the command name
* @return the identity
*/
public static CommandId of(String command) {
return new CommandId(command, Optional.empty());
}
/**
* Creates a container subcommand identity.
*
* @param command the container command name
* @param subcommand the subcommand name
* @return the identity
*/
public static CommandId of(String command, String subcommand) {
return new CommandId(command, Optional.of(subcommand));
}
/**
* Parses {@code COMMAND} or {@code COMMAND SUBCOMMAND} written with a single separator.
*
* @param text the identity text; the separator may be a space or a vertical bar
* @return the parsed identity
*/
public static CommandId parse(String text) {
Objects.requireNonNull(text, "command identity text must be non-null");
String[] parts = SEPARATOR.split(text.strip(), -1);
return switch (parts.length) {
case 1 -> of(parts[0]);
case 2 -> of(parts[0], parts[1]);
default ->
throw new IllegalArgumentException(
"command identity must be 'COMMAND' or 'COMMAND SUBCOMMAND'");
};
}
/**
* Returns the low-cardinality command family used for metrics and traces.
*
* @return the container command name
*/
public String family() {
return command;
}
@Override
public String toString() {
return subcommand.map(value -> command + " " + value).orElse(command);
}
}
@@ -0,0 +1,17 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.command;
/** How a Redis command is reachable from this SDK. */
public enum CommandSupport {
/** Reachable through the default typed API. */
TYPED,
/** Reachable through the advanced typed API with permit and budget. */
ADVANCED_TYPED,
/** Reachable only through the approved raw command gateway. */
RAW_ONLY,
/** Reachable only from the isolated admin plane. */
ADMIN_ONLY,
/** Reachable only when a probed capability proves the command exists. */
VERSION_GATED,
/** Never reachable from the SDK. */
BLOCKED
}
@@ -0,0 +1,76 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.command;
import java.util.ArrayList;
import java.util.List;
/**
* Positional key specification using the official Redis convention.
*
* <p>Positions are one-based over the command arguments excluding the command name itself. A
* negative {@code lastKey} counts back from the final argument, exactly as {@code COMMAND INFO}
* reports it. A movable key specification means positions cannot be derived statically and the raw
* gateway must ask the server with {@code COMMAND GETKEYSANDFLAGS}.
*/
public record KeySpec(int firstKey, int lastKey, int step, boolean movable) {
/** Specification for commands that take no key. */
public static final KeySpec NONE = new KeySpec(0, 0, 0, false);
/** Specification for the common single leading key. */
public static final KeySpec SINGLE_KEY = new KeySpec(1, 1, 1, false);
/** Specification for commands whose keys span every remaining argument. */
public static final KeySpec ALL_ARGUMENTS = new KeySpec(1, -1, 1, false);
public KeySpec {
if (firstKey < 0) {
throw new IllegalArgumentException("first key position must not be negative");
}
if (step < 0) {
throw new IllegalArgumentException("key step must not be negative");
}
if (firstKey == 0 && (lastKey != 0 || step != 0)) {
throw new IllegalArgumentException("a keyless specification must be entirely zero");
}
if (firstKey > 0 && step == 0) {
throw new IllegalArgumentException("a keyed specification requires a positive step");
}
}
/**
* Reports whether the command carries keys at all.
*
* @return {@code true} when at least one key position exists
*/
public boolean hasKeys() {
return firstKey > 0;
}
/**
* Resolves the one-based key positions for a concrete argument count.
*
* @param argumentCount number of arguments excluding the command name
* @return the resolved one-based positions, empty when the command is keyless
* @throws IllegalStateException when the specification is movable
*/
public List<Integer> resolvePositions(int argumentCount) {
if (movable) {
throw new IllegalStateException("movable key specification must be resolved by the server");
}
if (argumentCount < 0) {
throw new IllegalArgumentException("argument count must not be negative");
}
if (!hasKeys() || argumentCount < firstKey) {
return List.of();
}
int last = lastKey < 0 ? argumentCount + 1 + lastKey : lastKey;
if (last > argumentCount) {
last = argumentCount;
}
List<Integer> positions = new ArrayList<>();
for (int position = firstKey; position <= last; position += step) {
positions.add(position);
}
return List.copyOf(positions);
}
}
@@ -0,0 +1,16 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.command;
/**
* Capability token proving that a multi-key operation was explicitly approved.
*
* <p>On Cluster the permit does not relax slot rules; every key must still resolve to one slot.
*/
public interface MultiKeyPermit {
/**
* Returns the approved policy name.
*
* @return the policy name this permit was issued for
*/
String policyName();
}
@@ -0,0 +1,54 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.command;
import java.time.Duration;
import java.util.Objects;
/**
* Explicit bound a caller accepts for one advanced operation.
*
* <p>Every R2 API requires a budget. The budget is never optional and never defaulted, because the
* whole point is that the caller states the cost it is prepared to pay before Redis is asked.
*/
public record OperationBudget(
int maxElements, long maxRequestBytes, long maxReplyBytes, Duration timeout) {
public OperationBudget {
Objects.requireNonNull(timeout, "operation budget timeout must be non-null");
if (maxElements < 1 || maxRequestBytes < 1 || maxReplyBytes < 1) {
throw new IllegalArgumentException("Operation budget must be positive");
}
if (timeout.isZero() || timeout.isNegative()) {
throw new IllegalArgumentException("Operation budget must be positive");
}
}
/**
* Reports whether an element count fits the budget.
*
* @param elements the observed or requested element count
* @return {@code true} when the count is within budget
*/
public boolean allowsElements(long elements) {
return elements >= 0 && elements <= maxElements;
}
/**
* Reports whether a request size fits the budget.
*
* @param bytes the encoded request size
* @return {@code true} when the size is within budget
*/
public boolean allowsRequestBytes(long bytes) {
return bytes >= 0 && bytes <= maxRequestBytes;
}
/**
* Reports whether a reply size fits the budget.
*
* @param bytes the observed or estimated reply size
* @return {@code true} when the size is within budget
*/
public boolean allowsReplyBytes(long bytes) {
return bytes >= 0 && bytes <= maxReplyBytes;
}
}
@@ -0,0 +1,16 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.command;
/**
* Capability token proving that writing a key without expiry was explicitly approved.
*
* <p>Cache, session, lock, idempotency, and rate-limit APIs never accept this permit.
*/
public interface PersistentKeyPermit {
/**
* Returns the approved policy name.
*
* @return the policy name this permit was issued for
*/
String policyName();
}
@@ -0,0 +1,60 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.command;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.RedisVersion;
import java.util.Objects;
/**
* Immutable description of one command as this SDK is willing to run it.
*
* <p>The descriptor is the join between official server metadata and organization policy. Nothing
* downstream of the guard is allowed to re-derive risk, access, or timeout from a command name.
*/
public record RedisCommandDescriptor(
CommandId commandId,
RedisVersion minimumVersion,
RedisRiskLevel riskLevel,
CommandSupport support,
CommandAccess access,
boolean blocking,
boolean readOnly,
boolean retrySafe,
boolean mayBeAmbiguous,
KeySpec keySpec,
TimeoutProfile timeoutProfile) {
public RedisCommandDescriptor {
Objects.requireNonNull(commandId, "command id must be non-null");
Objects.requireNonNull(minimumVersion, "minimum version must be non-null");
Objects.requireNonNull(riskLevel, "risk level must be non-null");
Objects.requireNonNull(support, "support must be non-null");
Objects.requireNonNull(access, "access must be non-null");
Objects.requireNonNull(keySpec, "key specification must be non-null");
Objects.requireNonNull(timeoutProfile, "timeout profile must be non-null");
if (support == CommandSupport.BLOCKED && access != CommandAccess.NONE) {
throw new IllegalArgumentException("a blocked command must not carry an ACL account");
}
if (riskLevel == RedisRiskLevel.R4 && support != CommandSupport.BLOCKED) {
throw new IllegalArgumentException("an R4 command must be blocked");
}
if (riskLevel == RedisRiskLevel.R3
&& support != CommandSupport.ADMIN_ONLY
&& support != CommandSupport.BLOCKED) {
throw new IllegalArgumentException("an R3 command must be admin-only or blocked");
}
if (!readOnly && retrySafe && mayBeAmbiguous) {
throw new IllegalArgumentException(
"a write that may be ambiguous must not be declared retry-safe");
}
}
/**
* Reports whether the descriptor may be executed by ordinary application code.
*
* @return {@code true} for typed and advanced typed commands
*/
public boolean applicationReachable() {
return support == CommandSupport.TYPED
|| support == CommandSupport.ADVANCED_TYPED
|| support == CommandSupport.VERSION_GATED;
}
}
@@ -0,0 +1,34 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.command;
/**
* Verifies permit provenance before any guarded command executes.
*
* <p>Presence of a permit is never sufficient. The verifier checks the implementation type, the
* issuer identity, the signature, and the required policy name. A caller-implemented permit fails.
*/
public interface RedisPermitVerifier {
/**
* Verifies an advanced-operation permit.
*
* @param permit the presented permit
* @param requiredPolicy the policy name the command requires
*/
void verify(AdvancedOperationPermit permit, String requiredPolicy);
/**
* Verifies a multi-key permit.
*
* @param permit the presented permit
* @param requiredPolicy the policy name the command requires
*/
void verify(MultiKeyPermit permit, String requiredPolicy);
/**
* Verifies a persistent-key permit.
*
* @param permit the presented permit
* @param requiredPolicy the policy name the command requires
*/
void verify(PersistentKeyPermit permit, String requiredPolicy);
}
@@ -0,0 +1,34 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.command;
/**
* Sole issuer of capability permits.
*
* <p>The configured implementation issues only policy names that were explicitly enabled, and it
* stamps issuer identity and a signature into every permit so a verifier can prove provenance.
*/
public interface RedisPolicyAuthority {
/**
* Issues an advanced-operation permit.
*
* @param policyName an enabled policy name
* @return the issued permit
*/
AdvancedOperationPermit issueAdvanced(String policyName);
/**
* Issues a multi-key permit.
*
* @param policyName an enabled policy name
* @return the issued permit
*/
MultiKeyPermit issueMultiKey(String policyName);
/**
* Issues a persistent-key permit.
*
* @param policyName an enabled policy name
* @return the issued permit
*/
PersistentKeyPermit issuePersistentKey(String policyName);
}
@@ -0,0 +1,31 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.command;
/** Command risk classification that drives exposure, permits, ACL, and bean registration. */
public enum RedisRiskLevel {
/** Bounded, single key, ordinary fast command. Default typed API. */
R1,
/** O(N), unbounded reply, blocking, multi-key, or large payload. Permit plus budget. */
R2,
/** Server, client, ACL, or topology operations. Admin plane only. */
R3,
/** Destructive commands. Blocked for the whole SDK. */
R4;
/**
* Reports whether the level requires an issued permit and an operation budget.
*
* @return {@code true} for {@link #R2}
*/
public boolean requiresPermit() {
return this == R2;
}
/**
* Reports whether the level may never execute through the application or raw command path.
*
* @return {@code true} for {@link #R3} and {@link #R4}
*/
public boolean deniedToApplications() {
return this == R3 || this == R4;
}
}
@@ -0,0 +1,42 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.command;
import java.time.Duration;
/**
* Skeleton timeout guardrail per command family.
*
* <p>Defaults may be tightened by a service. Loosening them is a configuration warning, and beyond
* the hard ceiling it is a startup failure.
*/
public enum TimeoutProfile {
/** Single-key get/set, membership, score. */
FAST(Duration.ofMillis(500)),
/** Bounded range, scan page, set algebra. */
COLLECTION(Duration.ofSeconds(2)),
/** Registered Lua script or function. */
SCRIPT(Duration.ofSeconds(1)),
/** Pipeline or explicit batch. */
BATCH(Duration.ofSeconds(2)),
/** Read-only operational diagnostics. */
ADMIN(Duration.ofSeconds(3)),
/** Blocking command; the effective timeout is the server block plus a fixed margin. */
BLOCKING(Duration.ofSeconds(2));
/** Margin added to the requested server block for {@link #BLOCKING}. */
public static final Duration BLOCKING_MARGIN = Duration.ofSeconds(2);
private final Duration defaultTimeout;
TimeoutProfile(Duration defaultTimeout) {
this.defaultTimeout = defaultTimeout;
}
/**
* Returns the skeleton default timeout.
*
* @return the default timeout
*/
public Duration defaultTimeout() {
return defaultTimeout;
}
}
@@ -0,0 +1,28 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.error;
/** The bound ACL account is not permitted to run the command, subcommand, key, or channel. */
public class RedisAccessDeniedException extends RedisOperationException {
private static final long serialVersionUID = 1L;
/**
* Creates the failure.
*
* @param reason a fixed, payload-free reason
* @param metadata the low-cardinality failure metadata
*/
public RedisAccessDeniedException(String reason, RedisFailureMetadata metadata) {
super(reason, metadata);
}
/**
* Creates the failure with a driver cause.
*
* @param reason a fixed, payload-free reason
* @param metadata the low-cardinality failure metadata
* @param cause the originating driver failure, may be {@code null}
*/
public RedisAccessDeniedException(String reason, RedisFailureMetadata metadata, Throwable cause) {
super(reason, metadata, cause);
}
}
@@ -0,0 +1,31 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.error;
/**
* The command may or may not have executed on the server; it must never be retried automatically.
*/
public class RedisAmbiguousExecutionException extends RedisOperationException {
private static final long serialVersionUID = 1L;
/**
* Creates the failure.
*
* @param reason a fixed, payload-free reason
* @param metadata the low-cardinality failure metadata
*/
public RedisAmbiguousExecutionException(String reason, RedisFailureMetadata metadata) {
super(reason, metadata);
}
/**
* Creates the failure with a driver cause.
*
* @param reason a fixed, payload-free reason
* @param metadata the low-cardinality failure metadata
* @param cause the originating driver failure, may be {@code null}
*/
public RedisAmbiguousExecutionException(
String reason, RedisFailureMetadata metadata, Throwable cause) {
super(reason, metadata, cause);
}
}
@@ -0,0 +1,28 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.error;
/** The server is busy loading or running a script and rejected the command. */
public class RedisBusyException extends RedisOperationException {
private static final long serialVersionUID = 1L;
/**
* Creates the failure.
*
* @param reason a fixed, payload-free reason
* @param metadata the low-cardinality failure metadata
*/
public RedisBusyException(String reason, RedisFailureMetadata metadata) {
super(reason, metadata);
}
/**
* Creates the failure with a driver cause.
*
* @param reason a fixed, payload-free reason
* @param metadata the low-cardinality failure metadata
* @param cause the originating driver failure, may be {@code null}
*/
public RedisBusyException(String reason, RedisFailureMetadata metadata, Throwable cause) {
super(reason, metadata, cause);
}
}
@@ -0,0 +1,29 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.error;
/** A required server capability was not proven present by the capability probe. */
public class RedisCapabilityUnavailableException extends RedisOperationException {
private static final long serialVersionUID = 1L;
/**
* Creates the failure.
*
* @param reason a fixed, payload-free reason
* @param metadata the low-cardinality failure metadata
*/
public RedisCapabilityUnavailableException(String reason, RedisFailureMetadata metadata) {
super(reason, metadata);
}
/**
* Creates the failure with a driver cause.
*
* @param reason a fixed, payload-free reason
* @param metadata the low-cardinality failure metadata
* @param cause the originating driver failure, may be {@code null}
*/
public RedisCapabilityUnavailableException(
String reason, RedisFailureMetadata metadata, Throwable cause) {
super(reason, metadata, cause);
}
}
@@ -0,0 +1,29 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.error;
/** The SDK guard refused the command before it could reach Redis. */
public class RedisCommandRejectedException extends RedisOperationException {
private static final long serialVersionUID = 1L;
/**
* Creates the failure.
*
* @param reason a fixed, payload-free reason
* @param metadata the low-cardinality failure metadata
*/
public RedisCommandRejectedException(String reason, RedisFailureMetadata metadata) {
super(reason, metadata);
}
/**
* Creates the failure with a driver cause.
*
* @param reason a fixed, payload-free reason
* @param metadata the low-cardinality failure metadata
* @param cause the originating driver failure, may be {@code null}
*/
public RedisCommandRejectedException(
String reason, RedisFailureMetadata metadata, Throwable cause) {
super(reason, metadata, cause);
}
}
@@ -0,0 +1,28 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.error;
/** The connection was unavailable or lost before the command reached the server. */
public class RedisConnectionException extends RedisOperationException {
private static final long serialVersionUID = 1L;
/**
* Creates the failure.
*
* @param reason a fixed, payload-free reason
* @param metadata the low-cardinality failure metadata
*/
public RedisConnectionException(String reason, RedisFailureMetadata metadata) {
super(reason, metadata);
}
/**
* Creates the failure with a driver cause.
*
* @param reason a fixed, payload-free reason
* @param metadata the low-cardinality failure metadata
* @param cause the originating driver failure, may be {@code null}
*/
public RedisConnectionException(String reason, RedisFailureMetadata metadata, Throwable cause) {
super(reason, metadata, cause);
}
}
@@ -0,0 +1,28 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.error;
/** The requested keys do not resolve to a single Cluster hash slot. */
public class RedisCrossSlotException extends RedisOperationException {
private static final long serialVersionUID = 1L;
/**
* Creates the failure.
*
* @param reason a fixed, payload-free reason
* @param metadata the low-cardinality failure metadata
*/
public RedisCrossSlotException(String reason, RedisFailureMetadata metadata) {
super(reason, metadata);
}
/**
* Creates the failure with a driver cause.
*
* @param reason a fixed, payload-free reason
* @param metadata the low-cardinality failure metadata
* @param cause the originating driver failure, may be {@code null}
*/
public RedisCrossSlotException(String reason, RedisFailureMetadata metadata, Throwable cause) {
super(reason, metadata, cause);
}
}
@@ -0,0 +1,29 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.error;
/** The key holds a different Redis data structure than the typed operation expects. */
public class RedisDataTypeMismatchException extends RedisOperationException {
private static final long serialVersionUID = 1L;
/**
* Creates the failure.
*
* @param reason a fixed, payload-free reason
* @param metadata the low-cardinality failure metadata
*/
public RedisDataTypeMismatchException(String reason, RedisFailureMetadata metadata) {
super(reason, metadata);
}
/**
* Creates the failure with a driver cause.
*
* @param reason a fixed, payload-free reason
* @param metadata the low-cardinality failure metadata
* @param cause the originating driver failure, may be {@code null}
*/
public RedisDataTypeMismatchException(
String reason, RedisFailureMetadata metadata, Throwable cause) {
super(reason, metadata, cause);
}
}
@@ -0,0 +1,106 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.error;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.RedisDeploymentMode;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.RedisVersion;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.command.CommandAccess;
import java.time.Duration;
import java.util.Objects;
import java.util.Optional;
import java.util.OptionalInt;
/**
* Low-cardinality, payload-free description of a failed Redis operation.
*
* <p>Everything a caller needs to decide retry or compensation is here. Nothing a caller could use
* to reconstruct a key, a value, or a credential is here.
*/
public record RedisFailureMetadata(
String commandCategory,
CommandAccess access,
boolean readOperation,
boolean retryable,
boolean ambiguousExecution,
Optional<RedisVersion> serverVersion,
RedisDeploymentMode deploymentMode,
OptionalInt slot,
Duration elapsed) {
public RedisFailureMetadata {
Objects.requireNonNull(commandCategory, "command category must be non-null");
Objects.requireNonNull(access, "command access must be non-null");
Objects.requireNonNull(serverVersion, "server version must be non-null");
Objects.requireNonNull(deploymentMode, "deployment mode must be non-null");
Objects.requireNonNull(slot, "slot must be non-null");
Objects.requireNonNull(elapsed, "elapsed must be non-null");
if (commandCategory.isBlank()) {
throw new IllegalArgumentException("command category must not be blank");
}
if (elapsed.isNegative()) {
throw new IllegalArgumentException("elapsed must not be negative");
}
if (retryable && ambiguousExecution) {
throw new IllegalArgumentException("an ambiguous execution must never be marked retryable");
}
if (slot.isPresent() && (slot.getAsInt() < 0 || slot.getAsInt() > 16_383)) {
throw new IllegalArgumentException("cluster slot must be in 0..16383");
}
}
/**
* Creates metadata for a failure that never reached the server.
*
* @param commandCategory the low-cardinality command family
* @param access the ACL account family the command belongs to
* @param readOperation whether the command is a read
* @param deploymentMode the bound deployment mode
* @return metadata marked safe to retry for reads and not ambiguous
*/
/**
* Creates metadata for stored data that could not be decoded.
*
* <p>Deliberately not {@link #notSent}: that factory derives {@code retryable} from {@code
* readOperation}, so a corrupt or unreadable stored value came back marked "safe to retry" purely
* because reading it was a read. Retrying a decode of the same bytes produces the same failure —
* the value is wrong, not the attempt — and a caller that treats it as transient turns one bad
* key into a retry loop instead of surfacing the corruption.
*
* @param commandCategory the low-cardinality command family
* @param access the ACL account family the command belongs to
* @param readOperation whether the command is a read
* @param deploymentMode the deployment mode the caller is actually bound to
* @return metadata marked not retryable and not ambiguous
*/
public static RedisFailureMetadata storedDataCorruption(
String commandCategory,
CommandAccess access,
boolean readOperation,
RedisDeploymentMode deploymentMode) {
return new RedisFailureMetadata(
commandCategory,
access,
readOperation,
false,
false,
Optional.empty(),
deploymentMode,
OptionalInt.empty(),
Duration.ZERO);
}
public static RedisFailureMetadata notSent(
String commandCategory,
CommandAccess access,
boolean readOperation,
RedisDeploymentMode deploymentMode) {
return new RedisFailureMetadata(
commandCategory,
access,
readOperation,
readOperation,
false,
Optional.empty(),
deploymentMode,
OptionalInt.empty(),
Duration.ZERO);
}
}
@@ -0,0 +1,28 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.error;
/** A registered script was absent from the server script cache. */
public class RedisNoScriptException extends RedisOperationException {
private static final long serialVersionUID = 1L;
/**
* Creates the failure.
*
* @param reason a fixed, payload-free reason
* @param metadata the low-cardinality failure metadata
*/
public RedisNoScriptException(String reason, RedisFailureMetadata metadata) {
super(reason, metadata);
}
/**
* Creates the failure with a driver cause.
*
* @param reason a fixed, payload-free reason
* @param metadata the low-cardinality failure metadata
* @param cause the originating driver failure, may be {@code null}
*/
public RedisNoScriptException(String reason, RedisFailureMetadata metadata, Throwable cause) {
super(reason, metadata, cause);
}
}
@@ -0,0 +1,58 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.error;
import java.util.Objects;
/**
* Root of the stable Redis failure hierarchy.
*
* <p>Callers program against this hierarchy, never against driver exceptions. Messages carry a
* fixed reason and the command family only; keys, fields, members, values, arguments, and
* authentication material never appear.
*/
public class RedisOperationException extends RuntimeException {
private static final long serialVersionUID = 1L;
private final transient RedisFailureMetadata metadata;
/**
* Creates a failure.
*
* @param reason a fixed, payload-free reason
* @param metadata the low-cardinality failure metadata
*/
public RedisOperationException(String reason, RedisFailureMetadata metadata) {
this(reason, metadata, null);
}
/**
* Creates a failure with a driver cause.
*
* @param reason a fixed, payload-free reason
* @param metadata the low-cardinality failure metadata
* @param cause the originating driver failure, may be {@code null}
*/
public RedisOperationException(String reason, RedisFailureMetadata metadata, Throwable cause) {
super(
Objects.requireNonNull(reason, "failure reason must be non-null")
+ " [command="
+ Objects.requireNonNull(metadata, "failure metadata must be non-null")
.commandCategory()
+ ", mode="
+ metadata.deploymentMode()
+ ", ambiguous="
+ metadata.ambiguousExecution()
+ "]",
cause);
this.metadata = metadata;
}
/**
* Returns the failure metadata.
*
* @return the metadata captured when the failure was translated
*/
public RedisFailureMetadata metadata() {
return metadata;
}
}
@@ -0,0 +1,28 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.error;
/** A MOVED, ASK, or TRYAGAIN redirection could not be resolved within the bounded retry budget. */
public class RedisRedirectionException extends RedisOperationException {
private static final long serialVersionUID = 1L;
/**
* Creates the failure.
*
* @param reason a fixed, payload-free reason
* @param metadata the low-cardinality failure metadata
*/
public RedisRedirectionException(String reason, RedisFailureMetadata metadata) {
super(reason, metadata);
}
/**
* Creates the failure with a driver cause.
*
* @param reason a fixed, payload-free reason
* @param metadata the low-cardinality failure metadata
* @param cause the originating driver failure, may be {@code null}
*/
public RedisRedirectionException(String reason, RedisFailureMetadata metadata, Throwable cause) {
super(reason, metadata, cause);
}
}
@@ -0,0 +1,32 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.error;
/**
* A value could not be encoded, or stored bytes were corrupt, truncated, or of an unknown schema
* version.
*/
public class RedisSerializationException extends RedisOperationException {
private static final long serialVersionUID = 1L;
/**
* Creates the failure.
*
* @param reason a fixed, payload-free reason
* @param metadata the low-cardinality failure metadata
*/
public RedisSerializationException(String reason, RedisFailureMetadata metadata) {
super(reason, metadata);
}
/**
* Creates the failure with a driver cause.
*
* @param reason a fixed, payload-free reason
* @param metadata the low-cardinality failure metadata
* @param cause the originating driver failure, may be {@code null}
*/
public RedisSerializationException(
String reason, RedisFailureMetadata metadata, Throwable cause) {
super(reason, metadata, cause);
}
}
@@ -0,0 +1,28 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.error;
/** The command did not complete inside its timeout profile; for reads this may be safe to retry. */
public class RedisTimeoutException extends RedisOperationException {
private static final long serialVersionUID = 1L;
/**
* Creates the failure.
*
* @param reason a fixed, payload-free reason
* @param metadata the low-cardinality failure metadata
*/
public RedisTimeoutException(String reason, RedisFailureMetadata metadata) {
super(reason, metadata);
}
/**
* Creates the failure with a driver cause.
*
* @param reason a fixed, payload-free reason
* @param metadata the low-cardinality failure metadata
* @param cause the originating driver failure, may be {@code null}
*/
public RedisTimeoutException(String reason, RedisFailureMetadata metadata, Throwable cause) {
super(reason, metadata, cause);
}
}
@@ -0,0 +1,17 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.key;
import java.util.Objects;
/**
* Typed key for bitmap and bitfield operations.
*
* <p>A bitmap is addressed by offset rather than by an element codec, so no codec is carried.
*
* @param key the qualified key
*/
public record BitmapKey(QualifiedRedisKey key) implements RedisTypedKey {
public BitmapKey {
Objects.requireNonNull(key, "qualified key must be non-null");
}
}
@@ -0,0 +1,19 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.key;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.codec.RedisCodec;
import java.util.Objects;
/**
* Typed key for the Redis geospatial structure, which is a sorted set of geohash scores.
*
* @param key the qualified key
* @param memberCodec codec for the stored element type
* @param <V> the element type
*/
public record GeoKey<V>(QualifiedRedisKey key, RedisCodec<V> memberCodec) implements RedisTypedKey {
public GeoKey {
Objects.requireNonNull(key, "qualified key must be non-null");
Objects.requireNonNull(memberCodec, "memberCodec must be non-null");
}
}
@@ -0,0 +1,24 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.key;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.codec.RedisCodec;
import java.util.Objects;
/**
* Typed key for the Redis hash structure.
*
* @param key the qualified key
* @param fieldCodec codec for the field type
* @param valueCodec codec for the value type
* @param <F> the field type
* @param <V> the value type
*/
public record HashKey<F, V>(
QualifiedRedisKey key, RedisCodec<F> fieldCodec, RedisCodec<V> valueCodec)
implements RedisTypedKey {
public HashKey {
Objects.requireNonNull(key, "qualified key must be non-null");
Objects.requireNonNull(fieldCodec, "fieldCodec must be non-null");
Objects.requireNonNull(valueCodec, "valueCodec must be non-null");
}
}
@@ -0,0 +1,20 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.key;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.codec.RedisCodec;
import java.util.Objects;
/**
* Typed key for a HyperLogLog register. Cardinality answers are approximate by construction.
*
* @param key the qualified key
* @param memberCodec codec for the stored element type
* @param <V> the element type
*/
public record HyperLogLogKey<V>(QualifiedRedisKey key, RedisCodec<V> memberCodec)
implements RedisTypedKey {
public HyperLogLogKey {
Objects.requireNonNull(key, "qualified key must be non-null");
Objects.requireNonNull(memberCodec, "memberCodec must be non-null");
}
}
@@ -0,0 +1,20 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.key;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.codec.RedisCodec;
import java.util.Objects;
/**
* Typed key for the Redis list structure.
*
* @param key the qualified key
* @param elementCodec codec for the stored element type
* @param <V> the element type
*/
public record ListKey<V>(QualifiedRedisKey key, RedisCodec<V> elementCodec)
implements RedisTypedKey {
public ListKey {
Objects.requireNonNull(key, "qualified key must be non-null");
Objects.requireNonNull(elementCodec, "elementCodec must be non-null");
}
}
@@ -0,0 +1,49 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.key;
import java.util.Objects;
import java.util.Optional;
/**
* A fully qualified logical Redis key.
*
* <p>This is the only key shape the SDK accepts. There is no API that takes an already rendered key
* string, so namespace, slot, and size rules cannot be bypassed.
*
* @param namespace the owning namespace
* @param name the entity and identifier
* @param slotTag optional Cluster hash tag
*/
public record QualifiedRedisKey(
RedisNamespace namespace, RedisKeyName name, Optional<RedisSlotTag> slotTag) {
public QualifiedRedisKey {
Objects.requireNonNull(namespace, "namespace must be non-null");
Objects.requireNonNull(name, "key name must be non-null");
Objects.requireNonNull(slotTag, "slot tag must be non-null");
}
/**
* Creates a key without a Cluster hash tag.
*
* @param namespace the owning namespace
* @param name the entity and identifier
* @return the qualified key
*/
public static QualifiedRedisKey of(RedisNamespace namespace, RedisKeyName name) {
return new QualifiedRedisKey(namespace, name, Optional.empty());
}
/**
* Creates a key pinned to a Cluster hash tag.
*
* @param namespace the owning namespace
* @param name the entity and identifier
* @param slotTag the hash tag
* @return the qualified key
*/
public static QualifiedRedisKey tagged(
RedisNamespace namespace, RedisKeyName name, RedisSlotTag slotTag) {
return new QualifiedRedisKey(
namespace, name, Optional.of(Objects.requireNonNull(slotTag, "slot tag must be non-null")));
}
}
@@ -0,0 +1,15 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.key;
/**
* Entity plus identifier part of a qualified key.
*
* @param entity the entity token
* @param identifier the entity identifier
*/
public record RedisKeyName(String entity, String identifier) {
public RedisKeyName {
RedisKeyRules.requireToken("entity", entity);
RedisKeyRules.requireIdentifier(identifier);
}
}
@@ -0,0 +1,69 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.key;
import java.util.Objects;
/**
* Renders a {@link QualifiedRedisKey} into the single canonical physical key layout.
*
* <pre>{@code
* plain key: {environment}:{service}:{domain}:{entity}:{identifier}
* slot key: {environment}:{service}:{domain}:{{slotTag}}:{entity}:{identifier}
* }</pre>
*
* <p>The renderer is the only place braces are written, so the Cluster hash tag always covers the
* tag and nothing else.
*/
public final class RedisKeyRenderer {
private final int maxKeyBytes;
/**
* Creates a renderer.
*
* @param maxKeyBytes the configured maximum rendered key size in UTF-8 bytes
*/
public RedisKeyRenderer(int maxKeyBytes) {
if (maxKeyBytes < 1 || maxKeyBytes > RedisKeyRules.MAX_KEY_BYTES) {
throw new IllegalArgumentException(
"maximum key bytes must be in 1.." + RedisKeyRules.MAX_KEY_BYTES);
}
this.maxKeyBytes = maxKeyBytes;
}
/**
* Renders the physical key.
*
* @param key the qualified logical key
* @return the rendered physical key
*/
public String render(QualifiedRedisKey key) {
Objects.requireNonNull(key, "qualified key must be non-null");
StringBuilder rendered = new StringBuilder(64);
rendered.append(key.namespace().prefix()).append(':');
key.slotTag().ifPresent(tag -> rendered.append('{').append(tag.value()).append("}:"));
rendered.append(key.name().entity()).append(':').append(key.name().identifier());
return RedisKeyRules.requireRenderedSize(rendered.toString(), maxKeyBytes);
}
/**
* Renders the substring the Cluster slot is computed from.
*
* <p>For a tagged key this is the tag content; otherwise it is the whole rendered key.
*
* @param key the qualified logical key
* @return the slot-determining text
*/
public String slotSource(QualifiedRedisKey key) {
Objects.requireNonNull(key, "qualified key must be non-null");
return key.slotTag().map(RedisSlotTag::value).orElseGet(() -> render(key));
}
/**
* Returns the configured maximum rendered key size.
*
* @return maximum key size in UTF-8 bytes
*/
public int maxKeyBytes() {
return maxKeyBytes;
}
}
@@ -0,0 +1,101 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.key;
import java.util.Locale;
import java.util.Objects;
import java.util.regex.Pattern;
/**
* Validation rules shared by every part of a qualified Redis key.
*
* <p>The rules are mechanical. They reject the identifier shapes that can be recognized without
* business context — mail addresses, bearer material, JSON web tokens, international phone numbers,
* separator injection, and oversized tokens. Values that are indistinguishable from an ordinary
* surrogate identifier, such as a bare digit string, cannot be rejected here; those must be
* fingerprinted by the caller before they become a key part.
*/
public final class RedisKeyRules {
/** Maximum rendered key length in UTF-8 bytes. */
public static final int MAX_KEY_BYTES = 512;
private static final Pattern TOKEN = Pattern.compile("^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$");
private static final Pattern IDENTIFIER = Pattern.compile("^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$");
private static final Pattern JSON_WEB_TOKEN =
Pattern.compile("^[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}$");
private static final Pattern INTERNATIONAL_PHONE = Pattern.compile("^\\+\\d[\\d.~-]{7,}$");
private RedisKeyRules() {
throw new AssertionError("RedisKeyRules is a rule holder");
}
/**
* Validates a structural namespace token.
*
* @param field the field name used in the failure message
* @param value the candidate token
* @return the validated token
* @throws IllegalArgumentException when the token is missing or malformed
*/
public static String requireToken(String field, String value) {
Objects.requireNonNull(field, "field name must be non-null");
if (value == null || !TOKEN.matcher(value).matches()) {
throw new IllegalArgumentException(
field + " must be a lower-case alphanumeric token of 1..64 characters");
}
return value;
}
/**
* Validates an entity identifier.
*
* @param value the candidate identifier
* @return the validated identifier
* @throws IllegalArgumentException when the identifier is malformed or carries recognizable
* personal or authentication material
*/
public static String requireIdentifier(String value) {
if (value == null || !IDENTIFIER.matcher(value).matches()) {
throw new IllegalArgumentException(
"identifier must be 1..128 characters of [A-Za-z0-9._~-] and must not contain a"
+ " key separator");
}
String lowerCase = value.toLowerCase(Locale.ROOT);
if (value.indexOf('@') >= 0) {
throw new IllegalArgumentException("identifier must not contain a mail address");
}
if (JSON_WEB_TOKEN.matcher(value).matches()) {
throw new IllegalArgumentException("identifier must not contain a JSON web token");
}
if (INTERNATIONAL_PHONE.matcher(value).matches()) {
throw new IllegalArgumentException("identifier must not contain a phone number");
}
if (lowerCase.startsWith("bearer") || lowerCase.startsWith("eyj")) {
throw new IllegalArgumentException("identifier must not contain authentication material");
}
return value;
}
/**
* Validates the rendered key size.
*
* @param rendered the rendered key
* @param maxKeyBytes the configured maximum size in UTF-8 bytes
* @return the validated rendered key
* @throws IllegalArgumentException when the rendered key exceeds the maximum
*/
public static String requireRenderedSize(String rendered, int maxKeyBytes) {
Objects.requireNonNull(rendered, "rendered key must be non-null");
if (maxKeyBytes < 1 || maxKeyBytes > MAX_KEY_BYTES) {
throw new IllegalArgumentException("maximum key bytes must be in 1.." + MAX_KEY_BYTES);
}
int size = rendered.getBytes(java.nio.charset.StandardCharsets.UTF_8).length;
if (size > maxKeyBytes) {
throw new IllegalArgumentException(
"rendered key is " + size + " bytes and exceeds the configured " + maxKeyBytes);
}
return rendered;
}
}
@@ -0,0 +1,29 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.key;
/**
* Structural key prefix that isolates an environment, a service, and a domain.
*
* <p>The namespace is the unit the ACL key pattern and the raw gateway both check against, so it is
* never assembled from a free-form string.
*
* @param environment deployment environment token
* @param service owning service token
* @param domain logical domain token inside the service
*/
public record RedisNamespace(String environment, String service, String domain) {
public RedisNamespace {
RedisKeyRules.requireToken("environment", environment);
RedisKeyRules.requireToken("service", service);
RedisKeyRules.requireToken("domain", domain);
}
/**
* Returns the rendered namespace prefix without a trailing separator.
*
* @return the prefix, for example {@code prod:order:shared}
*/
public String prefix() {
return environment + ':' + service + ':' + domain;
}
}
@@ -0,0 +1,17 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.key;
/**
* Cluster hash tag.
*
* <p>Braces are added by the renderer, never by the caller, so a tag can neither escape its
* position nor create a second tag inside one key. A deliberately low-cardinality tag pins an
* entire tenant onto a single slot and is a documented misuse, not a supported pattern.
*
* @param value the tag content without braces
*/
public record RedisSlotTag(String value) {
public RedisSlotTag {
RedisKeyRules.requireIdentifier(value);
}
}
@@ -0,0 +1,26 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.key;
/**
* A qualified key that also carries its Redis data structure and its codecs.
*
* <p>Structure-specific key types are what stops a hash key from ever being handed to sorted-set
* operations: the mistake becomes a compile error instead of a {@code WRONGTYPE} at runtime.
*/
public sealed interface RedisTypedKey
permits ValueKey,
HashKey,
ListKey,
SetKey,
SortedSetKey,
BitmapKey,
HyperLogLogKey,
GeoKey,
StreamKey {
/**
* Returns the underlying qualified key.
*
* @return the qualified key
*/
QualifiedRedisKey key();
}
@@ -0,0 +1,19 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.key;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.codec.RedisCodec;
import java.util.Objects;
/**
* Typed key for the Redis set structure.
*
* @param key the qualified key
* @param memberCodec codec for the stored element type
* @param <V> the element type
*/
public record SetKey<V>(QualifiedRedisKey key, RedisCodec<V> memberCodec) implements RedisTypedKey {
public SetKey {
Objects.requireNonNull(key, "qualified key must be non-null");
Objects.requireNonNull(memberCodec, "memberCodec must be non-null");
}
}
@@ -0,0 +1,20 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.key;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.codec.RedisCodec;
import java.util.Objects;
/**
* Typed key for the Redis sorted set structure.
*
* @param key the qualified key
* @param memberCodec codec for the stored element type
* @param <V> the element type
*/
public record SortedSetKey<V>(QualifiedRedisKey key, RedisCodec<V> memberCodec)
implements RedisTypedKey {
public SortedSetKey {
Objects.requireNonNull(key, "qualified key must be non-null");
Objects.requireNonNull(memberCodec, "memberCodec must be non-null");
}
}
@@ -0,0 +1,20 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.key;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.codec.RedisCodec;
import java.util.Objects;
/**
* Typed key for the Redis stream structure.
*
* @param key the qualified key
* @param payloadCodec codec for the stored element type
* @param <V> the element type
*/
public record StreamKey<V>(QualifiedRedisKey key, RedisCodec<V> payloadCodec)
implements RedisTypedKey {
public StreamKey {
Objects.requireNonNull(key, "qualified key must be non-null");
Objects.requireNonNull(payloadCodec, "payloadCodec must be non-null");
}
}
@@ -0,0 +1,217 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.key;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.codec.RedisCodec;
import java.util.Objects;
/**
* Namespace-bound factory for typed keys.
*
* <p>Binding the namespace once removes the most common source of key drift: every call site names
* only the entity, the identifier, and, when Cluster co-location is required, the hash tag.
*/
public final class TypedRedisKeys {
private final RedisNamespace namespace;
/**
* Creates a factory bound to a namespace.
*
* @param namespace the owning namespace
*/
public TypedRedisKeys(RedisNamespace namespace) {
this.namespace = Objects.requireNonNull(namespace, "namespace must be non-null");
}
/**
* Creates a factory bound to a namespace.
*
* @param namespace the owning namespace
* @return the factory
*/
public static TypedRedisKeys in(RedisNamespace namespace) {
return new TypedRedisKeys(namespace);
}
/**
* Returns the bound namespace.
*
* @return the namespace
*/
public RedisNamespace namespace() {
return namespace;
}
/**
* Builds an untyped qualified key.
*
* @param entity the entity token
* @param identifier the entity identifier
* @return the qualified key
*/
public QualifiedRedisKey key(String entity, String identifier) {
return QualifiedRedisKey.of(namespace, new RedisKeyName(entity, identifier));
}
/**
* Builds an untyped qualified key pinned to a Cluster hash tag.
*
* @param entity the entity token
* @param identifier the entity identifier
* @param slotTag the hash tag content
* @return the qualified key
*/
public QualifiedRedisKey taggedKey(String entity, String identifier, String slotTag) {
return QualifiedRedisKey.tagged(
namespace, new RedisKeyName(entity, identifier), new RedisSlotTag(slotTag));
}
/**
* Builds a string key.
*
* @param entity the entity token
* @param identifier the entity identifier
* @param valueCodec the value codec
* @param <V> the value type
* @return the typed key
*/
public <V> ValueKey<V> value(String entity, String identifier, RedisCodec<V> valueCodec) {
return new ValueKey<>(key(entity, identifier), valueCodec);
}
/**
* Builds a string key pinned to a Cluster hash tag.
*
* @param entity the entity token
* @param identifier the entity identifier
* @param slotTag the hash tag content
* @param valueCodec the value codec
* @param <V> the value type
* @return the typed key
*/
public <V> ValueKey<V> valueWithSlot(
String entity, String identifier, String slotTag, RedisCodec<V> valueCodec) {
return new ValueKey<>(taggedKey(entity, identifier, slotTag), valueCodec);
}
/**
* Builds a hash key.
*
* @param entity the entity token
* @param identifier the entity identifier
* @param fieldCodec the field codec
* @param valueCodec the value codec
* @param <F> the field type
* @param <V> the value type
* @return the typed key
*/
public <F, V> HashKey<F, V> hash(
String entity, String identifier, RedisCodec<F> fieldCodec, RedisCodec<V> valueCodec) {
return new HashKey<>(key(entity, identifier), fieldCodec, valueCodec);
}
/**
* Builds a list key.
*
* @param entity the entity token
* @param identifier the entity identifier
* @param elementCodec the element codec
* @param <V> the element type
* @return the typed key
*/
public <V> ListKey<V> list(String entity, String identifier, RedisCodec<V> elementCodec) {
return new ListKey<>(key(entity, identifier), elementCodec);
}
/**
* Builds a set key.
*
* @param entity the entity token
* @param identifier the entity identifier
* @param memberCodec the member codec
* @param <V> the member type
* @return the typed key
*/
public <V> SetKey<V> set(String entity, String identifier, RedisCodec<V> memberCodec) {
return new SetKey<>(key(entity, identifier), memberCodec);
}
/**
* Builds a set key pinned to a Cluster hash tag.
*
* @param entity the entity token
* @param identifier the entity identifier
* @param slotTag the hash tag content
* @param memberCodec the member codec
* @param <V> the member type
* @return the typed key
*/
public <V> SetKey<V> setWithSlot(
String entity, String identifier, String slotTag, RedisCodec<V> memberCodec) {
return new SetKey<>(taggedKey(entity, identifier, slotTag), memberCodec);
}
/**
* Builds a sorted set key.
*
* @param entity the entity token
* @param identifier the entity identifier
* @param memberCodec the member codec
* @param <V> the member type
* @return the typed key
*/
public <V> SortedSetKey<V> sortedSet(
String entity, String identifier, RedisCodec<V> memberCodec) {
return new SortedSetKey<>(key(entity, identifier), memberCodec);
}
/**
* Builds a bitmap key.
*
* @param entity the entity token
* @param identifier the entity identifier
* @return the typed key
*/
public BitmapKey bitmap(String entity, String identifier) {
return new BitmapKey(key(entity, identifier));
}
/**
* Builds a HyperLogLog key.
*
* @param entity the entity token
* @param identifier the entity identifier
* @param memberCodec the member codec
* @param <V> the member type
* @return the typed key
*/
public <V> HyperLogLogKey<V> hyperLogLog(
String entity, String identifier, RedisCodec<V> memberCodec) {
return new HyperLogLogKey<>(key(entity, identifier), memberCodec);
}
/**
* Builds a geospatial key.
*
* @param entity the entity token
* @param identifier the entity identifier
* @param memberCodec the member codec
* @param <V> the member type
* @return the typed key
*/
public <V> GeoKey<V> geo(String entity, String identifier, RedisCodec<V> memberCodec) {
return new GeoKey<>(key(entity, identifier), memberCodec);
}
/**
* Builds a stream key.
*
* @param entity the entity token
* @param identifier the entity identifier
* @param payloadCodec the payload codec
* @param <V> the payload type
* @return the typed key
*/
public <V> StreamKey<V> stream(String entity, String identifier, RedisCodec<V> payloadCodec) {
return new StreamKey<>(key(entity, identifier), payloadCodec);
}
}
@@ -0,0 +1,20 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.key;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.codec.RedisCodec;
import java.util.Objects;
/**
* Typed key for the Redis string structure.
*
* @param key the qualified key
* @param valueCodec codec for the stored element type
* @param <V> the element type
*/
public record ValueKey<V>(QualifiedRedisKey key, RedisCodec<V> valueCodec)
implements RedisTypedKey {
public ValueKey {
Objects.requireNonNull(key, "qualified key must be non-null");
Objects.requireNonNull(valueCodec, "valueCodec must be non-null");
}
}
@@ -0,0 +1,40 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.operations;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.error.RedisOperationException;
import java.util.Objects;
import java.util.Optional;
/**
* Outcome of one command inside a batch.
*
* <p>The input index is preserved even when the batch was split across cluster nodes, so a caller
* can always map a failure back to the command it submitted.
*
* @param index the zero-based input index
* @param value the decoded result, empty when the command failed
* @param failure the translated failure, empty when the command succeeded
* @param <R> the result type
*/
public record BatchItemResult<R>(
int index, Optional<R> value, Optional<RedisOperationException> failure) {
public BatchItemResult {
Objects.requireNonNull(value, "value must be non-null");
Objects.requireNonNull(failure, "failure must be non-null");
if (index < 0) {
throw new IllegalArgumentException("batch item index must not be negative");
}
if (value.isPresent() == failure.isPresent()) {
throw new IllegalArgumentException("a batch item either produced a value or a failure");
}
}
/**
* Reports whether this item failed.
*
* @return {@code true} when the command failed
*/
public boolean failed() {
return failure.isPresent();
}
}
@@ -0,0 +1,44 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.operations;
import java.time.Duration;
import java.util.Objects;
/**
* Bounds for one pipelined batch.
*
* <p>A pipeline is not a transaction and this type never pretends otherwise. Its job is to bound
* command count, request size, expected reply size, and per-node in-flight depth, because an
* unbounded pipeline moves the failure from Redis into the caller's heap.
*
* @param maxCommands maximum commands in one batch
* @param maxRequestBytes maximum encoded request size
* @param maxReplyBytes maximum expected reply size
* @param maxInFlightPerNode maximum concurrent in-flight batches per node
* @param timeout batch timeout
*/
public record BatchOptions(
int maxCommands,
long maxRequestBytes,
long maxReplyBytes,
int maxInFlightPerNode,
Duration timeout) {
public BatchOptions {
Objects.requireNonNull(timeout, "timeout must be non-null");
if (maxCommands < 1 || maxRequestBytes < 1 || maxReplyBytes < 1 || maxInFlightPerNode < 1) {
throw new IllegalArgumentException("batch bounds must be positive");
}
if (timeout.isZero() || timeout.isNegative()) {
throw new IllegalArgumentException("batch timeout must be positive");
}
}
/**
* Returns the skeleton defaults: 500 commands, 4 MiB request, 16 MiB reply, 2 in flight, 2 s.
*
* @return the default options
*/
public static BatchOptions defaults() {
return new BatchOptions(500, 4L * 1024 * 1024, 16L * 1024 * 1024, 2, Duration.ofSeconds(2));
}
}
@@ -0,0 +1,16 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.operations;
/**
* Overflow behaviour for bitfield arithmetic.
*
* <p>There is no default. Silent wrap-around and silent saturation produce very different counters,
* so the caller states which one it means.
*/
public enum BitFieldOverflow {
/** Wrap around on overflow. */
WRAP,
/** Saturate at the representable bound. */
SATURATE,
/** Return no result for the overflowing subcommand. */
FAIL
}
@@ -0,0 +1,19 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.operations;
import java.util.Objects;
import java.util.OptionalLong;
/**
* Result of one bitfield subcommand.
*
* <p>The value is optional because {@link BitFieldOverflow#FAIL} returns nothing for a subcommand
* that overflowed; that is a distinct outcome from a zero.
*
* @param value the resulting value, empty when the subcommand overflowed under FAIL
*/
public record BitFieldResult(OptionalLong value) {
public BitFieldResult {
Objects.requireNonNull(value, "value must be non-null");
}
}
@@ -0,0 +1,39 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.operations;
import java.util.Objects;
/**
* One bitfield subcommand.
*
* @param kind the operation kind
* @param signed whether the field is signed
* @param bits field width in bits, 1..64 signed or 1..63 unsigned
* @param offset bit offset of the field
* @param operand value for {@link Kind#SET} and {@link Kind#INCREMENT_BY}
*/
public record BitFieldSubcommand(Kind kind, boolean signed, int bits, long offset, long operand) {
/** Bitfield operation kind. */
public enum Kind {
/** Read the field. */
GET,
/** Overwrite the field. */
SET,
/** Add to the field. */
INCREMENT_BY
}
public BitFieldSubcommand {
Objects.requireNonNull(kind, "kind must be non-null");
int maximumBits = signed ? 64 : 63;
if (bits < 1 || bits > maximumBits) {
throw new IllegalArgumentException("bitfield width must be in 1.." + maximumBits);
}
if (offset < 0) {
throw new IllegalArgumentException("bitfield offset must not be negative");
}
if (kind == Kind.GET && operand != 0) {
throw new IllegalArgumentException("a bitfield read must not carry an operand");
}
}
}
@@ -0,0 +1,13 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.operations;
/** Bitwise operation applied across bitmaps. */
public enum BitmapOperation {
/** Bitwise AND. */
AND,
/** Bitwise OR. */
OR,
/** Bitwise XOR. */
XOR,
/** Bitwise NOT; accepts exactly one source. */
NOT
}
@@ -0,0 +1,24 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.operations;
import java.util.List;
import java.util.Objects;
/**
* Result of an automatic claim sweep.
*
* @param nextStart the cursor to continue the sweep from
* @param records the claimed records
* @param deletedIds identifiers that were pending but no longer exist in the stream
* @param <V> the payload type
*/
public record ClaimResult<V>(
StreamId nextStart, List<StreamRecord<V>> records, List<StreamId> deletedIds) {
public ClaimResult {
Objects.requireNonNull(nextStart, "nextStart must be non-null");
Objects.requireNonNull(records, "records must be non-null");
Objects.requireNonNull(deletedIds, "deletedIds must be non-null");
records = List.copyOf(records);
deletedIds = List.copyOf(deletedIds);
}
}
@@ -0,0 +1,19 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.operations;
import java.util.Objects;
/**
* A distance and the unit it was measured in.
*
* @param value the magnitude
* @param unit the unit
*/
public record Distance(double value, DistanceUnit unit) {
public Distance {
Objects.requireNonNull(unit, "unit must be non-null");
if (Double.isNaN(value) || value < 0) {
throw new IllegalArgumentException("distance must be a non-negative number");
}
}
}
@@ -0,0 +1,13 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.operations;
/** Unit a geospatial distance is expressed in. */
public enum DistanceUnit {
/** Metres. */
METERS,
/** Kilometres. */
KILOMETERS,
/** Miles. */
MILES,
/** Feet. */
FEET
}
@@ -0,0 +1,52 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.operations;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.command.PersistentKeyPermit;
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
/**
* How long a written key lives.
*
* <p>Expiration is a required argument on every write rather than an optional one. A key with no
* expiry is the single most common cause of unbounded Redis growth, so writing one is a decision
* that has to be approved by a {@link PersistentKeyPermit} and cannot be reached by omission.
*/
public sealed interface Expiration {
/**
* No expiry. Requires an issued permit.
*
* @param permit the approving permit
*/
record Persistent(PersistentKeyPermit permit) implements Expiration {
public Persistent {
Objects.requireNonNull(permit, "a persistent key requires an issued permit");
}
}
/**
* Relative expiry.
*
* @param duration strictly positive time to live
*/
record After(Duration duration) implements Expiration {
public After {
Objects.requireNonNull(duration, "duration must be non-null");
if (duration.isZero() || duration.isNegative()) {
throw new IllegalArgumentException("relative expiration must be positive");
}
}
}
/**
* Absolute expiry.
*
* @param instant the expiry instant
*/
record At(Instant instant) implements Expiration {
public At {
Objects.requireNonNull(instant, "instant must be non-null");
}
}
}
@@ -0,0 +1,15 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.operations;
/** Condition guarding an explicit expiry change. */
public enum ExpirationCondition {
/** Always apply. */
ALWAYS,
/** Apply only when the key has no expiry. */
IF_NO_EXPIRY,
/** Apply only when the key already has an expiry. */
IF_HAS_EXPIRY,
/** Apply only when the new expiry is later than the current one. */
IF_GREATER,
/** Apply only when the new expiry is earlier than the current one. */
IF_LESS
}
@@ -0,0 +1,13 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.operations;
/** Outcome of an expiry change. */
public enum ExpirationResult {
/** The expiry was applied. */
APPLIED,
/** The condition rejected the change and the expiry is unchanged. */
CONDITION_NOT_MET,
/** The key or field does not exist. */
ABSENT,
/** The key or field was deleted because the requested expiry is already in the past. */
DELETED
}
@@ -0,0 +1,13 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.operations;
/** How a write interacts with an expiry that already exists. */
public enum ExpirationUpdatePolicy {
/** Leave the current expiry untouched. */
KEEP_EXISTING,
/** Replace whatever expiry the key has. */
REPLACE,
/** Apply only when the key currently has no expiry. */
ONLY_IF_NO_EXPIRY,
/** Apply only when the key currently has an expiry. */
ONLY_IF_HAS_EXPIRY
}
@@ -0,0 +1,18 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.operations;
import java.util.Objects;
/**
* A member positioned at a coordinate.
*
* @param member the member
* @param point the coordinate
* @param <V> the member type
*/
public record GeoLocation<V>(V member, GeoPoint point) {
public GeoLocation {
Objects.requireNonNull(member, "member must be non-null");
Objects.requireNonNull(point, "point must be non-null");
}
}
@@ -0,0 +1,19 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.operations;
/**
* WGS 84 coordinate.
*
* @param longitude degrees east, -180..180
* @param latitude degrees north, -85.05112878..85.05112878
*/
public record GeoPoint(double longitude, double latitude) {
public GeoPoint {
if (longitude < -180 || longitude > 180) {
throw new IllegalArgumentException("longitude must be in -180..180");
}
if (latitude < -85.05112878 || latitude > 85.05112878) {
throw new IllegalArgumentException("latitude must be in -85.05112878..85.05112878");
}
}
}
@@ -0,0 +1,70 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.operations;
import java.util.Objects;
import java.util.Optional;
/**
* Bounded geospatial search.
*
* <p>A count is mandatory. An unbounded radius search over a dense set is one of the classic ways a
* single Redis call returns tens of megabytes, so the API has no shape that expresses it.
*
* @param origin search centre, empty when searching from a member
* @param fromMember member to search from, empty when searching from a coordinate
* @param radius circular bound, empty when using a box
* @param boxWidth box width, empty when using a radius
* @param boxHeight box height, empty when using a radius
* @param count strictly positive result bound
* @param direction result ordering by distance
* @param <V> the member type
*/
public record GeoSearchRequest<V>(
Optional<GeoPoint> origin,
Optional<V> fromMember,
Optional<Distance> radius,
Optional<Distance> boxWidth,
Optional<Distance> boxHeight,
int count,
SortDirection direction) {
public GeoSearchRequest {
Objects.requireNonNull(origin, "origin must be non-null");
Objects.requireNonNull(fromMember, "fromMember must be non-null");
Objects.requireNonNull(radius, "radius must be non-null");
Objects.requireNonNull(boxWidth, "boxWidth must be non-null");
Objects.requireNonNull(boxHeight, "boxHeight must be non-null");
Objects.requireNonNull(direction, "direction must be non-null");
if (origin.isPresent() == fromMember.isPresent()) {
throw new IllegalArgumentException("a geo search starts from a coordinate or from a member");
}
if (radius.isPresent() == (boxWidth.isPresent() && boxHeight.isPresent())) {
throw new IllegalArgumentException("a geo search is bounded by a radius or by a box");
}
if (boxWidth.isPresent() != boxHeight.isPresent()) {
throw new IllegalArgumentException("a box bound needs both a width and a height");
}
if (count < 1) {
throw new IllegalArgumentException("a geo search must declare a positive result count");
}
}
/**
* Creates a bounded radius search around a coordinate.
*
* @param origin the search centre
* @param radius the circular bound
* @param count strictly positive result bound
* @param <V> the member type
* @return the request
*/
public static <V> GeoSearchRequest<V> byRadius(GeoPoint origin, Distance radius, int count) {
return new GeoSearchRequest<>(
Optional.of(origin),
Optional.empty(),
Optional.of(radius),
Optional.empty(),
Optional.empty(),
count,
SortDirection.ASCENDING);
}
}
@@ -0,0 +1,21 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.operations;
import java.util.Objects;
import java.util.Optional;
/**
* One geospatial search hit.
*
* @param member the member
* @param distance distance from the search origin
* @param point the member coordinate, when requested
* @param <V> the member type
*/
public record GeoSearchResult<V>(V member, Distance distance, Optional<GeoPoint> point) {
public GeoSearchResult {
Objects.requireNonNull(member, "member must be non-null");
Objects.requireNonNull(distance, "distance must be non-null");
Objects.requireNonNull(point, "point must be non-null");
}
}
@@ -0,0 +1,22 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.operations;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.key.QualifiedRedisKey;
import java.util.Objects;
/**
* A value together with the key it came from.
*
* <p>A blocking pop over several keys has to say which key answered, otherwise the caller cannot
* acknowledge or compensate correctly.
*
* @param key the answering key
* @param value the popped value
* @param <V> the value type
*/
public record KeyedValue<V>(QualifiedRedisKey key, V value) {
public KeyedValue {
Objects.requireNonNull(key, "key must be non-null");
Objects.requireNonNull(value, "value must be non-null");
}
}
@@ -0,0 +1,44 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.operations;
import java.util.Objects;
import java.util.Optional;
/**
* Lexicographic bounds for an equally scored sorted set.
*
* @param minimum lower bound, empty for negative infinity
* @param minimumInclusive whether the lower bound is inclusive
* @param maximum upper bound, empty for positive infinity
* @param maximumInclusive whether the upper bound is inclusive
*/
public record LexRange(
Optional<String> minimum,
boolean minimumInclusive,
Optional<String> maximum,
boolean maximumInclusive) {
public LexRange {
Objects.requireNonNull(minimum, "minimum must be non-null");
Objects.requireNonNull(maximum, "maximum must be non-null");
}
/**
* Creates an inclusive lexicographic range.
*
* @param minimum lower bound
* @param maximum upper bound
* @return the range
*/
public static LexRange closed(String minimum, String maximum) {
return new LexRange(Optional.of(minimum), true, Optional.of(maximum), true);
}
/**
* Creates the unbounded lexicographic range.
*
* @return the range covering every member
*/
public static LexRange unbounded() {
return new LexRange(Optional.empty(), true, Optional.empty(), true);
}
}
@@ -0,0 +1,9 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.operations;
/** Which end of a list an operation acts on. */
public enum ListSide {
/** The head of the list. */
LEFT,
/** The tail of the list. */
RIGHT
}
@@ -0,0 +1,19 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.operations;
/**
* Inclusive byte or bit index window.
*
* @param start inclusive start index
* @param end inclusive end index
*/
public record LongRange(long start, long end) {
public LongRange {
if (start < 0 || end < 0) {
throw new IllegalArgumentException("range bounds must not be negative");
}
if (end < start) {
throw new IllegalArgumentException("range end must not precede its start");
}
}
}
@@ -0,0 +1,29 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.operations;
/**
* Bounded offset and limit for a range read.
*
* @param offset zero-based offset into the matching range
* @param limit strictly positive maximum number of returned elements
*/
public record PageRequest(long offset, int limit) {
public PageRequest {
if (offset < 0) {
throw new IllegalArgumentException("page offset must not be negative");
}
if (limit < 1) {
throw new IllegalArgumentException("page limit must be positive");
}
}
/**
* Creates a first page.
*
* @param limit strictly positive maximum number of returned elements
* @return the page request
*/
public static PageRequest first(int limit) {
return new PageRequest(0, limit);
}
}
@@ -0,0 +1,29 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.operations;
import java.time.Duration;
import java.util.Objects;
import java.util.Optional;
/**
* Bounded query over a consumer group's pending entries.
*
* @param range the identifier window
* @param count strictly positive result bound
* @param minimumIdle only entries idle at least this long
* @param consumer restrict to one consumer
*/
public record PendingQuery(
StreamRange range,
int count,
Optional<Duration> minimumIdle,
Optional<StreamConsumer> consumer) {
public PendingQuery {
Objects.requireNonNull(range, "range must be non-null");
Objects.requireNonNull(minimumIdle, "minimumIdle must be non-null");
Objects.requireNonNull(consumer, "consumer must be non-null");
if (count < 1) {
throw new IllegalArgumentException("a pending query must declare a positive count");
}
}
}
@@ -0,0 +1,28 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.operations;
import java.time.Duration;
import java.util.Objects;
/**
* One pending entry.
*
* @param id the entry identifier
* @param consumer the consumer currently holding it
* @param idle how long it has been held without acknowledgement
* @param deliveryCount how often it has been delivered
*/
public record PendingRecord(
StreamId id, StreamConsumer consumer, Duration idle, long deliveryCount) {
public PendingRecord {
Objects.requireNonNull(id, "identifier must be non-null");
Objects.requireNonNull(consumer, "consumer must be non-null");
Objects.requireNonNull(idle, "idle must be non-null");
if (idle.isNegative()) {
throw new IllegalArgumentException("idle must not be negative");
}
if (deliveryCount < 1) {
throw new IllegalArgumentException("a pending entry has been delivered at least once");
}
}
}
@@ -0,0 +1,30 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.operations;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
/**
* Aggregate pending state of a consumer group.
*
* @param count total pending entries
* @param lowestId lowest pending identifier, empty when nothing is pending
* @param highestId highest pending identifier, empty when nothing is pending
* @param countByConsumer pending count per consumer
*/
public record PendingSummary(
long count,
Optional<StreamId> lowestId,
Optional<StreamId> highestId,
Map<StreamConsumer, Long> countByConsumer) {
public PendingSummary {
Objects.requireNonNull(lowestId, "lowestId must be non-null");
Objects.requireNonNull(highestId, "highestId must be non-null");
Objects.requireNonNull(countByConsumer, "countByConsumer must be non-null");
if (count < 0) {
throw new IllegalArgumentException("pending count must not be negative");
}
countByConsumer = Map.copyOf(countByConsumer);
}
}
@@ -0,0 +1,37 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.operations;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.codec.RedisCodec;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.key.RedisKeyName;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.key.RedisNamespace;
import java.util.Objects;
/**
* A namespaced Pub/Sub channel.
*
* <p>Pub/Sub is at-most-once. A subscriber that is disconnected when a message is published never
* receives it, and no reconnect recovers it. This type is deliberately separate from any durable
* messaging abstraction so the guarantee cannot be confused at a call site.
*
* @param namespace the owning namespace
* @param name the channel name
* @param messageCodec codec for published messages
* @param <V> the message type
*/
public record PubSubChannel<V>(
RedisNamespace namespace, RedisKeyName name, RedisCodec<V> messageCodec) {
public PubSubChannel {
Objects.requireNonNull(namespace, "namespace must be non-null");
Objects.requireNonNull(name, "name must be non-null");
Objects.requireNonNull(messageCodec, "message codec must be non-null");
}
/**
* Renders the physical channel name.
*
* @return the rendered channel
*/
public String render() {
return namespace.prefix() + ':' + name.entity() + ':' + name.identifier();
}
}
@@ -0,0 +1,42 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.operations;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.codec.RedisCodec;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.key.RedisKeyRules;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.key.RedisNamespace;
import java.util.Objects;
/**
* A pattern subscription confined to one namespace.
*
* <p>The namespace prefix is always literal; only the suffix is a glob. That keeps a pattern
* subscription from silently reaching another service's channels.
*
* @param namespace the owning namespace
* @param suffixPattern the glob applied inside the namespace
* @param messageCodec codec for published messages
* @param <V> the message type
*/
public record PubSubPattern<V>(
RedisNamespace namespace, String suffixPattern, RedisCodec<V> messageCodec) {
public PubSubPattern {
Objects.requireNonNull(namespace, "namespace must be non-null");
Objects.requireNonNull(suffixPattern, "suffix pattern must be non-null");
Objects.requireNonNull(messageCodec, "message codec must be non-null");
if (suffixPattern.isBlank() || suffixPattern.indexOf(':') >= 0) {
throw new IllegalArgumentException(
"a pattern suffix must be non-blank and must not cross a namespace separator");
}
RedisKeyRules.requireRenderedSize(
namespace.prefix() + ':' + suffixPattern, RedisKeyRules.MAX_KEY_BYTES);
}
/**
* Renders the physical subscription pattern.
*
* @return the rendered pattern
*/
public String render() {
return namespace.prefix() + ':' + suffixPattern;
}
}
@@ -0,0 +1,31 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.operations;
/**
* Bounded rank window.
*
* <p>Both bounds are required and the window is capped, so there is no rank equivalent of an
* unbounded {@code 0 -1} read.
*
* @param start zero-based inclusive start rank
* @param stop zero-based inclusive stop rank
*/
public record RankRange(long start, long stop) {
public RankRange {
if (start < 0 || stop < 0) {
throw new IllegalArgumentException("rank bounds must not be negative");
}
if (stop < start) {
throw new IllegalArgumentException("rank range stop must not precede its start");
}
}
/**
* Returns the number of ranks the window covers.
*
* @return the inclusive window size
*/
public long size() {
return stop - start + 1;
}
}
@@ -0,0 +1,34 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.api.operations;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.key.QualifiedRedisKey;
import java.util.List;
/**
* An ordered set of commands submitted together.
*
* <p>The implementation builds this through a typed builder; the public contract only exposes what
* a caller needs in order to reason about ordering and cluster partitioning.
*/
public interface RedisBatch {
/**
* Returns the number of submitted commands.
*
* @return the command count
*/
int size();
/**
* Returns the keys touched by the batch, in submission order.
*
* @return the touched keys
*/
List<QualifiedRedisKey> keys();
/**
* Returns the encoded request size in bytes.
*
* @return the request size
*/
long requestBytes();
}

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