chore: initialize from backend template 0a6dd0e
This commit is contained in:
@@ -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`.
|
||||
@@ -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=
|
||||
+213
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+428
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+306
@@ -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) {}
|
||||
}
|
||||
+529
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+106
@@ -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;
|
||||
}
|
||||
}
|
||||
+203
@@ -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) {}
|
||||
}
|
||||
+403
@@ -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;
|
||||
}
|
||||
}
|
||||
+49
@@ -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);
|
||||
}
|
||||
}
|
||||
+280
@@ -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) {}
|
||||
}
|
||||
+187
@@ -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());
|
||||
}
|
||||
}
|
||||
+26
@@ -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");
|
||||
}
|
||||
}
|
||||
+337
@@ -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);
|
||||
}
|
||||
}
|
||||
+103
@@ -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);
|
||||
}
|
||||
+28
@@ -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");
|
||||
}
|
||||
}
|
||||
+108
@@ -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();
|
||||
}
|
||||
+119
@@ -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 + "]";
|
||||
}
|
||||
}
|
||||
+72
@@ -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());
|
||||
}
|
||||
}
|
||||
+43
@@ -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;
|
||||
}
|
||||
}
|
||||
+109
@@ -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();
|
||||
}
|
||||
+84
@@ -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;
|
||||
}
|
||||
}
|
||||
+40
@@ -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);
|
||||
}
|
||||
+124
@@ -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
|
||||
+ "]";
|
||||
}
|
||||
}
|
||||
+55
@@ -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);
|
||||
}
|
||||
+19
@@ -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();
|
||||
}
|
||||
+17
@@ -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
|
||||
}
|
||||
+85
@@ -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);
|
||||
}
|
||||
}
|
||||
+17
@@ -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
|
||||
}
|
||||
+76
@@ -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);
|
||||
}
|
||||
}
|
||||
+16
@@ -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();
|
||||
}
|
||||
+54
@@ -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;
|
||||
}
|
||||
}
|
||||
+16
@@ -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();
|
||||
}
|
||||
+60
@@ -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;
|
||||
}
|
||||
}
|
||||
+34
@@ -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);
|
||||
}
|
||||
+34
@@ -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);
|
||||
}
|
||||
+31
@@ -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;
|
||||
}
|
||||
}
|
||||
+42
@@ -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;
|
||||
}
|
||||
}
|
||||
+28
@@ -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);
|
||||
}
|
||||
}
|
||||
+31
@@ -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);
|
||||
}
|
||||
}
|
||||
+28
@@ -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);
|
||||
}
|
||||
}
|
||||
+29
@@ -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);
|
||||
}
|
||||
}
|
||||
+29
@@ -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);
|
||||
}
|
||||
}
|
||||
+28
@@ -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);
|
||||
}
|
||||
}
|
||||
+28
@@ -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);
|
||||
}
|
||||
}
|
||||
+29
@@ -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);
|
||||
}
|
||||
}
|
||||
+106
@@ -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);
|
||||
}
|
||||
}
|
||||
+28
@@ -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);
|
||||
}
|
||||
}
|
||||
+58
@@ -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;
|
||||
}
|
||||
}
|
||||
+28
@@ -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);
|
||||
}
|
||||
}
|
||||
+32
@@ -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);
|
||||
}
|
||||
}
|
||||
+28
@@ -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);
|
||||
}
|
||||
}
|
||||
+17
@@ -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");
|
||||
}
|
||||
}
|
||||
+19
@@ -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");
|
||||
}
|
||||
}
|
||||
+24
@@ -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");
|
||||
}
|
||||
}
|
||||
+20
@@ -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");
|
||||
}
|
||||
}
|
||||
+20
@@ -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");
|
||||
}
|
||||
}
|
||||
+49
@@ -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")));
|
||||
}
|
||||
}
|
||||
+15
@@ -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);
|
||||
}
|
||||
}
|
||||
+69
@@ -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;
|
||||
}
|
||||
}
|
||||
+101
@@ -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;
|
||||
}
|
||||
}
|
||||
+29
@@ -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;
|
||||
}
|
||||
}
|
||||
+17
@@ -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);
|
||||
}
|
||||
}
|
||||
+26
@@ -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();
|
||||
}
|
||||
+19
@@ -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");
|
||||
}
|
||||
}
|
||||
+20
@@ -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");
|
||||
}
|
||||
}
|
||||
+20
@@ -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");
|
||||
}
|
||||
}
|
||||
+217
@@ -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);
|
||||
}
|
||||
}
|
||||
+20
@@ -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");
|
||||
}
|
||||
}
|
||||
+40
@@ -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();
|
||||
}
|
||||
}
|
||||
+44
@@ -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));
|
||||
}
|
||||
}
|
||||
+16
@@ -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
|
||||
}
|
||||
+19
@@ -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");
|
||||
}
|
||||
}
|
||||
+39
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
@@ -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
|
||||
}
|
||||
+24
@@ -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);
|
||||
}
|
||||
}
|
||||
+19
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
@@ -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
|
||||
}
|
||||
+52
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
@@ -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
|
||||
}
|
||||
+13
@@ -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
|
||||
}
|
||||
+13
@@ -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
|
||||
}
|
||||
+18
@@ -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");
|
||||
}
|
||||
}
|
||||
+19
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+70
@@ -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);
|
||||
}
|
||||
}
|
||||
+21
@@ -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");
|
||||
}
|
||||
}
|
||||
+22
@@ -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");
|
||||
}
|
||||
}
|
||||
+44
@@ -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);
|
||||
}
|
||||
}
|
||||
+9
@@ -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
|
||||
}
|
||||
+19
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
@@ -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);
|
||||
}
|
||||
}
|
||||
+29
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
@@ -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);
|
||||
}
|
||||
}
|
||||
+37
@@ -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();
|
||||
}
|
||||
}
|
||||
+42
@@ -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;
|
||||
}
|
||||
}
|
||||
+31
@@ -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;
|
||||
}
|
||||
}
|
||||
+34
@@ -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
Reference in New Issue
Block a user