chore: record pre-existing uncommitted repository state

Snapshot of the in-flight state that already existed, identically, in both
this worktree and the main checkout before this session began: the initial
HTTP Client platform implementation (previously untracked), the redis-lab
removal, and the JPA / object-storage / notification integration work.

Kept separate from this session's HTTP Client review response, which lands
in the following commit, so the two bodies of work stay reviewable apart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-11 16:48:43 +09:00
co-authored by Claude Opus 5
parent 1a3b560678
commit 5f10b791d3
1857 changed files with 130925 additions and 72491 deletions
+36 -15
View File
@@ -10,21 +10,31 @@
## 현재 readiness
현재 checked-in readiness registry에는 `selected` card가 없으므로 Redis R2 release claim도
없다.
readiness는 서로 다른 세 가지 질문이며 하나로 합치면 안 된다. "코드가 있다"는 "Spring이
조립한다"가 아니고, 그 둘 다 "실서버에서 증명됐다"가 아니다. 이 표를 한 축으로 읽으면 아직
존재하지 않는 wiring을 제공 기능으로 오독하게 된다.
| Capability card | 현재 상태 | Promotion topology |
| 축 | 뜻 | 증거 |
| --- | --- | --- |
| cache | `implemented-candidate` | standalone |
| edge rate limit | `implemented-candidate` | standalone |
| request-replay idempotency | `implemented-candidate` | standalone |
| cache refresh soft lease | `implemented-candidate` | standalone |
| session | `implemented-candidate` | standalone |
| fenced coordination | `not-implemented` | 없음 |
| **API 구현** | 타입·정책·contract test가 존재한다 | `:adapter:outbound:cache-redis:test` |
| **Spring composition 구현** | `APP_REDIS_ENABLED=true`에서 실제 bean이 조립된다 | `RedisSdkAutoConfigurationTest` |
| **실서버 qualification** | 지원 topology·버전에서 실제 서버로 증명됐다 | `redisTopologyTest` lane evidence |
`implemented-candidate`는 구현과 standalone/security/fault/compatibility evidence lane이 있다는
뜻일 뿐 release selection이나 R2 qualification이 아니다. 현재 evidence는 Sentinel/Cluster,
k3s multi-node, topology failover, credential/certificate rotation 또는 R3를 증명하지 않는다.
| 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
@@ -351,10 +361,21 @@ legacy `put`에도 positive TTL을 적용하지만, 사용자 제공 legacy clie
## 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
./gradlew :adapter:outbound:cache-redis:redisServiceTest \
-Dredis.test.host=127.0.0.1 -Dredis.test.port=6379 --console=plain
./gradlew :adapter:outbound:cache-redis:redisEfficiencyLeaseTest --console=plain
```
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> 을 추가로 요구한다.
```
+179 -593
View File
@@ -1,616 +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'
implementation 'org.springframework.session:spring-session-core'
implementation 'org.springframework.session:spring-session-data-redis'
implementation 'org.springframework.data:spring-data-redis'
// 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'
implementation 'io.micrometer:micrometer-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' }
sourceSets {
redisTest {
java.srcDir 'src/redisTest/java'
resources.srcDir 'src/redisTest/resources'
compileClasspath += sourceSets.main.output
runtimeClasspath += sourceSets.main.output
}
}
configurations {
redisTestImplementation.extendsFrom testImplementation
redisTestCompileOnly.extendsFrom testCompileOnly
redisTestRuntimeOnly.extendsFrom testRuntimeOnly
}
dependencies {
redisTestImplementation 'org.testcontainers:testcontainers'
}
// 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-service'
excludeTags 'redis-topology'
}
}
tasks.register('redisServiceTest', Test) {
// 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'
description = 'Runs the explicit real Redis standalone qualification lane.'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform {
includeTags 'redis-service'
}
['redis.test.host', 'redis.test.port'].each { propertyName ->
String propertyValue = System.getProperty(propertyName)
if (propertyValue != null) {
systemProperty propertyName, propertyValue
}
}
shouldRunAfter tasks.named('test')
}
def verifyRedisEvidenceSourcesPresent = tasks.register('verifyRedisEvidenceSourcesPresent') {
group = 'redis verification'
description = 'Fails readiness lanes when the redisTest evidence source set is empty.'
inputs.files(sourceSets.redisTest.allSource)
doLast {
Set<File> javaSources = sourceSets.redisTest.java.files.findAll {
it.isFile() && it.name.endsWith('.java')
}
if (javaSources.isEmpty()) {
throw new GradleException(
'Redis evidence source set is empty; readiness tasks must not pass as NO-SOURCE.')
}
File imageRegistry = rootProject.file('gradle/redis-test-images.properties')
if (!imageRegistry.isFile() || imageRegistry.length() == 0) {
throw new GradleException(
"Redis evidence image registry is missing or empty: ${imageRegistry}")
}
}
}
def redisCapabilityMetadata = rootProject.ext.redisCapabilityMetadata
def redisSanitizedEvidenceFileNames = [
'manifest.json',
'capability-card.json',
'topology-fault-timeline.json'
] as Set<String>
def redisSanitizedBundleSha256 = { File directory ->
java.security.MessageDigest digest = java.security.MessageDigest.getInstance('SHA-256')
redisSanitizedEvidenceFileNames.toList().sort().each { String name ->
File file = new File(directory, name)
if (!file.isFile()) {
throw new GradleException(
"Redis sanitized bundle is missing ${name}: ${directory}")
}
byte[] nameBytes = name.getBytes('UTF-8')
byte[] contentBytes = file.bytes
digest.update(java.nio.ByteBuffer.allocate(Long.BYTES).putLong(nameBytes.length).array())
digest.update(nameBytes)
digest.update(java.nio.ByteBuffer.allocate(Long.BYTES).putLong(contentBytes.length).array())
digest.update(contentBytes)
}
digest.digest().encodeHex().toString()
}
def registerRedisEvidenceTask = { String taskName, String tagExpression, String descriptionText ->
def evidenceTask = tasks.register(taskName, Test) {
group = 'redis verification'
description = descriptionText
dependsOn verifyRedisEvidenceSourcesPresent
testClassesDirs = sourceSets.redisTest.output.classesDirs
classpath = sourceSets.redisTest.runtimeClasspath
useJUnitPlatform {
includeTags tagExpression
}
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
jvmArgs '-Duser.timezone=UTC'
systemProperty 'redis.image.registry',
rootProject.file('gradle/redis-test-images.properties').absolutePath
List<Map<String, Object>> sanitizedTimeline = []
List<String> declaredTags = tagExpression.split(/\s*&\s*/).toList()
String cardTag = declaredTags.find { it.startsWith('card-') }
String cardId = cardTag == null ? null : cardTag.substring('card-'.length())
Set<String> evidenceCategories = [
'standalone',
'security',
'sentinel',
'cluster',
'fault',
'compatibility'
] as Set<String>
String evidenceCategory = declaredTags.find {
it.startsWith('redis-') && evidenceCategories.contains(it.substring('redis-'.length()))
}
if (evidenceCategory != null) {
evidenceCategory = evidenceCategory.substring('redis-'.length())
}
File evidenceDirectory = layout.buildDirectory.dir(
"redis-evidence/${taskName}").get().asFile
outputs.dir evidenceDirectory
afterTest { descriptor, result ->
String identity = "${descriptor.className ?: ''}#${descriptor.name ?: ''}"
String identityDigest = java.security.MessageDigest.getInstance('SHA-256')
.digest(identity.getBytes('UTF-8')).encodeHex().toString()
sanitizedTimeline << [
sequence : sanitizedTimeline.size() + 1,
testCaseIdSha256: identityDigest,
outcome : result.resultType.name(),
durationMillis : Math.max(0L, result.endTime - result.startTime)
]
}
afterSuite { descriptor, result ->
if (descriptor.parent != null) {
return
}
evidenceDirectory.mkdirs()
Map<String, Object> card = cardId == null
? null
: rootProject.ext.redisReadinessCards[cardId] as Map<String, Object>
Map<String, String> digests = rootProject.ext.redisEvidenceDigests()
Map<String, Object> metadata = cardId == null
? [
providerIds : [],
roles : [],
programs : [],
keyVersions : [],
codecVersions : [],
guarantees : ['cross-cutting Redis evidence lane'],
nonGuarantees : ['does not qualify a capability card by itself'],
requiredSettings: []
]
: redisCapabilityMetadata[cardId] as Map<String, Object>
Map<String, Object> capabilityCard = [
schemaVersion : 1,
cardId : cardId,
readiness : card?.state,
releaseQualification: 'NOT_CLAIMED',
promotionTopology : card?.selectedTopology,
sourceRevision : rootProject.ext.redisEvidenceSourceRevision,
sourceTreeState : rootProject.ext.redisEvidenceSourceTreeState,
digests : digests,
minimumRedisVersion: '7.2',
providerIds : metadata.providerIds,
roles : metadata.roles,
programIds : metadata.programs,
keyVersions : metadata.keyVersions,
codecVersions : metadata.codecVersions,
guarantees : metadata.guarantees,
nonGuarantees : metadata.nonGuarantees,
requiredSettings : metadata.requiredSettings,
evidenceProfile : card?.requiredEvidence ?: []
]
File capabilityCardFile = new File(evidenceDirectory, 'capability-card.json')
capabilityCardFile.setText(
groovy.json.JsonOutput.prettyPrint(
groovy.json.JsonOutput.toJson(capabilityCard)) + '\n',
'UTF-8')
Map<String, Object> timeline = [
schemaVersion: 1,
taskName : taskName,
cardId : cardId,
topology : card?.selectedTopology,
evidence : evidenceCategory,
timelineKind : 'SANITIZED_TEST_RESULT',
actualEventTimeline: 'NOT_CAPTURED',
sourceRevision: rootProject.ext.redisEvidenceSourceRevision,
sourceTreeState: rootProject.ext.redisEvidenceSourceTreeState,
digests : digests,
events : sanitizedTimeline
]
File timelineFile = new File(evidenceDirectory, 'topology-fault-timeline.json')
timelineFile.setText(
groovy.json.JsonOutput.prettyPrint(
groovy.json.JsonOutput.toJson(timeline)) + '\n',
'UTF-8')
Closure<String> sha256 = { File file ->
java.security.MessageDigest.getInstance('SHA-256')
.digest(file.bytes).encodeHex().toString()
}
String outcome = result.resultType.name() == 'FAILURE'
? 'failed'
: (result.testCount == 0 || result.skippedTestCount > 0
? 'skipped-with-reason'
: 'executed')
Map<String, Object> manifest = [
schemaVersion : 1,
taskPath : path,
tagExpression : tagExpression,
cardId : cardId,
cardState : card?.state,
selectedTopology : card?.selectedTopology,
evidenceCategory : evidenceCategory,
outcome : outcome,
tests : [
discovered: result.testCount,
executed : result.testCount - result.skippedTestCount,
passed : result.successfulTestCount,
failed : result.failedTestCount,
errors : 0,
skipped : result.skippedTestCount
],
runtimeImageAttestation: 'NOT_CAPTURED',
actualEventTimeline: 'NOT_CAPTURED',
releaseQualification: 'NOT_CLAIMED',
sourceRevision : rootProject.ext.redisEvidenceSourceRevision,
sourceTreeState : rootProject.ext.redisEvidenceSourceTreeState,
digests : digests,
companionSha256 : [
capabilityCardSha256: sha256(capabilityCardFile),
timelineSha256 : sha256(timelineFile)
]
]
new File(evidenceDirectory, 'manifest.json').setText(
groovy.json.JsonOutput.prettyPrint(
groovy.json.JsonOutput.toJson(manifest)) + '\n',
'UTF-8')
}
doFirst {
[
'manifest.json',
'capability-card.json',
'topology-fault-timeline.json'
].each { String generatedFile ->
new File(evidenceDirectory, generatedFile).delete()
}
layout.buildDirectory.file(
"redis-evidence-sanitizer/${taskName}.sha256").get().asFile.delete()
Set<File> matchingSources = sourceSets.redisTest.java.files.findAll { File source ->
if (!source.isFile() || !source.name.endsWith('.java')) {
return false
}
String content = source.getText('UTF-8')
declaredTags.every { String tag -> content.contains("@Tag(\"${tag}\")") }
}
if (matchingSources.isEmpty()) {
throw new GradleException(
"${taskName}: no redisTest source declares every required tag " +
"${declaredTags}; zero-evidence readiness must not pass.")
}
}
}
def sanitizerTask = tasks.register("${taskName}SanitizeEvidence") {
group = 'redis verification'
description = "Validates the bounded sanitized artifact for ${taskName} before upload."
mustRunAfter evidenceTask
File sanitizerMarker = layout.buildDirectory.file(
"redis-evidence-sanitizer/${taskName}.sha256").get().asFile
doFirst {
sanitizerMarker.delete()
}
doLast {
File evidenceDirectory = layout.buildDirectory.dir(
"redis-evidence/${taskName}").get().asFile
if (!evidenceDirectory.isDirectory()) {
throw new GradleException(
"${taskName}: sanitized evidence directory was not generated")
}
Set<String> allowedNames = redisSanitizedEvidenceFileNames
List<File> files = evidenceDirectory.listFiles()?.findAll { it.isFile() } ?: []
if (files.collect { it.name } as Set<String> != allowedNames ||
evidenceDirectory.listFiles()?.any { it.isDirectory() }) {
throw new GradleException(
"${taskName}: sanitized evidence must contain exactly ${allowedNames}")
}
files.each { File file ->
if (file.length() > 1_048_576L ||
java.nio.file.Files.isSymbolicLink(file.toPath()) ||
!file.toPath().toRealPath().startsWith(
evidenceDirectory.toPath().toRealPath())) {
throw new GradleException(
"${taskName}: oversized, symlinked, or path-escaping artifact ${file}")
}
String text = file.getText('UTF-8')
Map<String, java.util.regex.Pattern> forbidden = [
pem : java.util.regex.Pattern.compile(
'(?i)-----BEGIN [^-]*(?:PRIVATE KEY|CERTIFICATE)-----'),
aclMaterial : java.util.regex.Pattern.compile(
"(?i)(?:users\\.acl|--pass|[\"']password[\"']\\s*:)"),
uriUserInfo : java.util.regex.Pattern.compile(
'(?i)rediss?://[^\\s/@:]+:[^\\s/@]+@'),
secretReference : java.util.regex.Pattern.compile('(?i)secret://'),
rawMessageFields : java.util.regex.Pattern.compile(
'(?i)"(?:stackTrace|systemOut|systemErr|exception|containerId|host|ip|port|endpoint|rawKey|physicalKey|value|sessionId|csrf|idempotencyToken|ownerToken|operationToken)"\\s*:')
]
forbidden.each { String marker, java.util.regex.Pattern pattern ->
if (pattern.matcher(text).find()) {
throw new GradleException(
"${taskName}: sanitized artifact ${file.name} contains forbidden ${marker} material")
}
}
}
Map<String, Object> manifest = new groovy.json.JsonSlurper().parse(
new File(evidenceDirectory, 'manifest.json')) as Map<String, Object>
Map<String, Object> capability = new groovy.json.JsonSlurper().parse(
new File(evidenceDirectory, 'capability-card.json')) as Map<String, Object>
Map<String, Object> timeline = new groovy.json.JsonSlurper().parse(
new File(evidenceDirectory, 'topology-fault-timeline.json')) as Map<String, Object>
Set<String> manifestFields = [
'schemaVersion',
'taskPath',
'tagExpression',
'cardId',
'cardState',
'selectedTopology',
'evidenceCategory',
'outcome',
'tests',
'runtimeImageAttestation',
'actualEventTimeline',
'releaseQualification',
'sourceRevision',
'sourceTreeState',
'digests',
'companionSha256'
] as Set<String>
Set<String> capabilityFields = [
'schemaVersion',
'cardId',
'readiness',
'releaseQualification',
'promotionTopology',
'sourceRevision',
'sourceTreeState',
'digests',
'minimumRedisVersion',
'providerIds',
'roles',
'programIds',
'keyVersions',
'codecVersions',
'guarantees',
'nonGuarantees',
'requiredSettings',
'evidenceProfile'
] as Set<String>
Set<String> timelineFields = [
'schemaVersion',
'taskName',
'cardId',
'topology',
'evidence',
'timelineKind',
'actualEventTimeline',
'sourceRevision',
'sourceTreeState',
'digests',
'events'
] as Set<String>
if (manifest.keySet() != manifestFields ||
capability.keySet() != capabilityFields ||
timeline.keySet() != timelineFields ||
(manifest.tests as Map).keySet() != [
'discovered',
'executed',
'passed',
'failed',
'errors',
'skipped'
] as Set<String> ||
(manifest.digests as Map).keySet() != [
'registrySha256',
'imageRegistrySha256',
'programSetSha256',
'configurationSha256'
] as Set<String> ||
(manifest.companionSha256 as Map).keySet() != [
'capabilityCardSha256',
'timelineSha256'
] as Set<String>) {
throw new GradleException(
"${taskName}: sanitized evidence contains unknown or missing schema fields")
}
List<Map<String, Object>> events = timeline.events as List<Map<String, Object>>
if (events.size() > 10_000 ||
events.withIndex().any { Map<String, Object> event, int index ->
event.keySet() != [
'sequence',
'testCaseIdSha256',
'outcome',
'durationMillis'
] as Set<String> ||
event.sequence != index + 1 ||
!(event.testCaseIdSha256 ==~ /[0-9a-f]{64}/) ||
!(event.outcome in ['SUCCESS', 'FAILURE', 'SKIPPED']) ||
!(event.durationMillis instanceof Number) ||
(event.durationMillis as Number).longValue() < 0L
}) {
throw new GradleException(
"${taskName}: sanitized test summary contains malformed events")
}
if ((capability.requiredSettings as List).any {
!(it instanceof Map) ||
(it as Map).keySet() != ['name', 'type', 'constraint'] as Set<String>
}) {
throw new GradleException(
"${taskName}: capability card required settings are not a safe name/type/constraint projection")
}
Map<String, Object> tests = manifest.tests as Map<String, Object>
if (!(manifest.outcome in ['executed', 'failed', 'skipped-with-reason']) ||
events.size() != (tests.discovered as Number).intValue() ||
events.count { it.outcome == 'SUCCESS' } !=
(tests.passed as Number).intValue() ||
events.count { it.outcome == 'FAILURE' } !=
(tests.failed as Number).intValue() ||
events.count { it.outcome == 'SKIPPED' } !=
(tests.skipped as Number).intValue()) {
throw new GradleException(
"${taskName}: manifest outcome/counts do not match the sanitized test summary")
}
if (manifest.outcome == 'executed' &&
((tests.discovered as Number).longValue() <= 0L ||
(tests.executed as Number).longValue() <= 0L ||
(tests.passed as Number).longValue() <= 0L ||
(tests.failed as Number).longValue() != 0L ||
(tests.errors as Number).longValue() != 0L ||
(tests.skipped as Number).longValue() != 0L)) {
throw new GradleException(
"${taskName}: executed evidence must be positive with zero failure/error/skip")
}
if (manifest.outcome == 'failed' &&
(tests.failed as Number).longValue() <= 0L) {
throw new GradleException(
"${taskName}: failed evidence must retain a positive bounded failure count")
}
sanitizerMarker.parentFile.mkdirs()
String bundleSha = redisSanitizedBundleSha256(evidenceDirectory)
sanitizerMarker.setText("${bundleSha}\n", 'UTF-8')
if (manifest.outcome == 'skipped-with-reason') {
throw new GradleException(
"${taskName}: skipped or zero-executed evidence is not a passing readiness lane")
}
}
}
evidenceTask.configure {
finalizedBy sanitizerTask
}
evidenceTask
}
tasks.register('verifyRedisEvidenceArtifactsForUpload') {
group = 'redis verification'
description = 'Allows CI upload only when every generated Redis evidence directory was sanitized.'
doLast {
File evidenceRoot = layout.buildDirectory.dir('redis-evidence').get().asFile
File markerRoot = layout.buildDirectory.dir('redis-evidence-sanitizer').get().asFile
List<File> evidenceDirectories = evidenceRoot.isDirectory()
? evidenceRoot.listFiles().findAll { it.isDirectory() }
: []
if (evidenceDirectories.isEmpty()) {
throw new GradleException(
'No sanitized Redis evidence directory exists for upload')
}
Set<String> evidenceTasks = evidenceDirectories.collect { it.name } as Set<String>
Set<String> markerTasks = markerRoot.isDirectory()
? markerRoot.listFiles().findAll {
it.isFile() && it.name.endsWith('.sha256')
}.collect {
it.name.substring(0, it.name.length() - '.sha256'.length())
} as Set<String>
: [] as Set<String>
if (evidenceTasks != markerTasks) {
throw new GradleException(
"Redis evidence upload sanitizer coverage mismatch; evidence=${evidenceTasks}, markers=${markerTasks}")
}
evidenceDirectories.each { File directory ->
Set<String> files = directory.listFiles().findAll { it.isFile() }
.collect { it.name } as Set<String>
if (files != redisSanitizedEvidenceFileNames) {
throw new GradleException(
"Redis upload directory ${directory.name} is outside the sanitized allowlist")
}
String bundleSha = redisSanitizedBundleSha256(directory)
String recordedSha = new File(
markerRoot, "${directory.name}.sha256").getText('UTF-8').trim()
if (recordedSha != bundleSha) {
throw new GradleException(
"Redis upload sanitizer bundle marker is stale for ${directory.name}")
}
}
}
}
registerRedisEvidenceTask(
'redisStandaloneTest',
'redis-standalone',
'Runs real standalone Redis evidence. Docker/service absence and zero tests fail.')
registerRedisEvidenceTask(
'redisSecurityTest',
'redis-security',
'Runs Redis TLS, ACL, secret-redaction, and fail-closed security evidence.')
registerRedisEvidenceTask(
'redisSentinelTest',
'redis-sentinel',
'Runs the explicit Redis Sentinel topology evidence lane.')
registerRedisEvidenceTask(
'redisClusterTest',
'redis-cluster',
'Runs the explicit Redis Cluster topology evidence lane.')
registerRedisEvidenceTask(
'redisFaultTest',
'redis-fault',
'Runs bounded Redis outage, response-loss, memory, and recovery evidence.')
registerRedisEvidenceTask(
'redisCompatibilityTest',
'redis-compatibility',
'Runs pinned minimum/next/approved Redis compatibility evidence.')
registerRedisEvidenceTask(
'redisEfficiencyLeaseTest',
'redis-efficiency-lease',
'Runs non-fenced EFFICIENCY_ONLY lease standalone, security, fault, and compatibility qualification.')
def redisCardTags = [
redisCacheCapabilityTest : 'card-redis-cache',
redisRateLimitCapabilityTest : 'card-redis-edge-rate-limit',
redisIdempotencyCapabilityTest : 'card-redis-request-replay-idempotency',
redisSoftLeaseCapabilityTest : 'card-redis-cache-refresh-soft-lease',
redisFencedCoordinationCapabilityTest: 'card-redis-fenced-coordination',
redisSessionCapabilityTest : 'card-redis-session'
]
redisCardTags.each { String taskName, String cardTag ->
registerRedisEvidenceTask(
taskName,
cardTag,
"Runs all real-service evidence owned by Redis capability card ${cardTag}.")
}
def redisEvidenceTags = [
Standalone : 'redis-standalone',
Security : 'redis-security',
Sentinel : 'redis-sentinel',
Cluster : 'redis-cluster',
Fault : 'redis-fault',
Compatibility: 'redis-compatibility'
]
def redisCardTaskStems = [
Cache : 'card-redis-cache',
RateLimit : 'card-redis-edge-rate-limit',
Idempotency : 'card-redis-request-replay-idempotency',
SoftLease : 'card-redis-cache-refresh-soft-lease',
FencedCoordination: 'card-redis-fenced-coordination',
Session : 'card-redis-session'
]
redisCardTaskStems.each { String cardStem, String cardTag ->
redisEvidenceTags.each { String evidenceStem, String evidenceTag ->
registerRedisEvidenceTask(
"redis${cardStem}${evidenceStem}EvidenceTest",
"${cardTag} & ${evidenceTag}",
"Runs ${evidenceTag} evidence owned only by ${cardTag}.")
}
}
tasks.named('check') {
dependsOn tasks.named('redisStandaloneTest')
}
def redisLabContractDirectory = rootProject.file('../infra/redis-lab')
def redisLabContractTest = tasks.register('redisLabContractTest', Exec) {
group = 'verification'
description = 'Runs the VM-free Redis lab lifecycle and host-isolation contract with fake commands.'
workingDir rootProject.projectDir
executable 'bash'
args new File(redisLabContractDirectory, 'test/redis-lab-contract.sh').absolutePath
inputs.files(
new File(redisLabContractDirectory, 'versions.env'),
new File(redisLabContractDirectory, 'bin/redis-lab'),
new File(redisLabContractDirectory, 'cloud-init/node.yaml'),
new File(redisLabContractDirectory, 'lib/render-kubeconfig.awk'),
fileTree(new File(redisLabContractDirectory, 'test/fixtures')) {
include '**/*'
},
new File(redisLabContractDirectory, 'test/redis-lab-contract.sh'))
// 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()
tasks.named('check') {
dependsOn redisLabContractTest
// 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.")
}
}
+119 -139
View File
@@ -1,186 +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=redisTestCompileClasspath,testCompileClasspath
ch.qos.logback:logback-classic:1.5.21=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
ch.qos.logback:logback-core:1.5.21=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-annotations:2.20=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
com.github.docker-java:docker-java-api:3.7.0=redisTestCompileClasspath,redisTestRuntimeClasspath
com.github.docker-java:docker-java-transport-zerodep:3.7.0=redisTestCompileClasspath,redisTestRuntimeClasspath
com.github.docker-java:docker-java-transport:3.7.0=redisTestCompileClasspath,redisTestRuntimeClasspath
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
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=redisTestCompileClasspath,testCompileClasspath
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,redisTestAnnotationProcessor,testAnnotationProcessor
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
com.google.auto:auto-common:1.2.2=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
com.google.code.findbugs:jsr305:3.0.2=checkstyle,redisTestCompileClasspath,spotbugs,testCompileClasspath
com.google.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,redisTestAnnotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.38.0=redisTestCompileClasspath,testCompileClasspath
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,redisTestAnnotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,redisTestAnnotationProcessor,testAnnotationProcessor
com.google.guava:guava:33.5.0-jre=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
com.google.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,redisTestAnnotationProcessor,testAnnotationProcessor
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,redisTestAnnotationProcessor,testAnnotationProcessor
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
com.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=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
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=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath
commons-beanutils:commons-beanutils:1.11.0=checkstyle
commons-codec:commons-codec:1.19.0=redisTestCompileClasspath,redisTestRuntimeClasspath
commons-collections:commons-collections:3.2.2=checkstyle
commons-io:commons-io:2.20.0=redisTestCompileClasspath,redisTestRuntimeClasspath
commons-io:commons-io:2.21.0=spotbugs
commons-logging:commons-logging:1.3.5=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
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,redisTestAnnotationProcessor,testAnnotationProcessor
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
io.lettuce:lettuce-core:6.8.1.RELEASE=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-commons:1.16.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-core:1.16.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-observation:1.16.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-buffer:4.2.7.Final=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-codec-base:4.2.7.Final=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-codec-dns:4.2.7.Final=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-common:4.2.7.Final=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-handler:4.2.7.Final=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-resolver-dns:4.2.7.Final=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-resolver:4.2.7.Final=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-transport-native-unix-common:4.2.7.Final=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-transport:4.2.7.Final=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.projectreactor:reactor-core:3.8.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.activation:jakarta.activation-api:2.1.4=redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.annotation:jakarta.annotation-api:3.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
javax.inject:javax.inject:1=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
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=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.bytebuddy:byte-buddy:1.17.8=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.java.dev.jna:jna:5.18.1=redisTestCompileClasspath,redisTestRuntimeClasspath
net.minidev:accessors-smart:2.6.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.minidev:json-smart:2.6.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.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-compress:1.28.0=redisTestCompileClasspath,redisTestRuntimeClasspath
org.apache.commons:commons-lang3:3.20.0=checkstyle,redisTestCompileClasspath,redisTestRuntimeClasspath,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=redisTestCompileClasspath,redisTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
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=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
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=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.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=redisTestCompileClasspath,testCompileClasspath
org.assertj:assertj-core:3.27.6=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.awaitility:awaitility:4.3.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
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=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.hdrhistogram:HdrHistogram:2.2.2=redisTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath
org.javassist:javassist:3.28.0-GA=checkstyle
org.jetbrains:annotations:17.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,redisTestAnnotationProcessor,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-api:6.0.1=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:6.0.1=redisTestRuntimeClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-params:6.0.1=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter:6.0.1=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-commons:6.0.1=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-engine:6.0.1=redisTestRuntimeClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-launcher:6.0.1=redisTestRuntimeClasspath,testRuntimeClasspath
org.junit:junit-bom:6.0.1=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.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.latencyutils:LatencyUtils:2.0.3=redisTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
org.mockito:mockito-core:5.20.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.mockito:mockito-junit-jupiter:5.20.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.objenesis:objenesis:3.3=redisTestRuntimeClasspath,testRuntimeClasspath
org.opentest4j:opentest4j:1.3.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.osgi:org.osgi.annotation.bundle:2.0.0=redisTestCompileClasspath,testCompileClasspath
org.osgi:org.osgi.annotation.versioning:1.1.2=redisTestCompileClasspath,testCompileClasspath
org.osgi:org.osgi.resource:1.0.0=redisTestCompileClasspath,testCompileClasspath
org.osgi:org.osgi.service.serviceloader:1.0.0=redisTestCompileClasspath,testCompileClasspath
org.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=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.pcollections:pcollections:4.0.1=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor
org.reactivestreams:reactive-streams:1.0.4=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.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.rnorth.duct-tape:duct-tape:1.0.8=redisTestCompileClasspath,redisTestRuntimeClasspath
org.skyscreamer:jsonassert:1.5.3=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.slf4j:jul-to-slf4j:2.0.17=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-api:2.0.17=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
org.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,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
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-http-client:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-converter:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jackson:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-restclient:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-resttestclient:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-servlet:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-test:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-tomcat:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-web-server:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc-test:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot:4.0.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.data:spring-data-commons:4.0.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.data:spring-data-keyvalue:4.0.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.data:spring-data-redis:4.0.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.session:spring-session-core:4.0.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.session:spring-session-data-redis:4.0.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-aop:7.0.1=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-beans:7.0.1=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-context-support:7.0.1=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-context:7.0.1=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-core:7.0.1=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-expression:7.0.1=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-oxm:7.0.1=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-test:7.0.1=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-tx:7.0.1=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-web:7.0.1=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webmvc:7.0.1=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.testcontainers:testcontainers:2.0.2=redisTestCompileClasspath,redisTestRuntimeClasspath
org.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=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.yaml:snakeyaml:2.5=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
redis.clients.authentication:redis-authx-core:0.1.1-beta2=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-core:3.0.2=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-databind:3.0.2=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson:jackson-bom:3.0.2=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
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=
@@ -1,24 +0,0 @@
package dev.caskeleton.adapter.outbound.cache;
import java.util.Map;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* Logical-cache-name → backendId bindings bound from {@code app.cache.bindings.*} (relaxed binding
* also accepts env keys, e.g. {@code APP_CACHE_BINDINGS_WORKLOG=redis}).
*
* <p>Example: {@code app.cache.bindings.worklog=redis} routes {@code
* CacheStoreRouter.get("worklog", key)} to the backend whose {@link CacheBackend#backendId()} is
* {@code redis}. Absent keys default to an empty map so the cache template stays a non-required
* optional module. Binding consistency (every referenced backendId has an enabled backend) is
* validated fail-fast by {@code CacheStoreRouter} at startup.
*
* @param bindings logical cache name → backendId (default empty)
*/
@ConfigurationProperties(prefix = "app.cache")
public record CacheBindingSettings(Map<String, String> bindings) {
public CacheBindingSettings {
bindings = (bindings == null) ? Map.of() : Map.copyOf(bindings);
}
}
@@ -1,34 +0,0 @@
package dev.caskeleton.adapter.outbound.cache;
import dev.caskeleton.adapter.outbound.cache.core.CacheBackend;
import dev.caskeleton.adapter.outbound.cache.core.CacheStoreRouter;
import dev.caskeleton.adapter.outbound.cache.core.FailOpenCacheStore;
import dev.caskeleton.adapter.outbound.support.FailOpenDependencyLogger;
import java.util.List;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Assembles the {@link CacheStoreRouter} from every contributed {@link CacheBackend} bean. Adding a
* backend is new files only — this config and the router never change. The fail-open policy is
* applied here, centrally, by wrapping every backend in {@link FailOpenCacheStore}, so a backend
* config cannot forget it.
*/
@Configuration
@EnableConfigurationProperties(CacheBindingSettings.class)
public class CacheRouterConfig {
@Bean
public CacheStoreRouter cacheStoreRouter(
ObjectProvider<List<CacheBackend>> backends,
CacheBindingSettings settings,
FailOpenDependencyLogger failOpenDependencyLogger) {
List<FailOpenCacheStore> failOpenBackends =
backends.getIfAvailable(List::of).stream()
.map(backend -> new FailOpenCacheStore(backend, failOpenDependencyLogger))
.toList();
return new CacheStoreRouter(failOpenBackends, settings.bindings());
}
}
@@ -1,12 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.core;
/**
* Cache backend contribution contract. A backend opts into routing by registering a bean of this
* interface; {@link #backendId()} is the identifier referenced by {@code
* app.cache.bindings.<logicalName>} values.
*/
public interface CacheBackend extends CacheStore {
/** Stable backend identifier referenced by {@code app.cache.bindings.*} values. */
String backendId();
}
@@ -1,17 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.core;
/**
* Unchecked wrapper a cache backend binding throws when its integration client fails (the seam
* interfaces declare {@code throws Exception}, but {@link CacheStore} does not). The {@link
* FailOpenCacheStore} decorator catches it and applies the fail-open cache-miss contract — backend
* bindings must propagate failures, never swallow them, so a backend outage is observable and
* cannot be mistaken for a miss.
*/
public class CacheBackendException extends RuntimeException {
private static final long serialVersionUID = 1L;
public CacheBackendException(String backendId, Throwable cause) {
super("cache backend '" + backendId + "' access failed", cause);
}
}
@@ -1,24 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.core;
import java.util.Optional;
/**
* Per-backend cache SPI for the optional adapter template. Consumers do not inject this type
* directly — they call {@link CacheStoreRouter} with a logical cache name. {@link #get(String)}
* returns {@link Optional#empty()} on a miss (so no cache SDK type escapes the adapter — B7).
* Routing and fail-open composition rationale is in the module README.
*/
public interface CacheStore {
/**
* Reads a cached value. {@link Optional#empty()} == miss (or a degraded backend's fail-open
* downgrade).
*/
Optional<String> get(String key);
/**
* Writes a value. Backend failures are handled fail-open by the central {@link
* FailOpenCacheStore}.
*/
void put(String key, String value);
}
@@ -1,81 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.core;
import dev.caskeleton.shared.error.AdapterDisabledException;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
/**
* Routes logical cache names to contributed {@link CacheBackend}s ({@code
* app.cache.bindings.<logicalName>=<backendId>}). The Layer 3 fail-fast contract lives here: a
* duplicate backendId or a binding to a backendId with no enabled backend fails construction, and
* {@code get}/{@code put} on an unbound logical name throws {@link AdapterDisabledException} (never
* a silent no-op). With no backends and no bindings it constructs cleanly, so the cache template
* never becomes a required dependency. It does not expose the resolved {@link CacheStore}, so no
* adapter type escapes via a public return (B7).
*/
public final class CacheStoreRouter {
private static final String ADAPTER_NAME = "cache";
private final Map<String, CacheStore> backends;
private final Map<String, String> bindings;
public CacheStoreRouter(
Collection<? extends CacheBackend> backends, Map<String, String> bindings) {
Map<String, CacheStore> byId = new HashMap<>();
for (CacheBackend backend : backends) {
CacheStore previous = byId.putIfAbsent(backend.backendId(), backend);
if (previous != null) {
throw new IllegalStateException(
"duplicate cache backendId '"
+ backend.backendId()
+ "' — every contributed CacheBackend bean must have a unique backendId");
}
}
this.backends = Map.copyOf(byId);
this.bindings = Map.copyOf(bindings);
for (Map.Entry<String, String> binding : this.bindings.entrySet()) {
if (!this.backends.containsKey(binding.getValue())) {
throw new IllegalStateException(
"app.cache.bindings."
+ binding.getKey()
+ "="
+ binding.getValue()
+ " references cache backend '"
+ binding.getValue()
+ "' but no enabled backend contributes that id — enable the backend"
+ " (e.g. app.cache."
+ binding.getValue()
+ ".enabled=true)"
+ " or fix the binding");
}
}
}
/** Reads from the backend bound to {@code logicalName} (empty == miss). */
public Optional<String> get(String logicalName, String key) {
return resolve(logicalName).get(key);
}
/** Writes to the backend bound to {@code logicalName}. */
public void put(String logicalName, String key, String value) {
resolve(logicalName).put(key, value);
}
private CacheStore resolve(String logicalName) {
String backendId = bindings.get(logicalName);
if (backendId == null) {
throw new AdapterDisabledException(
ADAPTER_NAME,
"no cache backend bound for logical cache '"
+ logicalName
+ "' — set app.cache.bindings."
+ logicalName
+ "=<backendId> and enable that backend"
+ " (integration-adapter-templates Layer 3)");
}
return backends.get(backendId);
}
}
@@ -1,50 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.core;
import dev.caskeleton.adapter.outbound.support.FailOpenDependencyLogger;
import java.util.Optional;
/**
* Fail-open decorator: a cache backend outage degrades to a miss ({@code get} → empty, {@code put}
* swallowed), never a 5xx. Applied centrally by {@code CacheRouterConfig}.
*/
public final class FailOpenCacheStore implements CacheBackend {
private static final String DEPENDENCY_TYPE = "cache";
private final CacheBackend delegate;
private final FailOpenDependencyLogger dependencyLogger;
public FailOpenCacheStore(CacheBackend delegate, FailOpenDependencyLogger dependencyLogger) {
this.delegate = delegate;
this.dependencyLogger = dependencyLogger;
}
@Override
public String backendId() {
return delegate.backendId();
}
@Override
public Optional<String> get(String key) {
try {
Optional<String> value = delegate.get(key);
dependencyLogger.logSuccess(delegate.backendId(), DEPENDENCY_TYPE, "get");
return value;
} catch (Exception ex) {
// fail-open: an unavailable backend degrades to a cache-miss, not a 5xx.
dependencyLogger.logFailure(delegate.backendId(), DEPENDENCY_TYPE, "get", ex);
return Optional.empty();
}
}
@Override
public void put(String key, String value) {
try {
delegate.put(key, value);
dependencyLogger.logSuccess(delegate.backendId(), DEPENDENCY_TYPE, "put");
} catch (Exception ex) {
// fail-open: a failed cache write is observed, not propagated.
dependencyLogger.logFailure(delegate.backendId(), DEPENDENCY_TYPE, "put", ex);
}
}
}
@@ -1,231 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.time.Duration;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.function.LongSupplier;
/** One daemon worker with storage bounded by the finite active Redis role count. */
final class BoundedRedisSentinelRefreshWorker implements RedisSentinelRefreshWorker {
private final Object monitor = new Object();
private final int capacity;
private final ArrayDeque<Runnable> immediateTasks;
private final List<RecurringTask> recurringTasks;
private final Thread worker;
private final LongSupplier nanoTime;
private boolean closed;
private boolean preferDueRecurring;
BoundedRedisSentinelRefreshWorker(int capacity, String threadName) {
this(capacity, threadName, System::nanoTime);
}
BoundedRedisSentinelRefreshWorker(int capacity, String threadName, LongSupplier nanoTime) {
if (capacity < 1) {
throw new IllegalArgumentException("Redis Sentinel worker capacity must be positive");
}
this.capacity = capacity;
this.immediateTasks = new ArrayDeque<>(capacity);
this.recurringTasks = new ArrayList<>(capacity);
this.nanoTime = Objects.requireNonNull(nanoTime, "nanoTime must be non-null");
this.worker =
Thread.ofPlatform().daemon(true).name(requireText(threadName)).unstarted(this::runWorker);
this.worker.start();
}
@Override
public Cancellable scheduleWithFixedDelay(Runnable task, Duration delay) {
Objects.requireNonNull(task, "task must be non-null");
long delayNanos = positiveNanos(delay);
RecurringTask recurring =
new RecurringTask(task, delayNanos, nanoTime.getAsLong() + delayNanos);
synchronized (monitor) {
ensureOpen();
if (recurringTasks.size() >= capacity) {
throw new IllegalStateException("Redis Sentinel recurring task capacity is exhausted");
}
recurringTasks.add(recurring);
monitor.notifyAll();
}
return () -> cancel(recurring);
}
@Override
public boolean execute(Runnable task) {
Objects.requireNonNull(task, "task must be non-null");
synchronized (monitor) {
if (closed || immediateTasks.size() >= capacity) {
return false;
}
immediateTasks.addLast(task);
monitor.notifyAll();
return true;
}
}
@Override
public void shutdown(Duration timeout) {
long timeoutNanos = positiveNanos(timeout);
synchronized (monitor) {
if (!closed) {
closed = true;
recurringTasks.forEach(task -> task.cancelled = true);
recurringTasks.clear();
immediateTasks.clear();
monitor.notifyAll();
}
}
worker.interrupt();
if (Thread.currentThread() == worker) {
return;
}
try {
long millis = Math.max(1, Math.min(Long.MAX_VALUE, timeoutNanos / 1_000_000L));
worker.join(millis);
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
}
}
private void runWorker() {
while (true) {
Work work;
try {
work = awaitWork();
} catch (InterruptedException interrupted) {
if (isClosed()) {
Thread.currentThread().interrupt();
return;
}
continue;
}
if (work == null) {
return;
}
try {
work.task.run();
} catch (RuntimeException ignored) {
// Refresh failures are deliberately contained and rendered only through sanitized health.
} finally {
if (work.recurring != null) {
reschedule(work.recurring);
}
}
}
}
private Work awaitWork() throws InterruptedException {
synchronized (monitor) {
while (!closed) {
if (!preferDueRecurring) {
Runnable immediate = immediateTasks.pollFirst();
if (immediate != null) {
preferDueRecurring = true;
return new Work(immediate, null);
}
}
long now = nanoTime.getAsLong();
RecurringTask due = null;
long waitNanos = Long.MAX_VALUE;
for (RecurringTask task : recurringTasks) {
if (task.cancelled || task.running) {
continue;
}
long remaining = task.nextRunNanos - now;
if (remaining <= 0) {
due = task;
break;
}
waitNanos = Math.min(waitNanos, remaining);
}
if (due != null) {
due.running = true;
preferDueRecurring = false;
return new Work(due.task, due);
}
Runnable immediate = immediateTasks.pollFirst();
if (immediate != null) {
preferDueRecurring = true;
return new Work(immediate, null);
}
if (waitNanos == Long.MAX_VALUE) {
monitor.wait();
} else {
long millis = waitNanos / 1_000_000L;
int nanos = (int) (waitNanos % 1_000_000L);
monitor.wait(millis, nanos);
}
}
return null;
}
}
private void reschedule(RecurringTask task) {
synchronized (monitor) {
task.running = false;
if (!closed && !task.cancelled) {
task.nextRunNanos = nanoTime.getAsLong() + task.delayNanos;
}
monitor.notifyAll();
}
}
private void cancel(RecurringTask task) {
synchronized (monitor) {
task.cancelled = true;
recurringTasks.remove(task);
monitor.notifyAll();
}
}
private boolean isClosed() {
synchronized (monitor) {
return closed;
}
}
private void ensureOpen() {
if (closed) {
throw new IllegalStateException("Redis Sentinel refresh worker is closed");
}
}
private static long positiveNanos(Duration duration) {
Objects.requireNonNull(duration, "duration must be non-null");
if (duration.isZero() || duration.isNegative()) {
throw new IllegalArgumentException("Redis Sentinel worker duration must be positive");
}
try {
return duration.toNanos();
} catch (ArithmeticException overflow) {
return Long.MAX_VALUE;
}
}
private static String requireText(String value) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("Redis Sentinel worker name must be non-blank");
}
return value.trim();
}
private record Work(Runnable task, RecurringTask recurring) {}
private static final class RecurringTask {
private final Runnable task;
private final long delayNanos;
private long nextRunNanos;
private boolean running;
private boolean cancelled;
private RecurringTask(Runnable task, long delayNanos, long nextRunNanos) {
this.task = task;
this.delayNanos = delayNanos;
this.nextRunNanos = nextRunNanos;
}
}
}
@@ -1,83 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import io.lettuce.core.RedisChannelHandler;
import io.lettuce.core.RedisConnectionStateListener;
import io.lettuce.core.pubsub.RedisPubSubAdapter;
import io.lettuce.core.pubsub.StatefulRedisPubSubConnection;
import java.net.SocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
/** Managed standalone Redis Pub/Sub listener for best-effort cache invalidation hints. */
final class LettuceRedisCacheInvalidationSubscription implements AutoCloseable {
private final StatefulRedisPubSubConnection<byte[], byte[]> connection;
private final RedisCacheInvalidationSubscriber subscriber;
private final AtomicBoolean closed = new AtomicBoolean();
private LettuceRedisCacheInvalidationSubscription(
StatefulRedisPubSubConnection<byte[], byte[]> connection,
RedisCacheInvalidationSubscriber subscriber) {
this.connection = connection;
this.subscriber = subscriber;
}
static LettuceRedisCacheInvalidationSubscription subscribe(
LettuceRedisRuntime runtime,
String channel,
RedisCacheInvalidationMessage.Codec codec,
RedisCacheInvalidationSubscriber subscriber) {
Objects.requireNonNull(runtime, "runtime must be non-null");
Objects.requireNonNull(channel, "channel must be non-null");
Objects.requireNonNull(codec, "codec must be non-null");
Objects.requireNonNull(subscriber, "subscriber must be non-null");
byte[] channelBytes = channel.getBytes(StandardCharsets.US_ASCII);
StatefulRedisPubSubConnection<byte[], byte[]> connection =
runtime.openInvalidationSubscription();
connection.addListener(
new RedisPubSubAdapter<>() {
@Override
public void message(byte[] actualChannel, byte[] message) {
if (!Arrays.equals(channelBytes, actualChannel) || message == null) {
return;
}
codec
.decode(new String(message, StandardCharsets.US_ASCII))
.ifPresentOrElse(subscriber::onMessage, subscriber::onMalformedMessage);
}
});
connection.addListener(
new RedisConnectionStateListener() {
@Override
public void onRedisConnected(
RedisChannelHandler<?, ?> connection, SocketAddress remoteAddress) {
// A preceding disconnect already forced L1 flush and generation recheck.
}
@Override
public void onRedisDisconnected(RedisChannelHandler<?, ?> connection) {
subscriber.onDisconnected();
}
});
try {
connection.sync().subscribe(channelBytes);
return new LettuceRedisCacheInvalidationSubscription(connection, subscriber);
} catch (RuntimeException exception) {
connection.close();
throw exception;
}
}
@Override
public void close() {
if (closed.compareAndSet(false, true)) {
try {
connection.close();
} finally {
subscriber.onDisconnected();
}
}
}
}
@@ -1,228 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings;
import io.lettuce.core.AbstractRedisClient;
import io.lettuce.core.ClientOptions;
import io.lettuce.core.ConnectionFuture;
import io.lettuce.core.RedisClient;
import io.lettuce.core.RedisURI;
import io.lettuce.core.api.StatefulConnection;
import io.lettuce.core.api.StatefulRedisConnection;
import io.lettuce.core.cluster.ClusterClientOptions;
import io.lettuce.core.cluster.RedisClusterClient;
import io.lettuce.core.cluster.api.StatefulRedisClusterConnection;
import io.lettuce.core.codec.ByteArrayCodec;
import java.time.Duration;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
/** Opens, probes, and owns topology-native Lettuce clients and connections. */
final class LettuceRedisNativeClientFactory implements RedisNativeClientFactory {
interface LifecycleObserver {
LifecycleObserver NOOP = new LifecycleObserver() {};
default void clientCreated() {}
default void connectionClosed() {}
default void clientClosed() {}
}
private final LifecycleObserver observer;
LettuceRedisNativeClientFactory() {
this(LifecycleObserver.NOOP);
}
LettuceRedisNativeClientFactory(LifecycleObserver observer) {
this.observer = Objects.requireNonNull(observer, "observer must be non-null");
}
@Override
public RedisNativeClientHandle openStandalone(
RedisURI uri, ClientOptions options, RedisClientRuntimeSettings settings) {
Objects.requireNonNull(uri, "uri must be non-null");
Objects.requireNonNull(options, "options must be non-null");
Objects.requireNonNull(settings, "settings must be non-null");
RedisClient client = RedisClient.create(uri);
observer.clientCreated();
StatefulRedisConnection<byte[], byte[]> connection = null;
try {
client.setOptions(options);
long deadline = deadline(settings.overallTimeout());
ConnectionFuture<StatefulRedisConnection<byte[], byte[]>> connect =
client.connectAsync(ByteArrayCodec.INSTANCE, uri);
connection =
await(
connect,
boundedByRemaining(settings.acquireTimeout(), deadline),
"Redis standalone connect");
connection.setTimeout(settings.commandTimeout());
await(
connection.async().ping(),
boundedByRemaining(settings.commandTimeout(), deadline),
"Redis standalone probe");
return new LettuceHandle(client, connection, settings.shutdownTimeout(), observer);
} catch (RuntimeException exception) {
closeFailed(client, connection, settings.shutdownTimeout(), observer, List.of(uri));
throw sanitizedConnectFailure(exception);
}
}
@Override
public RedisNativeClientHandle openCluster(
List<RedisURI> seedUris, ClusterClientOptions options, RedisClientRuntimeSettings settings) {
List<RedisURI> uris =
List.copyOf(Objects.requireNonNull(seedUris, "seedUris must be non-null"));
Objects.requireNonNull(options, "options must be non-null");
Objects.requireNonNull(settings, "settings must be non-null");
RedisClusterClient client = RedisClusterClient.create(uris);
observer.clientCreated();
StatefulRedisClusterConnection<byte[], byte[]> connection = null;
try {
client.setOptions(options);
long deadline = deadline(settings.overallTimeout());
java.util.concurrent.CompletableFuture<StatefulRedisClusterConnection<byte[], byte[]>>
connect = client.connectAsync(ByteArrayCodec.INSTANCE);
connection =
await(
connect,
boundedByRemaining(settings.acquireTimeout(), deadline),
"Redis Cluster connect");
connection.setTimeout(settings.commandTimeout());
await(
connection.async().ping(),
boundedByRemaining(settings.commandTimeout(), deadline),
"Redis Cluster probe");
return new LettuceHandle(client, connection, settings.shutdownTimeout(), observer);
} catch (RuntimeException exception) {
closeFailed(client, connection, settings.shutdownTimeout(), observer, uris);
throw sanitizedConnectFailure(exception);
}
}
private static long deadline(Duration overallTimeout) {
long timeoutNanos = overallTimeout.toNanos();
long now = System.nanoTime();
return now > Long.MAX_VALUE - timeoutNanos ? Long.MAX_VALUE : now + timeoutNanos;
}
private static Duration boundedByRemaining(Duration operationTimeout, long deadline) {
long remaining = deadline - System.nanoTime();
if (remaining <= 0) {
throw new IllegalStateException("Redis overall connect deadline expired");
}
Duration remainingDuration = Duration.ofNanos(remaining);
return operationTimeout.compareTo(remainingDuration) < 0 ? operationTimeout : remainingDuration;
}
private static <T> T await(
java.util.concurrent.Future<T> future, Duration timeout, String operation) {
try {
return future.get(timeout.toNanos(), TimeUnit.NANOSECONDS);
} catch (InterruptedException exception) {
future.cancel(true);
Thread.currentThread().interrupt();
throw new IllegalStateException(operation + " was interrupted");
} catch (TimeoutException exception) {
future.cancel(true);
throw new IllegalStateException(operation + " exceeded its bounded timeout");
} catch (ExecutionException exception) {
throw new IllegalStateException(operation + " failed");
}
}
private static IllegalStateException sanitizedConnectFailure(RuntimeException ignored) {
return new IllegalStateException("Redis connect or probe failed within its bounded deadline");
}
private static void closeFailed(
AbstractRedisClient client,
StatefulConnection<?, ?> connection,
Duration shutdownTimeout,
LifecycleObserver observer,
List<RedisURI> uris) {
try {
closeConnection(connection, observer);
} finally {
try {
client.shutdown(Duration.ZERO, shutdownTimeout);
} finally {
observer.clientClosed();
uris.forEach(LettuceRedisNativeClientFactory::destroyCredentials);
}
}
}
private static void closeConnection(
StatefulConnection<?, ?> connection, LifecycleObserver observer) {
if (connection == null) {
return;
}
try {
connection.close();
} finally {
observer.connectionClosed();
}
}
private static void destroyCredentials(RedisURI uri) {
if (uri.getCredentialsProvider() instanceof javax.security.auth.Destroyable destroyable) {
try {
destroyable.destroy();
} catch (javax.security.auth.DestroyFailedException ignored) {
// The adapter-owned providers do not throw; remain fail-safe for alternate implementations.
}
}
}
private static final class LettuceHandle implements RedisNativeClientHandle {
private final AbstractRedisClient client;
private final StatefulConnection<?, ?> connection;
private final Duration configuredShutdownTimeout;
private final LifecycleObserver observer;
private final AtomicBoolean closed = new AtomicBoolean();
private LettuceHandle(
AbstractRedisClient client,
StatefulConnection<?, ?> connection,
Duration configuredShutdownTimeout,
LifecycleObserver observer) {
this.client = client;
this.connection = connection;
this.configuredShutdownTimeout = configuredShutdownTimeout;
this.observer = observer;
}
@Override
public Class<?> nativeClientType() {
return client.getClass();
}
@Override
public void close(Duration timeout) {
Objects.requireNonNull(timeout, "timeout must be non-null");
if (!timeout.equals(configuredShutdownTimeout)) {
throw new IllegalArgumentException("Redis shutdown timeout differs from runtime settings");
}
if (closed.compareAndSet(false, true)) {
try {
closeConnection(connection, observer);
} finally {
try {
client.shutdown(Duration.ZERO, timeout);
} finally {
observer.clientClosed();
}
}
}
}
}
}
@@ -1,346 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import io.lettuce.core.ClientOptions;
import io.lettuce.core.RedisCommandExecutionException;
import io.lettuce.core.RedisCommandInterruptedException;
import io.lettuce.core.RedisCommandTimeoutException;
import io.lettuce.core.RedisConnectionException;
import io.lettuce.core.RedisConnectionStateListener;
import io.lettuce.core.RedisException;
import io.lettuce.core.RedisURI;
import io.lettuce.core.ScriptOutputType;
import io.lettuce.core.SetArgs;
import io.lettuce.core.TimeoutOptions;
import io.lettuce.core.api.StatefulRedisConnection;
import io.lettuce.core.api.sync.RedisCommands;
import io.lettuce.core.codec.ByteArrayCodec;
import io.lettuce.core.pubsub.StatefulRedisPubSubConnection;
import java.net.SocketAddress;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Supplier;
/** Managed standalone Lettuce connection shared by cache and typed Lua facilities. */
final class LettuceRedisRuntime implements RedisClient, RedisBinaryCommands, AutoCloseable {
private static final String VALUE_TOO_LARGE_ERROR = "CA_VALUE_TOO_LARGE";
private static final RedisProgramCatalog FOUNDATION_CATALOG = RedisProgramCatalog.foundation();
private final io.lettuce.core.RedisClient client;
private final StatefulRedisConnection<byte[], byte[]> connection;
private final RedisCommands<byte[], byte[]> commands;
private final Duration legacyTtl;
private final Duration shutdownTimeout;
private final AtomicBoolean connected = new AtomicBoolean(true);
private final RedisCommandAdmission commandAdmission;
private final int maximumReadableValueBytes;
private final int maximumCommandBytes;
private final AtomicBoolean closed = new AtomicBoolean();
private LettuceRedisRuntime(
io.lettuce.core.RedisClient client,
StatefulRedisConnection<byte[], byte[]> connection,
RedisConnectionProfile settings) {
this.client = client;
this.connection = connection;
this.commands = connection.sync();
this.legacyTtl = settings.legacyTtl();
this.shutdownTimeout = settings.commandTimeout();
this.commandAdmission =
new RedisCommandAdmission(
settings.maximumQueuedCommands(), settings.maximumInFlightBytes());
this.maximumReadableValueBytes = settings.maximumReadableValueBytes();
this.maximumCommandBytes = settings.maximumCommandBytes();
connection.addListener(
new RedisConnectionStateListener() {
@Override
public void onRedisConnected(
io.lettuce.core.RedisChannelHandler<?, ?> connection, SocketAddress remoteAddress) {
connected.set(true);
}
@Override
public void onRedisDisconnected(io.lettuce.core.RedisChannelHandler<?, ?> connection) {
connected.set(false);
}
});
}
static LettuceRedisRuntime connect(RedisRuntimeSettings settings) {
return connect(RedisConnectionProfile.cache(settings));
}
static LettuceRedisRuntime connect(RedisLegacyStandaloneSettings settings) {
return connect(RedisConnectionProfile.rateLimit(settings));
}
private static LettuceRedisRuntime connect(RedisConnectionProfile settings) {
RedisURI uri = redisUri(settings);
io.lettuce.core.RedisClient client = io.lettuce.core.RedisClient.create(uri);
client.setOptions(clientOptions(settings));
try {
StatefulRedisConnection<byte[], byte[]> connection =
client.connect(ByteArrayCodec.INSTANCE, uri);
return new LettuceRedisRuntime(client, connection, settings);
} catch (RuntimeException exception) {
client.shutdown(Duration.ZERO, settings.commandTimeout());
throw exception;
}
}
static RedisURI redisUri(RedisRuntimeSettings settings) {
return redisUri(RedisConnectionProfile.cache(settings));
}
private static RedisURI redisUri(RedisConnectionProfile settings) {
RedisURI.Builder builder =
RedisURI.Builder.redis(settings.host(), settings.port())
.withTimeout(settings.commandTimeout());
if (!settings.password().isBlank()) {
builder.withPassword(settings.password().toCharArray());
}
return builder.build();
}
static ClientOptions clientOptions(RedisRuntimeSettings settings) {
return clientOptions(RedisConnectionProfile.cache(settings));
}
private static ClientOptions clientOptions(RedisConnectionProfile settings) {
return ClientOptions.builder()
.autoReconnect(true)
.replayFilter(ignored -> true)
.disconnectedBehavior(ClientOptions.DisconnectedBehavior.REJECT_COMMANDS)
.requestQueueSize(settings.maximumQueuedCommands())
.timeoutOptions(TimeoutOptions.enabled(settings.commandTimeout()))
.build();
}
@Override
public Optional<String> read(String key) {
byte[] value = get(RedisPhysicalKey.owned(new LegacyKeyMaterial(key)));
return value == null
? Optional.empty()
: Optional.of(new String(value, StandardCharsets.UTF_8));
}
@Override
public void write(String key, String value) {
set(
RedisPhysicalKey.owned(new LegacyKeyMaterial(key)),
RedisBinaryValue.utf8(value),
legacyTtl);
}
@Override
public byte[] get(RedisPhysicalKey key) {
RedisCatalogProgramInvocation invocation =
FOUNDATION_CATALOG.boundedGetInvocation(key, maximumReadableValueBytes);
byte[] value = RedisScriptRecovery.evalReadOnlyValue(this, invocation);
return value == null ? null : value.clone();
}
@Override
public void set(RedisPhysicalKey key, RedisBinaryValue value, Duration timeToLive) {
byte[] encodedKey = RedisPhysicalKey.WireCodec.copy(key);
byte[] encodedValue = value.copyEncoded();
String result =
execute(
true,
reservationBytes(64, List.of(encodedKey, encodedValue)),
() ->
commands.set(encodedKey, encodedValue, SetArgs.Builder.px(timeToLive.toMillis())));
if (!"OK".equals(result)) {
throw new IllegalStateException("Redis SET did not acknowledge the mutation");
}
}
@Override
public long delete(RedisPhysicalKey key) {
byte[] encodedKey = RedisPhysicalKey.WireCodec.copy(key);
return execute(true, reservationBytes(32, List.of(encodedKey)), () -> commands.del(encodedKey));
}
@Override
public RedisCatalogProgramReply executeCatalogProgram(RedisCatalogProgramInvocation invocation) {
boolean mutation =
invocation.replyShape() != RedisCatalogProgramInvocation.ReplyShape.READ_ONLY_VALUE
&& invocation.replyShape() != RedisCatalogProgramInvocation.ReplyShape.READ_ONLY_MULTI;
ScriptOutputType outputType =
invocation.replyShape() == RedisCatalogProgramInvocation.ReplyShape.MULTI
|| invocation.replyShape()
== RedisCatalogProgramInvocation.ReplyShape.READ_ONLY_MULTI
? ScriptOutputType.MULTI
: ScriptOutputType.VALUE;
try {
Object result =
execute(
mutation,
Math.max(256, invocation.encodedBytes()),
() ->
commands.evalsha(
RedisScriptRecovery.sha1(
RedisCatalogProgramInvocation.WireCodec.exactScript(invocation)),
outputType,
RedisCatalogProgramInvocation.WireCodec.keysArray(invocation),
RedisCatalogProgramInvocation.WireCodec.argumentsArray(invocation)));
if (outputType == ScriptOutputType.MULTI) {
@SuppressWarnings("unchecked")
List<byte[]> fields = (List<byte[]>) result;
return RedisCatalogProgramReply.multi(defensiveReply(fields));
}
return RedisCatalogProgramReply.value((byte[]) result);
} catch (io.lettuce.core.RedisNoScriptException exception) {
throw new RedisNoScriptException();
} catch (RedisCommandExecutionException exception) {
if (exception.getMessage() != null
&& exception.getMessage().contains(VALUE_TOO_LARGE_ERROR)) {
throw new RedisValueTooLargeException();
}
throw commandFailure(
mutation,
mutation
? "Redis Lua program execution failed"
: "Redis read-only Lua program execution failed",
exception);
}
}
@Override
public String loadCatalogProgram(RedisCatalogProgramInvocation invocation) {
byte[] script = RedisCatalogProgramInvocation.WireCodec.exactScript(invocation);
try {
return execute(
true, reservationBytes(64, List.of(script)), () -> commands.scriptLoad(script.clone()));
} catch (RedisCommandExecutionException exception) {
throw commandFailure(true, "Redis script load failed", exception);
}
}
void publishInvalidation(String channel, String message) {
byte[] channelBytes = channel.getBytes(StandardCharsets.US_ASCII);
byte[] messageBytes = message.getBytes(StandardCharsets.US_ASCII);
execute(
true,
reservationBytes(64, List.of(channelBytes, messageBytes)),
() -> commands.publish(channelBytes, messageBytes));
}
StatefulRedisPubSubConnection<byte[], byte[]> openInvalidationSubscription() {
ensureOpen();
return client.connectPubSub(ByteArrayCodec.INSTANCE);
}
@Override
public void close() {
if (!closed.compareAndSet(false, true)) {
return;
}
try {
connection.close();
} finally {
client.shutdown(Duration.ZERO, shutdownTimeout);
}
}
private void ensureOpen() {
if (closed.get()) {
throw new IllegalStateException("Redis runtime is closed");
}
}
private <T> T execute(boolean mutation, int reservationBytes, Supplier<T> command) {
ensureOpen();
if (reservationBytes > maximumCommandBytes) {
throw new RedisCommandFailureException(
RedisCommandFailureException.Kind.OVERLOADED,
RedisCommandFailureException.Certainty.NOT_APPLIED,
"Redis command exceeds the retained-byte bound",
null);
}
if (!connected.get()) {
throw new RedisCommandFailureException(
RedisCommandFailureException.Kind.UNAVAILABLE,
RedisCommandFailureException.Certainty.NOT_APPLIED,
"Redis command rejected while disconnected",
null);
}
RedisCommandAdmission.Lease admission = commandAdmission.tryAcquire(reservationBytes);
if (admission == null) {
throw new RedisCommandFailureException(
RedisCommandFailureException.Kind.OVERLOADED,
RedisCommandFailureException.Certainty.NOT_APPLIED,
"Redis command count or byte admission is saturated",
null);
}
try (admission) {
return command.get();
} catch (RedisCommandExecutionException exception) {
throw exception;
} catch (RedisCommandInterruptedException exception) {
Thread.currentThread().interrupt();
throw commandFailure(mutation, "Redis command was interrupted", exception);
} catch (RedisCommandTimeoutException exception) {
throw commandFailure(mutation, "Redis command timed out", exception);
} catch (RedisConnectionException exception) {
throw commandFailure(mutation, "Redis connection failed during a command", exception);
} catch (RedisException exception) {
throw commandFailure(mutation, "Redis transport failed during a command", exception);
}
}
private static RedisCommandFailureException commandFailure(
boolean mutation, String message, RuntimeException cause) {
return new RedisCommandFailureException(
RedisCommandFailureException.Kind.UNAVAILABLE,
mutation
? RedisCommandFailureException.Certainty.INDETERMINATE
: RedisCommandFailureException.Certainty.NOT_APPLIED,
message,
cause);
}
private static List<byte[]> defensiveReply(List<byte[]> result) {
if (result == null) {
return null;
}
return result.stream().map(value -> value == null ? null : value.clone()).toList();
}
@SafeVarargs
private static int reservationBytes(int responseBytes, List<byte[]>... groups) {
long total = Math.max(1, responseBytes);
for (List<byte[]> group : groups) {
for (byte[] value : group) {
if (value == null) {
return Integer.MAX_VALUE;
}
total += value.length;
if (total > Integer.MAX_VALUE) {
return Integer.MAX_VALUE;
}
}
}
return (int) total;
}
static final class LegacyKeyMaterial implements RedisOwnedPhysicalKeyMaterial {
private final byte[] encoded;
private LegacyKeyMaterial(String key) {
this.encoded =
Objects.requireNonNull(key, "legacy key must be non-null")
.getBytes(StandardCharsets.UTF_8);
}
@Override
public byte[] copyEncodedKey() {
return encoded.clone();
}
}
}
@@ -1,108 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.application.cache.CacheObservationEvent;
import dev.caskeleton.application.cache.CacheObservationPort;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
/** Micrometer rendering for the framework-free cache observation boundary. */
final class MicrometerCacheObservationPort implements CacheObservationPort {
private final MeterRegistry registry;
private final Set<String> cacheNames;
MicrometerCacheObservationPort(MeterRegistry registry, Set<String> cacheNames) {
this.registry = Objects.requireNonNull(registry, "registry must be non-null");
this.cacheNames = Set.copyOf(Objects.requireNonNull(cacheNames, "cacheNames must be non-null"));
if (this.cacheNames.isEmpty() || this.cacheNames.size() > 50) {
throw new IllegalArgumentException("cacheNames must contain 1..50 startup-registered names");
}
if (this.cacheNames.stream().anyMatch(name -> name == null || name.isBlank())) {
throw new IllegalArgumentException("cacheNames must contain non-blank names");
}
}
@Override
public void observe(CacheObservationEvent event) {
Objects.requireNonNull(event, "event must be non-null");
String cacheName =
event instanceof CacheObservationEvent.Lookup lookup
? lookup.cacheName()
: ((CacheObservationEvent.LocalMaintenance) event).cacheName();
if (!cacheNames.contains(cacheName)) {
throw new IllegalArgumentException("cacheName is not in the startup allowlist");
}
if (event instanceof CacheObservationEvent.Lookup lookup) {
observeLookup(lookup);
return;
}
CacheObservationEvent.LocalMaintenance maintenance =
(CacheObservationEvent.LocalMaintenance) event;
Counter.builder("cache.local.maintenance.total")
.tag("cache_name", maintenance.cacheName())
.tag("event", maintenanceEvent(maintenance))
.register(registry)
.increment();
}
private void observeLookup(CacheObservationEvent.Lookup lookup) {
if (lookup.tier() != CacheObservationEvent.Tier.LOCAL_L1) {
return;
}
Counter.builder("cache.local.requests.total")
.tag("cache_name", lookup.cacheName())
.tag("result", lower(lookup.result()))
.register(registry)
.increment();
if (lookup.result() == CacheObservationEvent.LookupResult.HIT) {
Timer.builder("cache.local.entry.age.seconds")
.tag("cache_name", lookup.cacheName())
.register(registry)
.record(lookup.entryAge());
}
}
private static String lower(Enum<?> value) {
return value.name().toLowerCase(Locale.ROOT);
}
private static String maintenanceEvent(CacheObservationEvent.LocalMaintenance event) {
if (event.action() == CacheObservationEvent.MaintenanceAction.EVICT) {
return "evict_" + lower(event.cause());
}
if (event.cause() == CacheObservationEvent.MaintenanceCause.GENERATION_CHANGED) {
return "reconcile_generation_changed";
}
if (event.action() == CacheObservationEvent.MaintenanceAction.RECONCILE) {
return event.result() == CacheObservationEvent.MaintenanceResult.ERROR
? "reconcile_error"
: "reconcile_unchanged";
}
if (event.cause() == CacheObservationEvent.MaintenanceCause.SUBSCRIBER_DISCONNECTED) {
return "subscriber_disconnected";
}
if (event.cause() == CacheObservationEvent.MaintenanceCause.SUBSCRIBER_OVERFLOW) {
return "subscriber_overflow";
}
if (event.cause() == CacheObservationEvent.MaintenanceCause.MALFORMED_MESSAGE) {
return "subscriber_malformed";
}
if (event.action() == CacheObservationEvent.MaintenanceAction.SUBSCRIBER_EVENT
&& event.result() == CacheObservationEvent.MaintenanceResult.FLUSHED) {
return "flush_invalidation";
}
if (event.action() == CacheObservationEvent.MaintenanceAction.SUBSCRIBER_EVENT) {
return event.result() == CacheObservationEvent.MaintenanceResult.SUCCESS
? "subscriber_publish_success"
: "subscriber_publish_error";
}
if (event.cause() == CacheObservationEvent.MaintenanceCause.INVALIDATION) {
return "flush_invalidation";
}
return "other";
}
}
@@ -1,120 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.Gauge;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import java.util.Locale;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
/** Renders the closed Redis capability event model to its six registry-approved meters. */
final class MicrometerRedisCapabilityObservationPort implements RedisCapabilityObservationPort {
private final MeterRegistry registry;
private final ConcurrentMap<
RedisCapabilityObservationEvent.Role, AtomicReference<InFlightSnapshot>>
inFlight = new ConcurrentHashMap<>();
MicrometerRedisCapabilityObservationPort(MeterRegistry registry) {
this.registry = Objects.requireNonNull(registry, "registry must be non-null");
}
@Override
public void observe(RedisCapabilityObservationEvent.Event event) {
Objects.requireNonNull(event, "event must be non-null");
switch (event) {
case RedisCapabilityObservationEvent.OperationCompleted operation ->
observeOperation(operation);
case RedisCapabilityObservationEvent.AdmissionChanged admission ->
observeAdmission(admission);
case RedisCapabilityObservationEvent.ReadinessObserved readiness ->
observeReadiness(readiness);
case RedisCapabilityObservationEvent.LifecycleDrainCompleted lifecycle ->
observeLifecycle(lifecycle);
}
}
private void observeOperation(RedisCapabilityObservationEvent.OperationCompleted event) {
Counter.builder("redis.capability.operations.total")
.tags(
"capability", lower(event.capability()),
"role", lower(event.role()),
"operation", lower(event.operation()),
"redis_outcome", lower(event.outcome()),
"certainty", lower(event.certainty()))
.register(registry)
.increment();
Timer.builder("redis.capability.duration.seconds")
.tags(
"capability", lower(event.capability()),
"role", lower(event.role()),
"operation", lower(event.operation()),
"redis_outcome", lower(event.outcome()))
.register(registry)
.record(event.durationNanos(), TimeUnit.NANOSECONDS);
}
private void observeAdmission(RedisCapabilityObservationEvent.AdmissionChanged event) {
if (event.admission() == RedisCapabilityObservationEvent.AdmissionState.REJECTED_SATURATED
|| event.admission() == RedisCapabilityObservationEvent.AdmissionState.REJECTED_CLOSED) {
Counter.builder("redis.capability.admission.rejected.total")
.tags("role", lower(event.role()), "admission", lower(event.admission()))
.register(registry)
.increment();
}
snapshot(event.role()).set(new InFlightSnapshot(event.state(), event.inFlightCommands()));
}
private void observeReadiness(RedisCapabilityObservationEvent.ReadinessObserved event) {
Counter.builder("redis.capability.readiness.total")
.tags(
"capability", lower(event.capability()),
"role", lower(event.role()),
"state", lower(event.state()),
"reason", lower(event.reason()),
"requirement", lower(event.requirement()))
.register(registry)
.increment();
}
private void observeLifecycle(RedisCapabilityObservationEvent.LifecycleDrainCompleted event) {
Counter.builder("redis.capability.lifecycle.drain.total")
.tags("role", lower(event.role()), "drain_outcome", lower(event.drainOutcome()))
.register(registry)
.increment();
}
private static String lower(Enum<?> value) {
return value.name().toLowerCase(Locale.ROOT);
}
private AtomicReference<InFlightSnapshot> snapshot(RedisCapabilityObservationEvent.Role role) {
return inFlight.computeIfAbsent(
role,
ignored -> {
AtomicReference<InFlightSnapshot> value =
new AtomicReference<>(
new InFlightSnapshot(RedisCapabilityObservationEvent.InFlightState.IDLE, 0));
for (RedisCapabilityObservationEvent.InFlightState state :
RedisCapabilityObservationEvent.InFlightState.values()) {
Gauge.builder(
"redis.capability.inflight.total",
value,
reference -> {
InFlightSnapshot current = reference.get();
return current.state() == state ? current.commands() : 0;
})
.tags("role", lower(role), "state", lower(state))
.register(registry);
}
return value;
});
}
private record InFlightSnapshot(
RedisCapabilityObservationEvent.InFlightState state, int commands) {}
}
@@ -1,14 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
enum NoOpRedisCapabilityObservationPort implements RedisCapabilityObservationPort {
INSTANCE;
static RedisCapabilityObservationPort instance() {
return INSTANCE;
}
@Override
public void observe(RedisCapabilityObservationEvent.Event event) {
// Intentionally disabled.
}
}
@@ -1,316 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Base64;
import java.util.List;
import java.util.Objects;
/** Typed facade for bounded owner-safe and expirable Redis mutations. */
final class RedisAtomicPrimitives {
private static final int MAXIMUM_OWNER_BYTES = 128;
private static final int MAXIMUM_OPERATION_ID_BYTES = 128;
private static final int MAXIMUM_VALUE_BYTES = 16_778_272;
private static final long MAXIMUM_TTL_MILLIS = Duration.ofDays(30).toMillis();
private static final long MAXIMUM_CONTROL_TTL_MILLIS = Duration.ofDays(31).toMillis();
private final RedisProgramCatalog catalog;
private final RedisProgramExecutor executor;
RedisAtomicPrimitives(RedisProgramCatalog catalog, RedisProgramExecutor executor) {
this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null");
this.executor = Objects.requireNonNull(executor, "executor must be non-null");
}
CompareDeleteResult compareAndDelete(String key, byte[] expectedOwner) {
byte[] keyBytes = key(key);
byte[] owner = bounded(expectedOwner, MAXIMUM_OWNER_BYTES, "expected owner");
String status = execute(RedisProgramId.COMPARE_AND_DELETE, List.of(keyBytes), List.of(owner));
return parse(RedisProgramId.COMPARE_AND_DELETE, status, CompareDeleteResult.class);
}
CompareExpireResult compareAndExpire(String key, byte[] expectedOwner, Duration timeToLive) {
byte[] keyBytes = key(key);
byte[] owner = bounded(expectedOwner, MAXIMUM_OWNER_BYTES, "expected owner");
byte[] ttl = ttl(timeToLive);
String status =
execute(RedisProgramId.COMPARE_AND_EXPIRE, List.of(keyBytes), List.of(owner, ttl));
return parse(RedisProgramId.COMPARE_AND_EXPIRE, status, CompareExpireResult.class);
}
SetIfAbsentResult setIfAbsentWithTtl(
String key, byte[] value, Duration timeToLive, String operationId) {
byte[] keyBytes = key(key);
byte[] boundedValue = bounded(value, MAXIMUM_VALUE_BYTES, "value");
byte[] ttl = ttl(timeToLive);
byte[] operation =
bounded(
Objects.requireNonNull(operationId, "operationId must be non-null")
.getBytes(StandardCharsets.UTF_8),
MAXIMUM_OPERATION_ID_BYTES,
"operationId");
String status =
execute(
RedisProgramId.SET_IF_ABSENT_WITH_TTL,
List.of(keyBytes),
List.of(boundedValue, ttl, operation));
return parse(RedisProgramId.SET_IF_ABSENT_WITH_TTL, status, SetIfAbsentResult.class);
}
ReplaceIfObservedResult replaceIfObservedWithTtl(
String key, String observationToken, byte[] value, Duration timeToLive, String operationId) {
byte[] keyBytes = key(key);
byte[] expectedDigest = observationDigest(observationToken);
byte[] boundedValue = bounded(value, MAXIMUM_VALUE_BYTES, "value");
byte[] ttl = ttl(timeToLive);
byte[] operation =
bounded(
Objects.requireNonNull(operationId, "operationId must be non-null")
.getBytes(StandardCharsets.UTF_8),
MAXIMUM_OPERATION_ID_BYTES,
"operationId");
String status =
execute(
RedisProgramId.REPLACE_IF_OBSERVED_WITH_TTL,
List.of(keyBytes),
List.of(expectedDigest, boundedValue, ttl, operation));
return parse(
RedisProgramId.REPLACE_IF_OBSERVED_WITH_TTL, status, ReplaceIfObservedResult.class);
}
GenerationInitResult initializeGeneration(String key, String candidateGeneration) {
return initializeGeneration(key, candidateGeneration, Duration.ZERO);
}
GenerationInitResult initializeGeneration(
String key, String candidateGeneration, Duration timeToLive) {
byte[] keyBytes = key(key);
byte[] generation = identifier(candidateGeneration, "candidateGeneration");
byte[] ttl = controlTtl(timeToLive);
String status =
execute(RedisProgramId.REGION_GENERATION_INIT, List.of(keyBytes), List.of(generation, ttl));
return parse(RedisProgramId.REGION_GENERATION_INIT, status, GenerationInitResult.class);
}
GenerationBumpResult bumpGeneration(String key, String candidateGeneration, String operationId) {
return bumpGeneration(key, candidateGeneration, operationId, Duration.ZERO);
}
GenerationBumpResult bumpGeneration(
String key, String candidateGeneration, String operationId, Duration timeToLive) {
byte[] keyBytes = key(key);
byte[] generation = identifier(candidateGeneration, "candidateGeneration");
byte[] operation = identifier(operationId, "operationId");
byte[] ttl = controlTtl(timeToLive);
String status =
execute(
RedisProgramId.REGION_GENERATION_BUMP,
List.of(keyBytes),
List.of(generation, operation, ttl));
return parse(RedisProgramId.REGION_GENERATION_BUMP, status, GenerationBumpResult.class);
}
RefreshClaimResult claimRefreshLease(
String key, String ownerToken, String operationToken, Duration timeToLive) {
byte[] keyBytes = key(key);
byte[] owner = identifier(ownerToken, "ownerToken");
byte[] operation = identifier(operationToken, "operationToken");
byte[] ttl = refreshLeaseTtl(timeToLive);
String status =
execute(
RedisProgramId.CACHE_REFRESH_CLAIM, List.of(keyBytes), List.of(owner, operation, ttl));
return parse(RedisProgramId.CACHE_REFRESH_CLAIM, status, RefreshClaimResult.class);
}
private String execute(RedisProgramId id, List<byte[]> keys, List<byte[]> arguments) {
RedisProgramDescriptor descriptor = catalog.descriptor(id);
if (keys.size() != descriptor.keyCount() || arguments.size() != descriptor.argumentCount()) {
throw new IllegalStateException("typed Redis program signature drift for " + id.externalId());
}
return executor.execute(catalog.capabilityInvocation(new ProgramMaterial(id, keys, arguments)));
}
static final class ProgramMaterial implements RedisCatalogProgramMaterial {
private final RedisProgramId programId;
private final List<byte[]> keys;
private final List<byte[]> arguments;
private ProgramMaterial(RedisProgramId programId, List<byte[]> keys, List<byte[]> arguments) {
this.programId = Objects.requireNonNull(programId, "programId must be non-null");
this.keys = keys.stream().map(byte[]::clone).toList();
this.arguments = arguments.stream().map(byte[]::clone).toList();
}
@Override
public RedisProgramId programId() {
return programId;
}
@Override
public RedisCatalogProgramInvocation.ReplyShape replyShape() {
return RedisCatalogProgramInvocation.ReplyShape.VALUE;
}
@Override
public List<byte[]> copyKeys() {
return keys.stream().map(byte[]::clone).toList();
}
@Override
public List<byte[]> copyArguments() {
return arguments.stream().map(byte[]::clone).toList();
}
}
private static byte[] key(String key) {
Objects.requireNonNull(key, "key must be non-null");
return bounded(key.getBytes(StandardCharsets.UTF_8), 512, "key");
}
private static byte[] ttl(Duration timeToLive) {
Objects.requireNonNull(timeToLive, "timeToLive must be non-null");
long milliseconds;
try {
milliseconds = timeToLive.toMillis();
} catch (ArithmeticException exception) {
throw new IllegalArgumentException("TTL exceeds supported range", exception);
}
if (milliseconds < 1 || milliseconds > MAXIMUM_TTL_MILLIS) {
throw new IllegalArgumentException(
"TTL must be between 1 and " + MAXIMUM_TTL_MILLIS + " milliseconds");
}
return Long.toString(milliseconds).getBytes(StandardCharsets.US_ASCII);
}
private static byte[] controlTtl(Duration timeToLive) {
Objects.requireNonNull(timeToLive, "timeToLive must be non-null");
long milliseconds;
try {
milliseconds = timeToLive.toMillis();
} catch (ArithmeticException exception) {
throw new IllegalArgumentException("control TTL exceeds supported range", exception);
}
if (milliseconds < 0 || milliseconds > MAXIMUM_CONTROL_TTL_MILLIS) {
throw new IllegalArgumentException(
"control TTL must be between 0 and " + MAXIMUM_CONTROL_TTL_MILLIS + " milliseconds");
}
return Long.toString(milliseconds).getBytes(StandardCharsets.US_ASCII);
}
private static byte[] refreshLeaseTtl(Duration timeToLive) {
Objects.requireNonNull(timeToLive, "timeToLive must be non-null");
long milliseconds;
try {
milliseconds = timeToLive.toMillis();
} catch (ArithmeticException exception) {
throw new IllegalArgumentException("refresh lease TTL exceeds supported range", exception);
}
long maximum = Duration.ofMinutes(5).toMillis();
if (milliseconds < 1 || milliseconds > maximum) {
throw new IllegalArgumentException(
"refresh lease TTL must be between 1 and " + maximum + " milliseconds");
}
return Long.toString(milliseconds).getBytes(StandardCharsets.US_ASCII);
}
private static byte[] observationDigest(String observationToken) {
Objects.requireNonNull(observationToken, "observationToken must be non-null");
byte[] digest;
try {
digest = Base64.getUrlDecoder().decode(observationToken);
} catch (IllegalArgumentException exception) {
throw new IllegalArgumentException("observationToken must be unpadded Base64URL", exception);
}
if (digest.length != 32
|| !Base64.getUrlEncoder()
.withoutPadding()
.encodeToString(digest)
.equals(observationToken)) {
throw new IllegalArgumentException(
"observationToken must encode exactly one canonical SHA-256 digest");
}
return digest;
}
private static byte[] identifier(String value, String field) {
Objects.requireNonNull(value, field + " must be non-null");
byte[] bytes = value.getBytes(StandardCharsets.US_ASCII);
if (bytes.length < 16 || bytes.length > 64 || !value.matches("[A-Za-z0-9_-]+")) {
throw new IllegalArgumentException(
field + " must be a Base64URL-safe identifier of 16..64 bytes");
}
return bytes;
}
private static byte[] bounded(byte[] value, int maximumBytes, String field) {
Objects.requireNonNull(value, field + " must be non-null");
if (value.length < 1 || value.length > maximumBytes) {
throw new IllegalArgumentException(field + " must contain 1.." + maximumBytes + " bytes");
}
return value.clone();
}
private static <E extends Enum<E>> E parse(
RedisProgramId id, String status, Class<E> resultType) {
try {
return Enum.valueOf(resultType, status);
} catch (IllegalArgumentException | NullPointerException exception) {
throw new RedisProgramCompatibilityException(id, status);
}
}
enum CompareDeleteResult {
DELETED,
ABSENT,
NOT_OWNER,
WRONG_TYPE,
INVALID
}
enum CompareExpireResult {
RENEWED,
ABSENT,
NOT_OWNER,
WRONG_TYPE,
INVALID
}
enum SetIfAbsentResult {
SET,
EXISTS,
WRONG_TYPE,
INVALID
}
enum ReplaceIfObservedResult {
REPLACED,
ABSENT,
NOT_MATCHED,
WRONG_TYPE,
INVALID
}
enum GenerationInitResult {
INITIALIZED,
EXISTING,
WRONG_TYPE,
INVALID
}
enum GenerationBumpResult {
BUMPED,
ALREADY_APPLIED,
WRONG_TYPE,
INVALID
}
enum RefreshClaimResult {
CLAIMED,
ALREADY_OWNED,
CONTENDED,
WRONG_TYPE,
INVALID
}
}
@@ -1,13 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.time.Duration;
/** Minimal binary Redis command surface owned entirely by this adapter. */
interface RedisBinaryCommands extends RedisStructuredCommands {
byte[] get(RedisPhysicalKey key);
void set(RedisPhysicalKey key, RedisBinaryValue value, Duration timeToLive);
long delete(RedisPhysicalKey key);
}
@@ -1,42 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
/** Opaque bounded adapter-private value crossing the command gateway. */
final class RedisBinaryValue {
private static final int MAXIMUM_VALUE_BYTES = 16_777_216;
private final byte[] encoded;
private RedisBinaryValue(byte[] encoded) {
Objects.requireNonNull(encoded, "Redis binary value must be non-null");
if (encoded.length < 1 || encoded.length > MAXIMUM_VALUE_BYTES) {
throw new IllegalArgumentException("Redis binary value is out of bounds");
}
this.encoded = encoded.clone();
}
static RedisBinaryValue encoded(byte[] encoded) {
return new RedisBinaryValue(encoded);
}
static RedisBinaryValue utf8(String encoded) {
Objects.requireNonNull(encoded, "Redis binary value must be non-null");
return new RedisBinaryValue(encoded.getBytes(StandardCharsets.UTF_8));
}
int encodedLength() {
return encoded.length;
}
byte[] copyEncoded() {
return encoded.clone();
}
@Override
public String toString() {
return "RedisBinaryValue[redacted]";
}
}
@@ -1,15 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
/** Descriptor-owned byte offset for BITCOUNT ranges (Redis BITCOUNT is byte-indexed). */
record RedisBitmapByteOffset(long value) {
RedisBitmapByteOffset {
if (value < 0 || value >= 1_048_576) {
throw new IllegalArgumentException("bitmap byte offset exceeds fixed descriptor domain");
}
}
static RedisBitmapByteOffset of(long value) {
return new RedisBitmapByteOffset(value);
}
}
@@ -1,55 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.util.OptionalInt;
/** SETBIT result preserves the previous bit instead of mislabelling it as an affected count. */
record RedisBitmapMutationResult(
Status status, RedisPrimitiveMutationResult.Certainty certainty, OptionalInt previousBit) {
enum Status {
APPLIED,
WRONG_TYPE,
UNKNOWN
}
RedisBitmapMutationResult {
if (status == null || certainty == null || previousBit == null) {
throw new IllegalArgumentException("bitmap mutation result is invalid");
}
previousBit.ifPresent(
bit -> {
if (bit != 0 && bit != 1) {
throw new IllegalArgumentException("previous bitmap bit is invalid");
}
});
}
static RedisBitmapMutationResult from(RedisPrimitiveReply reply) {
return switch (reply.status()) {
case APPLIED ->
new RedisBitmapMutationResult(
Status.APPLIED,
RedisPrimitiveMutationResult.Certainty.APPLIED,
OptionalInt.of(Math.toIntExact(reply.signedNumber().orElseThrow())));
case WRONG_TYPE ->
new RedisBitmapMutationResult(
Status.WRONG_TYPE,
RedisPrimitiveMutationResult.Certainty.NOT_APPLIED,
OptionalInt.empty());
default ->
new RedisBitmapMutationResult(
Status.UNKNOWN,
RedisPrimitiveMutationResult.Certainty.NOT_APPLIED,
OptionalInt.empty());
};
}
static RedisBitmapMutationResult failed(RedisCommandFailureException failure) {
return new RedisBitmapMutationResult(
Status.UNKNOWN,
failure.certainty() == RedisCommandFailureException.Certainty.INDETERMINATE
? RedisPrimitiveMutationResult.Certainty.INDETERMINATE
: RedisPrimitiveMutationResult.Certainty.NOT_APPLIED,
OptionalInt.empty());
}
}
@@ -1,19 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
/** Offset constrained to a descriptor-owned fixed bitmap domain. */
record RedisBitmapOffset(long value, long maximumExclusive) {
RedisBitmapOffset {
if (maximumExclusive < 1 || value < 0 || value >= maximumExclusive) {
throw new IllegalArgumentException("bitmap offset exceeds the fixed descriptor domain");
}
}
static RedisBitmapOffset of(long value, long maximumExclusive) {
return new RedisBitmapOffset(value, maximumExclusive);
}
long byteIndex() {
return value / Byte.SIZE;
}
}
@@ -1,57 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.util.List;
import java.util.Objects;
/** Fixed-domain non-authoritative bitmap helpers. */
final class RedisBitmapPrimitives {
private static final long MAXIMUM_OFFSET_EXCLUSIVE = 8_388_608;
private final RedisPrimitiveCatalog catalog;
private final RedisPrimitiveExecutor executor;
RedisBitmapPrimitives(RedisPrimitiveCatalog catalog, RedisPrimitiveCommands commands) {
this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null");
this.executor = new RedisPrimitiveExecutor(catalog, commands);
}
RedisPrimitiveKey key(String slot, String identity) {
return catalog.keyFactory(RedisPrimitiveId.BITMAP_GET).key(slot, identity);
}
RedisBitmapOffset offset(long value) {
return RedisBitmapOffset.of(value, MAXIMUM_OFFSET_EXCLUSIVE);
}
RedisBitmapByteOffset byteOffset(long value) {
return RedisBitmapByteOffset.of(value);
}
RedisPrimitiveReply get(RedisPrimitiveKey key, RedisBitmapOffset offset) {
return executor.execute(
RedisPrimitiveId.BITMAP_GET,
List.of(key),
new RedisPrimitiveInvocation.BitmapArguments(offset, offset, -1));
}
RedisBitmapMutationResult set(RedisPrimitiveKey key, RedisBitmapOffset offset, boolean bit) {
try {
return RedisBitmapMutationResult.from(
executor.execute(
RedisPrimitiveId.BITMAP_SET,
List.of(key),
new RedisPrimitiveInvocation.BitmapArguments(offset, offset, bit ? 1 : 0)));
} catch (RedisCommandFailureException failure) {
return RedisBitmapMutationResult.failed(failure);
}
}
RedisPrimitiveReply count(
RedisPrimitiveKey key, RedisBitmapByteOffset first, RedisBitmapByteOffset last) {
return executor.execute(
RedisPrimitiveId.BITMAP_COUNT_FIXED_RANGE,
List.of(key),
new RedisPrimitiveInvocation.BitmapCountArguments(first, last));
}
}
@@ -1,64 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import io.lettuce.core.codec.RedisCodec;
import java.nio.ByteBuffer;
import java.util.Objects;
/**
* Rejects an oversized Redis bulk value before allocating its destination byte array.
*
* <p>RESP aggregate element count and aggregate reply bytes are additionally checked by the
* semantic router because a codec invocation sees only one bulk element. Lettuce constructs the
* aggregate list before that final check, so multi-value commands remain restricted to the vetted
* program catalog and its bounded reply schemas; this codec is the pre-allocation bound for each
* bulk element, not a claim of a pre-allocation aggregate-list bound.
*/
final class RedisBoundedByteArrayCodec implements RedisCodec<byte[], byte[]> {
private final int maximumBulkBytes;
RedisBoundedByteArrayCodec(int maximumBulkBytes) {
if (maximumBulkBytes < 1024 || maximumBulkBytes > 16_777_216) {
throw new IllegalArgumentException("Redis codec bulk byte bound must be in 1024..16777216");
}
this.maximumBulkBytes = maximumBulkBytes;
}
@Override
public byte[] decodeKey(ByteBuffer bytes) {
return decode(bytes);
}
@Override
public byte[] decodeValue(ByteBuffer bytes) {
return decode(bytes);
}
@Override
public ByteBuffer encodeKey(byte[] key) {
return encode(key);
}
@Override
public ByteBuffer encodeValue(byte[] value) {
return encode(value);
}
private byte[] decode(ByteBuffer bytes) {
Objects.requireNonNull(bytes, "Redis decode buffer must be non-null");
if (bytes.remaining() > maximumBulkBytes) {
throw new IllegalStateException("Redis response bulk value exceeds its configured bound");
}
byte[] value = new byte[bytes.remaining()];
bytes.get(value);
return value;
}
private ByteBuffer encode(byte[] value) {
Objects.requireNonNull(value, "Redis encode value must be non-null");
if (value.length > maximumBulkBytes) {
throw new IllegalArgumentException("Redis command bulk value exceeds its configured bound");
}
return ByteBuffer.wrap(value);
}
}
@@ -1,158 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.core.CacheBackend;
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyNamespace;
import dev.caskeleton.application.cache.CacheObservationPort;
import dev.caskeleton.application.cache.DisabledCacheObservationPort;
import io.micrometer.core.instrument.MeterRegistry;
import java.time.Clock;
import java.util.Arrays;
import java.util.Set;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Layer 1 bean-gating for the Redis cache backend. The {@code app.cache.redis.enabled} flag (env
* {@code APP_CACHE_REDIS_ENABLED}, default false) decides whether this backend <em>contributes</em>
* a {@link CacheBackend} bean whose {@link CacheBackend#backendId()} is {@link
* RedisCacheStore#BACKEND_ID} — the id that {@code app.cache.bindings.<logicalName>=redis} routes
* to. Fail-open wrapping and dependency logging are applied centrally by {@code CacheRouterConfig};
* this config stays a thin contribution.
*
* <p>No disabled-sentinel bean: when disabled this config contributes nothing, and the Layer 3
* fail-fast contract is enforced by {@code CacheStoreRouter} (unbound logical name → {@code
* AdapterDisabledException}; binding to a disabled backend → startup failure). Backend configs
* therefore never need to know about each other — a new backend is new files only.
*/
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties({RedisRuntimeSettings.class, RedisLocalCacheSettings.class})
@ConditionalOnProperty(
name = "ca-skeleton.providers.redis.legacy-migration-enabled",
havingValue = "true",
matchIfMissing = false)
public class RedisCacheAdapterConfig {
@Configuration(proxyBeanMethods = false)
@ConditionalOnProperty(
name = "app.cache.redis.client-mode",
havingValue = "managed",
matchIfMissing = true)
static class ManagedRedisRuntimeConfig {
@Bean(destroyMethod = "close")
@ConditionalOnProperty(
name = "app.cache.redis.enabled",
havingValue = "true",
matchIfMissing = false)
LettuceRedisRuntime lettuceRedisRuntime(RedisRuntimeSettings settings) {
settings.hmacSecret();
return LettuceRedisRuntime.connect(settings);
}
}
@Bean(destroyMethod = "close")
@ConditionalOnBean(LettuceRedisRuntime.class)
@ConditionalOnProperty(
name = "app.cache.redis.enabled",
havingValue = "true",
matchIfMissing = false)
RedisCacheRegionRuntime redisStringCacheRegion(
LettuceRedisRuntime runtime,
RedisRuntimeSettings settings,
RedisLocalCacheSettings localSettings,
ObjectProvider<MeterRegistry> meterRegistryProvider) {
RedisKeyNamespace namespace =
new RedisKeyNamespace(
settings.namespaceApplication(),
settings.namespaceEnvironment(),
"cache",
settings.semanticRegion(),
1,
1,
"entry",
512);
byte[] policySecret = settings.hmacSecret();
RedisCacheRegionPolicy policy;
try {
policy =
new RedisCacheRegionPolicy(
namespace,
policySecret,
"runtime-settings-v2",
settings.positiveSoftTtl(),
settings.positiveTtl(),
settings.negativeTtl(),
settings.ttlJitter(),
settings.minimumHardTtl(),
settings.maximumValueBytes());
} finally {
Arrays.fill(policySecret, (byte) 0);
}
RedisStringCacheRegion l2 = new RedisStringCacheRegion(policy, runtime);
if (!localSettings.enabled()) {
return RedisCacheRegionRuntime.l2Only(l2);
}
MeterRegistry meterRegistry = meterRegistryProvider.getIfAvailable();
CacheObservationPort observations =
meterRegistry == null
? DisabledCacheObservationPort.instance()
: new MicrometerCacheObservationPort(meterRegistry, Set.of(settings.semanticRegion()));
String channel = l2.invalidationChannel();
byte[] codecSecret = settings.hmacSecret();
RedisCacheInvalidationMessage.Codec codec;
try {
codec = RedisCacheInvalidationMessage.Codec.fromOwnedSecret(codecSecret);
} finally {
Arrays.fill(codecSecret, (byte) 0);
}
return RedisCacheRegionRuntime.local(
l2,
new RedisLocalCacheRegion(
settings.semanticRegion(),
l2,
localSettings.policy(),
Clock.systemUTC(),
observations,
channel,
codec,
message -> runtime.publishInvalidation(channel, message)));
}
@Bean(destroyMethod = "close")
@ConditionalOnBean(LettuceRedisRuntime.class)
@ConditionalOnProperty(
name = {"app.cache.redis.enabled", "app.cache.redis.l1.enabled"},
havingValue = "true",
matchIfMissing = false)
LettuceRedisCacheInvalidationSubscription redisCacheInvalidationSubscription(
LettuceRedisRuntime runtime,
@Qualifier("redisStringCacheRegion") RedisCacheRegionRuntime cacheRegion) {
RedisLocalCacheRegion local =
cacheRegion
.local()
.orElseThrow(
() ->
new IllegalStateException(
"Redis L1 invalidation subscription requires the cache-only local"
+ " decorator"));
return LettuceRedisCacheInvalidationSubscription.subscribe(
runtime,
local.invalidationChannel(),
local.invalidationMessageCodec(),
local.invalidationSubscriber());
}
@Bean
@ConditionalOnProperty(
name = "app.cache.redis.enabled",
havingValue = "true",
matchIfMissing = false)
public CacheBackend redisCacheBackend(RedisClient redisClient) {
return new RedisCacheStore(redisClient);
}
}
@@ -1,219 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.application.cache.CacheWriteCondition;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.time.Duration;
import java.util.Base64;
import java.util.Objects;
import java.util.function.Supplier;
/**
* Owns non-expiring random cache generations and per-key revisions.
*
* <p>If an evictable control key disappears, initialization chooses a new random value. An old
* namespace therefore never becomes visible again by resetting to a constant default.
*/
final class RedisCacheConsistencyStore {
private static final String CONDITION_VERSION = "v1";
private static final SecureRandom RANDOM = new SecureRandom();
private static final Duration DEFAULT_KEY_REVISION_TTL = Duration.ofDays(30);
private final RedisBinaryCommands commands;
private final RedisAtomicPrimitives primitives;
private final Supplier<String> identifiers;
private final Duration keyRevisionTtl;
RedisCacheConsistencyStore(RedisBinaryCommands commands) {
this(commands, DEFAULT_KEY_REVISION_TTL);
}
RedisCacheConsistencyStore(RedisBinaryCommands commands, Duration keyRevisionTtl) {
this(
commands,
productionPrimitives(commands),
RedisCacheConsistencyStore::randomIdentifier,
keyRevisionTtl);
}
RedisCacheConsistencyStore(
RedisBinaryCommands commands,
RedisAtomicPrimitives primitives,
Supplier<String> identifiers) {
this(commands, primitives, identifiers, DEFAULT_KEY_REVISION_TTL);
}
RedisCacheConsistencyStore(
RedisBinaryCommands commands,
RedisAtomicPrimitives primitives,
Supplier<String> identifiers,
Duration keyRevisionTtl) {
this.commands = Objects.requireNonNull(commands, "commands must be non-null");
this.primitives = Objects.requireNonNull(primitives, "primitives must be non-null");
this.identifiers = Objects.requireNonNull(identifiers, "identifiers must be non-null");
this.keyRevisionTtl = boundedKeyRevisionTtl(keyRevisionTtl);
}
Snapshot capture(String regionGenerationKey, String keyRevisionKey) {
return new Snapshot(
currentOrInitialize(regionGenerationKey, Duration.ZERO),
currentOrInitialize(keyRevisionKey, keyRevisionTtl));
}
String currentRegionGeneration(String regionGenerationKey) {
return currentOrInitialize(regionGenerationKey, Duration.ZERO);
}
BumpResult bumpKeyRevision(String keyRevisionKey) {
return bumpKeyRevision(keyRevisionKey, nextIdentifier());
}
BumpResult bumpKeyRevision(String keyRevisionKey, String operationId) {
return bump(keyRevisionKey, operationId, keyRevisionTtl);
}
BumpResult bumpRegionGeneration(String regionGenerationKey) {
return bumpRegionGeneration(regionGenerationKey, nextIdentifier());
}
BumpResult bumpRegionGeneration(String regionGenerationKey, String operationId) {
return bump(regionGenerationKey, operationId, Duration.ZERO);
}
Snapshot decode(CacheWriteCondition condition) {
Objects.requireNonNull(condition, "condition must be non-null");
if (!condition.usable()) {
return null;
}
String[] components = condition.value().split("\\.", -1);
if (components.length != 3 || !CONDITION_VERSION.equals(components[0])) {
throw compatibility("MALFORMED_WRITE_CONDITION");
}
try {
return new Snapshot(components[1], components[2]);
} catch (IllegalArgumentException exception) {
throw compatibility("MALFORMED_WRITE_CONDITION");
}
}
private String currentOrInitialize(String key, Duration timeToLive) {
byte[] current = commands.get(physicalKey(key));
String candidate = current == null ? nextIdentifier() : parseState(current).generation();
RedisAtomicPrimitives.GenerationInitResult initialized =
primitives.initializeGeneration(key, candidate, timeToLive);
if (initialized == RedisAtomicPrimitives.GenerationInitResult.WRONG_TYPE
|| initialized == RedisAtomicPrimitives.GenerationInitResult.INVALID) {
throw compatibility(initialized.name());
}
current = commands.get(physicalKey(key));
if (current == null) {
throw compatibility("MISSING_AFTER_INITIALIZATION");
}
return parseState(current).generation();
}
private BumpResult bump(String key, String operationId, Duration timeToLive) {
RedisAtomicPrimitives.GenerationBumpResult result =
primitives.bumpGeneration(
key, nextIdentifier(), validateIdentifier(operationId, "operationId"), timeToLive);
return switch (result) {
case BUMPED -> BumpResult.BUMPED;
case ALREADY_APPLIED -> BumpResult.ALREADY_APPLIED;
case WRONG_TYPE, INVALID -> throw compatibility(result.name());
};
}
private String nextIdentifier() {
return validateIdentifier(identifiers.get(), "generated identifier");
}
private static State parseState(byte[] value) {
String state = new String(value, StandardCharsets.US_ASCII);
int separator = state.indexOf('|');
if (separator < 0 || separator != state.lastIndexOf('|')) {
throw compatibility("MALFORMED_GENERATION_STATE");
}
try {
String generation = validateIdentifier(state.substring(0, separator), "stored generation");
String operation = state.substring(separator + 1);
if (!"-".equals(operation)) {
validateIdentifier(operation, "stored operation");
}
return new State(generation, operation);
} catch (IllegalArgumentException exception) {
throw compatibility("MALFORMED_GENERATION_STATE");
}
}
private static String validateIdentifier(String value, String field) {
if (value == null
|| value.length() < 16
|| value.length() > 64
|| !value.matches("[A-Za-z0-9_-]+")) {
throw new IllegalArgumentException(field + " must contain 16..64 Base64URL-safe characters");
}
return value;
}
private static Duration boundedKeyRevisionTtl(Duration value) {
Objects.requireNonNull(value, "keyRevisionTtl must be non-null");
if (value.isZero() || value.isNegative() || value.compareTo(Duration.ofDays(31)) > 0) {
throw new IllegalArgumentException("keyRevisionTtl must be positive and at most 31 days");
}
return value;
}
private static RedisPhysicalKey physicalKey(String key) {
return RedisPhysicalKey.owned(new ConsistencyKeyMaterial(key));
}
private static String randomIdentifier() {
byte[] random = new byte[16];
RANDOM.nextBytes(random);
return Base64.getUrlEncoder().withoutPadding().encodeToString(random);
}
private static RedisAtomicPrimitives productionPrimitives(RedisBinaryCommands commands) {
RedisProgramCatalog catalog = RedisProgramCatalog.foundation();
return new RedisAtomicPrimitives(catalog, new RedisLuaProgramExecutor(catalog, commands));
}
private static RedisProgramCompatibilityException compatibility(String status) {
return new RedisProgramCompatibilityException(RedisProgramId.REGION_GENERATION_INIT, status);
}
enum BumpResult {
BUMPED,
ALREADY_APPLIED
}
record Snapshot(String generation, String keyRevision) {
Snapshot {
generation = validateIdentifier(generation, "generation");
keyRevision = validateIdentifier(keyRevision, "keyRevision");
}
CacheWriteCondition toWriteCondition() {
return new CacheWriteCondition(CONDITION_VERSION + "." + generation + "." + keyRevision);
}
}
private record State(String generation, String operation) {}
static final class ConsistencyKeyMaterial implements RedisOwnedPhysicalKeyMaterial {
private final byte[] encodedKey;
private ConsistencyKeyMaterial(String key) {
this.encodedKey =
Objects.requireNonNull(key, "key must be non-null").getBytes(StandardCharsets.UTF_8);
}
@Override
public byte[] copyEncodedKey() {
return encodedKey.clone();
}
}
}
@@ -1,246 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.application.cache.AuthoritativeAbsence;
import dev.caskeleton.application.cache.CacheLookup;
import dev.caskeleton.application.cache.CacheObservationToken;
import java.nio.BufferUnderflowException;
import java.nio.ByteBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.Arrays;
import java.util.Base64;
import java.util.Objects;
/** Strict versioned binary envelope for positive and authoritative-negative cache entries. */
final class RedisCacheEnvelopeCodec {
private static final int MAGIC = 0x43414348;
private static final int VERSION = 2;
private static final byte POSITIVE = 1;
private static final byte NEGATIVE = 2;
private static final int COMMON_HEADER_BYTES = Integer.BYTES + Byte.BYTES + Byte.BYTES;
private static final int POSITIVE_HEADER_BYTES =
COMMON_HEADER_BYTES + Short.BYTES + Integer.BYTES + Long.BYTES + Long.BYTES;
private static final int NEGATIVE_HEADER_BYTES = COMMON_HEADER_BYTES + Integer.BYTES + Long.BYTES;
private static final int DIGEST_BYTES = 32;
private RedisCacheEnvelopeCodec() {}
static byte[] positive(
String value,
String sourceRevision,
Instant softExpiresAt,
Instant hardExpiresAt,
int maximumValueBytes) {
Objects.requireNonNull(softExpiresAt, "softExpiresAt must be non-null");
Objects.requireNonNull(hardExpiresAt, "hardExpiresAt must be non-null");
if (softExpiresAt.isAfter(hardExpiresAt)) {
throw new IllegalArgumentException("softExpiresAt must not be after hardExpiresAt");
}
byte[] revision =
utf8(Objects.requireNonNull(sourceRevision, "sourceRevision must be non-null"));
if (!validSourceRevision(sourceRevision) || revision.length > 512) {
throw new IllegalArgumentException(
"sourceRevision must contain 1..128 characters and at most 512 UTF-8 bytes");
}
byte[] payload =
checkedPayload(
utf8(Objects.requireNonNull(value, "value must be non-null")), maximumValueBytes);
byte[] content =
ByteBuffer.allocate(POSITIVE_HEADER_BYTES + revision.length + payload.length)
.putInt(MAGIC)
.put((byte) VERSION)
.put(POSITIVE)
.putShort((short) revision.length)
.putInt(payload.length)
.putLong(softExpiresAt.toEpochMilli())
.putLong(hardExpiresAt.toEpochMilli())
.put(revision)
.put(payload)
.array();
return withDigest(content);
}
static byte[] negative(
AuthoritativeAbsence reason, Instant hardExpiresAt, int maximumValueBytes) {
Objects.requireNonNull(reason, "reason must be non-null");
Objects.requireNonNull(hardExpiresAt, "hardExpiresAt must be non-null");
byte[] payload = checkedPayload(utf8(reason.name()), maximumValueBytes);
byte[] content =
ByteBuffer.allocate(NEGATIVE_HEADER_BYTES + payload.length)
.putInt(MAGIC)
.put((byte) VERSION)
.put(NEGATIVE)
.putInt(payload.length)
.putLong(hardExpiresAt.toEpochMilli())
.put(payload)
.array();
return withDigest(content);
}
static Decoded decode(byte[] envelope, int maximumValueBytes) {
validateMaximumValueBytes(maximumValueBytes);
if (envelope == null) {
return incompatible(CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE);
}
if (envelope.length < COMMON_HEADER_BYTES + DIGEST_BYTES
|| envelope.length > maximumValueBytes + 1024 + DIGEST_BYTES) {
return incompatible(CacheLookup.SchemaCategory.CORRUPT_ENVELOPE);
}
CacheObservationToken observationToken = CacheObservationToken.unavailable();
try {
int contentLength = envelope.length - DIGEST_BYTES;
byte[] expectedDigest = sha256(Arrays.copyOf(envelope, contentLength));
byte[] actualDigest = Arrays.copyOfRange(envelope, contentLength, envelope.length);
if (!MessageDigest.isEqual(expectedDigest, actualDigest)) {
return incompatible(CacheLookup.SchemaCategory.CORRUPT_ENVELOPE);
}
observationToken = observationToken(actualDigest);
ByteBuffer buffer = ByteBuffer.wrap(envelope, 0, contentLength);
if (buffer.getInt() != MAGIC) {
return incompatible(CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE, observationToken);
}
int version = Byte.toUnsignedInt(buffer.get());
if (version > VERSION) {
return incompatible(CacheLookup.SchemaCategory.FUTURE_VERSION, observationToken);
}
if (version < VERSION) {
return incompatible(CacheLookup.SchemaCategory.RETIRED_VERSION, observationToken);
}
byte type = buffer.get();
if (type == POSITIVE) {
return decodePositive(buffer, maximumValueBytes, observationToken);
}
if (type == NEGATIVE) {
return decodeNegative(buffer, maximumValueBytes, observationToken);
}
return incompatible(CacheLookup.SchemaCategory.CORRUPT_ENVELOPE, observationToken);
} catch (BufferUnderflowException
| IllegalArgumentException
| CharacterCodingException exception) {
return incompatible(CacheLookup.SchemaCategory.CORRUPT_ENVELOPE, observationToken);
}
}
private static Decoded decodePositive(
ByteBuffer buffer, int maximumValueBytes, CacheObservationToken observationToken)
throws CharacterCodingException {
int revisionSize = Short.toUnsignedInt(buffer.getShort());
int payloadSize = buffer.getInt();
Instant softExpiresAt = Instant.ofEpochMilli(buffer.getLong());
Instant hardExpiresAt = Instant.ofEpochMilli(buffer.getLong());
if (revisionSize < 1
|| revisionSize > 512
|| payloadSize < 1
|| payloadSize > maximumValueBytes
|| buffer.remaining() != revisionSize + payloadSize
|| softExpiresAt.isAfter(hardExpiresAt)) {
return incompatible(CacheLookup.SchemaCategory.CORRUPT_ENVELOPE, observationToken);
}
byte[] revision = new byte[revisionSize];
byte[] payload = new byte[payloadSize];
buffer.get(revision);
buffer.get(payload);
String sourceRevision = strictUtf8(revision);
if (!validSourceRevision(sourceRevision)) {
return incompatible(CacheLookup.SchemaCategory.CORRUPT_ENVELOPE, observationToken);
}
return new Positive(
strictUtf8(payload), sourceRevision, softExpiresAt, hardExpiresAt, observationToken);
}
private static Decoded decodeNegative(
ByteBuffer buffer, int maximumValueBytes, CacheObservationToken observationToken)
throws CharacterCodingException {
int payloadSize = buffer.getInt();
Instant hardExpiresAt = Instant.ofEpochMilli(buffer.getLong());
if (payloadSize < 1 || payloadSize > maximumValueBytes || buffer.remaining() != payloadSize) {
return incompatible(CacheLookup.SchemaCategory.CORRUPT_ENVELOPE, observationToken);
}
byte[] payload = new byte[payloadSize];
buffer.get(payload);
return new Negative(
AuthoritativeAbsence.valueOf(strictUtf8(payload)), hardExpiresAt, observationToken);
}
private static byte[] checkedPayload(byte[] payload, int maximumValueBytes) {
validateMaximumValueBytes(maximumValueBytes);
if (payload.length < 1 || payload.length > maximumValueBytes) {
throw new IllegalArgumentException("cache payload exceeds configured maximum bytes");
}
return payload;
}
private static void validateMaximumValueBytes(int maximumValueBytes) {
if (maximumValueBytes < 1 || maximumValueBytes > 16_777_216) {
throw new IllegalArgumentException("maximumValueBytes must be in 1..16777216");
}
}
private static byte[] withDigest(byte[] content) {
return ByteBuffer.allocate(content.length + DIGEST_BYTES)
.put(content)
.put(sha256(content))
.array();
}
private static byte[] utf8(String value) {
return value.getBytes(StandardCharsets.UTF_8);
}
private static String strictUtf8(byte[] value) throws CharacterCodingException {
return StandardCharsets.UTF_8
.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(value))
.toString();
}
private static boolean validSourceRevision(String sourceRevision) {
return !sourceRevision.isBlank() && sourceRevision.length() <= 128;
}
private static CacheObservationToken observationToken(byte[] digest) {
return new CacheObservationToken(
Base64.getUrlEncoder().withoutPadding().encodeToString(digest));
}
private static byte[] sha256(byte[] content) {
try {
return MessageDigest.getInstance("SHA-256").digest(content);
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 unavailable for cache envelope", exception);
}
}
private static Incompatible incompatible(CacheLookup.SchemaCategory category) {
return new Incompatible(category, CacheObservationToken.unavailable());
}
private static Incompatible incompatible(
CacheLookup.SchemaCategory category, CacheObservationToken observationToken) {
return new Incompatible(category, observationToken);
}
sealed interface Decoded permits Positive, Negative, Incompatible {}
record Positive(
String value,
String sourceRevision,
Instant softExpiresAt,
Instant hardExpiresAt,
CacheObservationToken observationToken)
implements Decoded {}
record Negative(
AuthoritativeAbsence reason, Instant hardExpiresAt, CacheObservationToken observationToken)
implements Decoded {}
record Incompatible(CacheLookup.SchemaCategory category, CacheObservationToken observationToken)
implements Decoded {}
}
@@ -1,164 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.util.Arrays;
import java.util.Base64;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicBoolean;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
/** Authenticated, bounded Pub/Sub hint that contains no raw semantic cache key. */
sealed interface RedisCacheInvalidationMessage {
String value();
static RedisCacheInvalidationMessage key(String localEntryIdentity) {
return new Key(localEntryIdentity);
}
static RedisCacheInvalidationMessage region(String generation) {
return new Region(generation);
}
record Key(String value) implements RedisCacheInvalidationMessage {
public Key {
value = boundedAscii(value, "localEntryIdentity", 1024);
}
}
record Region(String value) implements RedisCacheInvalidationMessage {
public Region {
if (value == null
|| value.length() < 16
|| value.length() > 64
|| !value.matches("[A-Za-z0-9_-]+")) {
throw new IllegalArgumentException(
"generation must contain 16..64 Base64URL-safe characters");
}
}
}
/**
* HMAC protects hints from cross-channel corruption; Redis ACL still owns publisher authority.
*/
final class Codec implements AutoCloseable {
private static final int MAXIMUM_WIRE_CHARACTERS = 4096;
private static final Base64.Encoder ENCODER = Base64.getUrlEncoder().withoutPadding();
private static final Base64.Decoder DECODER = Base64.getUrlDecoder();
private final byte[] secret;
private final AtomicBoolean destroyed = new AtomicBoolean();
Codec(byte[] secret) {
Objects.requireNonNull(secret, "secret must be non-null");
if (secret.length < 32) {
throw new IllegalArgumentException("message HMAC secret must contain at least 32 bytes");
}
this.secret = secret.clone();
}
static Codec fromOwnedSecret(byte[] ownedSecret) {
Objects.requireNonNull(ownedSecret, "ownedSecret must be non-null");
try {
return new Codec(ownedSecret);
} finally {
Arrays.fill(ownedSecret, (byte) 0);
}
}
synchronized String encode(RedisCacheInvalidationMessage message) {
ensureUsable();
Objects.requireNonNull(message, "message must be non-null");
String kind = message instanceof Key ? "K" : "R";
byte[] payload = (kind + "\n" + message.value()).getBytes(StandardCharsets.US_ASCII);
return "v1." + ENCODER.encodeToString(payload) + "." + ENCODER.encodeToString(hmac(payload));
}
synchronized Optional<RedisCacheInvalidationMessage> decode(String wire) {
ensureUsable();
if (wire == null || wire.length() < 8 || wire.length() > MAXIMUM_WIRE_CHARACTERS) {
return Optional.empty();
}
String[] components = wire.split("\\.", -1);
if (components.length != 3 || !"v1".equals(components[0])) {
return Optional.empty();
}
try {
byte[] payload = DECODER.decode(components[1]);
byte[] suppliedMac = DECODER.decode(components[2]);
if (!MessageDigest.isEqual(hmac(payload), suppliedMac)) {
return Optional.empty();
}
String decoded = new String(payload, StandardCharsets.US_ASCII);
int separator = decoded.indexOf('\n');
if (separator != 1 || separator != decoded.lastIndexOf('\n')) {
return Optional.empty();
}
String value = decoded.substring(separator + 1);
return switch (decoded.charAt(0)) {
case 'K' -> Optional.of(key(value));
case 'R' -> Optional.of(region(value));
default -> Optional.empty();
};
} catch (IllegalArgumentException exception) {
return Optional.empty();
}
}
private byte[] hmac(byte[] payload) {
byte[] secretCopy = secret.clone();
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secretCopy, "HmacSHA256"));
return mac.doFinal(payload);
} catch (GeneralSecurityException exception) {
throw new IllegalStateException("HmacSHA256 unavailable for invalidation hints", exception);
} finally {
Arrays.fill(secretCopy, (byte) 0);
}
}
@Override
public synchronized void close() {
if (destroyed.compareAndSet(false, true)) {
Arrays.fill(secret, (byte) 0);
}
}
synchronized boolean destroyed() {
if (!destroyed.get()) {
return false;
}
for (byte value : secret) {
if (value != 0) {
return false;
}
}
return true;
}
private void ensureUsable() {
if (destroyed.get()) {
throw new IllegalStateException("invalidation message codec is destroyed");
}
}
}
private static String boundedAscii(String value, String field, int maximumCharacters) {
if (value == null
|| value.isBlank()
|| value.length() > maximumCharacters
|| value.chars().anyMatch(character -> character < 0x21 || character > 0x7e)) {
throw new IllegalArgumentException(
field + " must contain bounded non-whitespace ASCII characters");
}
return value;
}
}
@@ -1,64 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.util.Objects;
import java.util.concurrent.ArrayBlockingQueue;
/**
* Bounded handoff between a Redis Pub/Sub callback and cache request threads.
*
* <p>Pub/Sub has no replay. Disconnect or queue overflow therefore flushes L1 immediately and
* forces a generation read before local entries may be repopulated.
*/
final class RedisCacheInvalidationSubscriber {
interface Target {
void apply(RedisCacheInvalidationMessage message);
void disconnected();
void overflow();
void malformedMessage();
}
private final ArrayBlockingQueue<RedisCacheInvalidationMessage> hints;
private final Target target;
RedisCacheInvalidationSubscriber(int capacity, Target target) {
if (capacity < 1 || capacity > 65_536) {
throw new IllegalArgumentException("subscriber capacity must be in 1..65536");
}
this.hints = new ArrayBlockingQueue<>(capacity);
this.target = Objects.requireNonNull(target, "target must be non-null");
}
void onMessage(RedisCacheInvalidationMessage message) {
Objects.requireNonNull(message, "message must be non-null");
if (hints.offer(message)) {
return;
}
hints.clear();
target.overflow();
}
void onDisconnected() {
hints.clear();
target.disconnected();
}
void onMalformedMessage() {
target.malformedMessage();
}
void drain() {
RedisCacheInvalidationMessage hint;
while ((hint = hints.poll()) != null) {
target.apply(hint);
}
}
int queuedHintCount() {
return hints.size();
}
}
@@ -1,63 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
/** Lifecycle wrapper that decodes canonical CACHE-role invalidation traffic. */
final class RedisCacheInvalidationSubscription implements AutoCloseable {
private final RedisInvalidationTransport.Subscription delegate;
private final RedisCacheInvalidationSubscriber subscriber;
private final AtomicBoolean closed = new AtomicBoolean();
private RedisCacheInvalidationSubscription(
RedisInvalidationTransport.Subscription delegate,
RedisCacheInvalidationSubscriber subscriber) {
this.delegate = Objects.requireNonNull(delegate, "delegate must be non-null");
this.subscriber = Objects.requireNonNull(subscriber, "subscriber must be non-null");
}
static RedisCacheInvalidationSubscription subscribe(
RedisInvalidationTransport transport,
String channel,
RedisCacheInvalidationMessage.Codec codec,
RedisCacheInvalidationSubscriber subscriber) {
Objects.requireNonNull(transport, "transport must be non-null");
Objects.requireNonNull(channel, "channel must be non-null");
Objects.requireNonNull(codec, "codec must be non-null");
Objects.requireNonNull(subscriber, "subscriber must be non-null");
RedisInvalidationTransport.Subscription delegate =
transport.subscribe(
channel.getBytes(StandardCharsets.US_ASCII),
new RedisInvalidationTransport.Listener() {
@Override
public void onMessage(byte[] wireMessage) {
if (wireMessage == null) {
subscriber.onMalformedMessage();
return;
}
codec
.decode(new String(wireMessage, StandardCharsets.US_ASCII))
.ifPresentOrElse(subscriber::onMessage, subscriber::onMalformedMessage);
}
@Override
public void onDisconnected() {
subscriber.onDisconnected();
}
});
return new RedisCacheInvalidationSubscription(delegate, subscriber);
}
@Override
public void close() {
if (closed.compareAndSet(false, true)) {
try {
delegate.close();
} finally {
subscriber.onDisconnected();
}
}
}
}
@@ -1,18 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.application.cache.CacheRegionPort;
/**
* Internal cache-only L2 surface needed by the local decorator.
*
* <p>Session, idempotency, rate-limit and coordination providers do not implement this type and
* therefore cannot accidentally receive the fail-open local tier.
*/
interface RedisCacheL2Region extends CacheRegionPort<String, String> {
/** Stable HMAC-derived identity; never the raw semantic key. */
String localEntryIdentity(String key);
/** Current region generation used to recover from missed best-effort invalidation hints. */
String currentRegionGeneration();
}
@@ -1,295 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyBuilder;
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyDigest;
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyNamespace;
import dev.caskeleton.application.cache.CacheRefreshClaimAttempt;
import dev.caskeleton.application.cache.CacheRefreshClaimOutcome;
import dev.caskeleton.application.cache.CacheRefreshCoordinationPort;
import dev.caskeleton.application.cache.CacheRefreshOperationToken;
import dev.caskeleton.application.cache.CacheRefreshOwnerToken;
import dev.caskeleton.application.cache.CacheRefreshReleaseOutcome;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.time.Duration;
import java.util.Arrays;
import java.util.Base64;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.LongSupplier;
import java.util.function.Supplier;
/**
* Redis-backed cache refresh admission lease.
*
* <p>The lease only suppresses duplicate refresh work. Cache generation/revision fences remain the
* correctness mechanism for invalidation races.
*/
final class RedisCacheRefreshCoordinator
implements CacheRefreshCoordinationPort<String>, AutoCloseable {
private static final SecureRandom RANDOM = new SecureRandom();
private final RedisKeyNamespace namespace;
private final byte[] hmacSecret;
private final RedisAtomicPrimitives primitives;
private final Supplier<String> tokens;
private final RedisCapabilityObserver observer;
private final AtomicBoolean destroyed = new AtomicBoolean();
RedisCacheRefreshCoordinator(
RedisKeyNamespace namespace, byte[] hmacSecret, RedisBinaryCommands commands) {
this(
namespace,
hmacSecret,
productionPrimitives(commands),
RedisCacheRefreshCoordinator::randomToken,
NoOpRedisCapabilityObservationPort.instance(),
System::nanoTime);
}
RedisCacheRefreshCoordinator(
RedisKeyNamespace namespace,
byte[] hmacSecret,
RedisBinaryCommands commands,
RedisCapabilityObservationPort observations,
LongSupplier ticker) {
this(
namespace,
hmacSecret,
productionPrimitives(commands),
RedisCacheRefreshCoordinator::randomToken,
observations,
ticker);
}
RedisCacheRefreshCoordinator(
RedisKeyNamespace namespace,
byte[] hmacSecret,
RedisAtomicPrimitives primitives,
Supplier<String> tokens) {
this(
namespace,
hmacSecret,
primitives,
tokens,
NoOpRedisCapabilityObservationPort.instance(),
System::nanoTime);
}
RedisCacheRefreshCoordinator(
RedisKeyNamespace namespace,
byte[] hmacSecret,
RedisAtomicPrimitives primitives,
Supplier<String> tokens,
RedisCapabilityObservationPort observations,
LongSupplier ticker) {
this.namespace = refreshNamespace(namespace);
Objects.requireNonNull(hmacSecret, "hmacSecret must be non-null");
if (hmacSecret.length < 32) {
throw new IllegalArgumentException("hmacSecret must contain at least 32 bytes");
}
this.hmacSecret = hmacSecret.clone();
this.primitives = Objects.requireNonNull(primitives, "primitives must be non-null");
this.tokens = Objects.requireNonNull(tokens, "tokens must be non-null");
this.observer = new RedisCapabilityObserver(observations, ticker);
}
@Override
public CacheRefreshClaimAttempt newAttempt() {
ensureUsable();
return new CacheRefreshClaimAttempt(
new CacheRefreshOwnerToken(nextToken()), new CacheRefreshOperationToken(nextToken()));
}
@Override
public CacheRefreshClaimOutcome claim(
String key, CacheRefreshClaimAttempt attempt, Duration leaseTimeToLive) {
return observer.observe(
RedisCapabilityObservationEvent.Capability.CACHE,
RedisCapabilityObservationEvent.Role.CACHE,
RedisCapabilityObservationEvent.Operation.REFRESH_CLAIM,
() -> claimOpen(key, attempt, leaseTimeToLive),
RedisCacheRefreshCoordinator::classifyClaim);
}
private CacheRefreshClaimOutcome claimOpen(
String key, CacheRefreshClaimAttempt attempt, Duration leaseTimeToLive) {
ensureUsable();
requireUsable(attempt);
try {
RedisAtomicPrimitives.RefreshClaimResult result =
primitives.claimRefreshLease(
physicalKey(key),
attempt.ownerToken().value(),
attempt.operationToken().value(),
leaseTimeToLive);
return switch (result) {
case CLAIMED -> new CacheRefreshClaimOutcome.Claimed(attempt);
case ALREADY_OWNED -> new CacheRefreshClaimOutcome.AlreadyOwned(attempt);
case CONTENDED -> new CacheRefreshClaimOutcome.Contended();
case WRONG_TYPE, INVALID ->
throw new RedisProgramCompatibilityException(
RedisProgramId.CACHE_REFRESH_CLAIM, result.name());
};
} catch (RedisCommandFailureException exception) {
return exception.certainty() == RedisCommandFailureException.Certainty.NOT_APPLIED
? new CacheRefreshClaimOutcome.Unavailable()
: new CacheRefreshClaimOutcome.Indeterminate();
}
}
@Override
public CacheRefreshReleaseOutcome release(String key, CacheRefreshClaimAttempt attempt) {
return observer.observe(
RedisCapabilityObservationEvent.Capability.CACHE,
RedisCapabilityObservationEvent.Role.CACHE,
RedisCapabilityObservationEvent.Operation.REFRESH_RELEASE,
() -> releaseOpen(key, attempt),
RedisCacheRefreshCoordinator::classifyRelease);
}
private CacheRefreshReleaseOutcome releaseOpen(String key, CacheRefreshClaimAttempt attempt) {
ensureUsable();
requireUsable(attempt);
try {
RedisAtomicPrimitives.CompareDeleteResult result =
primitives.compareAndDelete(physicalKey(key), ownerState(attempt));
return switch (result) {
case DELETED -> new CacheRefreshReleaseOutcome.Released();
case ABSENT -> new CacheRefreshReleaseOutcome.AlreadyReleased();
case NOT_OWNER -> new CacheRefreshReleaseOutcome.NotOwner();
case WRONG_TYPE, INVALID ->
throw new RedisProgramCompatibilityException(
RedisProgramId.COMPARE_AND_DELETE, result.name());
};
} catch (RedisCommandFailureException exception) {
return exception.certainty() == RedisCommandFailureException.Certainty.NOT_APPLIED
? new CacheRefreshReleaseOutcome.Unavailable()
: new CacheRefreshReleaseOutcome.Indeterminate();
}
}
private String physicalKey(String semanticKey) {
if (semanticKey == null || semanticKey.isBlank()) {
throw new IllegalArgumentException("semantic cache key must be non-blank");
}
RedisKeyDigest digest =
RedisKeyDigest.sensitive(
namespace.hashKeyVersion(),
hmacSecret,
List.of(semanticKey.getBytes(StandardCharsets.UTF_8)));
return RedisKeyBuilder.build(namespace, digest);
}
@Override
public void close() {
if (destroyed.compareAndSet(false, true)) {
Arrays.fill(hmacSecret, (byte) 0);
}
}
private void ensureUsable() {
if (destroyed.get()) {
throw new IllegalStateException("Redis cache refresh coordinator is destroyed");
}
}
private String nextToken() {
String token = Objects.requireNonNull(tokens.get(), "generated token must be non-null");
if (!token.matches("[A-Za-z0-9_-]{16,63}")) {
throw new IllegalArgumentException(
"generated token must contain 16..63 Base64URL-safe characters");
}
return token;
}
private static byte[] ownerState(CacheRefreshClaimAttempt attempt) {
return (attempt.ownerToken().value() + "|" + attempt.operationToken().value())
.getBytes(StandardCharsets.US_ASCII);
}
private static void requireUsable(CacheRefreshClaimAttempt attempt) {
Objects.requireNonNull(attempt, "attempt must be non-null");
if (!attempt.usable()) {
throw new IllegalArgumentException("Redis refresh coordination requires a usable attempt");
}
}
private static RedisKeyNamespace refreshNamespace(RedisKeyNamespace namespace) {
Objects.requireNonNull(namespace, "namespace must be non-null");
return new RedisKeyNamespace(
namespace.application(),
namespace.environment(),
namespace.capability(),
namespace.region(),
namespace.hashKeyVersion(),
namespace.keyVersion(),
"refresh-lease",
namespace.maximumKeyBytes());
}
private static RedisAtomicPrimitives productionPrimitives(RedisBinaryCommands commands) {
RedisProgramCatalog catalog = RedisProgramCatalog.foundation();
return new RedisAtomicPrimitives(catalog, new RedisLuaProgramExecutor(catalog, commands));
}
private static String randomToken() {
byte[] random = new byte[16];
RANDOM.nextBytes(random);
return Base64.getUrlEncoder().withoutPadding().encodeToString(random);
}
private static RedisCapabilityObserver.Classification classifyClaim(
CacheRefreshClaimOutcome outcome) {
if (outcome instanceof CacheRefreshClaimOutcome.Claimed
|| outcome instanceof CacheRefreshClaimOutcome.AlreadyOwned) {
return classification(
RedisCapabilityObservationEvent.Outcome.SUCCESS,
RedisCapabilityObservationEvent.Certainty.DEFINITE);
}
if (outcome instanceof CacheRefreshClaimOutcome.Contended) {
return classification(
RedisCapabilityObservationEvent.Outcome.CONTENDED,
RedisCapabilityObservationEvent.Certainty.DEFINITE);
}
if (outcome instanceof CacheRefreshClaimOutcome.Indeterminate) {
return classification(
RedisCapabilityObservationEvent.Outcome.INDETERMINATE,
RedisCapabilityObservationEvent.Certainty.INDETERMINATE);
}
return classification(
RedisCapabilityObservationEvent.Outcome.UNAVAILABLE,
RedisCapabilityObservationEvent.Certainty.NOT_APPLIED);
}
private static RedisCapabilityObserver.Classification classifyRelease(
CacheRefreshReleaseOutcome outcome) {
if (outcome instanceof CacheRefreshReleaseOutcome.Released
|| outcome instanceof CacheRefreshReleaseOutcome.AlreadyReleased) {
return classification(
RedisCapabilityObservationEvent.Outcome.SUCCESS,
RedisCapabilityObservationEvent.Certainty.DEFINITE);
}
if (outcome instanceof CacheRefreshReleaseOutcome.NotOwner) {
return classification(
RedisCapabilityObservationEvent.Outcome.CONFLICT,
RedisCapabilityObservationEvent.Certainty.DEFINITE);
}
if (outcome instanceof CacheRefreshReleaseOutcome.Indeterminate) {
return classification(
RedisCapabilityObservationEvent.Outcome.INDETERMINATE,
RedisCapabilityObservationEvent.Certainty.INDETERMINATE);
}
return classification(
RedisCapabilityObservationEvent.Outcome.UNAVAILABLE,
RedisCapabilityObservationEvent.Certainty.NOT_APPLIED);
}
private static RedisCapabilityObserver.Classification classification(
RedisCapabilityObservationEvent.Outcome outcome,
RedisCapabilityObservationEvent.Certainty certainty) {
return new RedisCapabilityObserver.Classification(outcome, certainty);
}
}
@@ -1,250 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyNamespace;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.util.Arrays;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
/** Immutable key, TTL and envelope bounds for one semantic string cache region. */
final class RedisCacheRegionPolicy implements AutoCloseable {
private static final Duration MAXIMUM_TTL = Duration.ofDays(30);
private static final Duration COMPATIBILITY_MINIMUM_HARD_TTL = Duration.ofMillis(1);
private final RedisKeyNamespace namespace;
private final byte[] hmacSecret;
private final String policyRevision;
private final Duration positiveSoftTtl;
private final Duration positiveHardTtl;
private final Duration negativeTtl;
private final double jitterRatio;
private final Duration minimumHardTtl;
private final int maximumValueBytes;
private final AtomicBoolean destroyed = new AtomicBoolean();
/**
* Compatibility constructor for the existing single-positive-TTL settings contract.
*
* <p>It deliberately disables stale serving and jitter. New region bindings should use the full
* constructor so the effective policy revision and soft/hard bounds are explicit.
*/
RedisCacheRegionPolicy(
RedisKeyNamespace namespace,
byte[] hmacSecret,
Duration positiveTtl,
Duration negativeTtl,
int maximumValueBytes) {
this(
namespace,
hmacSecret,
"single-ttl-compatibility-r1",
positiveTtl,
positiveTtl,
negativeTtl,
0.0,
COMPATIBILITY_MINIMUM_HARD_TTL,
maximumValueBytes);
}
RedisCacheRegionPolicy(
RedisKeyNamespace namespace,
byte[] hmacSecret,
String policyRevision,
Duration positiveSoftTtl,
Duration positiveHardTtl,
Duration negativeTtl,
double jitterRatio,
Duration minimumHardTtl,
int maximumValueBytes) {
this.namespace = Objects.requireNonNull(namespace, "namespace must be non-null");
Objects.requireNonNull(hmacSecret, "hmacSecret must be non-null");
if (hmacSecret.length < 32) {
throw new IllegalArgumentException("hmacSecret must contain at least 32 bytes");
}
this.hmacSecret = hmacSecret.clone();
this.policyRevision = policyRevision(policyRevision);
this.positiveSoftTtl = positive(positiveSoftTtl, "positiveSoftTtl");
this.positiveHardTtl = positive(positiveHardTtl, "positiveHardTtl");
this.negativeTtl = positive(negativeTtl, "negativeTtl");
if (this.positiveSoftTtl.compareTo(this.positiveHardTtl) > 0) {
throw new IllegalArgumentException("positive soft TTL must not exceed positive hard TTL");
}
if (!Double.isFinite(jitterRatio) || jitterRatio < 0.0 || jitterRatio > 0.5) {
throw new IllegalArgumentException("jitter ratio must be finite and in 0.0..0.5");
}
this.jitterRatio = jitterRatio;
if (scale(this.positiveHardTtl, 1.0 + jitterRatio).compareTo(MAXIMUM_TTL) > 0
|| scale(this.negativeTtl, 1.0 + jitterRatio).compareTo(MAXIMUM_TTL) > 0) {
throw new IllegalArgumentException(
"configured hard TTL plus positive jitter must not exceed 30 days");
}
this.minimumHardTtl = positive(minimumHardTtl, "minimumHardTtl");
if (this.minimumHardTtl.compareTo(this.positiveHardTtl) > 0
|| this.minimumHardTtl.compareTo(this.negativeTtl) > 0) {
throw new IllegalArgumentException(
"minimum hard TTL must not exceed positive hard TTL or negative TTL");
}
if (maximumValueBytes < 1 || maximumValueBytes > 16_777_216) {
throw new IllegalArgumentException("maximumValueBytes must be in 1..16777216");
}
this.maximumValueBytes = maximumValueBytes;
}
RedisKeyNamespace namespace() {
return namespace;
}
byte[] hmacSecret() {
ensureUsable();
return hmacSecret.clone();
}
String policyRevision() {
return policyRevision;
}
Duration positiveSoftTtl() {
return positiveSoftTtl;
}
Duration positiveHardTtl() {
return positiveHardTtl;
}
/** Existing accessor retained while single-TTL runtime settings migrate to the full policy. */
Duration positiveTtl() {
return positiveHardTtl;
}
Duration negativeTtl() {
return negativeTtl;
}
Duration maximumEntryTimeToLive() {
Duration maximumConfigured =
positiveHardTtl.compareTo(negativeTtl) >= 0 ? positiveHardTtl : negativeTtl;
return scale(maximumConfigured, 1.0 + jitterRatio);
}
int maximumValueBytes() {
return maximumValueBytes;
}
@Override
public void close() {
if (destroyed.compareAndSet(false, true)) {
Arrays.fill(hmacSecret, (byte) 0);
}
}
PositiveExpiry positiveExpiry(byte[] hmacDerivedPhysicalKey) {
double factor = effectiveFactor(hmacDerivedPhysicalKey, "positive", positiveHardTtl);
Duration soft = scale(positiveSoftTtl, factor);
Duration hard = scale(positiveHardTtl, factor);
if (hard.compareTo(minimumHardTtl) < 0) {
hard = minimumHardTtl;
}
if (soft.compareTo(hard) > 0) {
soft = hard;
}
return new PositiveExpiry(soft, hard);
}
Duration negativeTimeToLive(byte[] hmacDerivedPhysicalKey) {
double factor = effectiveFactor(hmacDerivedPhysicalKey, "negative", negativeTtl);
Duration actual = scale(negativeTtl, factor);
return actual.compareTo(minimumHardTtl) < 0 ? minimumHardTtl : actual;
}
private double effectiveFactor(
byte[] hmacDerivedPhysicalKey, String expiryKind, Duration configuredHardTtl) {
Objects.requireNonNull(hmacDerivedPhysicalKey, "hmacDerivedPhysicalKey must be non-null");
if (hmacDerivedPhysicalKey.length == 0) {
throw new IllegalArgumentException("hmacDerivedPhysicalKey must not be empty");
}
double sampledFactor =
1.0 + (jitterRatio * symmetricSample(hmacDerivedPhysicalKey, expiryKind));
double minimumFactor =
((double) minimumHardTtl.toMillis()) / Math.max(1L, configuredHardTtl.toMillis());
return Math.max(sampledFactor, minimumFactor);
}
private double symmetricSample(byte[] hmacDerivedPhysicalKey, String expiryKind) {
byte[] kind = expiryKind.getBytes(StandardCharsets.UTF_8);
byte[] revision = policyRevision.getBytes(StandardCharsets.UTF_8);
ByteBuffer canonical =
ByteBuffer.allocate(
Integer.BYTES
+ hmacDerivedPhysicalKey.length
+ Integer.BYTES
+ revision.length
+ Integer.BYTES
+ kind.length);
canonical
.putInt(hmacDerivedPhysicalKey.length)
.put(hmacDerivedPhysicalKey)
.putInt(revision.length)
.put(revision)
.putInt(kind.length)
.put(kind);
long sampleBits = ByteBuffer.wrap(sha256(canonical.array())).getLong() >>> 11;
double unitInterval = sampleBits * 0x1.0p-53;
return (unitInterval * 2.0) - 1.0;
}
private static Duration scale(Duration configured, double factor) {
long configuredMillis = Math.max(1L, configured.toMillis());
long actualMillis = Math.max(1L, Math.round(configuredMillis * factor));
return Duration.ofMillis(actualMillis);
}
private static Duration positive(Duration value, String field) {
Objects.requireNonNull(value, field + " must be non-null");
if (value.isZero() || value.isNegative() || value.compareTo(MAXIMUM_TTL) > 0) {
throw new IllegalArgumentException(field + " must be positive and at most 30 days");
}
return value;
}
private static String policyRevision(String value) {
Objects.requireNonNull(value, "policyRevision must be non-null");
if (value.isBlank() || value.length() > 128) {
throw new IllegalArgumentException("policyRevision must contain 1..128 characters");
}
return value;
}
private static byte[] sha256(byte[] content) {
try {
return MessageDigest.getInstance("SHA-256").digest(content);
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 unavailable for cache TTL jitter", exception);
}
}
private void ensureUsable() {
if (destroyed.get()) {
throw new IllegalStateException("Redis cache region policy is destroyed");
}
}
record PositiveExpiry(Duration softTtl, Duration hardTtl) {
PositiveExpiry {
Objects.requireNonNull(softTtl, "softTtl must be non-null");
Objects.requireNonNull(hardTtl, "hardTtl must be non-null");
if (softTtl.isZero()
|| softTtl.isNegative()
|| hardTtl.isZero()
|| hardTtl.isNegative()
|| softTtl.compareTo(hardTtl) > 0) {
throw new IllegalArgumentException("positive expiry requires 0 < softTtl <= hardTtl");
}
}
}
}
@@ -1,111 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.application.cache.AuthoritativeAbsence;
import dev.caskeleton.application.cache.CacheInvalidationOutcome;
import dev.caskeleton.application.cache.CacheLookup;
import dev.caskeleton.application.cache.CacheRecordMetadata;
import dev.caskeleton.application.cache.CacheRecordOutcome;
import dev.caskeleton.application.cache.CacheRegionPort;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicBoolean;
/** Lifecycle-owning composition of the Redis L2 and its optional cache-only local decorator. */
final class RedisCacheRegionRuntime implements CacheRegionPort<String, String>, AutoCloseable {
private final CacheRegionPort<String, String> delegate;
private final RedisCacheL2Region l2;
private final RedisLocalCacheRegion local;
private final AtomicBoolean closed = new AtomicBoolean();
private RedisCacheRegionRuntime(
CacheRegionPort<String, String> delegate,
RedisCacheL2Region l2,
RedisLocalCacheRegion local) {
this.delegate = Objects.requireNonNull(delegate, "delegate must be non-null");
this.l2 = Objects.requireNonNull(l2, "l2 must be non-null");
this.local = local;
}
static RedisCacheRegionRuntime l2Only(RedisCacheL2Region l2) {
return new RedisCacheRegionRuntime(l2, l2, null);
}
static RedisCacheRegionRuntime local(RedisCacheL2Region l2, RedisLocalCacheRegion local) {
return new RedisCacheRegionRuntime(
Objects.requireNonNull(local, "local must be non-null"), l2, local);
}
Optional<RedisLocalCacheRegion> local() {
return Optional.ofNullable(local);
}
@Override
public CacheLookup<String> lookup(String key) {
ensureOpen();
return delegate.lookup(key);
}
@Override
public CacheRecordOutcome record(String key, String value, CacheRecordMetadata metadata) {
ensureOpen();
return delegate.record(key, value, metadata);
}
@Override
public CacheRecordOutcome recordAbsent(
String key, AuthoritativeAbsence reason, CacheRecordMetadata metadata) {
ensureOpen();
return delegate.recordAbsent(key, reason, metadata);
}
@Override
public CacheInvalidationOutcome invalidate(String key) {
ensureOpen();
return delegate.invalidate(key);
}
@Override
public CacheInvalidationOutcome invalidateRegion() {
ensureOpen();
return delegate.invalidateRegion();
}
@Override
public void close() {
if (closed.compareAndSet(false, true)) {
RuntimeException failure = null;
try {
if (local != null) {
local.close();
}
} catch (RuntimeException exception) {
failure = exception;
}
if (l2 instanceof AutoCloseable closeable) {
try {
closeable.close();
} catch (Exception exception) {
RuntimeException closeFailure =
exception instanceof RuntimeException runtimeException
? runtimeException
: new IllegalStateException("Redis cache L2 close failed", exception);
if (failure == null) {
failure = closeFailure;
} else {
failure.addSuppressed(closeFailure);
}
}
}
if (failure != null) {
throw failure;
}
}
}
private void ensureOpen() {
if (closed.get()) {
throw new IllegalStateException("Redis cache region runtime is closed");
}
}
}
@@ -1,48 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.core.CacheBackend;
import dev.caskeleton.adapter.outbound.cache.core.CacheBackendException;
import java.util.Optional;
/**
* Thin Redis binding of {@link CacheBackend} (active only when {@code
* app.cache.redis.enabled=true}). Delegates to the project-supplied {@link RedisClient} seam and
* wraps its checked failures into {@link CacheBackendException}. The fail-open contract (outage ==
* cache-miss, never a 5xx) lives in {@code FailOpenCacheStore}, which {@code CacheRouterConfig}
* composes around every contributed backend centrally — keeping the policy identical across all
* backends.
*/
public class RedisCacheStore implements CacheBackend {
/** Routing id referenced by {@code app.cache.bindings.*} values. */
public static final String BACKEND_ID = "redis";
private final RedisClient client;
public RedisCacheStore(RedisClient client) {
this.client = client;
}
@Override
public String backendId() {
return BACKEND_ID;
}
@Override
public Optional<String> get(String key) {
try {
return client.read(key);
} catch (Exception ex) {
throw new CacheBackendException(BACKEND_ID, ex);
}
}
@Override
public void put(String key, String value) {
try {
client.write(key, value);
} catch (Exception ex) {
throw new CacheBackendException(BACKEND_ID, ex);
}
}
}
@@ -1,24 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
/** Rejects ambiguous canonical/legacy activation and unapproved legacy production primaries. */
final class RedisCanonicalActivationValidator {
private RedisCanonicalActivationValidator() {}
static void validate(
boolean canonicalActive,
boolean legacyMigrationEnabled,
boolean legacyCacheEnabled,
boolean legacyRateLimitEnabled) {
boolean legacyActive = legacyCacheEnabled || legacyRateLimitEnabled;
if (canonicalActive && legacyActive) {
throw new IllegalStateException(
"Canonical and legacy Redis configuration cannot be active simultaneously; no precedence"
+ " is defined");
}
if (legacyActive && !legacyMigrationEnabled) {
throw new IllegalStateException(
"Legacy standalone Redis activation requires explicit migration input mode");
}
}
}
@@ -1,159 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.redis.config.RedisProviderSettings;
import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole;
import dev.caskeleton.adapter.outbound.cache.redis.security.RedisCredentialMaterialProvider;
import dev.caskeleton.application.cache.CacheObservationPort;
import dev.caskeleton.application.cache.DisabledCacheObservationPort;
import io.micrometer.core.instrument.MeterRegistry;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Duration;
import java.util.Arrays;
import java.util.Set;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/** Canonical default-region cache composition, isolated to the physical Redis CACHE role. */
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties({RedisCanonicalCacheSettings.class, RedisProviderSettings.class})
@ConditionalOnProperty(
name = "ca-skeleton.capabilities.cache.bindings.default",
havingValue = "redis",
matchIfMissing = false)
public class RedisCanonicalCacheConfig {
private static final int MAXIMUM_COMMAND_OVERHEAD_BYTES = 4096;
@Bean(name = "redisCanonicalDefaultCacheRegion", destroyMethod = "close")
@ConditionalOnProperty(
name = "ca-skeleton.capabilities.cache.bindings.default",
havingValue = "redis",
matchIfMissing = false)
RedisCacheRegionRuntime redisCanonicalDefaultCacheRegion(
RedisCanonicalCacheSettings settings,
RedisProviderSettings providerProperties,
RedisCanonicalRoleRegistry roleRegistry,
RedisCredentialMaterialProvider credentialProvider,
ObjectProvider<Clock> clockProvider,
ObjectProvider<MeterRegistry> meterRegistryProvider,
ObjectProvider<RedisCapabilityObservationPort> capabilityObservationsProvider) {
settings.validateActive();
validateCommandBound(settings, providerProperties.runtime());
Clock clock = clockProvider.getIfAvailable(Clock::systemUTC);
RedisCapabilityObservationPort capabilityObservations =
capabilityObservationsProvider.getIfUnique(NoOpRedisCapabilityObservationPort::instance);
RedisRoleCommandRouter router = roleRegistry.router(RedisRole.CACHE);
byte[] hmacSecret =
RedisHmacMaterialResolver.resolve(
settings.keyHmacSecretReference(), credentialProvider, clock, "cache");
RedisCacheRegionPolicy policy = null;
RedisStringCacheRegion l2 = null;
RedisCacheInvalidationMessage.Codec codec = null;
try {
policy =
new RedisCacheRegionPolicy(
settings.namespace(),
hmacSecret,
settings.policyRevision(),
settings.positiveSoftTtl(),
settings.positiveHardTtl(),
settings.negativeTtl(),
settings.ttlJitter(),
minimumHardTtl(settings),
settings.maximumValueBytes());
l2 =
new RedisStringCacheRegion(
policy, router, clock, capabilityObservations, System::nanoTime);
policy = null;
if (!settings.l1().enabled()) {
RedisCacheRegionRuntime runtime = RedisCacheRegionRuntime.l2Only(l2);
l2 = null;
return runtime;
}
MeterRegistry meterRegistry = meterRegistryProvider.getIfAvailable();
CacheObservationPort observations =
meterRegistry == null
? DisabledCacheObservationPort.instance()
: new MicrometerCacheObservationPort(
meterRegistry, Set.of(settings.semanticRegion()));
String channel = l2.invalidationChannel();
codec = new RedisCacheInvalidationMessage.Codec(hmacSecret);
RedisLocalCacheRegion local =
new RedisLocalCacheRegion(
settings.semanticRegion(),
l2,
settings.l1().policy(),
clock,
observations,
channel,
codec,
message ->
router.publish(
channel.getBytes(StandardCharsets.US_ASCII),
message.getBytes(StandardCharsets.US_ASCII)));
RedisCacheRegionRuntime runtime = RedisCacheRegionRuntime.local(l2, local);
l2 = null;
codec = null;
return runtime;
} finally {
Arrays.fill(hmacSecret, (byte) 0);
if (codec != null) {
codec.close();
}
if (l2 != null) {
l2.close();
}
if (policy != null) {
policy.close();
}
}
}
@Bean(name = "redisCanonicalDefaultCacheInvalidationSubscription", destroyMethod = "close")
@ConditionalOnProperty(
name = "ca-skeleton.capabilities.cache.regions.default.l1.enabled",
havingValue = "true",
matchIfMissing = false)
RedisCacheInvalidationSubscription redisCanonicalDefaultCacheInvalidationSubscription(
RedisCanonicalRoleRegistry roleRegistry,
@Qualifier("redisCanonicalDefaultCacheRegion") RedisCacheRegionRuntime cacheRegion) {
RedisLocalCacheRegion local =
cacheRegion
.local()
.orElseThrow(
() ->
new IllegalStateException(
"Canonical Redis L1 subscription requires the cache-only local decorator"));
return RedisCacheInvalidationSubscription.subscribe(
roleRegistry.router(RedisRole.CACHE),
local.invalidationChannel(),
local.invalidationMessageCodec(),
local.invalidationSubscriber());
}
private static void validateCommandBound(
RedisCanonicalCacheSettings settings, RedisProviderSettings.RuntimeProperties runtime) {
long required = (long) settings.maximumValueBytes() + MAXIMUM_COMMAND_OVERHEAD_BYTES;
if (required > runtime.maximumCommandBytes()) {
throw new IllegalArgumentException(
"Canonical Redis cache maximum value bytes exceed the CACHE router command bound");
}
}
private static Duration minimumHardTtl(RedisCanonicalCacheSettings settings) {
Duration minimum = Duration.ofSeconds(1);
if (minimum.compareTo(settings.positiveHardTtl()) > 0
|| minimum.compareTo(settings.negativeTtl()) > 0) {
return settings.positiveHardTtl().compareTo(settings.negativeTtl()) <= 0
? settings.positiveHardTtl()
: settings.negativeTtl();
}
return minimum;
}
}
@@ -1,166 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyNamespace;
import dev.caskeleton.adapter.outbound.cache.redis.security.RedisSecretReference;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.ConstructorBinding;
/**
* Canonical policy for the skeleton's default semantic Redis cache region.
*
* <p>Provider connection and authentication settings intentionally do not exist here. The CACHE
* role binding owns the topology router, while this capability policy owns only semantic cache
* behavior and a reference to HMAC key material.
*/
@ConfigurationProperties(prefix = "ca-skeleton.capabilities.cache.regions.default")
public record RedisCanonicalCacheSettings(
String keyHmacSecretReference,
String namespaceApplication,
String namespaceEnvironment,
String semanticRegion,
int hashKeyVersion,
int keyVersion,
String policyRevision,
Duration positiveSoftTtl,
Duration positiveHardTtl,
Duration negativeTtl,
Double ttlJitter,
int maximumValueBytes,
LocalProperties l1) {
private static final Duration MAXIMUM_TTL = Duration.ofDays(30);
@ConstructorBinding
public RedisCanonicalCacheSettings {
keyHmacSecretReference = keyHmacSecretReference == null ? "" : keyHmacSecretReference.trim();
namespaceApplication = defaultText(namespaceApplication, "ca-skeleton");
namespaceEnvironment = defaultText(namespaceEnvironment, "local");
semanticRegion = defaultText(semanticRegion, "default");
hashKeyVersion = hashKeyVersion == 0 ? 1 : hashKeyVersion;
keyVersion = keyVersion == 0 ? 1 : keyVersion;
policyRevision = defaultText(policyRevision, "canonical-default-r1");
positiveHardTtl =
positive(positiveHardTtl, Duration.ofMinutes(5), MAXIMUM_TTL, "positiveHardTtl");
positiveSoftTtl =
positive(
positiveSoftTtl,
positiveHardTtl.multipliedBy(4).dividedBy(5),
MAXIMUM_TTL,
"positiveSoftTtl");
negativeTtl = positive(negativeTtl, Duration.ofMinutes(1), MAXIMUM_TTL, "negativeTtl");
ttlJitter = ttlJitter == null ? 0.10d : ttlJitter;
maximumValueBytes = maximumValueBytes == 0 ? 61_440 : maximumValueBytes;
l1 = l1 == null ? LocalProperties.defaults() : l1;
if (positiveSoftTtl.compareTo(positiveHardTtl) > 0) {
throw new IllegalArgumentException("positiveSoftTtl must not exceed positiveHardTtl");
}
if (!Double.isFinite(ttlJitter) || ttlJitter < 0.0d || ttlJitter > 0.5d) {
throw new IllegalArgumentException("ttlJitter must be in 0.0..0.5");
}
if (policyRevision.length() > 128 || policyRevision.chars().anyMatch(Character::isISOControl)) {
throw new IllegalArgumentException("policyRevision must contain 1..128 safe characters");
}
if (maximumValueBytes < 1 || maximumValueBytes > 16_777_216) {
throw new IllegalArgumentException("maximumValueBytes must be in 1..16777216");
}
// Centralizes slug and key-version validation without retaining a duplicate rule set.
new RedisKeyNamespace(
namespaceApplication,
namespaceEnvironment,
"cache",
semanticRegion,
hashKeyVersion,
keyVersion,
"entry",
512);
}
void validateActive() {
RedisSecretReference.parse(keyHmacSecretReference);
}
RedisKeyNamespace namespace() {
return new RedisKeyNamespace(
namespaceApplication,
namespaceEnvironment,
"cache",
semanticRegion,
hashKeyVersion,
keyVersion,
"entry",
512);
}
public record LocalProperties(
boolean enabled,
int maximumEntries,
long maximumWeightBytes,
long maximumEntryWeightBytes,
Duration timeToLive,
Duration generationRecheckInterval,
int invalidationQueueCapacity) {
@ConstructorBinding
public LocalProperties {
maximumEntries = maximumEntries == 0 ? 10_000 : maximumEntries;
maximumWeightBytes = maximumWeightBytes == 0 ? 67_108_864L : maximumWeightBytes;
maximumEntryWeightBytes = maximumEntryWeightBytes == 0 ? 1_048_576L : maximumEntryWeightBytes;
timeToLive = timeToLive == null ? Duration.ofSeconds(30) : timeToLive;
generationRecheckInterval =
generationRecheckInterval == null ? Duration.ofSeconds(5) : generationRecheckInterval;
invalidationQueueCapacity = invalidationQueueCapacity == 0 ? 1024 : invalidationQueueCapacity;
policy(
maximumEntries,
maximumWeightBytes,
maximumEntryWeightBytes,
timeToLive,
generationRecheckInterval,
invalidationQueueCapacity);
}
RedisLocalCachePolicy policy() {
return policy(
maximumEntries,
maximumWeightBytes,
maximumEntryWeightBytes,
timeToLive,
generationRecheckInterval,
invalidationQueueCapacity);
}
private static LocalProperties defaults() {
return new LocalProperties(false, 0, 0, 0, null, null, 0);
}
private static RedisLocalCachePolicy policy(
int maximumEntries,
long maximumWeightBytes,
long maximumEntryWeightBytes,
Duration timeToLive,
Duration generationRecheckInterval,
int invalidationQueueCapacity) {
return new RedisLocalCachePolicy(
maximumEntries,
maximumWeightBytes,
maximumEntryWeightBytes,
timeToLive,
generationRecheckInterval,
invalidationQueueCapacity);
}
}
private static Duration positive(
Duration value, Duration fallback, Duration maximum, String field) {
Duration actual = value == null ? fallback : value;
if (actual.isZero() || actual.isNegative() || actual.compareTo(maximum) > 0) {
throw new IllegalArgumentException(field + " must be positive and bounded");
}
return actual;
}
private static String defaultText(String value, String fallback) {
return value == null || value.isBlank() ? fallback : value.trim();
}
}
@@ -1,181 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings;
import dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettingsFactory;
import dev.caskeleton.adapter.outbound.cache.redis.config.RedisProviderSettings;
import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole;
import dev.caskeleton.adapter.outbound.cache.redis.security.RedisCredentialMaterialProvider;
import dev.caskeleton.adapter.outbound.cache.redis.security.RedisTrustMaterialProvider;
import dev.caskeleton.shared.health.RedisHealthSnapshotProvider;
import io.micrometer.core.instrument.MeterRegistry;
import java.time.Clock;
import java.util.EnumMap;
import java.util.EnumSet;
import java.util.Map;
import java.util.Set;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
/**
* Canonical Redis composition root.
*
* <p>Provider definitions alone are inert. Only an explicit role binding resolves material and
* opens a topology-native client.
*/
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(RedisProviderSettings.class)
public class RedisCanonicalConfig {
@Bean
RedisCapabilityObservationPort redisCapabilityObservationPort(
ObjectProvider<MeterRegistry> meterRegistryProvider) {
MeterRegistry registry = meterRegistryProvider.getIfAvailable();
RedisCapabilityObservationPort delegate =
registry == null
? NoOpRedisCapabilityObservationPort.instance()
: new MicrometerRedisCapabilityObservationPort(registry);
return new SafeRedisCapabilityObservationPort(delegate);
}
@Bean(name = "redisCanonicalRoleRegistry", destroyMethod = "close")
RedisCanonicalRoleRegistry redisCanonicalRoleRegistry(
RedisProviderSettings properties,
Environment environment,
ObjectProvider<RedisCredentialMaterialProvider> credentialProvider,
ObjectProvider<RedisTrustMaterialProvider> trustProvider,
ObjectProvider<Clock> clockProvider,
ObjectProvider<RedisRuntimeConnector> connectorProvider,
ObjectProvider<RedisSentinelRuntimeConnector> sentinelConnectorProvider,
RedisCapabilityObservationPort observations) {
Map<RedisRole, Set<RedisHealthSnapshotProvider.Capability>> selectedCapabilities =
selectedCapabilities(environment);
Map<dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole, RedisDeploymentSettings>
active =
new RedisDeploymentSettingsFactory()
.compileActive(properties, selectedRoles(selectedCapabilities));
RedisCanonicalActivationValidator.validate(
!active.isEmpty(),
properties.legacyMigrationEnabled(),
environment.getProperty("app.cache.redis.enabled", Boolean.class, false),
environment.getProperty("app.rate-limit.legacy-standalone-enabled", Boolean.class, false));
RedisProviderSettings.RuntimeProperties runtime = properties.runtime();
Clock clock = clockProvider.getIfAvailable(Clock::systemUTC);
RedisRuntimeConnector connector =
connectorProvider.getIfAvailable(
() ->
deployment ->
connect(
deployment,
runtime,
requiredUnique(credentialProvider, "Redis credential material provider"),
requiredUnique(trustProvider, "Redis trust material provider"),
clock));
RedisSentinelRuntimeConnector sentinelConnector =
active.values().stream().anyMatch(RedisDeploymentSettings.Sentinel.class::isInstance)
? sentinelConnectorProvider.getIfAvailable(
() ->
new DefaultRedisSentinelRuntimeConnector(
runtime.clientSettings(),
runtime.maximumCommandBytes(),
requiredUnique(credentialProvider, "Redis credential material provider"),
requiredUnique(trustProvider, "Redis trust material provider"),
clock))
: null;
return new RedisCanonicalRoleRegistry(
active,
runtime.clientSettings(),
runtime.maximumInFlightCommands(),
runtime.maximumCommandBytes(),
runtime.maximumInFlightBytes(),
runtime.routeDrainTimeout(),
runtime.defaultWriteTtl(),
connector::connect,
properties.roles(),
selectedCapabilities,
clock,
runtime.semanticProbeMinimumInterval(),
runtime.semanticProbeMaximumStaleness(),
System::nanoTime,
observations,
sentinelConnector,
runtime.sentinelDiscoveryRefreshPeriod(),
BoundedRedisSentinelRefreshWorker::new);
}
private static RedisRoutableCommandRuntime connect(
RedisDeploymentSettings deployment,
RedisProviderSettings.RuntimeProperties runtime,
RedisCredentialMaterialProvider credentialProvider,
RedisTrustMaterialProvider trustProvider,
Clock clock) {
return RedisTopologyCommandRuntime.connect(
deployment,
runtime.clientSettings(),
runtime.maximumCommandBytes(),
credentialProvider,
trustProvider,
clock);
}
private static <T> T requiredUnique(ObjectProvider<T> provider, String capability) {
T instance = provider.getIfUnique();
if (instance == null) {
throw new IllegalStateException(
capability + " must have exactly one bean for a canonically bound Redis role");
}
return instance;
}
public static Map<RedisRole, Set<RedisHealthSnapshotProvider.Capability>> selectedCapabilities(
Environment environment) {
Map<RedisRole, Set<RedisHealthSnapshotProvider.Capability>> selected =
new EnumMap<>(RedisRole.class);
EnumSet<RedisHealthSnapshotProvider.Capability> cache =
EnumSet.noneOf(RedisHealthSnapshotProvider.Capability.class);
if (selected(environment, "ca-skeleton.capabilities.cache.bindings.default", "redis")) {
cache.add(RedisHealthSnapshotProvider.Capability.CACHE);
}
selected.put(RedisRole.CACHE, Set.copyOf(cache));
EnumSet<RedisHealthSnapshotProvider.Capability> coordination =
EnumSet.noneOf(RedisHealthSnapshotProvider.Capability.class);
if (selected(environment, "ca-skeleton.capabilities.rate-limit.provider", "redis")) {
coordination.add(RedisHealthSnapshotProvider.Capability.RATE_LIMIT);
}
if (selected(environment, "ca-skeleton.capabilities.idempotency.provider", "redis")) {
coordination.add(RedisHealthSnapshotProvider.Capability.IDEMPOTENCY);
}
if (selected(environment, "ca-skeleton.capabilities.lease.provider", "redis")) {
coordination.add(RedisHealthSnapshotProvider.Capability.EFFICIENCY_LEASE);
}
selected.put(RedisRole.COORDINATION, Set.copyOf(coordination));
EnumSet<RedisHealthSnapshotProvider.Capability> session =
EnumSet.noneOf(RedisHealthSnapshotProvider.Capability.class);
if (selected(environment, "ca-skeleton.security.auth-mode", "redis-session")) {
session.add(RedisHealthSnapshotProvider.Capability.SESSION);
}
selected.put(RedisRole.SESSION, Set.copyOf(session));
return Map.copyOf(selected);
}
private static Set<RedisRole> selectedRoles(
Map<RedisRole, Set<RedisHealthSnapshotProvider.Capability>> capabilities) {
EnumSet<RedisRole> roles = EnumSet.noneOf(RedisRole.class);
capabilities.forEach(
(role, selectedCapabilities) -> {
if (!selectedCapabilities.isEmpty()) {
roles.add(role);
}
});
return Set.copyOf(roles);
}
private static boolean selected(Environment environment, String property, String expected) {
return expected.equalsIgnoreCase(environment.getProperty(property, ""));
}
}
@@ -1,758 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings;
import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole;
import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRoleBinding;
import dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings;
import dev.caskeleton.shared.health.RedisHealthSnapshotProvider;
import java.time.Clock;
import java.time.Duration;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.LongSupplier;
/** Owns exactly the command routers selected by canonical Redis role bindings. */
final class RedisCanonicalRoleRegistry implements AutoCloseable, RedisHealthSnapshotProvider {
private static final Duration SENTINEL_CLEANUP_COMPLETION_MARGIN = Duration.ofMillis(100);
@FunctionalInterface
interface RuntimeFactory {
RedisRoutableCommandRuntime connect(RedisDeploymentSettings deployment);
}
private final Map<RedisRole, RedisRoleCommandRouter> routers;
private final Map<RedisRole, RedisSemanticProbeObservationCache> observations;
private final Map<RedisRole, RedisRoleBinding> bindings;
private final Map<RedisRole, Set<Capability>> capabilities;
private final Map<RedisRole, RedisSemanticProbePlan> probePlans;
private final Map<RedisRole, RecoveryState> recoveries;
private final RedisSemanticReadinessProbe semanticProbe;
private final RuntimeFactory runtimeFactory;
private final Clock clock;
private final Duration probeTimeout;
private final Duration drainTimeout;
private final int maximumInFlight;
private final int maximumCommandBytes;
private final long maximumInFlightBytes;
private final Duration defaultWriteTtl;
private final LongSupplier ticker;
private final RedisCapabilityObservationPort observationsPort;
private final RedisSentinelFailoverCoordinator failoverCoordinator;
private final AtomicBoolean closed = new AtomicBoolean();
RedisCanonicalRoleRegistry(
Map<RedisRole, RedisDeploymentSettings> activeDeployments,
RedisClientRuntimeSettings clientSettings,
int maximumInFlight,
int maximumCommandBytes,
long maximumInFlightBytes,
Duration drainTimeout,
Duration defaultWriteTtl,
RuntimeFactory runtimeFactory) {
this(
activeDeployments,
clientSettings,
maximumInFlight,
maximumCommandBytes,
maximumInFlightBytes,
drainTimeout,
defaultWriteTtl,
runtimeFactory,
Map.of(),
Map.of(),
Clock.systemUTC());
}
RedisCanonicalRoleRegistry(
Map<RedisRole, RedisDeploymentSettings> activeDeployments,
RedisClientRuntimeSettings clientSettings,
int maximumInFlight,
int maximumCommandBytes,
long maximumInFlightBytes,
Duration drainTimeout,
Duration defaultWriteTtl,
RuntimeFactory runtimeFactory,
Map<RedisRole, RedisRoleBinding> bindings,
Map<RedisRole, Set<Capability>> capabilities,
Clock clock) {
this(
activeDeployments,
clientSettings,
maximumInFlight,
maximumCommandBytes,
maximumInFlightBytes,
drainTimeout,
defaultWriteTtl,
runtimeFactory,
bindings,
capabilities,
clock,
Duration.ofSeconds(5),
Duration.ofSeconds(15),
System::nanoTime,
NoOpRedisCapabilityObservationPort.instance());
}
RedisCanonicalRoleRegistry(
Map<RedisRole, RedisDeploymentSettings> activeDeployments,
RedisClientRuntimeSettings clientSettings,
int maximumInFlight,
int maximumCommandBytes,
long maximumInFlightBytes,
Duration drainTimeout,
Duration defaultWriteTtl,
RuntimeFactory runtimeFactory,
Map<RedisRole, RedisRoleBinding> bindings,
Map<RedisRole, Set<Capability>> capabilities,
Clock clock,
Duration semanticProbeMinimumInterval,
Duration semanticProbeMaximumStaleness,
LongSupplier ticker) {
this(
activeDeployments,
clientSettings,
maximumInFlight,
maximumCommandBytes,
maximumInFlightBytes,
drainTimeout,
defaultWriteTtl,
runtimeFactory,
bindings,
capabilities,
clock,
semanticProbeMinimumInterval,
semanticProbeMaximumStaleness,
ticker,
NoOpRedisCapabilityObservationPort.instance(),
null,
Duration.ofSeconds(30),
BoundedRedisSentinelRefreshWorker::new);
}
RedisCanonicalRoleRegistry(
Map<RedisRole, RedisDeploymentSettings> activeDeployments,
RedisClientRuntimeSettings clientSettings,
int maximumInFlight,
int maximumCommandBytes,
long maximumInFlightBytes,
Duration drainTimeout,
Duration defaultWriteTtl,
RuntimeFactory runtimeFactory,
Map<RedisRole, RedisRoleBinding> bindings,
Map<RedisRole, Set<Capability>> capabilities,
Clock clock,
Duration semanticProbeMinimumInterval,
Duration semanticProbeMaximumStaleness,
LongSupplier ticker,
RedisCapabilityObservationPort observationsPort) {
this(
activeDeployments,
clientSettings,
maximumInFlight,
maximumCommandBytes,
maximumInFlightBytes,
drainTimeout,
defaultWriteTtl,
runtimeFactory,
bindings,
capabilities,
clock,
semanticProbeMinimumInterval,
semanticProbeMaximumStaleness,
ticker,
observationsPort,
null,
Duration.ofSeconds(30),
BoundedRedisSentinelRefreshWorker::new);
}
RedisCanonicalRoleRegistry(
Map<RedisRole, RedisDeploymentSettings> activeDeployments,
RedisClientRuntimeSettings clientSettings,
int maximumInFlight,
int maximumCommandBytes,
long maximumInFlightBytes,
Duration drainTimeout,
Duration defaultWriteTtl,
RuntimeFactory runtimeFactory,
Map<RedisRole, RedisRoleBinding> bindings,
Map<RedisRole, Set<Capability>> capabilities,
Clock clock,
Duration semanticProbeMinimumInterval,
Duration semanticProbeMaximumStaleness,
LongSupplier ticker,
RedisCapabilityObservationPort observationsPort,
RedisSentinelRuntimeConnector sentinelConnector,
Duration sentinelDiscoveryRefreshPeriod,
RedisSentinelFailoverCoordinator.WorkerFactory workerFactory) {
Objects.requireNonNull(ticker, "ticker must be non-null");
Objects.requireNonNull(activeDeployments, "activeDeployments must be non-null");
Objects.requireNonNull(clientSettings, "clientSettings must be non-null");
Objects.requireNonNull(runtimeFactory, "runtimeFactory must be non-null");
Objects.requireNonNull(bindings, "bindings must be non-null");
Map<RedisRole, RedisRoleBinding> activeBindings = new EnumMap<>(RedisRole.class);
activeDeployments.forEach(
(role, ignored) -> {
RedisRoleBinding binding = bindings.get(role);
if (binding != null) {
activeBindings.put(role, binding);
}
});
this.bindings = Map.copyOf(activeBindings);
Map<RedisRole, Set<Capability>> safeCapabilities = new EnumMap<>(RedisRole.class);
Objects.requireNonNull(capabilities, "capabilities must be non-null")
.forEach((role, values) -> safeCapabilities.put(role, Set.copyOf(values)));
this.capabilities = Map.copyOf(safeCapabilities);
this.clock = Objects.requireNonNull(clock, "clock must be non-null");
this.runtimeFactory = runtimeFactory;
this.ticker = ticker;
this.observationsPort =
new SafeRedisCapabilityObservationPort(
Objects.requireNonNull(observationsPort, "observationsPort must be non-null"));
this.semanticProbe = RedisSemanticReadinessProbe.system(this.clock);
Map<RedisRole, RedisSemanticProbePlan> plans = new EnumMap<>(RedisRole.class);
activeBindings.forEach(
(role, ignored) ->
plans.put(
role,
RedisSemanticProbePlan.forRole(
role, this.capabilities.getOrDefault(role, Set.of()))));
this.probePlans = Map.copyOf(plans);
this.probeTimeout = clientSettings.commandTimeout();
this.drainTimeout = Objects.requireNonNull(drainTimeout, "drainTimeout must be non-null");
this.maximumInFlight = maximumInFlight;
this.maximumCommandBytes = maximumCommandBytes;
this.maximumInFlightBytes = maximumInFlightBytes;
this.defaultWriteTtl =
Objects.requireNonNull(defaultWriteTtl, "defaultWriteTtl must be non-null");
if (drainTimeout.compareTo(clientSettings.overallTimeout().plusMillis(100)) < 0) {
throw new IllegalArgumentException(
"Redis route drain timeout must include the runtime overall timeout and a 100ms safety"
+ " margin");
}
activeDeployments.forEach(RedisCanonicalRoleRegistry::rejectUnsupportedTopology);
Map<RedisRole, RedisDeploymentSettings.Sentinel> sentinelDeployments =
sentinelDeployments(activeDeployments);
if (!sentinelDeployments.isEmpty() && sentinelConnector == null) {
throw new IllegalStateException(
"Redis Sentinel refresh connector is required for every active Sentinel role");
}
Map<RedisRole, RedisRoleCommandRouter> created = new EnumMap<>(RedisRole.class);
Map<RedisRole, RedisSemanticProbeObservationCache> createdObservations =
new EnumMap<>(RedisRole.class);
Map<RedisRole, RecoveryState> createdRecoveries = new EnumMap<>(RedisRole.class);
AtomicReference<RedisSentinelFailoverCoordinator> coordinatorReference =
new AtomicReference<>();
RedisSentinelFailoverCoordinator createdCoordinator = null;
try {
activeDeployments.forEach(
(role, deployment) -> {
RedisRoleCommandRouter.TopologyFailureListener topologyFailureListener =
deployment instanceof RedisDeploymentSettings.Sentinel
? (failedRoute, failure) -> {
RedisSentinelFailoverCoordinator coordinator = coordinatorReference.get();
if (coordinator != null) {
coordinator.requestRecovery(role, failedRoute);
}
}
: RedisRoleCommandRouter.TopologyFailureListener.ignore();
RedisRoutableCommandRuntime runtime;
try {
runtime =
deployment instanceof RedisDeploymentSettings.Sentinel sentinel
? connectSentinel(sentinelConnector, sentinel)
: runtimeFactory.connect(deployment);
} catch (RedisTemporaryConnectionException temporary) {
if (!isOptionalCache(role)) {
throw temporary;
}
installDormant(
role,
deployment,
created,
createdObservations,
createdRecoveries,
semanticProbeMinimumInterval,
semanticProbeMaximumStaleness,
ticker,
topologyFailureListener);
return;
}
RedisRoleCommandRouter router = newRouter(role, runtime, topologyFailureListener);
try {
RedisSemanticProbePlan plan = probePlans.get(role);
if (plan == null) {
router.probe(probeTimeout);
} else {
RedisSemanticReadinessProbe.Result qualification =
semanticProbe.probeResult(plan, router);
if (qualification.disposition()
== RedisSemanticReadinessProbe.Disposition.RETRYABLE_TRANSPORT
&& isOptionalCache(role)) {
router.close();
installDormant(
role,
deployment,
created,
createdObservations,
createdRecoveries,
semanticProbeMinimumInterval,
semanticProbeMaximumStaleness,
ticker,
topologyFailureListener);
return;
}
if (qualification.disposition()
!= RedisSemanticReadinessProbe.Disposition.SUCCEEDED) {
throw new IllegalStateException(
"Redis semantic qualification failed: " + qualification.reason().name());
}
RedisSemanticProbeObservationCache observation =
new RedisSemanticProbeObservationCache(
semanticProbeMinimumInterval,
semanticProbeMaximumStaleness,
this.clock,
ticker);
observation.seed(qualification.reason());
createdObservations.put(role, observation);
}
created.put(role, router);
} catch (RuntimeException exception) {
router.close();
throw exception;
}
});
if (!sentinelDeployments.isEmpty()) {
createdCoordinator =
new RedisSentinelFailoverCoordinator(
sentinelDeployments,
created,
sentinelConnector,
this::qualifyCandidate,
this::observeSentinelInstall,
probeTimeout,
drainTimeout,
sentinelDiscoveryRefreshPeriod,
longer(
clientSettings.shutdownTimeout().plus(SENTINEL_CLEANUP_COMPLETION_MARGIN),
drainTimeout),
Objects.requireNonNull(workerFactory, "workerFactory must be non-null"));
coordinatorReference.set(createdCoordinator);
}
} catch (RuntimeException exception) {
if (createdCoordinator != null) {
createdCoordinator.close();
}
created.values().forEach(RedisRoleCommandRouter::close);
throw exception;
}
this.routers = Map.copyOf(created);
this.observations = Map.copyOf(createdObservations);
this.recoveries = Map.copyOf(createdRecoveries);
this.failoverCoordinator = createdCoordinator;
}
Set<RedisRole> boundRoles() {
return routers.keySet();
}
boolean isClosed() {
return closed.get();
}
RedisRoleCommandRouter router(RedisRole role) {
RedisRoleCommandRouter router =
routers.get(Objects.requireNonNull(role, "role must be non-null"));
if (router == null) {
throw new IllegalStateException("Redis role is not canonically bound: " + role);
}
return router;
}
RedisRoleCommandRouter.SwapResult rotate(RedisRole role, RedisRoutableCommandRuntime candidate) {
Objects.requireNonNull(candidate, "candidate must be non-null");
RedisSemanticProbePlan plan = probePlans.get(role);
if (plan == null) {
return router(role).swap(candidate, probeTimeout, drainTimeout);
}
RedisRoleCommandRouter qualificationRouter =
new RedisRoleCommandRouter(
role,
candidate,
maximumInFlight,
maximumCommandBytes,
maximumInFlightBytes,
drainTimeout,
defaultWriteTtl);
Reason qualification = semanticProbe.probe(plan, qualificationRouter);
if (qualification != Reason.SEMANTIC_PROBE_SUCCEEDED) {
qualificationRouter.close();
return RedisRoleCommandRouter.SwapResult.PROBE_FAILED;
}
RedisRoutableCommandRuntime qualified =
qualificationRouter.releaseQualifiedRuntimeForTransfer();
RedisRoleCommandRouter.SwapResult result;
try {
result = router(role).swap(qualified, probeTimeout, drainTimeout);
} catch (RuntimeException failure) {
closeQuietly(qualified);
throw failure;
}
if (result != RedisRoleCommandRouter.SwapResult.PROBE_FAILED) {
observations.get(role).seed(Reason.SEMANTIC_PROBE_SUCCEEDED);
}
return result;
}
void probe(RedisRole role) {
router(role).probe(probeTimeout);
}
@Override
public Snapshot snapshot() {
List<RoleHealth> roles = new ArrayList<>(bindings.size());
for (RedisRole role : RedisRole.values()) {
RedisRoleBinding binding = bindings.get(role);
if (binding != null) {
roles.add(probeHealth(role, binding));
}
}
return new Snapshot(clock.instant(), roles);
}
@Override
public void close() {
if (closed.compareAndSet(false, true)) {
if (failoverCoordinator != null) {
failoverCoordinator.close();
}
recoveries.values().forEach(recovery -> recovery.markTerminal(terminalClosed()));
routers.values().forEach(RedisRoleCommandRouter::close);
}
}
private static void rejectUnsupportedTopology(
RedisRole role, RedisDeploymentSettings deployment) {
if (role == RedisRole.SESSION && deployment instanceof RedisDeploymentSettings.Cluster) {
throw new UnsupportedOperationException(
"Redis SESSION role cannot use Cluster until session rotation preserves one hash slot");
}
}
private RoleHealth probeHealth(RedisRole role, RedisRoleBinding binding) {
RedisRoleCommandRouter router = router(role);
RedisSemanticProbeObservationCache observationCache = observations.get(role);
RedisSemanticProbeObservationCache.Observation observation;
RecoveryState recovery = recoveries.get(role);
if (closed.get()) {
observation = observationCache.seed(Reason.ROUTE_CLOSED);
} else if (recovery != null && !recovery.active()) {
observation =
recovery.deployment() instanceof RedisDeploymentSettings.Sentinel
? observationCache.seed(Reason.COMMAND_UNAVAILABLE)
: observationCache.observe(() -> recover(role, recovery).reason());
} else if (router.isClosed()) {
observation = observationCache.seed(Reason.ROUTE_CLOSED);
} else if (router.hadRecentCommandFailure()) {
observation = observationCache.seed(Reason.RECENT_COMMAND_FAILURE);
} else {
observation =
observationCache.observe(() -> semanticProbe.probe(probePlans.get(role), router));
}
if (closed.get() && observation.reason() != Reason.ROUTE_CLOSED) {
observation = observationCache.seed(Reason.ROUTE_CLOSED);
}
Reason reason = observation.reason();
State state =
switch (reason) {
case SEMANTIC_PROBE_SUCCEEDED -> State.AVAILABLE;
case COMMAND_SATURATED -> State.OVERLOADED;
default -> State.UNAVAILABLE;
};
RoleHealth health =
new RoleHealth(
Role.valueOf(role.name()),
binding.deploymentId(),
binding.required(),
EvictionPolicy.valueOf(
binding.expectedEviction().trim().replace('-', '_').toUpperCase(Locale.ROOT)),
EvictionAttestation.CONFIGURED_EXPECTATION_ONLY,
capabilities.getOrDefault(role, Set.of()),
state,
reason,
observation.observedAt(),
observation.age().toMillis(),
observation.stale());
capabilities
.getOrDefault(role, Set.of())
.forEach(
capability ->
observationsPort.observe(
new RedisCapabilityObservationEvent.ReadinessObserved(
RedisCapabilityObservationEvent.Capability.valueOf(capability.name()),
RedisCapabilityObservationEvent.Role.valueOf(health.role().name()),
health.state(),
health.reason(),
health.required()
? RedisCapabilityObservationEvent.Requirement.REQUIRED
: RedisCapabilityObservationEvent.Requirement.OPTIONAL)));
return health;
}
private RedisSemanticReadinessProbe.Result recover(RedisRole role, RecoveryState recovery) {
if (closed.get()) {
return recovery.markTerminal(terminalClosed());
}
RedisSemanticReadinessProbe.Result terminal = recovery.terminal();
if (terminal != null) {
return terminal;
}
RedisRoutableCommandRuntime candidate;
try {
candidate = runtimeFactory.connect(recovery.deployment());
} catch (RedisTemporaryConnectionException temporary) {
return retryableUnavailable();
} catch (RuntimeException permanent) {
return recovery.markTerminal(terminalUnavailable());
}
if (closed.get()) {
closeQuietly(candidate);
return recovery.markTerminal(terminalClosed());
}
RedisRoleCommandRouter qualificationRouter = newRouter(role, candidate);
RedisSemanticReadinessProbe.Result qualification =
semanticProbe.probeResult(probePlans.get(role), qualificationRouter);
if (closed.get()) {
qualificationRouter.close();
return recovery.markTerminal(terminalClosed());
}
if (qualification.disposition() == RedisSemanticReadinessProbe.Disposition.TERMINAL_CONTRACT) {
qualificationRouter.close();
return recovery.markTerminal(qualification);
}
if (qualification.disposition()
== RedisSemanticReadinessProbe.Disposition.RETRYABLE_TRANSPORT) {
qualificationRouter.close();
return qualification;
}
RedisRoutableCommandRuntime qualified =
qualificationRouter.releaseQualifiedRuntimeForTransfer();
if (closed.get()) {
closeQuietly(qualified);
return recovery.markTerminal(terminalClosed());
}
RedisRoleCommandRouter.SwapResult swap;
try {
swap = router(role).swap(qualified, probeTimeout, drainTimeout);
} catch (RuntimeException failure) {
closeQuietly(qualified);
return closed.get() ? recovery.markTerminal(terminalClosed()) : retryableUnavailable();
}
if (swap == RedisRoleCommandRouter.SwapResult.PROBE_FAILED) {
return retryableUnavailable();
}
if (closed.get() || !recovery.markActive()) {
return recovery.markTerminal(terminalClosed());
}
return qualification;
}
private void installDormant(
RedisRole role,
RedisDeploymentSettings deployment,
Map<RedisRole, RedisRoleCommandRouter> created,
Map<RedisRole, RedisSemanticProbeObservationCache> createdObservations,
Map<RedisRole, RecoveryState> createdRecoveries,
Duration minimumInterval,
Duration maximumStaleness,
LongSupplier ticker,
RedisRoleCommandRouter.TopologyFailureListener topologyFailureListener) {
created.put(
role,
newRouter(
role,
new RedisDormantCommandRuntime(deployment.deploymentId()),
topologyFailureListener));
RedisSemanticProbeObservationCache observation =
new RedisSemanticProbeObservationCache(minimumInterval, maximumStaleness, clock, ticker);
observation.seed(Reason.COMMAND_UNAVAILABLE);
createdObservations.put(role, observation);
createdRecoveries.put(role, new RecoveryState(deployment));
}
private RedisRoleCommandRouter newRouter(RedisRole role, RedisRoutableCommandRuntime runtime) {
return newRouter(role, runtime, RedisRoleCommandRouter.TopologyFailureListener.ignore());
}
private RedisRoleCommandRouter newRouter(
RedisRole role,
RedisRoutableCommandRuntime runtime,
RedisRoleCommandRouter.TopologyFailureListener topologyFailureListener) {
return new RedisRoleCommandRouter(
role,
runtime,
maximumInFlight,
maximumCommandBytes,
maximumInFlightBytes,
drainTimeout,
defaultWriteTtl,
ticker,
observationsPort,
RedisDrainWaiter.system(),
topologyFailureListener);
}
private RedisSentinelFailoverCoordinator.CandidateQualification qualifyCandidate(
RedisRole role, RedisRoutableCommandRuntime candidate) {
Objects.requireNonNull(candidate, "candidate must be non-null");
if (closed.get()) {
return RedisSentinelFailoverCoordinator.CandidateQualification.REJECTED;
}
RedisSemanticProbePlan plan = probePlans.get(role);
if (plan == null) {
return RedisSentinelFailoverCoordinator.CandidateQualification.ACCEPTED;
}
RedisRoleCommandRouter qualificationRouter;
try {
qualificationRouter = newRouter(role, candidate);
} catch (RuntimeException failure) {
return RedisSentinelFailoverCoordinator.CandidateQualification.REJECTED;
}
RedisSemanticReadinessProbe.Result qualification;
try {
qualification = semanticProbe.probeResult(plan, qualificationRouter);
} catch (RuntimeException failure) {
detachQualificationRouter(qualificationRouter);
return RedisSentinelFailoverCoordinator.CandidateQualification.REJECTED;
}
if (!detachQualificationRouter(qualificationRouter)) {
return RedisSentinelFailoverCoordinator.CandidateQualification.REJECTED;
}
return qualification.disposition() == RedisSemanticReadinessProbe.Disposition.SUCCEEDED
&& !closed.get()
? RedisSentinelFailoverCoordinator.CandidateQualification.ACCEPTED
: RedisSentinelFailoverCoordinator.CandidateQualification.REJECTED;
}
private void observeSentinelInstall(RedisRole role, RedisRoleCommandRouter.SwapResult result) {
if (result == RedisRoleCommandRouter.SwapResult.DRAINED
|| result == RedisRoleCommandRouter.SwapResult.FORCED_AFTER_TIMEOUT) {
RedisSemanticProbeObservationCache observation = observations.get(role);
if (observation != null) {
observation.seed(Reason.SEMANTIC_PROBE_SUCCEEDED);
}
RecoveryState recovery = recoveries.get(role);
if (recovery != null) {
recovery.markActive();
}
}
}
private static boolean detachQualificationRouter(RedisRoleCommandRouter qualificationRouter) {
try {
qualificationRouter.releaseQualifiedRuntimeForTransfer();
return true;
} catch (RuntimeException failure) {
return false;
}
}
private static RedisRoutableCommandRuntime connectSentinel(
RedisSentinelRuntimeConnector connector, RedisDeploymentSettings.Sentinel deployment) {
RedisSentinelDiscoveredRoute route = connector.discover(deployment);
return connector.connect(deployment, route);
}
private static Map<RedisRole, RedisDeploymentSettings.Sentinel> sentinelDeployments(
Map<RedisRole, RedisDeploymentSettings> deployments) {
EnumMap<RedisRole, RedisDeploymentSettings.Sentinel> sentinels = new EnumMap<>(RedisRole.class);
deployments.forEach(
(role, deployment) -> {
if (deployment instanceof RedisDeploymentSettings.Sentinel sentinel) {
sentinels.put(role, sentinel);
}
});
return Map.copyOf(sentinels);
}
private static Duration longer(Duration first, Duration second) {
return first.compareTo(second) >= 0 ? first : second;
}
private boolean isOptionalCache(RedisRole role) {
RedisRoleBinding binding = bindings.get(role);
return role == RedisRole.CACHE && binding != null && !binding.required();
}
private static RedisSemanticReadinessProbe.Result retryableUnavailable() {
return new RedisSemanticReadinessProbe.Result(
Reason.COMMAND_UNAVAILABLE, RedisSemanticReadinessProbe.Disposition.RETRYABLE_TRANSPORT);
}
private static RedisSemanticReadinessProbe.Result terminalUnavailable() {
return new RedisSemanticReadinessProbe.Result(
Reason.COMMAND_UNAVAILABLE, RedisSemanticReadinessProbe.Disposition.TERMINAL_CONTRACT);
}
private static RedisSemanticReadinessProbe.Result terminalClosed() {
return new RedisSemanticReadinessProbe.Result(
Reason.ROUTE_CLOSED, RedisSemanticReadinessProbe.Disposition.TERMINAL_CONTRACT);
}
private static void closeQuietly(RedisRoutableCommandRuntime runtime) {
try {
runtime.close();
} catch (RuntimeException ignored) {
// Recovery cleanup cannot expose provider detail through health.
}
}
private static final class RecoveryState {
private final RedisDeploymentSettings deployment;
private volatile RedisSemanticReadinessProbe.Result terminal;
private volatile boolean active;
private RecoveryState(RedisDeploymentSettings deployment) {
this.deployment = deployment;
}
private synchronized RedisDeploymentSettings deployment() {
return deployment;
}
private synchronized RedisSemanticReadinessProbe.Result terminal() {
return terminal;
}
private synchronized boolean active() {
return active;
}
private synchronized RedisSemanticReadinessProbe.Result markTerminal(
RedisSemanticReadinessProbe.Result result) {
if (!active && terminal == null) {
terminal = result;
}
return terminal == null ? result : terminal;
}
private synchronized boolean markActive() {
if (terminal != null) {
return false;
}
active = true;
return true;
}
}
}
@@ -1,175 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.shared.health.RedisHealthSnapshotProvider;
import java.time.Duration;
import java.util.Objects;
/** Closed, identity-free operational facts emitted only inside the Redis adapter leaf. */
final class RedisCapabilityObservationEvent {
static final long MAXIMUM_DURATION_NANOS = Duration.ofMinutes(5).toNanos();
static final int MAXIMUM_IN_FLIGHT_COMMANDS = 4096;
static final long MAXIMUM_IN_FLIGHT_BYTES = 268_435_456L;
private RedisCapabilityObservationEvent() {}
sealed interface Event
permits OperationCompleted, AdmissionChanged, ReadinessObserved, LifecycleDrainCompleted {}
record OperationCompleted(
Capability capability,
Role role,
Operation operation,
Outcome outcome,
Certainty certainty,
long durationNanos)
implements Event {
public OperationCompleted {
Objects.requireNonNull(capability, "capability must be non-null");
Objects.requireNonNull(role, "role must be non-null");
Objects.requireNonNull(operation, "operation must be non-null");
Objects.requireNonNull(outcome, "outcome must be non-null");
Objects.requireNonNull(certainty, "certainty must be non-null");
if (durationNanos < 0 || durationNanos > MAXIMUM_DURATION_NANOS) {
throw new IllegalArgumentException("durationNanos must be non-negative and bounded");
}
}
}
record AdmissionChanged(
Role role,
AdmissionState admission,
InFlightState state,
int inFlightCommands,
long inFlightBytes)
implements Event {
public AdmissionChanged {
Objects.requireNonNull(role, "role must be non-null");
Objects.requireNonNull(admission, "admission must be non-null");
Objects.requireNonNull(state, "state must be non-null");
if (inFlightCommands < 0 || inFlightCommands > MAXIMUM_IN_FLIGHT_COMMANDS) {
throw new IllegalArgumentException("inFlightCommands must be non-negative and bounded");
}
if (inFlightBytes < 0 || inFlightBytes > MAXIMUM_IN_FLIGHT_BYTES) {
throw new IllegalArgumentException("inFlightBytes must be non-negative and bounded");
}
}
}
record ReadinessObserved(
Capability capability,
Role role,
RedisHealthSnapshotProvider.State state,
RedisHealthSnapshotProvider.Reason reason,
Requirement requirement)
implements Event {
public ReadinessObserved {
Objects.requireNonNull(capability, "capability must be non-null");
Objects.requireNonNull(role, "role must be non-null");
Objects.requireNonNull(state, "state must be non-null");
Objects.requireNonNull(reason, "reason must be non-null");
Objects.requireNonNull(requirement, "requirement must be non-null");
}
}
record LifecycleDrainCompleted(Role role, DrainOutcome drainOutcome) implements Event {
public LifecycleDrainCompleted {
Objects.requireNonNull(role, "role must be non-null");
Objects.requireNonNull(drainOutcome, "drainOutcome must be non-null");
}
}
enum Capability {
CACHE,
RATE_LIMIT,
IDEMPOTENCY,
EFFICIENCY_LEASE,
SESSION,
RUNTIME
}
enum Role {
CACHE,
COORDINATION,
SESSION
}
enum Operation {
LOOKUP,
RECORD,
INVALIDATE,
REFRESH_CLAIM,
REFRESH_RELEASE,
RATE_EVALUATE,
IDEMPOTENCY_CLAIM,
IDEMPOTENCY_START,
IDEMPOTENCY_RENEW,
IDEMPOTENCY_COMPLETE,
IDEMPOTENCY_FAIL,
IDEMPOTENCY_RELEASE,
IDEMPOTENCY_INSPECT,
LEASE_ACQUIRE,
LEASE_INSPECT,
LEASE_RENEW,
LEASE_RELEASE,
SESSION_CREATE,
SESSION_INSPECT,
SESSION_SAVE,
SESSION_TOUCH,
SESSION_REVOKE,
SESSION_ROTATE,
ROUTE_COMMAND
}
enum Outcome {
SUCCESS,
HIT,
MISS,
DENIED,
CONTENDED,
CONFLICT,
INCOMPATIBLE,
UNAVAILABLE,
OVERLOADED,
CLOSED,
INDETERMINATE,
STALE,
SKIPPED,
TOMBSTONED,
ABSOLUTE_EXPIRED
}
enum Certainty {
DEFINITE,
NOT_APPLIED,
INDETERMINATE
}
enum AdmissionState {
ADMITTED,
REJECTED_SATURATED,
REJECTED_CLOSED,
NOT_APPLICABLE
}
enum InFlightState {
IDLE,
ACTIVE,
SATURATED
}
enum Requirement {
OPTIONAL,
REQUIRED
}
enum DrainOutcome {
DRAINED,
FORCED_AFTER_TIMEOUT,
INTERRUPTED
}
}
@@ -1,7 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
@FunctionalInterface
interface RedisCapabilityObservationPort {
void observe(RedisCapabilityObservationEvent.Event event);
}
@@ -1,125 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.util.Objects;
import java.util.function.Function;
import java.util.function.LongSupplier;
import java.util.function.Supplier;
/** Measures one logical semantic operation without accepting request identity or wire material. */
final class RedisCapabilityObserver {
private static final long UNAVAILABLE_TICK = Long.MIN_VALUE;
private final RedisCapabilityObservationPort observations;
private final LongSupplier ticker;
RedisCapabilityObserver(RedisCapabilityObservationPort observations, LongSupplier ticker) {
this.observations =
new SafeRedisCapabilityObservationPort(
Objects.requireNonNull(observations, "observations must be non-null"));
this.ticker = Objects.requireNonNull(ticker, "ticker must be non-null");
}
static RedisCapabilityObserver disabled() {
return new RedisCapabilityObserver(
NoOpRedisCapabilityObservationPort.instance(), System::nanoTime);
}
<T> T observe(
RedisCapabilityObservationEvent.Capability capability,
RedisCapabilityObservationEvent.Role role,
RedisCapabilityObservationEvent.Operation operation,
Supplier<T> action,
Function<T, Classification> classifier) {
return observe(
capability,
role,
operation,
action,
classifier,
ignored ->
new Classification(
RedisCapabilityObservationEvent.Outcome.UNAVAILABLE,
RedisCapabilityObservationEvent.Certainty.NOT_APPLIED));
}
<T> T observe(
RedisCapabilityObservationEvent.Capability capability,
RedisCapabilityObservationEvent.Role role,
RedisCapabilityObservationEvent.Operation operation,
Supplier<T> action,
Function<T, Classification> classifier,
Function<RuntimeException, Classification> failureClassifier) {
Objects.requireNonNull(action, "action must be non-null");
Objects.requireNonNull(classifier, "classifier must be non-null");
Objects.requireNonNull(failureClassifier, "failureClassifier must be non-null");
long started = safeTick();
T result;
try {
result = action.get();
} catch (RuntimeException failure) {
Classification failureClassification;
try {
failureClassification =
Objects.requireNonNull(
failureClassifier.apply(failure), "failure classification must be non-null");
} catch (RuntimeException diagnosticFailure) {
throw failure;
}
completedSafely(capability, role, operation, failureClassification, started);
throw failure;
}
Classification classification;
try {
classification =
Objects.requireNonNull(classifier.apply(result), "classification must be non-null");
} catch (RuntimeException diagnosticFailure) {
return result;
}
completedSafely(capability, role, operation, classification, started);
return result;
}
private void completedSafely(
RedisCapabilityObservationEvent.Capability capability,
RedisCapabilityObservationEvent.Role role,
RedisCapabilityObservationEvent.Operation operation,
Classification classification,
long started) {
try {
long finished = safeTick();
long elapsed =
started == UNAVAILABLE_TICK || finished == UNAVAILABLE_TICK ? 0L : finished - started;
long bounded =
Math.min(RedisCapabilityObservationEvent.MAXIMUM_DURATION_NANOS, Math.max(0L, elapsed));
observations.observe(
new RedisCapabilityObservationEvent.OperationCompleted(
capability,
role,
operation,
classification.outcome(),
classification.certainty(),
bounded));
} catch (RuntimeException ignored) {
// Diagnostic timing/event construction cannot change the authoritative command result.
}
}
private long safeTick() {
try {
return ticker.getAsLong();
} catch (RuntimeException ignored) {
return UNAVAILABLE_TICK;
}
}
record Classification(
RedisCapabilityObservationEvent.Outcome outcome,
RedisCapabilityObservationEvent.Certainty certainty) {
Classification {
Objects.requireNonNull(outcome, "outcome must be non-null");
Objects.requireNonNull(certainty, "certainty must be non-null");
}
}
}
@@ -1,321 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.function.Supplier;
/**
* Validated catalog-owned invocation. It is the only program identity carried through command
* ports; raw SHA, Lua source and raw key collections never cross those ports.
*/
final class RedisCatalogProgramInvocation {
enum ReplyShape {
VALUE,
READ_ONLY_VALUE,
MULTI,
READ_ONLY_MULTI
}
private final RedisProgramDescriptor descriptor;
private final byte[] exactScript;
private final String externalId;
private final List<Key> keys;
private final List<Argument> arguments;
private final ReplyShape replyShape;
private final int encodedBytes;
private final Supplier<Duration> remainingBudget;
private RedisCatalogProgramInvocation(
RedisProgramCatalog owner,
RedisProgramDescriptor descriptor,
List<byte[]> keys,
List<byte[]> arguments,
ReplyShape replyShape,
Supplier<Duration> remainingBudget) {
Objects.requireNonNull(owner, "owner must be non-null");
this.descriptor = Objects.requireNonNull(descriptor, "descriptor must be non-null");
this.exactScript = descriptor.scriptBytes();
this.externalId = descriptor.id().externalId();
if (owner.descriptor(descriptor.id()) != descriptor) {
throw new IllegalArgumentException("Redis program descriptor is not owned by this catalog");
}
Objects.requireNonNull(keys, "keys must be non-null");
Objects.requireNonNull(arguments, "arguments must be non-null");
if (keys.size() != descriptor.keyCount() || arguments.size() != descriptor.argumentCount()) {
throw new IllegalArgumentException("Redis program signature does not match descriptor");
}
List<Key> safeKeys = new ArrayList<>(keys.size());
long bytes = 0;
for (byte[] key : keys) {
if (key == null || key.length < 1 || key.length > descriptor.maximumKeyBytes()) {
throw new IllegalArgumentException("Redis program key is out of bounds");
}
safeKeys.add(new Key(key));
bytes += key.length;
}
List<Argument> safeArguments = new ArrayList<>(arguments.size());
for (byte[] argument : arguments) {
if (argument == null
|| argument.length < 1
|| argument.length > descriptor.maximumArgumentBytes()) {
throw new IllegalArgumentException("Redis program argument is out of bounds");
}
Argument safeArgument = new Argument(argument);
safeArguments.add(safeArgument);
bytes += safeArgument.encodedLength();
}
if (bytes > Integer.MAX_VALUE) {
throw new IllegalArgumentException("Redis program invocation is too large");
}
this.keys = List.copyOf(safeKeys);
this.arguments = List.copyOf(safeArguments);
this.replyShape = Objects.requireNonNull(replyShape, "replyShape must be non-null");
this.encodedBytes = (int) bytes;
this.remainingBudget = remainingBudget;
}
private RedisCatalogProgramInvocation(RedisSemanticReadinessProbe.AclProbeMaterial material) {
this.descriptor = null;
this.externalId = "semantic-capability-acl-v1";
this.exactScript = RedisSemanticAclProbeCatalog.scriptBytes();
List<byte[]> keys = material.copyKeys();
Objects.requireNonNull(keys, "keys must be non-null");
List<Key> safeKeys = new ArrayList<>(keys.size());
long bytes = 0;
for (byte[] key : keys) {
if (key == null || key.length < 1 || key.length > 512) {
throw new IllegalArgumentException("Redis program key is out of bounds");
}
safeKeys.add(new Key(key));
bytes += key.length;
}
byte[] encodedCapability =
Objects.requireNonNull(material.capability(), "capability must be non-null")
.name()
.getBytes(java.nio.charset.StandardCharsets.US_ASCII);
List<Argument> safeArguments = List.of(new Argument(encodedCapability));
bytes += encodedCapability.length;
if (bytes > Integer.MAX_VALUE) {
throw new IllegalArgumentException("Redis program invocation is too large");
}
this.keys = List.copyOf(safeKeys);
this.arguments = List.copyOf(safeArguments);
this.replyShape = ReplyShape.READ_ONLY_VALUE;
this.encodedBytes = (int) bytes;
this.remainingBudget = null;
}
static RedisCatalogProgramInvocation capabilityOwned(
RedisProgramCatalog owner, RedisCatalogProgramMaterial material, ReplyShape replyShape) {
RedisProgramDescriptor descriptor = owner.descriptor(material.programId());
return new RedisCatalogProgramInvocation(
owner, descriptor, material.copyKeys(), material.copyArguments(), replyShape, null);
}
static RedisCatalogProgramInvocation primitiveOwned(
RedisProgramCatalog owner,
RedisProgramDescriptor descriptor,
RedisPrimitiveInvocation primitive,
ReplyShape replyShape) {
if (primitive.descriptor().programId() != descriptor.id()) {
throw new IllegalArgumentException("primitive program identity is inconsistent");
}
return new RedisCatalogProgramInvocation(
owner,
descriptor,
primitiveKeys(primitive),
primitiveArguments(descriptor, primitive),
replyShape,
primitive::remainingDeadline);
}
static RedisCatalogProgramInvocation boundedGetOwned(
RedisProgramCatalog owner,
RedisProgramDescriptor descriptor,
RedisPhysicalKey key,
int maximumValueBytes) {
if (descriptor.id() != RedisProgramId.BOUNDED_GET_V1) {
throw new IllegalArgumentException("bounded GET descriptor is required");
}
return new RedisCatalogProgramInvocation(
owner,
descriptor,
List.of(RedisPhysicalKey.WireCodec.copy(key)),
List.of(
Integer.toString(maximumValueBytes)
.getBytes(java.nio.charset.StandardCharsets.US_ASCII)),
ReplyShape.READ_ONLY_VALUE,
null);
}
static RedisCatalogProgramInvocation semanticAclProbe(
RedisSemanticReadinessProbe.AclProbeMaterial material) {
return new RedisCatalogProgramInvocation(
Objects.requireNonNull(material, "semantic ACL material must be non-null"));
}
private static List<byte[]> primitiveKeys(RedisPrimitiveInvocation primitive) {
return primitive.keys().stream()
.map(RedisPrimitiveKey::physicalKey)
.map(RedisPhysicalKey.WireCodec::copy)
.toList();
}
private static List<byte[]> primitiveArguments(
RedisProgramDescriptor descriptor, RedisPrimitiveInvocation primitive) {
if (descriptor.id() == RedisProgramId.BOUNDED_GET_V1) {
return List.of(
Integer.toString(primitive.descriptor().maximumValueBytes())
.getBytes(java.nio.charset.StandardCharsets.US_ASCII));
}
if (!(primitive.arguments() instanceof RedisPrimitiveInvocation.ProgramArguments arguments)) {
throw new IllegalArgumentException("primitive program arguments are not closed");
}
return arguments.programValues().stream().map(RedisPrimitiveValue::copyEncoded).toList();
}
RedisProgramDescriptor descriptor() {
if (descriptor == null) {
throw new IllegalStateException("Redis exact program has no manifest descriptor");
}
return descriptor;
}
RedisProgramId programIdOrNull() {
return descriptor == null ? null : descriptor.id();
}
String externalId() {
return externalId;
}
private byte[] copyExactScript() {
return exactScript.clone();
}
ReplyShape replyShape() {
return replyShape;
}
int keyCount() {
return keys.size();
}
int argumentCount() {
return arguments.size();
}
private byte[] copyArgument(int index) {
return arguments.get(index).copyEncoded();
}
int encodedBytes() {
return encodedBytes;
}
Duration boundedTimeout(Duration defaultTimeout) {
Objects.requireNonNull(defaultTimeout, "defaultTimeout must be non-null");
if (remainingBudget == null) {
return defaultTimeout;
}
Duration remaining = remainingBudget.get();
return remaining.compareTo(defaultTimeout) < 0 ? remaining : defaultTimeout;
}
private byte[][] copyKeysArray() {
byte[][] result = new byte[keys.size()][];
for (int index = 0; index < keys.size(); index++) {
result[index] = keys.get(index).copyEncoded();
}
return result;
}
private List<byte[]> copyKeys() {
List<byte[]> result = new ArrayList<>(keys.size());
for (Key key : keys) {
result.add(key.copyEncoded());
}
return List.copyOf(result);
}
private List<byte[]> copyArguments() {
List<byte[]> result = new ArrayList<>(arguments.size());
for (Argument argument : arguments) {
result.add(argument.copyEncoded());
}
return List.copyOf(result);
}
String sha1() {
return RedisScriptRecovery.sha1(exactScript);
}
private byte[][] copyArgumentsArray() {
byte[][] result = new byte[arguments.size()][];
for (int index = 0; index < arguments.size(); index++) {
result[index] = arguments.get(index).copyEncoded();
}
return result;
}
private static final class Argument {
private final byte[] encoded;
private Argument(byte[] encoded) {
this.encoded = encoded.clone();
}
private int encodedLength() {
return encoded.length;
}
private byte[] copyEncoded() {
return encoded.clone();
}
}
private static final class Key {
private final byte[] encoded;
private Key(byte[] encoded) {
this.encoded = encoded.clone();
}
private byte[] copyEncoded() {
return encoded.clone();
}
}
/** Sole terminal wire unwrap; every byte is derived from an already validated invocation. */
static final class WireCodec {
private WireCodec() {}
static byte[] exactScript(RedisCatalogProgramInvocation invocation) {
return invocation.copyExactScript();
}
static byte[] argument(RedisCatalogProgramInvocation invocation, int index) {
return invocation.copyArgument(index);
}
static byte[][] keysArray(RedisCatalogProgramInvocation invocation) {
return invocation.copyKeysArray();
}
static byte[][] argumentsArray(RedisCatalogProgramInvocation invocation) {
return invocation.copyArgumentsArray();
}
static List<byte[]> keys(RedisCatalogProgramInvocation invocation) {
return invocation.copyKeys();
}
static List<byte[]> arguments(RedisCatalogProgramInvocation invocation) {
return invocation.copyArguments();
}
}
}
@@ -1,27 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.util.List;
/**
* Closed capability-owned material consumed only while constructing a catalog invocation.
*
* <p>Every permitted implementation has a private constructor in its semantic owner. No command
* executor receives this raw material and no arbitrary package peer can implement the contract.
*/
sealed interface RedisCatalogProgramMaterial
permits RedisAtomicPrimitives.ProgramMaterial,
RedisEdgeRateLimitProvider.ProgramInvocation,
RedisEfficiencyLeaseProvider.ProgramInvocation,
RedisEfficiencyLeaseHandle.ProgramInvocation,
RedisIdempotencyStoreProvider.ProgramInvocation,
RedisLuaVersionedSessionStore.ProgramInvocation,
RedisSemanticReadinessProbe.ProgramInvocation {
RedisProgramId programId();
RedisCatalogProgramInvocation.ReplyShape replyShape();
List<byte[]> copyKeys();
List<byte[]> copyArguments();
}
@@ -1,43 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.util.ArrayList;
import java.util.List;
/** Bounded defensive reply from one catalog invocation. */
final class RedisCatalogProgramReply {
private final byte[] value;
private final List<byte[]> fields;
private RedisCatalogProgramReply(byte[] value, List<byte[]> fields) {
this.value = value == null ? null : value.clone();
this.fields = defensive(fields);
}
static RedisCatalogProgramReply value(byte[] value) {
return new RedisCatalogProgramReply(value, List.of());
}
static RedisCatalogProgramReply multi(List<byte[]> fields) {
return new RedisCatalogProgramReply(null, fields);
}
byte[] copyValue() {
return value == null ? null : value.clone();
}
List<byte[]> copyFields() {
return defensive(fields);
}
private static List<byte[]> defensive(List<byte[]> fields) {
if (fields == null || fields.isEmpty()) {
return List.of();
}
List<byte[]> safe = new ArrayList<>(fields.size());
for (byte[] field : fields) {
safe.add(field == null ? null : field.clone());
}
return List.copyOf(safe);
}
}
@@ -1,27 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.util.Optional;
/**
* Integration seam the forking project implements to bind the Redis cache template to a real
* client. The skeleton carries no Redis SDK dependency — the implementation is supplied by the
* project that enables Redis. Implementations may throw on a backend outage; the fail-open handling
* is done by {@code FailOpenCacheStore} (module README).
*/
public interface RedisClient {
/**
* Reads a value from Redis.
*
* @return the value, or empty if absent
* @throws Exception on a backend/connection failure (handled fail-open by the store)
*/
Optional<String> read(String key) throws Exception;
/**
* Writes a value to Redis.
*
* @throws Exception on a backend/connection failure (handled fail-open by the store)
*/
void write(String key, String value) throws Exception;
}
@@ -1,45 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.util.concurrent.Semaphore;
import java.util.concurrent.atomic.AtomicBoolean;
/** Immediate dual count/byte admission for commands retained by the managed connection. */
final class RedisCommandAdmission {
private final Semaphore commands;
private final Semaphore bytes;
RedisCommandAdmission(int maximumCommands, int maximumBytes) {
commands = new Semaphore(maximumCommands);
bytes = new Semaphore(maximumBytes);
}
Lease tryAcquire(int reservationBytes) {
if (reservationBytes < 1 || !commands.tryAcquire()) {
return null;
}
if (!bytes.tryAcquire(reservationBytes)) {
commands.release();
return null;
}
return new Lease(reservationBytes);
}
final class Lease implements AutoCloseable {
private final int reservationBytes;
private final AtomicBoolean closed = new AtomicBoolean();
private Lease(int reservationBytes) {
this.reservationBytes = reservationBytes;
}
@Override
public void close() {
if (closed.compareAndSet(false, true)) {
bytes.release(reservationBytes);
commands.release();
}
}
}
}
@@ -1,53 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.util.Objects;
/** Adapter-internal transport failure with explicit overload and mutation certainty. */
final class RedisCommandFailureException extends RuntimeException {
private static final long serialVersionUID = 1L;
private final Kind kind;
private final Certainty certainty;
private final RecoveryHint recoveryHint;
RedisCommandFailureException(Kind kind, Certainty certainty, String message, Throwable cause) {
this(kind, certainty, RecoveryHint.NONE, message, cause);
}
RedisCommandFailureException(
Kind kind, Certainty certainty, RecoveryHint recoveryHint, String message, Throwable cause) {
super(message, cause);
this.kind = Objects.requireNonNull(kind, "kind must be non-null");
this.certainty = Objects.requireNonNull(certainty, "certainty must be non-null");
this.recoveryHint = Objects.requireNonNull(recoveryHint, "recoveryHint must be non-null");
}
Kind kind() {
return kind;
}
Certainty certainty() {
return certainty;
}
RecoveryHint recoveryHint() {
return recoveryHint;
}
enum Kind {
UNAVAILABLE,
OVERLOADED,
ACL_DENIED
}
enum Certainty {
NOT_APPLIED,
INDETERMINATE
}
enum RecoveryHint {
NONE,
REDISCOVER_SENTINEL
}
}
@@ -1,57 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.time.Duration;
import java.util.Objects;
/** Adapter-internal immutable connection/admission profile for one dedicated Redis role. */
record RedisConnectionProfile(
String host,
int port,
String password,
Duration commandTimeout,
Duration legacyTtl,
int maximumReadableValueBytes,
int maximumCommandBytes,
int maximumQueuedCommands,
int maximumInFlightBytes) {
private static final int LEGACY_COMMAND_OVERHEAD_BYTES = 4_096;
RedisConnectionProfile {
Objects.requireNonNull(host, "host must be non-null");
Objects.requireNonNull(password, "password must be non-null");
Objects.requireNonNull(commandTimeout, "commandTimeout must be non-null");
Objects.requireNonNull(legacyTtl, "legacyTtl must be non-null");
if (maximumReadableValueBytes < 1 || maximumCommandBytes < 1) {
throw new IllegalArgumentException("Redis byte bounds must be positive");
}
}
static RedisConnectionProfile cache(RedisRuntimeSettings settings) {
Objects.requireNonNull(settings, "settings must be non-null");
return new RedisConnectionProfile(
settings.host(),
settings.port(),
settings.password(),
settings.commandTimeout(),
settings.positiveTtl(),
settings.maximumReadableValueBytes(),
settings.maximumCommandBytes(),
settings.maximumQueuedCommands(),
settings.maximumInFlightBytes());
}
static RedisConnectionProfile rateLimit(RedisLegacyStandaloneSettings settings) {
Objects.requireNonNull(settings, "settings must be non-null");
return new RedisConnectionProfile(
settings.host(),
settings.port(),
settings.password(),
settings.commandTimeout(),
Duration.ofSeconds(1),
settings.maximumCommandBytes() - LEGACY_COMMAND_OVERHEAD_BYTES,
settings.maximumCommandBytes(),
settings.maximumQueuedCommands(),
settings.maximumInFlightBytes());
}
}
@@ -1,40 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.time.Duration;
import java.util.List;
import java.util.Objects;
/** Signed exact counter helpers; increment is atomic with initial TTL. */
final class RedisCounterPrimitives {
private final RedisPrimitiveCatalog catalog;
private final RedisPrimitiveExecutor executor;
RedisCounterPrimitives(RedisPrimitiveCatalog catalog, RedisPrimitiveCommands commands) {
this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null");
this.executor = new RedisPrimitiveExecutor(catalog, commands);
}
RedisPrimitiveKey key(String slot, String identity) {
return catalog.keyFactory(RedisPrimitiveId.COUNTER_READ).key(slot, identity);
}
RedisPrimitiveReply read(RedisPrimitiveKey key) {
return executor.execute(
RedisPrimitiveId.COUNTER_READ, List.of(key), RedisPrimitiveInvocation.NoArguments.INSTANCE);
}
RedisCounterResult increment(
RedisPrimitiveKey key, long delta, long minimum, long maximum, Duration initialTimeToLive) {
try {
return RedisCounterResult.from(
executor.execute(
RedisPrimitiveId.COUNTER_INCREMENT_INITIAL_TTL,
List.of(key),
new RedisPrimitiveInvocation.CounterArguments(
delta, minimum, maximum, initialTimeToLive)));
} catch (RedisCommandFailureException failure) {
return RedisCounterResult.failed(failure);
}
}
}
@@ -1,63 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.util.OptionalLong;
/**
* Exact signed counter outcome; the resulting value is never reinterpreted as an affected count.
*/
record RedisCounterResult(
Status status, Certainty certainty, OptionalLong value, String diagnosticCode) {
enum Status {
UPDATED,
LIMIT_EXCEEDED,
OVERFLOW,
MISSING_TTL,
MALFORMED_VALUE,
WRONG_TYPE,
INVALID,
UNKNOWN
}
enum Certainty {
APPLIED,
NOT_APPLIED,
INDETERMINATE
}
RedisCounterResult {
if (status == null || certainty == null || value == null) {
throw new IllegalArgumentException("counter result is invalid");
}
diagnosticCode = diagnosticCode == null ? "" : diagnosticCode;
}
static RedisCounterResult from(RedisPrimitiveReply reply) {
Status status =
switch (reply.status()) {
case UPDATED -> Status.UPDATED;
case LIMIT_EXCEEDED -> Status.LIMIT_EXCEEDED;
case OVERFLOW -> Status.OVERFLOW;
case MISSING_TTL -> Status.MISSING_TTL;
case MALFORMED_VALUE -> Status.MALFORMED_VALUE;
case WRONG_TYPE -> Status.WRONG_TYPE;
case INVALID, TTL_APPLY_FAILED -> Status.INVALID;
default -> Status.UNKNOWN;
};
return new RedisCounterResult(
status,
status == Status.UPDATED ? Certainty.APPLIED : Certainty.NOT_APPLIED,
reply.signedNumber(),
reply.diagnosticCode());
}
static RedisCounterResult failed(RedisCommandFailureException failure) {
return new RedisCounterResult(
Status.UNKNOWN,
failure.certainty() == RedisCommandFailureException.Certainty.INDETERMINATE
? Certainty.INDETERMINATE
: Certainty.NOT_APPLIED,
OptionalLong.empty(),
"");
}
}
@@ -1,85 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings;
import dev.caskeleton.adapter.outbound.cache.redis.security.RedisRotatableRuntime;
import java.time.Duration;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
/** Lifecycle-safe Redis deployment runtime with no public native command surface. */
final class RedisDeploymentRuntime implements RedisRotatableRuntime {
public enum Topology {
STANDALONE,
SENTINEL,
CLUSTER
}
public record Timeouts(
Duration connect, Duration acquire, Duration command, Duration overall, Duration shutdown) {
public Timeouts {
Objects.requireNonNull(connect, "connect must be non-null");
Objects.requireNonNull(acquire, "acquire must be non-null");
Objects.requireNonNull(command, "command must be non-null");
Objects.requireNonNull(overall, "overall must be non-null");
Objects.requireNonNull(shutdown, "shutdown must be non-null");
}
}
private final String deploymentId;
private final Topology topology;
private final Timeouts timeouts;
private final RedisNativeClientHandle nativeClient;
private final RedisLettuceUris credentialOwner;
private final AtomicBoolean closed = new AtomicBoolean();
RedisDeploymentRuntime(
String deploymentId,
Topology topology,
RedisClientRuntimeSettings settings,
RedisNativeClientHandle nativeClient,
RedisLettuceUris credentialOwner) {
this.deploymentId = Objects.requireNonNull(deploymentId, "deploymentId must be non-null");
this.topology = Objects.requireNonNull(topology, "topology must be non-null");
Objects.requireNonNull(settings, "settings must be non-null");
this.timeouts =
new Timeouts(
settings.connectTimeout(),
settings.acquireTimeout(),
settings.commandTimeout(),
settings.overallTimeout(),
settings.shutdownTimeout());
this.nativeClient = Objects.requireNonNull(nativeClient, "nativeClient must be non-null");
this.credentialOwner =
Objects.requireNonNull(credentialOwner, "credentialOwner must be non-null");
}
@Override
public String deploymentId() {
return deploymentId;
}
public Topology topology() {
return topology;
}
public Timeouts timeouts() {
return timeouts;
}
public boolean isClosed() {
return closed.get();
}
@Override
public void close() {
if (closed.compareAndSet(false, true)) {
try {
nativeClient.close(timeouts.shutdown());
} finally {
credentialOwner.close();
}
}
}
}
@@ -1,118 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings;
import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole;
import dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings;
import dev.caskeleton.adapter.outbound.cache.redis.security.RedisSslOptionsFactory;
import io.lettuce.core.SslOptions;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
/** Creates one topology-native runtime only for an explicitly bound Redis role. */
final class RedisDeploymentRuntimeFactory {
private final RedisLettuceUriFactory uriFactory;
private final RedisSslOptionsFactory sslOptionsFactory;
private final RedisLettuceClientOptionsFactory optionsFactory;
private final RedisNativeClientFactory nativeClientFactory;
RedisDeploymentRuntimeFactory(
RedisLettuceUriFactory uriFactory, RedisSslOptionsFactory sslOptionsFactory) {
this(
uriFactory,
sslOptionsFactory,
new RedisLettuceClientOptionsFactory(),
new LettuceRedisNativeClientFactory());
}
RedisDeploymentRuntimeFactory(
RedisLettuceUriFactory uriFactory,
RedisSslOptionsFactory sslOptionsFactory,
RedisLettuceClientOptionsFactory optionsFactory,
RedisNativeClientFactory nativeClientFactory) {
this.uriFactory = Objects.requireNonNull(uriFactory, "uriFactory must be non-null");
this.sslOptionsFactory =
Objects.requireNonNull(sslOptionsFactory, "sslOptionsFactory must be non-null");
this.optionsFactory = Objects.requireNonNull(optionsFactory, "optionsFactory must be non-null");
this.nativeClientFactory =
Objects.requireNonNull(nativeClientFactory, "nativeClientFactory must be non-null");
}
Optional<RedisDeploymentRuntime> createIfBound(
RedisRole role,
Map<RedisRole, RedisDeploymentSettings> activeDeployments,
RedisClientRuntimeSettings clientSettings) {
Objects.requireNonNull(role, "role must be non-null");
Objects.requireNonNull(activeDeployments, "activeDeployments must be non-null");
RedisDeploymentSettings deployment = activeDeployments.get(role);
if (deployment == null) {
return Optional.empty();
}
return Optional.of(create(deployment, clientSettings));
}
RedisDeploymentRuntime create(
RedisDeploymentSettings deployment, RedisClientRuntimeSettings clientSettings) {
Objects.requireNonNull(deployment, "deployment must be non-null");
Objects.requireNonNull(clientSettings, "clientSettings must be non-null");
if (deployment instanceof RedisDeploymentSettings.Sentinel) {
throw new UnsupportedOperationException(
"Redis Sentinel separate discovery and data trust is unsupported by one Lettuce SSL"
+ " context");
}
SslOptions sslOptions =
sslOptionsFactory.create(deployment.dataTls(), clientSettings.tlsHandshakeTimeout());
RedisLettuceUris uris = uriFactory.create(deployment, clientSettings);
try {
return switch (uris) {
case RedisLettuceUris.Standalone standalone ->
runtime(
deployment,
RedisDeploymentRuntime.Topology.STANDALONE,
clientSettings,
nativeClientFactory.openStandalone(
standalone.dataUri(),
optionsFactory.clientOptions(clientSettings, sslOptions),
clientSettings),
uris);
case RedisLettuceUris.Cluster cluster ->
runtime(
deployment,
RedisDeploymentRuntime.Topology.CLUSTER,
clientSettings,
nativeClientFactory.openCluster(
cluster.seedUris(),
optionsFactory.clusterClientOptions(clientSettings, sslOptions),
clientSettings),
uris);
case RedisLettuceUris.SentinelDiscovery ignored ->
throw new IllegalStateException("Redis Sentinel fail-closed guard was bypassed");
case RedisLettuceUris.SentinelData ignored ->
throw new IllegalStateException("Redis Sentinel fail-closed guard was bypassed");
};
} catch (RuntimeException exception) {
uris.close();
throw exception;
}
}
private static RedisDeploymentRuntime runtime(
RedisDeploymentSettings deployment,
RedisDeploymentRuntime.Topology topology,
RedisClientRuntimeSettings settings,
RedisNativeClientHandle client,
RedisLettuceUris credentialOwner) {
try {
return new RedisDeploymentRuntime(
deployment.deploymentId(), topology, settings, client, credentialOwner);
} catch (RuntimeException exception) {
try {
client.close(settings.shutdownTimeout());
} finally {
credentialOwner.close();
}
throw exception;
}
}
}
@@ -1,76 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.time.Duration;
import java.util.Objects;
/** Non-owning unavailable route used while an optional CACHE deployment awaits recovery. */
final class RedisDormantCommandRuntime implements RedisRoutableCommandRuntime {
private final String deploymentId;
RedisDormantCommandRuntime(String deploymentId) {
this.deploymentId = Objects.requireNonNull(deploymentId, "deploymentId must be non-null");
}
@Override
public void probe(Duration timeout) {
throw unavailable();
}
@Override
public String deploymentId() {
return deploymentId;
}
@Override
public byte[] get(RedisPhysicalKey key) {
throw unavailable();
}
@Override
public void set(RedisPhysicalKey key, RedisBinaryValue value, Duration timeToLive) {
throw unavailable();
}
@Override
public long delete(RedisPhysicalKey key) {
throw unavailable();
}
@Override
public RedisPrimitiveReply execute(RedisPrimitiveInvocation invocation) {
throw unavailable();
}
@Override
public RedisCatalogProgramReply executeCatalogProgram(RedisCatalogProgramInvocation invocation) {
throw unavailable();
}
@Override
public String loadCatalogProgram(RedisCatalogProgramInvocation invocation) {
throw unavailable();
}
@Override
public long publish(byte[] channel, byte[] message) {
throw unavailable();
}
@Override
public Subscription subscribe(byte[] channel, Listener listener) {
Objects.requireNonNull(listener, "listener must be non-null");
return () -> {};
}
@Override
public void close() {}
private static RedisCommandFailureException unavailable() {
return new RedisCommandFailureException(
RedisCommandFailureException.Kind.UNAVAILABLE,
RedisCommandFailureException.Certainty.NOT_APPLIED,
"Redis optional role is temporarily unavailable",
null);
}
}
@@ -1,50 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.time.Duration;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import java.util.function.IntSupplier;
@FunctionalInterface
interface RedisDrainWaiter {
Result await(IntSupplier inFlight, Object monitor, Duration timeout);
static RedisDrainWaiter system() {
return (inFlight, monitor, timeout) -> {
Objects.requireNonNull(inFlight, "inFlight must be non-null");
Objects.requireNonNull(monitor, "monitor must be non-null");
Objects.requireNonNull(timeout, "timeout must be non-null");
long deadline = saturatedAdd(System.nanoTime(), timeout.toNanos());
synchronized (monitor) {
while (inFlight.getAsInt() > 0) {
long remaining = deadline - System.nanoTime();
if (remaining <= 0) {
return Result.TIMED_OUT;
}
try {
TimeUnit.NANOSECONDS.timedWait(monitor, remaining);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
return Result.INTERRUPTED;
}
}
return Result.DRAINED;
}
};
}
private static long saturatedAdd(long left, long right) {
try {
return Math.addExact(left, right);
} catch (ArithmeticException ignored) {
return Long.MAX_VALUE;
}
}
enum Result {
DRAINED,
TIMED_OUT,
INTERRUPTED
}
}
@@ -1,553 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyBuilder;
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyDigest;
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyNamespace;
import dev.caskeleton.shared.ratelimit.EdgeRateLimitPort;
import dev.caskeleton.shared.ratelimit.RateLimitAlgorithm;
import dev.caskeleton.shared.ratelimit.RateLimitDecision;
import dev.caskeleton.shared.ratelimit.RateLimitOutcome;
import dev.caskeleton.shared.ratelimit.RateLimitPolicy;
import dev.caskeleton.shared.ratelimit.RateLimitRequest;
import dev.caskeleton.shared.ratelimit.RateParameters;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.function.LongSupplier;
/** Provider-neutral edge-rate port backed by one exact Redis Lua program per policy evaluation. */
final class RedisEdgeRateLimitProvider implements EdgeRateLimitPort, AutoCloseable {
private static final int PROGRAM_SCHEMA_VERSION = 2;
private static final long SCALE = 1_000_000L;
private static final long MAXIMUM_LIMIT = 1_000_000_000L;
private static final Duration MAXIMUM_WINDOW = Duration.ofDays(1);
private static final Duration MAXIMUM_GRACE = Duration.ofDays(1);
private static final Duration MAXIMUM_CLOCK_REGRESSION = Duration.ofHours(1);
private static final int MAXIMUM_KEY_BYTES = 512;
private final Map<String, RateLimitPolicy> policies;
private final RedisProgramCatalog catalog;
private final RedisRateProgramExecutor executor;
private final String application;
private final String environment;
private final int hashKeyVersion;
private final int keyVersion;
private final byte[] hmacSecret;
private final Clock clock;
private final Duration failureRetryAfter;
private final Duration minimumCallerBudget;
private final RedisCapabilityObserver observer;
private final ReentrantReadWriteLock lifecycle = new ReentrantReadWriteLock();
private boolean closed;
RedisEdgeRateLimitProvider(
Map<String, RateLimitPolicy> policies,
RedisProgramCatalog catalog,
RedisRateProgramExecutor executor,
String application,
String environment,
int hashKeyVersion,
int keyVersion,
byte[] hmacSecret,
Clock clock,
Duration failureRetryAfter) {
this(
policies,
catalog,
executor,
application,
environment,
hashKeyVersion,
keyVersion,
hmacSecret,
clock,
failureRetryAfter,
Duration.ZERO,
NoOpRedisCapabilityObservationPort.instance(),
System::nanoTime);
}
RedisEdgeRateLimitProvider(
Map<String, RateLimitPolicy> policies,
RedisProgramCatalog catalog,
RedisRateProgramExecutor executor,
String application,
String environment,
int hashKeyVersion,
int keyVersion,
byte[] hmacSecret,
Clock clock,
Duration failureRetryAfter,
Duration minimumCallerBudget) {
this(
policies,
catalog,
executor,
application,
environment,
hashKeyVersion,
keyVersion,
hmacSecret,
clock,
failureRetryAfter,
minimumCallerBudget,
NoOpRedisCapabilityObservationPort.instance(),
System::nanoTime);
}
RedisEdgeRateLimitProvider(
Map<String, RateLimitPolicy> policies,
RedisProgramCatalog catalog,
RedisRateProgramExecutor executor,
String application,
String environment,
int hashKeyVersion,
int keyVersion,
byte[] hmacSecret,
Clock clock,
Duration failureRetryAfter,
Duration minimumCallerBudget,
RedisCapabilityObservationPort observations,
LongSupplier ticker) {
this.policies = Map.copyOf(Objects.requireNonNull(policies, "policies must be non-null"));
if (this.policies.isEmpty()) {
throw new IllegalArgumentException("Redis rate-limit provider requires at least one policy");
}
this.policies.forEach(
(id, policy) -> {
Objects.requireNonNull(policy, "rate-limit policy must be non-null");
if (!id.equals(policy.policyId())) {
throw new IllegalArgumentException("rate-limit policy map key must match policyId");
}
validateProviderBounds(policy);
});
this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null");
this.executor = Objects.requireNonNull(executor, "executor must be non-null");
this.application = Objects.requireNonNull(application, "application must be non-null");
this.environment = Objects.requireNonNull(environment, "environment must be non-null");
this.hashKeyVersion = hashKeyVersion;
this.keyVersion = keyVersion;
this.hmacSecret =
Arrays.copyOf(
Objects.requireNonNull(hmacSecret, "hmacSecret must be non-null"), hmacSecret.length);
if (this.hmacSecret.length < 32) {
throw new IllegalArgumentException(
"rate-limit key HMAC secret must contain at least 32 bytes");
}
this.clock = Objects.requireNonNull(clock, "clock must be non-null");
this.failureRetryAfter =
Objects.requireNonNull(failureRetryAfter, "failureRetryAfter must be non-null");
if (failureRetryAfter.isZero()
|| failureRetryAfter.isNegative()
|| failureRetryAfter.compareTo(Duration.ofDays(30)) > 0) {
throw new IllegalArgumentException("failureRetryAfter must be positive and bounded");
}
this.minimumCallerBudget =
Objects.requireNonNull(minimumCallerBudget, "minimumCallerBudget must be non-null");
if (minimumCallerBudget.isNegative()
|| minimumCallerBudget.compareTo(Duration.ofSeconds(30)) > 0) {
throw new IllegalArgumentException("minimumCallerBudget must be non-negative and bounded");
}
this.observer = new RedisCapabilityObserver(observations, ticker);
RateLimitPolicy first = this.policies.values().iterator().next();
namespace(first, "state");
}
@Override
public RateLimitOutcome evaluate(RateLimitRequest request) {
return observer.observe(
RedisCapabilityObservationEvent.Capability.RATE_LIMIT,
RedisCapabilityObservationEvent.Role.COORDINATION,
RedisCapabilityObservationEvent.Operation.RATE_EVALUATE,
() -> evaluateWithLifecycle(request),
RedisEdgeRateLimitProvider::classify);
}
private RateLimitOutcome evaluateWithLifecycle(RateLimitRequest request) {
lifecycle.readLock().lock();
try {
if (closed) {
throw new IllegalStateException("Redis rate-limit provider is closed");
}
return evaluateOpen(request);
} finally {
lifecycle.readLock().unlock();
}
}
private RateLimitOutcome evaluateOpen(RateLimitRequest request) {
Objects.requireNonNull(request, "request must be non-null");
RateLimitPolicy policy = policies.get(request.policyId());
if (policy == null) {
return incompatible(
request.policyId(), RateLimitOutcome.IncompatibleCategory.STATE_INCOMPATIBLE);
}
if (request.cost() > policy.maximumCost()) {
throw new IllegalArgumentException("rate-limit request cost exceeds policy maximumCost");
}
Instant now = clock.instant();
if (!request.callerDeadline().isAfter(now)
|| Duration.between(now, request.callerDeadline()).compareTo(minimumCallerBudget) < 0) {
return unavailable(
policy.policyId(), RateLimitOutcome.UnavailableCategory.NO_MUTATION_CONFIRMED);
}
ProgramInvocation invocation = invocation(policy, request);
RedisRateProgramReply reply;
try {
reply = execute(invocation);
} catch (RedisProgramCompatibilityException exception) {
return incompatible(
policy.policyId(), RateLimitOutcome.IncompatibleCategory.REPLY_INCOMPATIBLE);
} catch (RedisCommandFailureException exception) {
if (!canReplayIndeterminate(policy, request, exception)) {
return mapCommandFailure(policy.policyId(), exception);
}
try {
reply = execute(invocation);
} catch (RedisProgramCompatibilityException retryException) {
return incompatible(
policy.policyId(), RateLimitOutcome.IncompatibleCategory.REPLY_INCOMPATIBLE);
} catch (RedisCommandFailureException retryException) {
return mapCommandFailure(policy.policyId(), retryException);
}
}
return mapReply(policy, reply);
}
@Override
public void close() {
lifecycle.writeLock().lock();
try {
if (!closed) {
Arrays.fill(hmacSecret, (byte) 0);
closed = true;
}
} finally {
lifecycle.writeLock().unlock();
}
}
boolean destroyed() {
lifecycle.readLock().lock();
try {
if (!closed) {
return false;
}
for (byte value : hmacSecret) {
if (value != 0) {
return false;
}
}
return true;
} finally {
lifecycle.readLock().unlock();
}
}
private RedisRateProgramReply execute(ProgramInvocation invocation) {
return executor.execute(catalog.capabilityInvocation(invocation));
}
private boolean canReplayIndeterminate(
RateLimitPolicy policy, RateLimitRequest request, RedisCommandFailureException exception) {
if (exception.certainty() != RedisCommandFailureException.Certainty.INDETERMINATE
|| !policy.evaluationDedupPolicy().enabled()
|| request.evaluationId().isEmpty()
|| minimumCallerBudget.isZero()) {
return false;
}
Instant now = clock.instant();
return request.callerDeadline().isAfter(now)
&& Duration.between(now, request.callerDeadline()).compareTo(minimumCallerBudget) >= 0;
}
private RateLimitOutcome mapReply(RateLimitPolicy policy, RedisRateProgramReply reply) {
Objects.requireNonNull(reply, "rate program reply must be non-null");
return switch (reply.status()) {
case ALLOWED -> evaluated(policy, reply, RedisRateProgramDecision.ALLOWED);
case DENIED -> evaluated(policy, reply, RedisRateProgramDecision.DENIED);
case DEDUP_REPLAY -> evaluated(policy, reply, reply.decision());
case CLOCK_UNSAFE ->
unavailable(policy.policyId(), RateLimitOutcome.UnavailableCategory.CLOCK_UNSAFE);
case STATE_INCOMPATIBLE ->
incompatible(policy.policyId(), RateLimitOutcome.IncompatibleCategory.STATE_INCOMPATIBLE);
case INVALID ->
incompatible(
policy.policyId(), RateLimitOutcome.IncompatibleCategory.PROGRAM_INCOMPATIBLE);
};
}
private RateLimitOutcome evaluated(
RateLimitPolicy policy, RedisRateProgramReply reply, RedisRateProgramDecision decision) {
if (decision == RedisRateProgramDecision.NONE) {
return incompatible(
policy.policyId(), RateLimitOutcome.IncompatibleCategory.REPLY_INCOMPATIBLE);
}
boolean allowed = decision == RedisRateProgramDecision.ALLOWED;
long expectedLimit = limit(policy);
if (reply.limit() != expectedLimit
|| reply.remaining() > reply.limit()
|| reply.effectiveNowMillis() < reply.serverNowMillis()
|| (allowed && reply.retryAfterMillis() != 0)
|| (!allowed && reply.retryAfterMillis() < 1)
|| reply.resetAtMillis() < reply.effectiveNowMillis()) {
return incompatible(
policy.policyId(), RateLimitOutcome.IncompatibleCategory.REPLY_INCOMPATIBLE);
}
RateLimitDecision.DecisionCertainty certainty =
policy.algorithm() == RateLimitAlgorithm.SLIDING_COUNTER
? RateLimitDecision.DecisionCertainty.APPROXIMATE_ALGORITHM
: RateLimitDecision.DecisionCertainty.CERTAIN;
try {
return new RateLimitOutcome.Evaluated(
new RateLimitDecision(
allowed,
reply.limit(),
reply.remaining(),
Duration.ofMillis(reply.retryAfterMillis()),
Instant.ofEpochMilli(reply.resetAtMillis()),
policy.policyId(),
policy.policyRevision(),
RateLimitDecision.DecisionSource.GLOBAL_REDIS,
certainty));
} catch (RuntimeException exception) {
return incompatible(
policy.policyId(), RateLimitOutcome.IncompatibleCategory.REPLY_INCOMPATIBLE);
}
}
private RateLimitOutcome mapCommandFailure(
String policyId, RedisCommandFailureException exception) {
if (exception.certainty() == RedisCommandFailureException.Certainty.INDETERMINATE) {
return new RateLimitOutcome.Indeterminate(policyId, failureRetryAfter);
}
RateLimitOutcome.UnavailableCategory category =
exception.kind() == RedisCommandFailureException.Kind.OVERLOADED
? RateLimitOutcome.UnavailableCategory.ADMISSION_REJECTED
: RateLimitOutcome.UnavailableCategory.UNAVAILABLE_BEFORE_SEND;
return unavailable(policyId, category);
}
private RateLimitOutcome unavailable(
String policyId, RateLimitOutcome.UnavailableCategory category) {
return new RateLimitOutcome.Unavailable(policyId, failureRetryAfter, category);
}
private static RateLimitOutcome incompatible(
String policyId, RateLimitOutcome.IncompatibleCategory category) {
return new RateLimitOutcome.Incompatible(policyId, category);
}
private static RedisCapabilityObserver.Classification classify(RateLimitOutcome outcome) {
if (outcome instanceof RateLimitOutcome.Evaluated evaluated) {
return classification(
evaluated.decision().allowed()
? RedisCapabilityObservationEvent.Outcome.SUCCESS
: RedisCapabilityObservationEvent.Outcome.DENIED,
RedisCapabilityObservationEvent.Certainty.DEFINITE);
}
if (outcome instanceof RateLimitOutcome.Indeterminate) {
return classification(
RedisCapabilityObservationEvent.Outcome.INDETERMINATE,
RedisCapabilityObservationEvent.Certainty.INDETERMINATE);
}
if (outcome instanceof RateLimitOutcome.Incompatible) {
return classification(
RedisCapabilityObservationEvent.Outcome.INCOMPATIBLE,
RedisCapabilityObservationEvent.Certainty.DEFINITE);
}
RateLimitOutcome.Unavailable unavailable = (RateLimitOutcome.Unavailable) outcome;
return classification(
unavailable.category() == RateLimitOutcome.UnavailableCategory.ADMISSION_REJECTED
? RedisCapabilityObservationEvent.Outcome.OVERLOADED
: RedisCapabilityObservationEvent.Outcome.UNAVAILABLE,
RedisCapabilityObservationEvent.Certainty.NOT_APPLIED);
}
private static RedisCapabilityObserver.Classification classification(
RedisCapabilityObservationEvent.Outcome outcome,
RedisCapabilityObservationEvent.Certainty certainty) {
return new RedisCapabilityObserver.Classification(outcome, certainty);
}
private ProgramInvocation invocation(RateLimitPolicy policy, RateLimitRequest request) {
String algorithm = algorithmId(policy.algorithm());
RedisKeyDigest digest =
RedisKeyDigest.sensitive(
hashKeyVersion,
hmacSecret,
List.of(
utf8(policy.policyId()),
utf8(policy.policyRevision()),
utf8(algorithm),
utf8(request.subjectDigest())));
List<byte[]> keys =
List.of(
physicalKey(policy, digest, "state"),
physicalKey(policy, digest, "dedup"),
physicalKey(policy, digest, "dedup-order"));
String evaluationId =
policy.evaluationDedupPolicy().enabled() && !request.evaluationId().isEmpty()
? request.evaluationId()
: "-";
List<byte[]> arguments =
switch (policy.parameters()) {
case RateParameters.FixedWindow fixed ->
commonArguments(policy, request.cost(), fixed.limit(), fixed.window(), evaluationId);
case RateParameters.SlidingCounter sliding ->
commonArguments(
policy, request.cost(), sliding.limit(), sliding.window(), evaluationId);
case RateParameters.TokenBucket token ->
List.of(
ascii(PROGRAM_SCHEMA_VERSION),
utf8(policy.policyRevision()),
ascii(Math.multiplyExact(token.capacity(), SCALE)),
ascii(Math.multiplyExact(token.refillTokens(), SCALE)),
ascii(token.refillPeriod().toMillis()),
ascii(Math.multiplyExact(request.cost(), SCALE)),
ascii(policy.cleanupGrace().toMillis()),
ascii(policy.maximumClockRegression().toMillis()),
utf8(evaluationId),
ascii(policy.evaluationDedupPolicy().timeToLive().toMillis()),
ascii(policy.evaluationDedupPolicy().maximumEntries()),
ascii(policy.evaluationDedupPolicy().maximumStoredBytes()));
};
RedisProgramId programId =
switch (policy.algorithm()) {
case FIXED_WINDOW -> RedisProgramId.RATE_FIXED_WINDOW_V2;
case SLIDING_COUNTER -> RedisProgramId.RATE_SLIDING_COUNTER_V2;
case TOKEN_BUCKET -> RedisProgramId.RATE_TOKEN_BUCKET_V2;
};
return new ProgramInvocation(programId, keys, arguments);
}
private static List<byte[]> commonArguments(
RateLimitPolicy policy, long cost, long limit, Duration window, String evaluationId) {
return List.of(
ascii(PROGRAM_SCHEMA_VERSION),
utf8(policy.policyRevision()),
ascii(limit),
ascii(cost),
ascii(window.toMillis()),
ascii(policy.cleanupGrace().toMillis()),
ascii(policy.maximumClockRegression().toMillis()),
utf8(evaluationId),
ascii(policy.evaluationDedupPolicy().timeToLive().toMillis()),
ascii(policy.evaluationDedupPolicy().maximumEntries()),
ascii(policy.evaluationDedupPolicy().maximumStoredBytes()));
}
private byte[] physicalKey(RateLimitPolicy policy, RedisKeyDigest digest, String kind) {
return utf8(RedisKeyBuilder.build(namespace(policy, kind), digest));
}
private RedisKeyNamespace namespace(RateLimitPolicy policy, String kind) {
return new RedisKeyNamespace(
application,
environment,
"rate",
policy.policyId(),
hashKeyVersion,
keyVersion,
kind,
MAXIMUM_KEY_BYTES);
}
private static void validateProviderBounds(RateLimitPolicy policy) {
if (policy.cleanupGrace().compareTo(MAXIMUM_GRACE) > 0
|| policy.maximumClockRegression().compareTo(MAXIMUM_CLOCK_REGRESSION) > 0) {
throw new IllegalArgumentException("rate-limit grace or clock regression exceeds v2 bounds");
}
switch (policy.parameters()) {
case RateParameters.FixedWindow fixed -> {
boundedLimitAndWindow(fixed.limit(), fixed.window());
}
case RateParameters.SlidingCounter sliding -> {
boundedLimitAndWindow(sliding.limit(), sliding.window());
}
case RateParameters.TokenBucket token -> {
if (token.capacity() > MAXIMUM_LIMIT
|| token.refillPeriod().compareTo(MAXIMUM_WINDOW) > 0) {
throw new IllegalArgumentException("token-bucket policy exceeds v2 program bounds");
}
}
}
}
private static void boundedLimitAndWindow(long limit, Duration window) {
if (limit > MAXIMUM_LIMIT || window.compareTo(MAXIMUM_WINDOW) > 0) {
throw new IllegalArgumentException("rate-limit policy exceeds v2 program bounds");
}
}
private static long limit(RateLimitPolicy policy) {
return switch (policy.parameters()) {
case RateParameters.FixedWindow fixed -> fixed.limit();
case RateParameters.SlidingCounter sliding -> sliding.limit();
case RateParameters.TokenBucket token -> token.capacity();
};
}
private static String algorithmId(RateLimitAlgorithm algorithm) {
return switch (algorithm) {
case FIXED_WINDOW -> "fixed-window";
case SLIDING_COUNTER -> "sliding-window-counter";
case TOKEN_BUCKET -> "token-bucket";
};
}
private static byte[] ascii(long value) {
return Long.toString(value).getBytes(StandardCharsets.US_ASCII);
}
private static byte[] utf8(String value) {
return value.getBytes(StandardCharsets.UTF_8);
}
static final class ProgramInvocation implements RedisCatalogProgramMaterial {
private final RedisProgramId programId;
private final List<byte[]> keys;
private final List<byte[]> arguments;
private ProgramInvocation(RedisProgramId programId, List<byte[]> keys, List<byte[]> arguments) {
this.programId = Objects.requireNonNull(programId, "programId must be non-null");
this.keys =
Objects.requireNonNull(keys, "keys must be non-null").stream()
.map(byte[]::clone)
.toList();
this.arguments =
Objects.requireNonNull(arguments, "arguments must be non-null").stream()
.map(byte[]::clone)
.toList();
}
@Override
public RedisProgramId programId() {
return programId;
}
@Override
public RedisCatalogProgramInvocation.ReplyShape replyShape() {
return RedisCatalogProgramInvocation.ReplyShape.MULTI;
}
@Override
public List<byte[]> copyKeys() {
return keys.stream().map(byte[]::clone).toList();
}
@Override
public List<byte[]> copyArguments() {
return arguments.stream().map(byte[]::clone).toList();
}
}
}
@@ -1,56 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole;
import dev.caskeleton.adapter.outbound.cache.redis.security.RedisCredentialMaterialProvider;
import dev.caskeleton.application.lease.DistributedLeasePort;
import java.time.Clock;
import java.util.Arrays;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/** Canonical COORDINATION-role composition for the owner-safe Redis efficiency lease. */
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(RedisLeaseSettings.class)
@ConditionalOnProperty(
name = "ca-skeleton.capabilities.lease.provider",
havingValue = "redis",
matchIfMissing = false)
public class RedisEfficiencyLeaseConfig {
@Bean(name = "distributedLeasePort", destroyMethod = "close")
@ConditionalOnProperty(
name = "ca-skeleton.capabilities.lease.provider",
havingValue = "redis",
matchIfMissing = false)
DistributedLeasePort distributedLeasePort(
RedisLeaseSettings settings,
RedisCanonicalRoleRegistry roleRegistry,
RedisCredentialMaterialProvider credentialProvider,
ObjectProvider<Clock> clockProvider,
ObjectProvider<RedisCapabilityObservationPort> observationsProvider) {
settings.validateActive();
Clock clock = clockProvider.getIfAvailable(Clock::systemUTC);
RedisCapabilityObservationPort observations =
observationsProvider.getIfUnique(NoOpRedisCapabilityObservationPort::instance);
byte[] hmacSecret =
RedisHmacMaterialResolver.resolve(
settings.keyHmacSecretReference(), credentialProvider, clock, "efficiency-lease");
try {
return RedisEfficiencyLeaseProvider.create(
settings.namespaceApplication(),
settings.namespaceEnvironment(),
settings.hashKeyVersion(),
settings.keyVersion(),
hmacSecret,
roleRegistry.router(RedisRole.COORDINATION),
clock,
settings.driftBudget(),
observations);
} finally {
Arrays.fill(hmacSecret, (byte) 0);
}
}
}
@@ -1,394 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.application.lease.LeaseAttempt;
import dev.caskeleton.application.lease.LeaseHandle;
import dev.caskeleton.application.lease.LeaseReleaseOutcome;
import dev.caskeleton.application.lease.LeaseRenewOutcome;
import dev.caskeleton.application.lease.LeaseState;
import dev.caskeleton.application.lease.LeaseUnavailableCategory;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.LongSupplier;
/** Thread-safe local validity handle for one Redis efficiency lease. */
final class RedisEfficiencyLeaseHandle implements LeaseHandle {
private static final int PROGRAM_SCHEMA_VERSION = 1;
private static final Duration MAXIMUM_LEASE = Duration.ofHours(24);
private final byte[] key;
private final LeaseAttempt attempt;
private final RedisLeaseProgramExecutor programs;
private final RedisLeaseLifecycle lifecycle;
private final LongSupplier nanoTime;
private final long driftNanos;
private final Instant acquiredAt;
private final AtomicLong validityDeadlineNanos = new AtomicLong();
private final AtomicReference<Instant> observedServerExpiry;
private final AtomicReference<LeaseState> state = new AtomicReference<>(LeaseState.ACTIVE);
private final Object mutationMonitor = new Object();
private final RedisCapabilityObserver observer;
RedisEfficiencyLeaseHandle(
byte[] key,
LeaseAttempt attempt,
RedisLeaseProgramExecutor programs,
RedisLeaseLifecycle lifecycle,
LongSupplier nanoTime,
Duration driftBudget,
Instant acquiredAt,
RedisLeaseProgramReply reply,
long commandStartedNanos,
long commandFinishedNanos,
RedisCapabilityObserver observer) {
this.key = Objects.requireNonNull(key, "key must be non-null").clone();
this.attempt = Objects.requireNonNull(attempt, "attempt must be non-null");
this.programs = Objects.requireNonNull(programs, "programs must be non-null");
this.lifecycle = Objects.requireNonNull(lifecycle, "lifecycle must be non-null");
this.nanoTime = Objects.requireNonNull(nanoTime, "nanoTime must be non-null");
this.driftNanos = Objects.requireNonNull(driftBudget, "driftBudget must be non-null").toNanos();
this.acquiredAt = Objects.requireNonNull(acquiredAt, "acquiredAt must be non-null");
this.observedServerExpiry =
new AtomicReference<>(Instant.ofEpochMilli(reply.serverExpiryMillis()));
this.observer = Objects.requireNonNull(observer, "observer must be non-null");
updateValidity(reply.remainingMillis(), commandStartedNanos, commandFinishedNanos);
}
@Override
public String ownerToken() {
return attempt.ownerToken();
}
@Override
public String operationId() {
return attempt.operationId();
}
@Override
public Instant acquiredAt() {
return acquiredAt;
}
@Override
public Duration remainingValidity() {
if (state.get() != LeaseState.ACTIVE) {
return Duration.ZERO;
}
long remaining = validityDeadlineNanos.get() - nanoTime.getAsLong();
if (remaining <= 0) {
state.compareAndSet(LeaseState.ACTIVE, LeaseState.LOST);
return Duration.ZERO;
}
return Duration.ofNanos(remaining);
}
@Override
public Instant observedServerExpiry() {
return observedServerExpiry.get();
}
@Override
public LeaseState state() {
remainingValidity();
return state.get();
}
@Override
public LeaseRenewOutcome renew(Duration leaseTtl) {
Objects.requireNonNull(leaseTtl, "leaseTtl must be non-null");
if (leaseTtl.isZero()
|| leaseTtl.isNegative()
|| leaseTtl.compareTo(MAXIMUM_LEASE) > 0
|| !Duration.ofMillis(leaseTtl.toMillis()).equals(leaseTtl)) {
throw new IllegalArgumentException("leaseTtl must be positive, bounded, whole milliseconds");
}
return observer.observe(
RedisCapabilityObservationEvent.Capability.EFFICIENCY_LEASE,
RedisCapabilityObservationEvent.Role.COORDINATION,
RedisCapabilityObservationEvent.Operation.LEASE_RENEW,
() -> renewOpen(leaseTtl),
RedisEfficiencyLeaseHandle::classifyRenew);
}
private LeaseRenewOutcome renewOpen(Duration leaseTtl) {
synchronized (mutationMonitor) {
LeaseState current = state();
if (current == LeaseState.RELEASED || current == LeaseState.LOST) {
return new LeaseRenewOutcome.Absent();
}
if (current == LeaseState.UNKNOWN) {
return new LeaseRenewOutcome.Indeterminate(attempt.operationId());
}
long started = nanoTime.getAsLong();
RedisLeaseProgramReply reply;
try {
reply =
lifecycle.withOpen(
() ->
programs.execute(
new ProgramInvocation(
RedisProgramId.LEASE_RENEW_V1,
key,
List.of(
ascii(PROGRAM_SCHEMA_VERSION),
ascii(attempt.ownerToken()),
ascii(attempt.operationId()),
ascii(leaseTtl.toMillis())))));
} catch (RedisCommandFailureException failure) {
state.set(LeaseState.UNKNOWN);
return failure.certainty() == RedisCommandFailureException.Certainty.INDETERMINATE
? new LeaseRenewOutcome.Indeterminate(attempt.operationId())
: new LeaseRenewOutcome.Unavailable(category(failure));
} catch (RedisProgramCompatibilityException | IllegalArgumentException failure) {
state.set(LeaseState.UNKNOWN);
return new LeaseRenewOutcome.Unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
} catch (IllegalStateException failure) {
state.set(LeaseState.UNKNOWN);
return new LeaseRenewOutcome.Unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
}
long finished = nanoTime.getAsLong();
return mapRenew(reply, started, finished);
}
}
private LeaseRenewOutcome mapRenew(RedisLeaseProgramReply reply, long started, long finished) {
return switch (reply.status()) {
case "RENEWED" -> {
if (!validLiveReply(reply)) {
state.set(LeaseState.UNKNOWN);
yield new LeaseRenewOutcome.Unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
}
observedServerExpiry.set(Instant.ofEpochMilli(reply.serverExpiryMillis()));
if (!updateValidity(reply.remainingMillis(), started, finished)) {
yield new LeaseRenewOutcome.Unavailable(LeaseUnavailableCategory.DEADLINE_EXPIRED);
}
yield new LeaseRenewOutcome.Renewed(remainingValidity());
}
case "ABSENT" -> {
state.set(LeaseState.LOST);
yield new LeaseRenewOutcome.Absent();
}
case "NOT_OWNER", "OWNER_OPERATION_CONFLICT" -> {
state.set(LeaseState.LOST);
yield new LeaseRenewOutcome.NotOwner();
}
case "STATE_INCOMPATIBLE", "INVALID" -> {
state.set(LeaseState.UNKNOWN);
yield new LeaseRenewOutcome.Unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
}
default -> {
state.set(LeaseState.UNKNOWN);
yield new LeaseRenewOutcome.Unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
}
};
}
@Override
public LeaseReleaseOutcome release() {
return observer.observe(
RedisCapabilityObservationEvent.Capability.EFFICIENCY_LEASE,
RedisCapabilityObservationEvent.Role.COORDINATION,
RedisCapabilityObservationEvent.Operation.LEASE_RELEASE,
this::releaseOpen,
RedisEfficiencyLeaseHandle::classifyRelease);
}
private LeaseReleaseOutcome releaseOpen() {
synchronized (mutationMonitor) {
if (state.get() == LeaseState.RELEASED) {
return new LeaseReleaseOutcome.AlreadyAbsent();
}
RedisLeaseProgramReply reply;
try {
reply =
lifecycle.withOpen(
() ->
programs.execute(
new ProgramInvocation(
RedisProgramId.LEASE_RELEASE_V1,
key,
List.of(
ascii(PROGRAM_SCHEMA_VERSION),
ascii(attempt.ownerToken()),
ascii(attempt.operationId())))));
} catch (RedisCommandFailureException failure) {
state.set(LeaseState.UNKNOWN);
return failure.certainty() == RedisCommandFailureException.Certainty.INDETERMINATE
? new LeaseReleaseOutcome.Indeterminate(attempt.operationId())
: new LeaseReleaseOutcome.Unavailable(category(failure));
} catch (RedisProgramCompatibilityException | IllegalArgumentException failure) {
state.set(LeaseState.UNKNOWN);
return new LeaseReleaseOutcome.Unavailable(
LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
} catch (IllegalStateException failure) {
state.set(LeaseState.UNKNOWN);
return new LeaseReleaseOutcome.Unavailable(
LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
}
return mapRelease(reply);
}
}
private LeaseReleaseOutcome mapRelease(RedisLeaseProgramReply reply) {
return switch (reply.status()) {
case "RELEASED" -> {
state.set(LeaseState.RELEASED);
yield new LeaseReleaseOutcome.Released();
}
case "ALREADY_ABSENT" -> {
state.set(LeaseState.RELEASED);
yield new LeaseReleaseOutcome.AlreadyAbsent();
}
case "NOT_OWNER", "OWNER_OPERATION_CONFLICT" -> {
state.set(LeaseState.LOST);
yield new LeaseReleaseOutcome.NotOwner();
}
case "STATE_INCOMPATIBLE", "INVALID" -> {
state.set(LeaseState.UNKNOWN);
yield new LeaseReleaseOutcome.Unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
}
default -> {
state.set(LeaseState.UNKNOWN);
yield new LeaseReleaseOutcome.Unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
}
};
}
private boolean updateValidity(long remainingMillis, long started, long finished) {
long commandElapsed = Math.max(0L, finished - started);
long rawValidity;
try {
rawValidity = Math.multiplyExact(remainingMillis, 1_000_000L);
} catch (ArithmeticException failure) {
state.set(LeaseState.UNKNOWN);
return false;
}
long effective = rawValidity - commandElapsed - driftNanos;
if (effective <= 0) {
validityDeadlineNanos.set(finished);
state.set(LeaseState.LOST);
return false;
}
validityDeadlineNanos.set(saturatedAdd(finished, effective));
state.set(LeaseState.ACTIVE);
return true;
}
private boolean validLiveReply(RedisLeaseProgramReply reply) {
return reply.remainingMillis() > 0
&& reply.remainingMillis() <= MAXIMUM_LEASE.toMillis()
&& reply.stateRevision() > 0
&& reply.serverExpiryMillis() >= reply.serverNowMillis()
&& reply.serverExpiryMillis() - reply.serverNowMillis() == reply.remainingMillis()
&& attempt.operationId().equals(reply.operationId());
}
private static LeaseUnavailableCategory category(RedisCommandFailureException failure) {
return failure.kind() == RedisCommandFailureException.Kind.OVERLOADED
? LeaseUnavailableCategory.ADMISSION_REJECTED
: LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND;
}
private static long saturatedAdd(long left, long right) {
try {
return Math.addExact(left, right);
} catch (ArithmeticException failure) {
return Long.MAX_VALUE;
}
}
private static RedisCapabilityObserver.Classification classifyRenew(LeaseRenewOutcome outcome) {
if (outcome instanceof LeaseRenewOutcome.Renewed) {
return definite(RedisCapabilityObservationEvent.Outcome.SUCCESS);
}
if (outcome instanceof LeaseRenewOutcome.NotOwner) {
return definite(RedisCapabilityObservationEvent.Outcome.CONFLICT);
}
if (outcome instanceof LeaseRenewOutcome.Absent) {
return definite(RedisCapabilityObservationEvent.Outcome.MISS);
}
if (outcome instanceof LeaseRenewOutcome.Indeterminate) {
return indeterminate();
}
return unavailable();
}
private static RedisCapabilityObserver.Classification classifyRelease(
LeaseReleaseOutcome outcome) {
if (outcome instanceof LeaseReleaseOutcome.Released
|| outcome instanceof LeaseReleaseOutcome.AlreadyAbsent) {
return definite(RedisCapabilityObservationEvent.Outcome.SUCCESS);
}
if (outcome instanceof LeaseReleaseOutcome.NotOwner) {
return definite(RedisCapabilityObservationEvent.Outcome.CONFLICT);
}
if (outcome instanceof LeaseReleaseOutcome.Indeterminate) {
return indeterminate();
}
return unavailable();
}
private static RedisCapabilityObserver.Classification definite(
RedisCapabilityObservationEvent.Outcome outcome) {
return new RedisCapabilityObserver.Classification(
outcome, RedisCapabilityObservationEvent.Certainty.DEFINITE);
}
private static RedisCapabilityObserver.Classification indeterminate() {
return new RedisCapabilityObserver.Classification(
RedisCapabilityObservationEvent.Outcome.INDETERMINATE,
RedisCapabilityObservationEvent.Certainty.INDETERMINATE);
}
private static RedisCapabilityObserver.Classification unavailable() {
return new RedisCapabilityObserver.Classification(
RedisCapabilityObservationEvent.Outcome.UNAVAILABLE,
RedisCapabilityObservationEvent.Certainty.NOT_APPLIED);
}
private static byte[] ascii(long value) {
return Long.toString(value).getBytes(StandardCharsets.US_ASCII);
}
private static byte[] ascii(String value) {
return value.getBytes(StandardCharsets.US_ASCII);
}
static final class ProgramInvocation implements RedisCatalogProgramMaterial {
private final RedisProgramId programId;
private final byte[] key;
private final List<byte[]> arguments;
private ProgramInvocation(RedisProgramId programId, byte[] key, List<byte[]> arguments) {
this.programId = Objects.requireNonNull(programId, "programId must be non-null");
this.key = Objects.requireNonNull(key, "key must be non-null").clone();
this.arguments = arguments.stream().map(byte[]::clone).toList();
}
@Override
public RedisProgramId programId() {
return programId;
}
@Override
public RedisCatalogProgramInvocation.ReplyShape replyShape() {
return RedisCatalogProgramInvocation.ReplyShape.MULTI;
}
@Override
public List<byte[]> copyKeys() {
return List.of(key.clone());
}
@Override
public List<byte[]> copyArguments() {
return arguments.stream().map(byte[]::clone).toList();
}
}
}
@@ -1,503 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.application.lease.DistributedLeasePort;
import dev.caskeleton.application.lease.LeaseAcquireOutcome;
import dev.caskeleton.application.lease.LeaseAttempt;
import dev.caskeleton.application.lease.LeaseInspectionOutcome;
import dev.caskeleton.application.lease.LeaseInspectionRequest;
import dev.caskeleton.application.lease.LeaseRequest;
import dev.caskeleton.application.lease.LeaseUnavailableCategory;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.time.Clock;
import java.time.Duration;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.ThreadLocalRandom;
import java.util.function.LongSupplier;
/**
* Redis owner-safe efficiency lease provider.
*
* <p>This provider has no fencing token and must not authorize correctness-sensitive writes.
*/
final class RedisEfficiencyLeaseProvider implements DistributedLeasePort, AutoCloseable {
private static final int PROGRAM_SCHEMA_VERSION = 1;
private static final Duration MAXIMUM_RETRY_AFTER = Duration.ofMinutes(5);
private final RedisLeaseKeyFactory keys;
private final RedisLeaseProgramExecutor programs;
private final RedisLeaseTokenGenerator tokens;
private final Clock clock;
private final LongSupplier nanoTime;
private final Duration driftBudget;
private final RedisLeaseWaitStrategy waitStrategy;
private final RedisLeaseLifecycle lifecycle = new RedisLeaseLifecycle();
private final RedisCapabilityObserver observer;
RedisEfficiencyLeaseProvider(
RedisLeaseKeyFactory keys,
RedisLeaseProgramExecutor programs,
RedisLeaseTokenGenerator tokens,
Clock clock,
LongSupplier nanoTime,
Duration driftBudget,
RedisLeaseWaitStrategy waitStrategy) {
this(
keys,
programs,
tokens,
clock,
nanoTime,
driftBudget,
waitStrategy,
NoOpRedisCapabilityObservationPort.instance());
}
RedisEfficiencyLeaseProvider(
RedisLeaseKeyFactory keys,
RedisLeaseProgramExecutor programs,
RedisLeaseTokenGenerator tokens,
Clock clock,
LongSupplier nanoTime,
Duration driftBudget,
RedisLeaseWaitStrategy waitStrategy,
RedisCapabilityObservationPort observations) {
this.keys = Objects.requireNonNull(keys, "keys must be non-null");
this.programs = Objects.requireNonNull(programs, "programs must be non-null");
this.tokens = Objects.requireNonNull(tokens, "tokens must be non-null");
this.clock = Objects.requireNonNull(clock, "clock must be non-null");
this.nanoTime = Objects.requireNonNull(nanoTime, "nanoTime must be non-null");
this.driftBudget = Objects.requireNonNull(driftBudget, "driftBudget must be non-null");
if (driftBudget.isNegative()
|| driftBudget.compareTo(Duration.ofSeconds(5)) > 0
|| !Duration.ofMillis(driftBudget.toMillis()).equals(driftBudget)) {
throw new IllegalArgumentException("driftBudget must be non-negative, bounded milliseconds");
}
this.waitStrategy = Objects.requireNonNull(waitStrategy, "waitStrategy must be non-null");
this.observer = new RedisCapabilityObserver(observations, nanoTime);
}
static RedisEfficiencyLeaseProvider create(
String application,
String environment,
int hashKeyVersion,
int keyVersion,
byte[] hmacSecret,
RedisStructuredCommands commands,
Clock clock,
Duration driftBudget) {
return create(
application,
environment,
hashKeyVersion,
keyVersion,
hmacSecret,
commands,
clock,
driftBudget,
NoOpRedisCapabilityObservationPort.instance());
}
static RedisEfficiencyLeaseProvider create(
String application,
String environment,
int hashKeyVersion,
int keyVersion,
byte[] hmacSecret,
RedisStructuredCommands commands,
Clock clock,
Duration driftBudget,
RedisCapabilityObservationPort observations) {
RedisProgramCatalog catalog = RedisProgramCatalog.efficiencyLease();
return new RedisEfficiencyLeaseProvider(
new RedisLeaseKeyFactory(application, environment, hashKeyVersion, keyVersion, hmacSecret),
new RedisLeaseProgramExecutor(catalog, commands),
new RedisLeaseTokenGenerator(new SecureRandom()),
clock,
System::nanoTime,
driftBudget,
RedisLeaseWaitStrategy.parking(),
observations);
}
@Override
public LeaseAttempt newAttempt(String operationId) {
return lifecycle.withOpen(() -> new LeaseAttempt(tokens.next(), operationId));
}
@Override
public LeaseAcquireOutcome tryAcquire(LeaseRequest request) {
return observer.observe(
RedisCapabilityObservationEvent.Capability.EFFICIENCY_LEASE,
RedisCapabilityObservationEvent.Role.COORDINATION,
RedisCapabilityObservationEvent.Operation.LEASE_ACQUIRE,
() -> tryAcquireOpen(request),
RedisEfficiencyLeaseProvider::classifyAcquire);
}
private LeaseAcquireOutcome tryAcquireOpen(LeaseRequest request) {
Objects.requireNonNull(request, "request must be non-null");
byte[] key;
try {
key = lifecycle.withOpen(() -> keys.physicalKey(request.purpose(), request.resourceDigest()));
} catch (IllegalStateException failure) {
return unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
}
long waitStarted = nanoTime.getAsLong();
int backoffAttempt = 0;
while (true) {
long commandStarted = nanoTime.getAsLong();
RedisLeaseProgramReply reply;
try {
reply =
lifecycle.withOpen(
() ->
programs.execute(
new ProgramInvocation(
RedisProgramId.LEASE_ACQUIRE_V1,
key,
List.of(
ascii(PROGRAM_SCHEMA_VERSION),
ascii(request.attempt().ownerToken()),
ascii(request.attempt().operationId()),
ascii(request.leaseTtl().toMillis())))));
} catch (RedisCommandFailureException failure) {
return mapAcquireFailure(request, failure);
} catch (RedisProgramCompatibilityException | IllegalArgumentException failure) {
return unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
} catch (IllegalStateException failure) {
return unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
}
long commandFinished = nanoTime.getAsLong();
LeaseAcquireOutcome mapped = mapAcquire(request, key, reply, commandStarted, commandFinished);
long elapsedWaitNanos = Math.max(0L, commandFinished - waitStarted);
if (!(mapped instanceof LeaseAcquireOutcome.Contended contended)
|| request.waitTimeout().isZero()
|| elapsedWaitNanos >= request.waitTimeout().toNanos()) {
return mapped;
}
long remainingWaitNanos = request.waitTimeout().toNanos() - elapsedWaitNanos;
Duration pause =
pause(
contended.retryAfter(),
Duration.ofNanos(Math.max(0L, remainingWaitNanos)),
backoffAttempt++);
if (pause.isZero()) {
return mapped;
}
try {
waitStrategy.await(pause);
} catch (RedisLeaseWaitInterruptedException interrupted) {
return unavailable(LeaseUnavailableCategory.DEADLINE_EXPIRED);
}
}
}
private LeaseAcquireOutcome mapAcquire(
LeaseRequest request,
byte[] key,
RedisLeaseProgramReply reply,
long commandStarted,
long commandFinished) {
return switch (reply.status()) {
case "ACQUIRED" -> {
if (!validOwnedReply(request.attempt(), request.leaseTtl(), reply)) {
yield unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
}
RedisEfficiencyLeaseHandle handle =
handle(request.attempt(), key, reply, commandStarted, commandFinished);
if (handle.state() != dev.caskeleton.application.lease.LeaseState.ACTIVE) {
handle.release();
yield unavailable(LeaseUnavailableCategory.DEADLINE_EXPIRED);
}
yield new LeaseAcquireOutcome.Acquired(handle);
}
case "REPLAYED_SAME_OPERATION" -> {
if (!validOwnedReply(request.attempt(), Duration.ofHours(24), reply)) {
yield unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
}
RedisEfficiencyLeaseHandle handle =
handle(request.attempt(), key, reply, commandStarted, commandFinished);
if (handle.state() != dev.caskeleton.application.lease.LeaseState.ACTIVE) {
handle.release();
yield unavailable(LeaseUnavailableCategory.DEADLINE_EXPIRED);
}
yield new LeaseAcquireOutcome.ReplayedSameOperation(handle);
}
case "CONTENDED" -> {
if (!validLiveReply(reply)) {
yield unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
}
yield new LeaseAcquireOutcome.Contended(
Duration.ofMillis(Math.min(reply.remainingMillis(), MAXIMUM_RETRY_AFTER.toMillis())));
}
case "OWNER_OPERATION_CONFLICT" ->
validLiveReply(reply)
? new LeaseAcquireOutcome.OwnerOperationConflict()
: unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
case "STATE_INCOMPATIBLE", "INVALID" ->
unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
default -> unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
};
}
@Override
public LeaseInspectionOutcome inspect(LeaseInspectionRequest request) {
return observer.observe(
RedisCapabilityObservationEvent.Capability.EFFICIENCY_LEASE,
RedisCapabilityObservationEvent.Role.COORDINATION,
RedisCapabilityObservationEvent.Operation.LEASE_INSPECT,
() -> inspectOpen(request),
RedisEfficiencyLeaseProvider::classifyInspection);
}
private LeaseInspectionOutcome inspectOpen(LeaseInspectionRequest request) {
Objects.requireNonNull(request, "request must be non-null");
byte[] key;
try {
key = lifecycle.withOpen(() -> keys.physicalKey(request.purpose(), request.resourceDigest()));
} catch (IllegalStateException failure) {
return new LeaseInspectionOutcome.Unavailable(
LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
}
long started = nanoTime.getAsLong();
RedisLeaseProgramReply reply;
try {
reply =
lifecycle.withOpen(
() ->
programs.execute(
new ProgramInvocation(
RedisProgramId.LEASE_INSPECT_V1,
key,
List.of(
ascii(PROGRAM_SCHEMA_VERSION),
ascii(request.attempt().ownerToken()),
ascii(request.attempt().operationId())))));
} catch (RedisCommandFailureException failure) {
return failure.certainty() == RedisCommandFailureException.Certainty.INDETERMINATE
? new LeaseInspectionOutcome.Indeterminate(request.attempt().operationId())
: new LeaseInspectionOutcome.Unavailable(category(failure));
} catch (RedisProgramCompatibilityException | IllegalArgumentException failure) {
return new LeaseInspectionOutcome.Unavailable(
LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
} catch (IllegalStateException failure) {
return new LeaseInspectionOutcome.Unavailable(
LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
}
long finished = nanoTime.getAsLong();
return mapInspection(request, key, reply, started, finished);
}
private LeaseInspectionOutcome mapInspection(
LeaseInspectionRequest request,
byte[] key,
RedisLeaseProgramReply reply,
long started,
long finished) {
return switch (reply.status()) {
case "OWNED" -> {
if (!validOwnedReply(request.attempt(), Duration.ofHours(24), reply)) {
yield new LeaseInspectionOutcome.Unavailable(
LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
}
RedisEfficiencyLeaseHandle handle =
handle(request.attempt(), key, reply, started, finished);
yield handle.state() == dev.caskeleton.application.lease.LeaseState.ACTIVE
? new LeaseInspectionOutcome.Owned(handle)
: new LeaseInspectionOutcome.Unavailable(LeaseUnavailableCategory.DEADLINE_EXPIRED);
}
case "ABSENT" -> new LeaseInspectionOutcome.Absent();
case "NOT_OWNER" -> new LeaseInspectionOutcome.NotOwner();
case "OWNER_OPERATION_CONFLICT" -> new LeaseInspectionOutcome.OwnerOperationConflict();
case "STATE_INCOMPATIBLE", "INVALID" ->
new LeaseInspectionOutcome.Unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
default ->
new LeaseInspectionOutcome.Unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
};
}
private RedisEfficiencyLeaseHandle handle(
LeaseAttempt attempt,
byte[] key,
RedisLeaseProgramReply reply,
long commandStarted,
long commandFinished) {
return new RedisEfficiencyLeaseHandle(
key,
attempt,
programs,
lifecycle,
nanoTime,
driftBudget,
clock.instant(),
reply,
commandStarted,
commandFinished,
observer);
}
private static boolean validOwnedReply(
LeaseAttempt attempt, Duration maximumTtl, RedisLeaseProgramReply reply) {
return reply.remainingMillis() > 0
&& reply.remainingMillis() <= maximumTtl.toMillis()
&& reply.stateRevision() > 0
&& reply.serverExpiryMillis() >= reply.serverNowMillis()
&& reply.serverExpiryMillis() - reply.serverNowMillis() == reply.remainingMillis()
&& attempt.operationId().equals(reply.operationId());
}
private static boolean validLiveReply(RedisLeaseProgramReply reply) {
return reply.remainingMillis() > 0
&& reply.remainingMillis() <= Duration.ofHours(24).toMillis()
&& reply.stateRevision() > 0
&& reply.serverExpiryMillis() >= reply.serverNowMillis()
&& reply.serverExpiryMillis() - reply.serverNowMillis() == reply.remainingMillis();
}
private static Duration pause(Duration contention, Duration remainingWait, int backoffAttempt) {
if (remainingWait.isZero()) {
return Duration.ZERO;
}
int shift = Math.min(backoffAttempt, 7);
long capMillis = Math.min(250L, 2L << shift);
long jitterMillis = ThreadLocalRandom.current().nextLong(1L, capMillis + 1L);
long millis =
Math.min(
jitterMillis,
Math.min(Math.max(1L, contention.toMillis()), Math.max(0L, remainingWait.toMillis())));
return millis < 1 ? Duration.ZERO : Duration.ofMillis(millis);
}
private static LeaseAcquireOutcome mapAcquireFailure(
LeaseRequest request, RedisCommandFailureException failure) {
if (failure.certainty() == RedisCommandFailureException.Certainty.INDETERMINATE) {
return new LeaseAcquireOutcome.Indeterminate(request.attempt().operationId());
}
if (failure.kind() == RedisCommandFailureException.Kind.OVERLOADED) {
return new LeaseAcquireOutcome.Overloaded();
}
return unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND);
}
private static LeaseAcquireOutcome unavailable(LeaseUnavailableCategory category) {
return new LeaseAcquireOutcome.Unavailable(category);
}
private static LeaseUnavailableCategory category(RedisCommandFailureException failure) {
return failure.kind() == RedisCommandFailureException.Kind.OVERLOADED
? LeaseUnavailableCategory.ADMISSION_REJECTED
: LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND;
}
private static RedisCapabilityObserver.Classification classifyAcquire(
LeaseAcquireOutcome outcome) {
if (outcome instanceof LeaseAcquireOutcome.Acquired
|| outcome instanceof LeaseAcquireOutcome.ReplayedSameOperation) {
return definite(RedisCapabilityObservationEvent.Outcome.SUCCESS);
}
if (outcome instanceof LeaseAcquireOutcome.Contended) {
return definite(RedisCapabilityObservationEvent.Outcome.CONTENDED);
}
if (outcome instanceof LeaseAcquireOutcome.OwnerOperationConflict) {
return definite(RedisCapabilityObservationEvent.Outcome.CONFLICT);
}
if (outcome instanceof LeaseAcquireOutcome.Overloaded) {
return new RedisCapabilityObserver.Classification(
RedisCapabilityObservationEvent.Outcome.OVERLOADED,
RedisCapabilityObservationEvent.Certainty.NOT_APPLIED);
}
if (outcome instanceof LeaseAcquireOutcome.Indeterminate) {
return indeterminate();
}
return unavailable();
}
private static RedisCapabilityObserver.Classification classifyInspection(
LeaseInspectionOutcome outcome) {
if (outcome instanceof LeaseInspectionOutcome.Owned) {
return definite(RedisCapabilityObservationEvent.Outcome.SUCCESS);
}
if (outcome instanceof LeaseInspectionOutcome.Absent) {
return definite(RedisCapabilityObservationEvent.Outcome.MISS);
}
if (outcome instanceof LeaseInspectionOutcome.NotOwner
|| outcome instanceof LeaseInspectionOutcome.OwnerOperationConflict) {
return definite(RedisCapabilityObservationEvent.Outcome.CONFLICT);
}
if (outcome instanceof LeaseInspectionOutcome.Indeterminate) {
return indeterminate();
}
return unavailable();
}
private static RedisCapabilityObserver.Classification definite(
RedisCapabilityObservationEvent.Outcome outcome) {
return new RedisCapabilityObserver.Classification(
outcome, RedisCapabilityObservationEvent.Certainty.DEFINITE);
}
private static RedisCapabilityObserver.Classification indeterminate() {
return new RedisCapabilityObserver.Classification(
RedisCapabilityObservationEvent.Outcome.INDETERMINATE,
RedisCapabilityObservationEvent.Certainty.INDETERMINATE);
}
private static RedisCapabilityObserver.Classification unavailable() {
return new RedisCapabilityObserver.Classification(
RedisCapabilityObservationEvent.Outcome.UNAVAILABLE,
RedisCapabilityObservationEvent.Certainty.NOT_APPLIED);
}
@Override
public void close() {
lifecycle.close(keys::close);
}
boolean destroyed() {
return lifecycle.closed() && keys.destroyed();
}
private static byte[] ascii(long value) {
return Long.toString(value).getBytes(StandardCharsets.US_ASCII);
}
private static byte[] ascii(String value) {
return value.getBytes(StandardCharsets.US_ASCII);
}
static final class ProgramInvocation implements RedisCatalogProgramMaterial {
private final RedisProgramId programId;
private final byte[] key;
private final List<byte[]> arguments;
private ProgramInvocation(RedisProgramId programId, byte[] key, List<byte[]> arguments) {
this.programId = Objects.requireNonNull(programId, "programId must be non-null");
this.key = Objects.requireNonNull(key, "key must be non-null").clone();
this.arguments = arguments.stream().map(byte[]::clone).toList();
}
@Override
public RedisProgramId programId() {
return programId;
}
@Override
public RedisCatalogProgramInvocation.ReplyShape replyShape() {
return RedisCatalogProgramInvocation.ReplyShape.MULTI;
}
@Override
public List<byte[]> copyKeys() {
return List.of(key.clone());
}
@Override
public List<byte[]> copyArguments() {
return arguments.stream().map(byte[]::clone).toList();
}
}
}
@@ -1,39 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
/** Bounded WGS84 coordinate whose text form is always redacted. */
record RedisGeoCoordinate(double longitude, double latitude) {
RedisGeoCoordinate {
if (!Double.isFinite(longitude)
|| !Double.isFinite(latitude)
|| longitude < -180
|| longitude > 180
|| latitude < -85.05112878
|| latitude > 85.05112878) {
throw new IllegalArgumentException("geo coordinate exceeds descriptor bounds");
}
if (canonical(longitude).length() > 20 || canonical(latitude).length() > 20) {
throw new IllegalArgumentException("geo coordinate exceeds canonical encoding bounds");
}
}
String canonicalLongitude() {
return canonical(longitude);
}
String canonicalLatitude() {
return canonical(latitude);
}
private static String canonical(double value) {
if (value == 0) {
return "0";
}
return java.math.BigDecimal.valueOf(value).stripTrailingZeros().toPlainString();
}
@Override
public String toString() {
return "RedisGeoCoordinate[redacted]";
}
}
@@ -1,81 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.time.Duration;
import java.util.List;
import java.util.Objects;
/** Privacy-sensitive bounded GEO helpers; coordinates are never returned or stringified. */
final class RedisGeoPrimitives {
private final RedisPrimitiveCatalog catalog;
private final RedisPrimitiveExecutor executor;
private final RedisPrimitiveDescriptor admission;
RedisGeoPrimitives(RedisPrimitiveCatalog catalog, RedisPrimitiveCommands commands) {
this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null");
this.executor = new RedisPrimitiveExecutor(catalog, commands);
this.admission = catalog.descriptor(RedisPrimitiveId.GEO_ADD);
}
RedisPrimitiveKey key(String slot, String identity) {
return catalog.keyFactory(RedisPrimitiveId.GEO_ADD).key(slot, identity);
}
RedisPrimitiveValue member(String member) {
return RedisPrimitiveValue.utf8(member, admission.maximumMemberBytes());
}
RedisPrimitiveMutationResult admitOrUpdate(
RedisPrimitiveKey key,
RedisPrimitiveValue member,
RedisGeoCoordinate coordinate,
Duration initialTimeToLive) {
return executor.mutate(
RedisPrimitiveId.GEO_ADD,
List.of(key),
new RedisPrimitiveInvocation.GeoAdmissionArguments(
member,
coordinate,
RedisPrimitiveLimit.of(admission.maximumElements(), admission),
initialTimeToLive));
}
RedisPrimitiveReply search(
RedisPrimitiveKey key,
RedisGeoCoordinate center,
double radiusMeters,
int count,
RedisPrimitiveInvocation.GeoArguments.Sort sort) {
RedisPrimitiveDescriptor descriptor = catalog.descriptor(RedisPrimitiveId.GEO_SEARCH);
return executor.execute(
RedisPrimitiveId.GEO_SEARCH,
List.of(key),
new RedisPrimitiveInvocation.GeoArguments(
center,
RedisPrimitiveInvocation.GeoArguments.Shape.RADIUS,
radiusMeters,
0,
RedisPrimitiveLimit.of(count, descriptor),
sort));
}
RedisPrimitiveReply searchBox(
RedisPrimitiveKey key,
RedisGeoCoordinate center,
double widthMeters,
double heightMeters,
int count,
RedisPrimitiveInvocation.GeoArguments.Sort sort) {
RedisPrimitiveDescriptor descriptor = catalog.descriptor(RedisPrimitiveId.GEO_SEARCH);
return executor.execute(
RedisPrimitiveId.GEO_SEARCH,
List.of(key),
new RedisPrimitiveInvocation.GeoArguments(
center,
RedisPrimitiveInvocation.GeoArguments.Shape.BOX,
widthMeters,
heightMeters,
RedisPrimitiveLimit.of(count, descriptor),
sort));
}
}
@@ -1,98 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.time.Duration;
import java.util.List;
import java.util.Objects;
/** Bounded hash helpers. Field growth is admitted atomically against descriptor capacity. */
final class RedisHashPrimitives {
private final RedisPrimitiveCatalog catalog;
private final RedisPrimitiveExecutor executor;
private final RedisPrimitiveDescriptor putDescriptor;
RedisHashPrimitives(RedisPrimitiveCatalog catalog, RedisPrimitiveCommands commands) {
this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null");
this.executor = new RedisPrimitiveExecutor(catalog, commands);
this.putDescriptor = catalog.descriptor(RedisPrimitiveId.HASH_SET_FIELDS);
}
RedisPrimitiveKey key(String slot, String identity) {
return catalog.keyFactory(RedisPrimitiveId.HASH_GET).key(slot, identity);
}
RedisPrimitiveValue field(String field) {
return RedisPrimitiveValue.utf8(field, putDescriptor.maximumFieldBytes());
}
RedisPrimitiveValue value(String value) {
return RedisPrimitiveValue.utf8(value, putDescriptor.maximumValueBytes());
}
RedisPrimitiveMutationResult put(
RedisPrimitiveKey key,
RedisPrimitiveValue field,
RedisPrimitiveValue value,
Duration initialTimeToLive) {
return executor.mutate(
RedisPrimitiveId.HASH_SET_FIELDS,
List.of(key),
new RedisPrimitiveInvocation.HashAdmissionArguments(
field,
value,
RedisPrimitiveLimit.of(putDescriptor.maximumElements(), putDescriptor),
initialTimeToLive));
}
RedisPrimitiveReply get(RedisPrimitiveKey key, RedisPrimitiveValue field) {
return executor.execute(
RedisPrimitiveId.HASH_GET,
List.of(key),
new RedisPrimitiveInvocation.BinaryArguments(List.of(field)));
}
RedisPrimitiveReply multiGet(RedisPrimitiveKey key, List<RedisPrimitiveValue> fields) {
RedisPrimitiveDescriptor descriptor = catalog.descriptor(RedisPrimitiveId.HASH_MGET);
RedisPrimitiveLimit.of(fields.size(), descriptor);
return executor.execute(
RedisPrimitiveId.HASH_MGET,
List.of(key),
new RedisPrimitiveInvocation.BinaryArguments(fields));
}
RedisPrimitiveMutationResult delete(RedisPrimitiveKey key, List<RedisPrimitiveValue> fields) {
RedisPrimitiveDescriptor descriptor = catalog.descriptor(RedisPrimitiveId.HASH_DELETE_FIELDS);
RedisPrimitiveLimit.of(fields.size(), descriptor);
return executor.mutate(
RedisPrimitiveId.HASH_DELETE_FIELDS,
List.of(key),
new RedisPrimitiveInvocation.BinaryArguments(fields));
}
RedisPrimitiveScanOutcome<RedisPrimitiveHashEntry> scan(
RedisPrimitiveKey key, RedisPrimitiveCursor cursor, long routeEpoch) {
RedisPrimitiveDescriptor descriptor = catalog.descriptor(RedisPrimitiveId.HASH_SCAN_PAGE);
cursor.validateFor(catalog, descriptor, key, routeEpoch);
return RedisPrimitiveScanOutcome.from(
executor.execute(
RedisPrimitiveId.HASH_SCAN_PAGE,
List.of(key),
new RedisPrimitiveInvocation.ScanPageArguments(
cursor, descriptor.maximumElements(), descriptor.maximumResultBytes())),
RedisPrimitiveHashEntry.class);
}
RedisPrimitiveMutationResult compareRevision(
RedisPrimitiveKey key,
RedisPrimitiveInvocation.HashRevisionArguments.ExpectedKind expectedKind,
String expectedRevision,
String nextRevision,
RedisPrimitiveValue value,
Duration initialTimeToLive) {
return executor.mutate(
RedisPrimitiveId.HASH_REVISION_CAS,
List.of(key),
new RedisPrimitiveInvocation.HashRevisionArguments(
expectedKind, expectedRevision, nextRevision, value, initialTimeToLive));
}
}
@@ -1,54 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.redis.security.RedisCredentialMaterialProvider;
import dev.caskeleton.adapter.outbound.cache.redis.security.RedisSecretReference;
import dev.caskeleton.adapter.outbound.cache.redis.security.VersionedRedisCredentialMaterial;
import java.time.Clock;
import java.util.Arrays;
import java.util.Base64;
/** Resolves bounded Base64 HMAC material without retaining a raw configuration secret. */
final class RedisHmacMaterialResolver {
private RedisHmacMaterialResolver() {}
static byte[] resolve(
String reference,
RedisCredentialMaterialProvider credentialProvider,
Clock clock,
String capability) {
try (VersionedRedisCredentialMaterial material =
credentialProvider.resolve(RedisSecretReference.parse(reference))) {
if (material.isExpiredAt(clock.instant())) {
throw failure(capability);
}
byte[] decoded =
material.useSecret(
chars -> {
byte[] encoded = new byte[chars.length];
try {
for (int index = 0; index < chars.length; index++) {
if (chars[index] > 0x7f) {
throw failure(capability);
}
encoded[index] = (byte) chars[index];
}
return Base64.getDecoder().decode(encoded);
} finally {
Arrays.fill(encoded, (byte) 0);
}
});
if (decoded.length < 32 || decoded.length > 4096) {
Arrays.fill(decoded, (byte) 0);
throw failure(capability);
}
return decoded;
} catch (RuntimeException ignored) {
throw failure(capability);
}
}
private static IllegalStateException failure(String capability) {
return new IllegalStateException("Redis " + capability + " HMAC material resolution failed");
}
}
@@ -1,54 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/** Approximate HLL helpers forbidden for billing, authorization, quota, audit and security. */
final class RedisHyperLogLogPrimitives {
private final RedisPrimitiveCatalog catalog;
private final RedisPrimitiveExecutor executor;
private final RedisPrimitiveDescriptor addDescriptor;
RedisHyperLogLogPrimitives(RedisPrimitiveCatalog catalog, RedisPrimitiveCommands commands) {
this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null");
this.executor = new RedisPrimitiveExecutor(catalog, commands);
this.addDescriptor = catalog.descriptor(RedisPrimitiveId.HLL_ADD);
}
RedisPrimitiveKey key(String slot, String identity) {
return catalog.keyFactory(RedisPrimitiveId.HLL_ADD).key(slot, identity);
}
RedisPrimitiveValue element(String value) {
return RedisPrimitiveValue.utf8(value, addDescriptor.maximumValueBytes());
}
RedisPrimitiveMutationResult add(RedisPrimitiveKey key, List<RedisPrimitiveValue> elements) {
RedisPrimitiveLimit.of(elements.size(), addDescriptor);
return executor.mutate(
RedisPrimitiveId.HLL_ADD,
List.of(key),
new RedisPrimitiveInvocation.BinaryArguments(elements));
}
RedisPrimitiveReply count(RedisPrimitiveKey key) {
return executor.execute(
RedisPrimitiveId.HLL_COUNT, List.of(key), RedisPrimitiveInvocation.NoArguments.INSTANCE);
}
RedisPrimitiveMutationResult merge(
RedisPrimitiveKey destination, List<RedisPrimitiveKey> sources) {
RedisPrimitiveDescriptor descriptor = catalog.descriptor(RedisPrimitiveId.HLL_MERGE_SAME_SLOT);
if (sources.isEmpty() || sources.size() > descriptor.maximumKeys() - 1) {
throw new IllegalArgumentException("HLL merge fan-in exceeds descriptor bounds");
}
ArrayList<RedisPrimitiveKey> keys = new ArrayList<>();
keys.add(destination);
keys.addAll(sources);
descriptor.validateKeys(keys);
return executor.mutate(
RedisPrimitiveId.HLL_MERGE_SAME_SLOT, keys, RedisPrimitiveInvocation.NoArguments.INSTANCE);
}
}
@@ -1,77 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole;
import dev.caskeleton.adapter.outbound.cache.redis.security.RedisCredentialMaterialProvider;
import dev.caskeleton.application.idempotency.IdempotencyExecutorV2;
import dev.caskeleton.application.idempotency.IdempotencyStorePortV2;
import java.security.SecureRandom;
import java.time.Clock;
import java.util.Arrays;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/** Canonical COORDINATION-role composition for Redis request-replay idempotency V2. */
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(RedisIdempotencySettings.class)
@ConditionalOnProperty(
name = "ca-skeleton.capabilities.idempotency.provider",
havingValue = "redis",
matchIfMissing = false)
public class RedisIdempotencyConfig {
@Bean(name = "redisIdempotencyStoreV2", destroyMethod = "close")
@ConditionalOnProperty(
name = "ca-skeleton.capabilities.idempotency.provider",
havingValue = "redis",
matchIfMissing = false)
IdempotencyStorePortV2 redisIdempotencyStoreV2(
RedisIdempotencySettings settings,
RedisCanonicalRoleRegistry roleRegistry,
RedisCredentialMaterialProvider credentialProvider,
ObjectProvider<Clock> clockProvider,
ObjectProvider<RedisCapabilityObservationPort> observationsProvider) {
settings.validateActive();
Clock clock = clockProvider.getIfAvailable(Clock::systemUTC);
RedisCapabilityObservationPort observations =
observationsProvider.getIfUnique(NoOpRedisCapabilityObservationPort::instance);
byte[] hmacSecret =
RedisHmacMaterialResolver.resolve(
settings.keyHmacSecretReference(), credentialProvider, clock, "idempotency");
RedisProgramCatalog catalog = RedisProgramCatalog.idempotencyV2();
try {
return new RedisIdempotencyStoreProvider(
new RedisIdempotencyKeyFactory(
settings.namespaceApplication(),
settings.namespaceEnvironment(),
settings.hashKeyVersion(),
settings.keyVersion(),
hmacSecret),
new RedisIdempotencyProgramExecutor(catalog, roleRegistry.router(RedisRole.COORDINATION)),
new RedisIdempotencyRecordCodec(),
new RedisIdempotencyTokenGenerator(new SecureRandom()),
observations,
System::nanoTime);
} finally {
Arrays.fill(hmacSecret, (byte) 0);
}
}
@Bean(name = "idempotencyExecutorV2")
@ConditionalOnProperty(
name = "ca-skeleton.capabilities.idempotency.provider",
havingValue = "redis",
matchIfMissing = false)
IdempotencyExecutorV2 idempotencyExecutorV2(
IdempotencyStorePortV2 store, RedisIdempotencySettings settings) {
return new IdempotencyExecutorV2(
store,
settings.processingLease(),
settings.replayTtl(),
settings.failureRetention(),
settings.responseCodecId(),
settings.policyRevision());
}
}
@@ -1,78 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyBuilder;
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyDigest;
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyNamespace;
import dev.caskeleton.application.idempotency.IdempotencyScope;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
/** HMAC-pseudonymizes every request-replay scope dimension into one Cluster-safe record key. */
final class RedisIdempotencyKeyFactory implements AutoCloseable {
private final RedisKeyNamespace namespace;
private final byte[] hmacSecret;
private final AtomicBoolean closed = new AtomicBoolean();
RedisIdempotencyKeyFactory(
String application,
String environment,
int hashKeyVersion,
int keyVersion,
byte[] hmacSecret) {
this.namespace =
new RedisKeyNamespace(
application,
environment,
"idempotency",
"request",
hashKeyVersion,
keyVersion,
"record",
512);
this.hmacSecret =
Arrays.copyOf(
Objects.requireNonNull(hmacSecret, "hmacSecret must be non-null"), hmacSecret.length);
if (this.hmacSecret.length < 32) {
throw new IllegalArgumentException(
"idempotency scope HMAC secret requires at least 32 bytes");
}
}
byte[] physicalKey(IdempotencyScope scope) {
if (closed.get()) {
throw new IllegalStateException("idempotency key material is closed");
}
Objects.requireNonNull(scope, "scope must be non-null");
RedisKeyDigest digest =
RedisKeyDigest.sensitive(
namespace.hashKeyVersion(),
hmacSecret,
List.of(
utf8(scope.tenant() == null ? "-" : scope.tenant()),
utf8(scope.principal()),
utf8(scope.idempotencyKey()),
utf8(scope.useCaseName())));
return utf8(RedisKeyBuilder.build(namespace, digest));
}
@Override
public void close() {
if (closed.compareAndSet(false, true)) {
Arrays.fill(hmacSecret, (byte) 0);
}
}
boolean destroyed() {
return closed.get()
&& java.util.stream.IntStream.range(0, hmacSecret.length)
.allMatch(index -> hmacSecret[index] == 0);
}
private static byte[] utf8(String value) {
return value.getBytes(StandardCharsets.UTF_8);
}
}
@@ -1,44 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.function.Supplier;
/** Prevents request-replay commands from racing provider close and HMAC destruction. */
final class RedisIdempotencyLifecycle {
private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
private boolean closed;
<T> T withOpen(Supplier<T> operation) {
lock.readLock().lock();
try {
if (closed) {
throw new IllegalStateException("Redis idempotency provider is closed");
}
return operation.get();
} finally {
lock.readLock().unlock();
}
}
void close(Runnable destroy) {
lock.writeLock().lock();
try {
if (!closed) {
destroy.run();
closed = true;
}
} finally {
lock.writeLock().unlock();
}
}
boolean closed() {
lock.readLock().lock();
try {
return closed;
} finally {
lock.readLock().unlock();
}
}
}
@@ -1,74 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Objects;
/** Executes and fail-closed parses the fixed six-field request-replay program protocol. */
final class RedisIdempotencyProgramExecutor {
private static final long MAXIMUM_EXACT_LUA_INTEGER = 9_007_199_254_740_991L;
private final RedisProgramCatalog catalog;
private final RedisStructuredCommands commands;
RedisIdempotencyProgramExecutor(RedisProgramCatalog catalog, RedisStructuredCommands commands) {
this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null");
this.commands = Objects.requireNonNull(commands, "commands must be non-null");
}
RedisIdempotencyProgramReply execute(RedisIdempotencyStoreProvider.ProgramInvocation material) {
RedisProgramId id = material.programId();
RedisProgramDescriptor descriptor = catalog.descriptor(id);
List<byte[]> result =
RedisScriptRecovery.evalMulti(commands, catalog.capabilityInvocation(material));
if (result == null || result.size() != 6) {
throw incompatible(id);
}
for (byte[] field : result) {
if (field == null || field.length > descriptor.maximumReplyFieldBytes()) {
throw incompatible(id);
}
}
String status = ascii(result.get(0), id);
if (!descriptor.statuses().contains(status)) {
throw new RedisProgramCompatibilityException(id, status);
}
return new RedisIdempotencyProgramReply(
status,
unsigned(result.get(1), id),
unsigned(result.get(2), id),
ascii(result.get(3), id),
ascii(result.get(4), id),
ascii(result.get(5), id));
}
private static String ascii(byte[] value, RedisProgramId id) {
for (byte character : value) {
if (character < 0x20 || character > 0x7e) {
throw incompatible(id);
}
}
return new String(value, StandardCharsets.US_ASCII);
}
private static long unsigned(byte[] value, RedisProgramId id) {
String encoded = ascii(value, id);
if (!encoded.matches("0|[1-9][0-9]{0,15}")) {
throw incompatible(id);
}
try {
long parsed = Long.parseLong(encoded);
if (parsed > MAXIMUM_EXACT_LUA_INTEGER) {
throw incompatible(id);
}
return parsed;
} catch (NumberFormatException exception) {
throw incompatible(id);
}
}
private static RedisProgramCompatibilityException incompatible(RedisProgramId id) {
return new RedisProgramCompatibilityException(id, "<malformed-idempotency-reply>");
}
}
@@ -1,10 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
/** Six-field bounded reply shared by all request-replay programs. */
record RedisIdempotencyProgramReply(
String status,
long attempt,
long expiresAtMillis,
String payload,
String digest,
String operationId) {}
@@ -1,111 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.application.idempotency.StoredResponse;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
import java.util.HexFormat;
import java.util.Objects;
/** Bounded UTF-8/Base64URL response codec used by the Redis request-replay programs. */
final class RedisIdempotencyRecordCodec {
static final int MAXIMUM_PAYLOAD_BYTES = 8_192;
static final int MAXIMUM_ENCODED_BYTES = 10_924;
private static final HexFormat HEX = HexFormat.of();
EncodedResponse encode(StoredResponse response) {
Objects.requireNonNull(response, "response must be non-null");
byte[] payload = strictUtf8(response.payload());
if (payload.length > MAXIMUM_PAYLOAD_BYTES) {
throw new IllegalArgumentException("idempotency response exceeds the Redis payload bound");
}
String encoded =
payload.length == 0 ? "-" : Base64.getUrlEncoder().withoutPadding().encodeToString(payload);
return new EncodedResponse(encoded, sha256(payload));
}
StoredResponse decode(String encodedPayload, String expectedDigest) {
Objects.requireNonNull(encodedPayload, "encodedPayload must be non-null");
if (expectedDigest == null || !expectedDigest.matches("[0-9a-f]{64}")) {
throw new RedisProgramCompatibilityException(
RedisProgramId.IDEMPOTENCY_INSPECT_V1, "<malformed-response-digest>");
}
if (encodedPayload.length() > MAXIMUM_ENCODED_BYTES) {
throw new RedisProgramCompatibilityException(
RedisProgramId.IDEMPOTENCY_INSPECT_V1, "<oversized-response>");
}
byte[] decoded;
try {
decoded =
"-".equals(encodedPayload) ? new byte[0] : Base64.getUrlDecoder().decode(encodedPayload);
} catch (IllegalArgumentException exception) {
throw new RedisProgramCompatibilityException(
RedisProgramId.IDEMPOTENCY_INSPECT_V1, "<malformed-response>");
}
if (decoded.length > MAXIMUM_PAYLOAD_BYTES) {
throw new RedisProgramCompatibilityException(
RedisProgramId.IDEMPOTENCY_INSPECT_V1, "<oversized-response>");
}
if (!MessageDigest.isEqual(
expectedDigest.getBytes(StandardCharsets.US_ASCII),
sha256(decoded).getBytes(StandardCharsets.US_ASCII))) {
throw new RedisProgramCompatibilityException(
RedisProgramId.IDEMPOTENCY_INSPECT_V1, "<response-digest-mismatch>");
}
try {
return new StoredResponse(
StandardCharsets.UTF_8
.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(decoded))
.toString());
} catch (CharacterCodingException exception) {
throw new RedisProgramCompatibilityException(
RedisProgramId.IDEMPOTENCY_INSPECT_V1, "<invalid-utf8-response>");
}
}
private static byte[] strictUtf8(String value) {
try {
ByteBuffer encoded =
StandardCharsets.UTF_8
.newEncoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.encode(CharBuffer.wrap(value));
byte[] result = new byte[encoded.remaining()];
encoded.get(result);
return result;
} catch (CharacterCodingException exception) {
throw new IllegalArgumentException("idempotency response is not valid Unicode", exception);
}
}
private static String sha256(byte[] value) {
try {
return HEX.formatHex(MessageDigest.getInstance("SHA-256").digest(value));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 unavailable", exception);
}
}
record EncodedResponse(String payload, String digest) {
EncodedResponse {
Objects.requireNonNull(payload, "payload must be non-null");
if (payload.isEmpty() || payload.length() > MAXIMUM_ENCODED_BYTES) {
throw new IllegalArgumentException("encoded idempotency response is out of bounds");
}
if (digest == null || !digest.matches("[0-9a-f]{64}")) {
throw new IllegalArgumentException("idempotency response digest is invalid");
}
}
}
}
@@ -1,77 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyNamespace;
import dev.caskeleton.adapter.outbound.cache.redis.security.RedisSecretReference;
import dev.caskeleton.application.idempotency.IdempotencyClaimAttempt;
import dev.caskeleton.application.idempotency.IdempotencyClaimRequest;
import dev.caskeleton.application.idempotency.IdempotencyScope;
import dev.caskeleton.application.idempotency.RequestFingerprint;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.ConstructorBinding;
/** Canonical Redis request-replay policy, separate from topology and credential material. */
@ConfigurationProperties(prefix = "ca-skeleton.capabilities.idempotency")
public record RedisIdempotencySettings(
String provider,
String keyHmacSecretReference,
String namespaceApplication,
String namespaceEnvironment,
int hashKeyVersion,
int keyVersion,
Duration processingLease,
Duration replayTtl,
Duration failureRetention,
String responseCodecId,
String policyRevision) {
@ConstructorBinding
public RedisIdempotencySettings {
provider = provider == null ? "" : provider.trim();
keyHmacSecretReference = keyHmacSecretReference == null ? "" : keyHmacSecretReference.trim();
namespaceApplication = defaultText(namespaceApplication, "ca-skeleton");
namespaceEnvironment = defaultText(namespaceEnvironment, "local");
hashKeyVersion = hashKeyVersion == 0 ? 1 : hashKeyVersion;
keyVersion = keyVersion == 0 ? 1 : keyVersion;
processingLease = processingLease == null ? Duration.ofSeconds(30) : processingLease;
replayTtl = replayTtl == null ? Duration.ofHours(24) : replayTtl;
failureRetention = failureRetention == null ? Duration.ofHours(24) : failureRetention;
responseCodecId = defaultText(responseCodecId, "json-v2");
policyRevision = defaultText(policyRevision, "request-replay-v2");
new RedisKeyNamespace(
namespaceApplication,
namespaceEnvironment,
"idempotency",
"validation",
hashKeyVersion,
keyVersion,
"record",
512);
new IdempotencyClaimRequest(
IdempotencyScope.of("validation", "validation", "validation"),
new RequestFingerprint("0".repeat(64)),
new IdempotencyClaimAttempt("validation_owner", "validation_operation"),
processingLease,
replayTtl,
responseCodecId,
policyRevision);
if (failureRetention.isZero()
|| failureRetention.isNegative()
|| failureRetention.compareTo(Duration.ofDays(30)) > 0) {
throw new IllegalArgumentException("failureRetention must be positive and at most 30 days");
}
}
void validateActive() {
if (!"redis".equals(provider)) {
throw new IllegalArgumentException(
"idempotency provider must be redis when this adapter is active");
}
RedisSecretReference.parse(keyHmacSecretReference);
}
private static String defaultText(String value, String fallback) {
return value == null || value.isBlank() ? fallback : value.trim();
}
}
@@ -1,568 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.application.idempotency.IdempotencyClaimAttempt;
import dev.caskeleton.application.idempotency.IdempotencyClaimOutcome;
import dev.caskeleton.application.idempotency.IdempotencyClaimRequest;
import dev.caskeleton.application.idempotency.IdempotencyCompleteOutcome;
import dev.caskeleton.application.idempotency.IdempotencyFailOutcome;
import dev.caskeleton.application.idempotency.IdempotencyFailureDisposition;
import dev.caskeleton.application.idempotency.IdempotencyInspection;
import dev.caskeleton.application.idempotency.IdempotencyInspectionRequest;
import dev.caskeleton.application.idempotency.IdempotencyOwner;
import dev.caskeleton.application.idempotency.IdempotencyReleaseOutcome;
import dev.caskeleton.application.idempotency.IdempotencyRenewOutcome;
import dev.caskeleton.application.idempotency.IdempotencyStartOutcome;
import dev.caskeleton.application.idempotency.IdempotencyStorePortV2;
import dev.caskeleton.application.idempotency.StoredResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Objects;
import java.util.function.LongSupplier;
/**
* Redis request-replay candidate provider.
*
* <p>Atomic ownership protects one Redis record only. This provider does not claim cross-store
* exactly-once behavior for business side effects.
*/
final class RedisIdempotencyStoreProvider implements IdempotencyStorePortV2, AutoCloseable {
private static final int PROGRAM_SCHEMA_VERSION = 2;
private static final Duration MAXIMUM_RETRY_AFTER = Duration.ofMinutes(5);
private final RedisIdempotencyKeyFactory keys;
private final RedisIdempotencyProgramExecutor programs;
private final RedisIdempotencyRecordCodec responses;
private final RedisIdempotencyTokenGenerator tokens;
private final RedisIdempotencyLifecycle lifecycle = new RedisIdempotencyLifecycle();
private final RedisCapabilityObserver observer;
RedisIdempotencyStoreProvider(
RedisIdempotencyKeyFactory keys,
RedisIdempotencyProgramExecutor programs,
RedisIdempotencyRecordCodec responses,
RedisIdempotencyTokenGenerator tokens) {
this(
keys,
programs,
responses,
tokens,
NoOpRedisCapabilityObservationPort.instance(),
System::nanoTime);
}
RedisIdempotencyStoreProvider(
RedisIdempotencyKeyFactory keys,
RedisIdempotencyProgramExecutor programs,
RedisIdempotencyRecordCodec responses,
RedisIdempotencyTokenGenerator tokens,
RedisCapabilityObservationPort observations,
LongSupplier ticker) {
this.keys = Objects.requireNonNull(keys, "keys must be non-null");
this.programs = Objects.requireNonNull(programs, "programs must be non-null");
this.responses = Objects.requireNonNull(responses, "responses must be non-null");
this.tokens = Objects.requireNonNull(tokens, "tokens must be non-null");
this.observer = new RedisCapabilityObserver(observations, ticker);
}
@Override
public IdempotencyClaimAttempt newClaimAttempt(String operationId) {
return lifecycle.withOpen(() -> new IdempotencyClaimAttempt(tokens.next(), operationId));
}
@Override
public IdempotencyClaimOutcome claim(IdempotencyClaimRequest request) {
return observer.observe(
RedisCapabilityObservationEvent.Capability.IDEMPOTENCY,
RedisCapabilityObservationEvent.Role.COORDINATION,
RedisCapabilityObservationEvent.Operation.IDEMPOTENCY_CLAIM,
() -> claimOpen(request),
RedisIdempotencyStoreProvider::classifyClaim);
}
private IdempotencyClaimOutcome claimOpen(IdempotencyClaimRequest request) {
Objects.requireNonNull(request, "request must be non-null");
RedisIdempotencyProgramReply reply;
try {
reply =
execute(
RedisProgramId.IDEMPOTENCY_CLAIM_V1,
request.scope(),
List.of(
ascii(PROGRAM_SCHEMA_VERSION),
ascii(request.fingerprint().hex()),
ascii(request.claimAttempt().ownerToken()),
ascii(request.claimAttempt().operationId()),
ascii(request.processingLeaseTtl().toMillis()),
ascii(request.recoveryRetention().toMillis()),
ascii(request.responseCodecId()),
ascii(request.policyRevision())));
} catch (RedisCommandFailureException failure) {
return failure.certainty() == RedisCommandFailureException.Certainty.INDETERMINATE
? new IdempotencyClaimOutcome.Indeterminate(request.claimAttempt().operationId())
: new IdempotencyClaimOutcome.Unavailable();
} catch (RedisProgramCompatibilityException
| IllegalArgumentException
| IllegalStateException failure) {
return new IdempotencyClaimOutcome.Unavailable();
}
try {
return mapClaim(request, reply);
} catch (RedisProgramCompatibilityException | IllegalArgumentException failure) {
return new IdempotencyClaimOutcome.Unavailable();
}
}
private IdempotencyClaimOutcome mapClaim(
IdempotencyClaimRequest request, RedisIdempotencyProgramReply reply) {
return switch (reply.status()) {
case "ACQUIRED" ->
new IdempotencyClaimOutcome.Acquired(
owner(request, reply.attempt()), instant(reply.expiresAtMillis()));
case "REPLAYED_ACQUIRE" ->
new IdempotencyClaimOutcome.ReplayedAcquire(
owner(request, reply.attempt()), instant(reply.expiresAtMillis()));
case "TAKEN_OVER_CLAIMED" ->
new IdempotencyClaimOutcome.TakenOverClaimed(
owner(request, reply.attempt()), instant(reply.expiresAtMillis()));
case "COMPLETED_REPLAY" ->
new IdempotencyClaimOutcome.CompletedReplay(
responses.decode(reply.payload(), reply.digest()), instant(reply.expiresAtMillis()));
case "IN_PROGRESS" ->
new IdempotencyClaimOutcome.InProgress(
Duration.ofMillis(Math.min(reply.expiresAtMillis(), MAXIMUM_RETRY_AFTER.toMillis())),
reply.attempt());
case "RECOVERY_REQUIRED" -> new IdempotencyClaimOutcome.RecoveryRequired(reply.attempt());
case "FINGERPRINT_MISMATCH" -> new IdempotencyClaimOutcome.FingerprintMismatch();
case "OWNER_OPERATION_CONFLICT" -> new IdempotencyClaimOutcome.OwnerOperationConflict();
case "STATE_INCOMPATIBLE", "INVALID" -> new IdempotencyClaimOutcome.Unavailable();
default -> new IdempotencyClaimOutcome.Unavailable();
};
}
@Override
public IdempotencyStartOutcome markExecutionStarted(IdempotencyOwner owner, String operationId) {
Objects.requireNonNull(owner, "owner must be non-null");
return mutation(
RedisCapabilityObservationEvent.Operation.IDEMPOTENCY_START,
operationId,
RedisProgramId.IDEMPOTENCY_START_V1,
owner,
List.of(
ascii(PROGRAM_SCHEMA_VERSION),
ascii(owner.ownerToken()),
ascii(owner.attempt()),
ascii(operationId)),
reply ->
new IdempotencyStartOutcome(
IdempotencyStartOutcome.Status.valueOf(reply.status()), null),
IdempotencyStartOutcome::indeterminate,
IdempotencyStartOutcome::unavailable,
RedisIdempotencyStoreProvider::classifyStart);
}
@Override
public IdempotencyRenewOutcome renew(
IdempotencyOwner owner, Duration processingLeaseTtl, String operationId) {
Objects.requireNonNull(owner, "owner must be non-null");
positive(processingLeaseTtl, Duration.ofHours(24), "processingLeaseTtl");
return mutation(
RedisCapabilityObservationEvent.Operation.IDEMPOTENCY_RENEW,
operationId,
RedisProgramId.IDEMPOTENCY_RENEW_V1,
owner,
List.of(
ascii(PROGRAM_SCHEMA_VERSION),
ascii(owner.ownerToken()),
ascii(owner.attempt()),
ascii(processingLeaseTtl.toMillis()),
ascii(operationId)),
reply ->
new IdempotencyRenewOutcome(
IdempotencyRenewOutcome.Status.valueOf(reply.status()), null),
IdempotencyRenewOutcome::indeterminate,
IdempotencyRenewOutcome::unavailable,
RedisIdempotencyStoreProvider::classifyRenew);
}
@Override
public IdempotencyCompleteOutcome complete(
IdempotencyOwner owner, StoredResponse response, Duration replayTtl, String operationId) {
Objects.requireNonNull(owner, "owner must be non-null");
positive(replayTtl, Duration.ofDays(30), "replayTtl");
RedisIdempotencyRecordCodec.EncodedResponse encoded = responses.encode(response);
return mutation(
RedisCapabilityObservationEvent.Operation.IDEMPOTENCY_COMPLETE,
operationId,
RedisProgramId.IDEMPOTENCY_COMPLETE_V1,
owner,
List.of(
ascii(PROGRAM_SCHEMA_VERSION),
ascii(owner.ownerToken()),
ascii(owner.attempt()),
ascii(encoded.payload()),
ascii(encoded.digest()),
ascii(replayTtl.toMillis()),
ascii(operationId)),
reply ->
new IdempotencyCompleteOutcome(
IdempotencyCompleteOutcome.Status.valueOf(reply.status()), null),
IdempotencyCompleteOutcome::indeterminate,
IdempotencyCompleteOutcome::unavailable,
RedisIdempotencyStoreProvider::classifyComplete);
}
@Override
public IdempotencyFailOutcome markFailed(
IdempotencyOwner owner,
IdempotencyFailureDisposition disposition,
Duration retention,
String operationId) {
Objects.requireNonNull(owner, "owner must be non-null");
Objects.requireNonNull(disposition, "disposition must be non-null");
positive(retention, Duration.ofDays(30), "retention");
return mutation(
RedisCapabilityObservationEvent.Operation.IDEMPOTENCY_FAIL,
operationId,
RedisProgramId.IDEMPOTENCY_FAIL_V1,
owner,
List.of(
ascii(PROGRAM_SCHEMA_VERSION),
ascii(owner.ownerToken()),
ascii(owner.attempt()),
ascii(disposition.name()),
ascii(retention.toMillis()),
ascii(operationId)),
reply ->
new IdempotencyFailOutcome(IdempotencyFailOutcome.Status.valueOf(reply.status()), null),
IdempotencyFailOutcome::indeterminate,
IdempotencyFailOutcome::unavailable,
RedisIdempotencyStoreProvider::classifyFail);
}
@Override
public IdempotencyReleaseOutcome releaseBeforeExecution(
IdempotencyOwner owner, String operationId) {
Objects.requireNonNull(owner, "owner must be non-null");
return mutation(
RedisCapabilityObservationEvent.Operation.IDEMPOTENCY_RELEASE,
operationId,
RedisProgramId.IDEMPOTENCY_RELEASE_V1,
owner,
List.of(
ascii(PROGRAM_SCHEMA_VERSION),
ascii(owner.ownerToken()),
ascii(owner.attempt()),
ascii(operationId)),
reply ->
new IdempotencyReleaseOutcome(
IdempotencyReleaseOutcome.Status.valueOf(reply.status()), null),
IdempotencyReleaseOutcome::indeterminate,
IdempotencyReleaseOutcome::unavailable,
RedisIdempotencyStoreProvider::classifyRelease);
}
@Override
public IdempotencyInspection inspect(IdempotencyInspectionRequest request) {
return observer.observe(
RedisCapabilityObservationEvent.Capability.IDEMPOTENCY,
RedisCapabilityObservationEvent.Role.COORDINATION,
RedisCapabilityObservationEvent.Operation.IDEMPOTENCY_INSPECT,
() -> inspectOpen(request),
RedisIdempotencyStoreProvider::classifyInspection);
}
private IdempotencyInspection inspectOpen(IdempotencyInspectionRequest request) {
Objects.requireNonNull(request, "request must be non-null");
RedisIdempotencyProgramReply reply;
try {
reply =
execute(
RedisProgramId.IDEMPOTENCY_INSPECT_V1,
request.scope(),
List.of(
ascii(PROGRAM_SCHEMA_VERSION),
ascii(request.fingerprint().hex()),
ascii(request.claimAttempt().ownerToken()),
ascii(request.claimAttempt().operationId())));
} catch (RedisCommandFailureException
| RedisProgramCompatibilityException
| IllegalStateException failure) {
return new IdempotencyInspection.Unavailable();
}
try {
return switch (reply.status()) {
case "ABSENT" -> new IdempotencyInspection.Absent();
case "CLAIMED_SAME_OPERATION" ->
new IdempotencyInspection.ClaimedSameOperation(
inspectionOwner(request, reply.attempt()), instant(reply.expiresAtMillis()));
case "EXECUTING_SAME_OPERATION" ->
new IdempotencyInspection.ExecutingSameOperation(
inspectionOwner(request, reply.attempt()), instant(reply.expiresAtMillis()));
case "COMPLETED_REPLAY" ->
new IdempotencyInspection.CompletedReplay(
responses.decode(reply.payload(), reply.digest()),
instant(reply.expiresAtMillis()));
case "IN_PROGRESS_OTHER" -> new IdempotencyInspection.InProgressOther(reply.attempt());
case "FAILED_RETRYABLE" -> new IdempotencyInspection.FailedRetryable(reply.attempt());
case "ABANDONED" -> new IdempotencyInspection.Abandoned(reply.attempt());
case "FINGERPRINT_MISMATCH" -> new IdempotencyInspection.FingerprintMismatch();
case "OPERATION_CONFLICT" -> new IdempotencyInspection.OperationConflict();
case "STATE_INCOMPATIBLE", "INVALID" -> new IdempotencyInspection.Unavailable();
default -> new IdempotencyInspection.Unavailable();
};
} catch (RedisProgramCompatibilityException | IllegalArgumentException failure) {
return new IdempotencyInspection.Unavailable();
}
}
private <T> T mutation(
RedisCapabilityObservationEvent.Operation operation,
String operationId,
RedisProgramId program,
IdempotencyOwner owner,
List<byte[]> arguments,
java.util.function.Function<RedisIdempotencyProgramReply, T> mapper,
java.util.function.Function<String, T> indeterminate,
java.util.function.Supplier<T> unavailable,
java.util.function.Function<T, RedisCapabilityObserver.Classification> classifier) {
return observer.observe(
RedisCapabilityObservationEvent.Capability.IDEMPOTENCY,
RedisCapabilityObservationEvent.Role.COORDINATION,
operation,
() ->
mutationOpen(
operationId, program, owner, arguments, mapper, indeterminate, unavailable),
classifier);
}
private <T> T mutationOpen(
String operationId,
RedisProgramId program,
IdempotencyOwner owner,
List<byte[]> arguments,
java.util.function.Function<RedisIdempotencyProgramReply, T> mapper,
java.util.function.Function<String, T> indeterminate,
java.util.function.Supplier<T> unavailable) {
try {
RedisIdempotencyProgramReply reply = execute(program, owner.scope(), arguments);
if ("STATE_INCOMPATIBLE".equals(reply.status()) || "INVALID".equals(reply.status())) {
return unavailable.get();
}
return mapper.apply(reply);
} catch (RedisCommandFailureException failure) {
return failure.certainty() == RedisCommandFailureException.Certainty.INDETERMINATE
? indeterminate.apply(operationId)
: unavailable.get();
} catch (RedisProgramCompatibilityException
| IllegalArgumentException
| IllegalStateException failure) {
return unavailable.get();
}
}
private RedisIdempotencyProgramReply execute(
RedisProgramId program,
dev.caskeleton.application.idempotency.IdempotencyScope scope,
List<byte[]> arguments) {
return lifecycle.withOpen(
() -> programs.execute(new ProgramInvocation(program, keys.physicalKey(scope), arguments)));
}
private static IdempotencyOwner owner(IdempotencyClaimRequest request, long attempt) {
return new IdempotencyOwner(request.scope(), request.claimAttempt().ownerToken(), attempt);
}
private static IdempotencyOwner inspectionOwner(
IdempotencyInspectionRequest request, long attempt) {
return new IdempotencyOwner(request.scope(), request.claimAttempt().ownerToken(), attempt);
}
private static Instant instant(long epochMillis) {
return Instant.ofEpochMilli(epochMillis);
}
private static RedisCapabilityObserver.Classification classifyClaim(
IdempotencyClaimOutcome outcome) {
if (outcome instanceof IdempotencyClaimOutcome.Acquired
|| outcome instanceof IdempotencyClaimOutcome.ReplayedAcquire
|| outcome instanceof IdempotencyClaimOutcome.TakenOverClaimed
|| outcome instanceof IdempotencyClaimOutcome.CompletedReplay) {
return definite(RedisCapabilityObservationEvent.Outcome.SUCCESS);
}
if (outcome instanceof IdempotencyClaimOutcome.InProgress
|| outcome instanceof IdempotencyClaimOutcome.RecoveryRequired) {
return definite(RedisCapabilityObservationEvent.Outcome.CONTENDED);
}
if (outcome instanceof IdempotencyClaimOutcome.FingerprintMismatch
|| outcome instanceof IdempotencyClaimOutcome.OwnerOperationConflict) {
return definite(RedisCapabilityObservationEvent.Outcome.CONFLICT);
}
if (outcome instanceof IdempotencyClaimOutcome.Indeterminate) {
return indeterminate();
}
return notApplied();
}
private static RedisCapabilityObserver.Classification classifyInspection(
IdempotencyInspection outcome) {
if (outcome instanceof IdempotencyInspection.Unavailable) {
return notApplied();
}
if (outcome instanceof IdempotencyInspection.FingerprintMismatch
|| outcome instanceof IdempotencyInspection.OperationConflict) {
return definite(RedisCapabilityObservationEvent.Outcome.CONFLICT);
}
if (outcome instanceof IdempotencyInspection.InProgressOther) {
return definite(RedisCapabilityObservationEvent.Outcome.CONTENDED);
}
if (outcome instanceof IdempotencyInspection.Absent) {
return definite(RedisCapabilityObservationEvent.Outcome.MISS);
}
return definite(RedisCapabilityObservationEvent.Outcome.SUCCESS);
}
static RedisCapabilityObserver.Classification classifyStart(IdempotencyStartOutcome outcome) {
return switch (outcome.status()) {
case STARTED, ALREADY_STARTED_SAME_OPERATION ->
definite(RedisCapabilityObservationEvent.Outcome.SUCCESS);
case ABSENT -> definite(RedisCapabilityObservationEvent.Outcome.MISS);
case NOT_OWNER -> definite(RedisCapabilityObservationEvent.Outcome.DENIED);
case NOT_CLAIMED, OPERATION_CONFLICT ->
definite(RedisCapabilityObservationEvent.Outcome.CONFLICT);
case INDETERMINATE -> indeterminate();
case UNAVAILABLE -> notApplied();
};
}
static RedisCapabilityObserver.Classification classifyRenew(IdempotencyRenewOutcome outcome) {
return switch (outcome.status()) {
case RENEWED, ALREADY_RENEWED_SAME_OPERATION ->
definite(RedisCapabilityObservationEvent.Outcome.SUCCESS);
case ABSENT -> definite(RedisCapabilityObservationEvent.Outcome.MISS);
case NOT_OWNER -> definite(RedisCapabilityObservationEvent.Outcome.DENIED);
case NOT_IN_PROGRESS, OPERATION_CONFLICT ->
definite(RedisCapabilityObservationEvent.Outcome.CONFLICT);
case INDETERMINATE -> indeterminate();
case UNAVAILABLE -> notApplied();
};
}
static RedisCapabilityObserver.Classification classifyComplete(
IdempotencyCompleteOutcome outcome) {
return switch (outcome.status()) {
case COMPLETED, ALREADY_COMPLETED_SAME_RESULT ->
definite(RedisCapabilityObservationEvent.Outcome.SUCCESS);
case ABSENT -> definite(RedisCapabilityObservationEvent.Outcome.MISS);
case NOT_OWNER -> definite(RedisCapabilityObservationEvent.Outcome.DENIED);
case RESPONSE_CONFLICT, NOT_IN_PROGRESS, OPERATION_CONFLICT ->
definite(RedisCapabilityObservationEvent.Outcome.CONFLICT);
case INDETERMINATE -> indeterminate();
case UNAVAILABLE -> notApplied();
};
}
static RedisCapabilityObserver.Classification classifyFail(IdempotencyFailOutcome outcome) {
return switch (outcome.status()) {
case MARKED_RETRYABLE, MARKED_ABANDONED, ALREADY_MARKED_SAME_OPERATION ->
definite(RedisCapabilityObservationEvent.Outcome.SUCCESS);
case ABSENT -> definite(RedisCapabilityObservationEvent.Outcome.MISS);
case NOT_OWNER -> definite(RedisCapabilityObservationEvent.Outcome.DENIED);
case NOT_IN_PROGRESS, OPERATION_CONFLICT ->
definite(RedisCapabilityObservationEvent.Outcome.CONFLICT);
case INDETERMINATE -> indeterminate();
case UNAVAILABLE -> notApplied();
};
}
static RedisCapabilityObserver.Classification classifyRelease(IdempotencyReleaseOutcome outcome) {
return switch (outcome.status()) {
case RELEASED_BEFORE_EXECUTION, ALREADY_RELEASED_SAME_OPERATION ->
definite(RedisCapabilityObservationEvent.Outcome.SUCCESS);
case ABSENT -> definite(RedisCapabilityObservationEvent.Outcome.MISS);
case NOT_OWNER -> definite(RedisCapabilityObservationEvent.Outcome.DENIED);
case EXECUTION_ALREADY_STARTED, OPERATION_CONFLICT ->
definite(RedisCapabilityObservationEvent.Outcome.CONFLICT);
case INDETERMINATE -> indeterminate();
case UNAVAILABLE -> notApplied();
};
}
private static RedisCapabilityObserver.Classification definite(
RedisCapabilityObservationEvent.Outcome outcome) {
return new RedisCapabilityObserver.Classification(
outcome, RedisCapabilityObservationEvent.Certainty.DEFINITE);
}
private static RedisCapabilityObserver.Classification indeterminate() {
return new RedisCapabilityObserver.Classification(
RedisCapabilityObservationEvent.Outcome.INDETERMINATE,
RedisCapabilityObservationEvent.Certainty.INDETERMINATE);
}
private static RedisCapabilityObserver.Classification notApplied() {
return new RedisCapabilityObserver.Classification(
RedisCapabilityObservationEvent.Outcome.UNAVAILABLE,
RedisCapabilityObservationEvent.Certainty.NOT_APPLIED);
}
private static void positive(Duration value, Duration maximum, String field) {
Objects.requireNonNull(value, field + " must be non-null");
if (value.isZero() || value.isNegative() || value.compareTo(maximum) > 0) {
throw new IllegalArgumentException(field + " must be positive and bounded");
}
}
private static byte[] ascii(long value) {
return Long.toString(value).getBytes(StandardCharsets.US_ASCII);
}
private static byte[] ascii(String value) {
return Objects.requireNonNull(value, "value must be non-null")
.getBytes(StandardCharsets.US_ASCII);
}
static final class ProgramInvocation implements RedisCatalogProgramMaterial {
private final RedisProgramId programId;
private final byte[] key;
private final List<byte[]> arguments;
private ProgramInvocation(RedisProgramId programId, byte[] key, List<byte[]> arguments) {
this.programId = Objects.requireNonNull(programId, "programId must be non-null");
this.key = Objects.requireNonNull(key, "key must be non-null").clone();
this.arguments = arguments.stream().map(byte[]::clone).toList();
}
@Override
public RedisProgramId programId() {
return programId;
}
@Override
public RedisCatalogProgramInvocation.ReplyShape replyShape() {
return RedisCatalogProgramInvocation.ReplyShape.MULTI;
}
@Override
public List<byte[]> copyKeys() {
return List.of(key.clone());
}
@Override
public List<byte[]> copyArguments() {
return arguments.stream().map(byte[]::clone).toList();
}
}
@Override
public void close() {
lifecycle.close(keys::close);
}
boolean destroyed() {
return lifecycle.closed() && keys.destroyed();
}
}
@@ -1,25 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.Objects;
/** Generates caller-retained owner tokens without embedding request scope data. */
final class RedisIdempotencyTokenGenerator {
private final SecureRandom random;
RedisIdempotencyTokenGenerator(SecureRandom random) {
this.random = Objects.requireNonNull(random, "random must be non-null");
}
static RedisIdempotencyTokenGenerator secure() {
return new RedisIdempotencyTokenGenerator(new SecureRandom());
}
String next() {
byte[] entropy = new byte[24];
random.nextBytes(entropy);
return Base64.getUrlEncoder().withoutPadding().encodeToString(entropy);
}
}
@@ -1,27 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
/**
* Adapter-private bounded invalidation transport used by canonical CACHE-role composition.
*
* <p>Delivery is intentionally at-most-once. Consumers must treat disconnect as a signal to evict
* or bypass L1 until their own recovery policy declares the subscription healthy again.
*/
interface RedisInvalidationTransport {
long publish(byte[] channel, byte[] message);
Subscription subscribe(byte[] channel, Listener listener);
interface Listener {
void onMessage(byte[] wireMessage);
void onDisconnected();
}
interface Subscription extends AutoCloseable {
@Override
void close();
}
}
@@ -1,82 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyBuilder;
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyDigest;
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyNamespace;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
/** Produces one Cluster-safe HMAC-pseudonymous key for an efficiency lease resource. */
final class RedisLeaseKeyFactory implements AutoCloseable {
private final String application;
private final String environment;
private final int hashKeyVersion;
private final int keyVersion;
private final byte[] hmacSecret;
private final AtomicBoolean closed = new AtomicBoolean();
RedisLeaseKeyFactory(
String application,
String environment,
int hashKeyVersion,
int keyVersion,
byte[] hmacSecret) {
this.application = Objects.requireNonNull(application, "application must be non-null");
this.environment = Objects.requireNonNull(environment, "environment must be non-null");
this.hashKeyVersion = hashKeyVersion;
this.keyVersion = keyVersion;
this.hmacSecret =
Arrays.copyOf(
Objects.requireNonNull(hmacSecret, "hmacSecret must be non-null"), hmacSecret.length);
if (this.hmacSecret.length < 32) {
throw new IllegalArgumentException("lease key HMAC secret requires at least 32 bytes");
}
}
byte[] physicalKey(String purpose, String resourceDigest) {
if (closed.get()) {
throw new IllegalStateException("lease key material is closed");
}
RedisKeyNamespace namespace =
new RedisKeyNamespace(
application,
environment,
"lease",
Objects.requireNonNull(purpose, "purpose must be non-null"),
hashKeyVersion,
keyVersion,
"owner",
512);
RedisKeyDigest digest =
RedisKeyDigest.sensitive(
hashKeyVersion, hmacSecret, List.of(utf8(purpose), utf8(resourceDigest)));
return utf8(RedisKeyBuilder.build(namespace, digest));
}
@Override
public void close() {
if (closed.compareAndSet(false, true)) {
Arrays.fill(hmacSecret, (byte) 0);
}
}
boolean destroyed() {
if (!closed.get()) {
return false;
}
for (byte value : hmacSecret) {
if (value != 0) {
return false;
}
}
return true;
}
private static byte[] utf8(String value) {
return value.getBytes(StandardCharsets.UTF_8);
}
}
@@ -1,44 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.function.Supplier;
/** Prevents lease commands from racing provider shutdown and secret destruction. */
final class RedisLeaseLifecycle {
private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
private boolean closed;
<T> T withOpen(Supplier<T> operation) {
lock.readLock().lock();
try {
if (closed) {
throw new IllegalStateException("Redis efficiency-lease provider is closed");
}
return operation.get();
} finally {
lock.readLock().unlock();
}
}
void close(Runnable destroy) {
lock.writeLock().lock();
try {
if (!closed) {
destroy.run();
closed = true;
}
} finally {
lock.writeLock().unlock();
}
}
boolean closed() {
lock.readLock().lock();
try {
return closed;
} finally {
lock.readLock().unlock();
}
}
}
@@ -1,81 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Objects;
/** Executes and fail-closed parses the efficiency-lease six-field protocol. */
final class RedisLeaseProgramExecutor {
private static final long MAXIMUM_EXACT_LUA_INTEGER = 9_007_199_254_740_991L;
private final RedisProgramCatalog catalog;
private final RedisStructuredCommands commands;
RedisLeaseProgramExecutor(RedisProgramCatalog catalog, RedisStructuredCommands commands) {
this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null");
this.commands = Objects.requireNonNull(commands, "commands must be non-null");
}
RedisLeaseProgramReply execute(RedisEfficiencyLeaseProvider.ProgramInvocation material) {
return executeOwned(material);
}
RedisLeaseProgramReply execute(RedisEfficiencyLeaseHandle.ProgramInvocation material) {
return executeOwned(material);
}
private RedisLeaseProgramReply executeOwned(RedisCatalogProgramMaterial material) {
RedisProgramId id = material.programId();
RedisProgramDescriptor descriptor = catalog.descriptor(id);
RedisCatalogProgramInvocation invocation = catalog.capabilityInvocation(material);
List<byte[]> result = RedisScriptRecovery.evalMulti(commands, invocation);
if (result == null || result.size() != descriptor.replyFieldCount()) {
throw incompatible(id);
}
for (byte[] field : result) {
if (field == null || field.length > descriptor.maximumReplyFieldBytes()) {
throw incompatible(id);
}
}
String status = ascii(result.get(0), id);
if (!descriptor.statuses().contains(status)) {
throw new RedisProgramCompatibilityException(id, status);
}
return new RedisLeaseProgramReply(
status,
unsigned(result.get(1), id),
unsigned(result.get(2), id),
unsigned(result.get(3), id),
unsigned(result.get(4), id),
ascii(result.get(5), id));
}
private static String ascii(byte[] value, RedisProgramId id) {
for (byte character : value) {
if (character < 0x20 || character > 0x7e) {
throw incompatible(id);
}
}
return new String(value, StandardCharsets.US_ASCII);
}
private static long unsigned(byte[] value, RedisProgramId id) {
String encoded = ascii(value, id);
if (!encoded.matches("0|[1-9][0-9]{0,15}")) {
throw incompatible(id);
}
try {
long parsed = Long.parseLong(encoded);
if (parsed > MAXIMUM_EXACT_LUA_INTEGER) {
throw incompatible(id);
}
return parsed;
} catch (NumberFormatException failure) {
throw incompatible(id);
}
}
private static RedisProgramCompatibilityException incompatible(RedisProgramId id) {
return new RedisProgramCompatibilityException(id, "<malformed-lease-reply>");
}
}
@@ -1,10 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
/** Fixed six-field efficiency-lease program reply. */
record RedisLeaseProgramReply(
String status,
long remainingMillis,
long serverNowMillis,
long serverExpiryMillis,
long stateRevision,
String operationId) {}
@@ -1,64 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyNamespace;
import dev.caskeleton.adapter.outbound.cache.redis.security.RedisSecretReference;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.ConstructorBinding;
/**
* Canonical policy and key namespace for the Redis efficiency-lease capability.
*
* <p>Topology, credentials, ACL and TLS stay in the provider/deployment registry. This settings
* group only names the HMAC material and lease-specific local validity assumptions.
*/
@ConfigurationProperties(prefix = "ca-skeleton.capabilities.lease")
public record RedisLeaseSettings(
String provider,
String keyHmacSecretReference,
String namespaceApplication,
String namespaceEnvironment,
int hashKeyVersion,
int keyVersion,
Duration driftBudget) {
private static final Duration MAXIMUM_DRIFT_BUDGET = Duration.ofSeconds(5);
@ConstructorBinding
public RedisLeaseSettings {
provider = provider == null ? "" : provider.trim();
keyHmacSecretReference = keyHmacSecretReference == null ? "" : keyHmacSecretReference.trim();
namespaceApplication = defaultText(namespaceApplication, "ca-skeleton");
namespaceEnvironment = defaultText(namespaceEnvironment, "local");
hashKeyVersion = hashKeyVersion == 0 ? 1 : hashKeyVersion;
keyVersion = keyVersion == 0 ? 1 : keyVersion;
driftBudget = driftBudget == null ? Duration.ofMillis(10) : driftBudget;
if (driftBudget.isNegative()
|| driftBudget.compareTo(MAXIMUM_DRIFT_BUDGET) > 0
|| !Duration.ofMillis(driftBudget.toMillis()).equals(driftBudget)) {
throw new IllegalArgumentException(
"lease driftBudget must be non-negative, at most 5 seconds, and use whole milliseconds");
}
new RedisKeyNamespace(
namespaceApplication,
namespaceEnvironment,
"lease",
"validation",
hashKeyVersion,
keyVersion,
"owner",
512);
}
void validateActive() {
if (!"redis".equals(provider)) {
throw new IllegalArgumentException(
"lease provider must be redis when this adapter is active");
}
RedisSecretReference.parse(keyHmacSecretReference);
}
private static String defaultText(String value, String fallback) {
return value == null || value.isBlank() ? fallback : value.trim();
}
}
@@ -1,21 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.Objects;
/** Allocates caller-retained owner tokens without a Redis side effect. */
final class RedisLeaseTokenGenerator {
private final SecureRandom random;
RedisLeaseTokenGenerator(SecureRandom random) {
this.random = Objects.requireNonNull(random, "random must be non-null");
}
String next() {
byte[] entropy = new byte[24];
random.nextBytes(entropy);
return Base64.getUrlEncoder().withoutPadding().encodeToString(entropy);
}
}
@@ -1,7 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
/** Internal signal preserving interruption while a bounded lease wait is cancelled. */
final class RedisLeaseWaitInterruptedException extends RuntimeException {
private static final long serialVersionUID = 1L;
}
@@ -1,20 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.time.Duration;
import java.util.concurrent.locks.LockSupport;
@FunctionalInterface
interface RedisLeaseWaitStrategy {
void await(Duration duration);
static RedisLeaseWaitStrategy parking() {
return duration -> {
LockSupport.parkNanos(duration.toNanos());
if (Thread.interrupted()) {
Thread.currentThread().interrupt();
throw new RedisLeaseWaitInterruptedException();
}
};
}
}
@@ -1,87 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.time.Duration;
import java.util.Base64;
import java.util.Objects;
/**
* Explicit migration-only settings for the retired standalone rate-limit runtime.
*
* <p>This type is not a configuration-properties target and cannot become the production primary.
*/
record RedisLegacyStandaloneSettings(
String host,
int port,
String password,
String legacyKeyHmacSecret,
Duration commandTimeout,
int maximumCommandBytes,
int maximumQueuedCommands,
int maximumInFlightBytes,
String namespaceApplication,
String namespaceEnvironment) {
private static final Duration MAXIMUM_TIMEOUT = Duration.ofSeconds(30);
RedisLegacyStandaloneSettings {
host = host == null ? "" : host.trim();
port = port == 0 ? 6379 : port;
password = password == null ? "" : password;
legacyKeyHmacSecret = legacyKeyHmacSecret == null ? "" : legacyKeyHmacSecret;
commandTimeout = commandTimeout == null ? Duration.ofSeconds(1) : commandTimeout;
maximumCommandBytes = maximumCommandBytes == 0 ? 16_384 : maximumCommandBytes;
maximumQueuedCommands = maximumQueuedCommands == 0 ? 32 : maximumQueuedCommands;
maximumInFlightBytes = maximumInFlightBytes == 0 ? 1_048_576 : maximumInFlightBytes;
namespaceApplication = defaultText(namespaceApplication, "ca-skeleton");
namespaceEnvironment = defaultText(namespaceEnvironment, "local");
if (host.length() > 253
|| host.chars().anyMatch(Character::isWhitespace)
|| host.contains("/")
|| host.contains("\\")) {
throw new IllegalArgumentException("legacy rate-limit Redis host is invalid");
}
if (port < 1 || port > 65_535) {
throw new IllegalArgumentException("legacy rate-limit Redis port must be in 1..65535");
}
Objects.requireNonNull(commandTimeout, "commandTimeout must be non-null");
if (commandTimeout.isZero()
|| commandTimeout.isNegative()
|| commandTimeout.compareTo(MAXIMUM_TIMEOUT) > 0) {
throw new IllegalArgumentException("legacy rate-limit Redis command timeout is invalid");
}
if (maximumCommandBytes < 16_384 || maximumCommandBytes > 65_536) {
throw new IllegalArgumentException("legacy rate-limit Redis command bytes are invalid");
}
if (maximumQueuedCommands < 1 || maximumQueuedCommands > 4096) {
throw new IllegalArgumentException("legacy rate-limit Redis queue bound is invalid");
}
if (maximumInFlightBytes < maximumCommandBytes || maximumInFlightBytes > 268_435_456) {
throw new IllegalArgumentException("legacy rate-limit Redis byte budget is invalid");
}
slug(namespaceApplication, "legacy rate-limit namespace application");
slug(namespaceEnvironment, "legacy rate-limit namespace environment");
}
byte[] hmacSecret() {
try {
byte[] decoded = Base64.getDecoder().decode(legacyKeyHmacSecret);
if (decoded.length < 32) {
throw new IllegalArgumentException("legacy HMAC material is too short");
}
return decoded;
} catch (IllegalArgumentException ignored) {
throw new IllegalArgumentException("legacy HMAC material is invalid");
}
}
private static void slug(String value, String field) {
if (!value.matches("[a-z][a-z0-9-]{0,62}")) {
throw new IllegalArgumentException(field + " has invalid format");
}
}
private static String defaultText(String value, String fallback) {
return value == null || value.isBlank() ? fallback : value.trim();
}
}
@@ -1,68 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings;
import io.lettuce.core.ClientOptions;
import io.lettuce.core.SocketOptions;
import io.lettuce.core.SslOptions;
import io.lettuce.core.TimeoutOptions;
import io.lettuce.core.cluster.ClusterClientOptions;
import io.lettuce.core.cluster.ClusterTopologyRefreshOptions;
/** Builds bounded no-replay Lettuce options for standalone/Sentinel and Cluster clients. */
final class RedisLettuceClientOptionsFactory {
ClientOptions clientOptions(RedisClientRuntimeSettings settings) {
return clientOptions(settings, null);
}
public ClientOptions clientOptions(
RedisClientRuntimeSettings settings, SslOptions explicitSslOptions) {
SocketOptions socketOptions =
SocketOptions.builder().connectTimeout(settings.connectTimeout()).build();
ClientOptions.Builder builder =
ClientOptions.builder()
.autoReconnect(true)
.replayFilter(ignored -> true)
.disconnectedBehavior(ClientOptions.DisconnectedBehavior.REJECT_COMMANDS)
.requestQueueSize(settings.maximumQueuedCommands())
.socketOptions(socketOptions)
.timeoutOptions(TimeoutOptions.enabled(settings.commandTimeout()));
if (explicitSslOptions != null) {
builder.sslOptions(explicitSslOptions);
}
return builder.build();
}
ClusterClientOptions clusterClientOptions(RedisClientRuntimeSettings settings) {
return clusterClientOptions(settings, null);
}
public ClusterClientOptions clusterClientOptions(
RedisClientRuntimeSettings settings, SslOptions explicitSslOptions) {
SocketOptions socketOptions =
SocketOptions.builder().connectTimeout(settings.connectTimeout()).build();
ClusterTopologyRefreshOptions topologyRefresh =
ClusterTopologyRefreshOptions.builder()
.enablePeriodicRefresh(settings.clusterTopologyRefreshPeriod())
.enableAllAdaptiveRefreshTriggers()
.adaptiveRefreshTriggersTimeout(settings.commandTimeout())
.closeStaleConnections(true)
.dynamicRefreshSources(true)
.build();
ClusterClientOptions.Builder builder = ClusterClientOptions.builder();
builder.autoReconnect(true);
builder.replayFilter(ignored -> true);
builder.disconnectedBehavior(ClientOptions.DisconnectedBehavior.REJECT_COMMANDS);
builder.requestQueueSize(settings.maximumQueuedCommands());
builder.socketOptions(socketOptions);
builder.timeoutOptions(TimeoutOptions.enabled(settings.commandTimeout()));
if (explicitSslOptions != null) {
builder.sslOptions(explicitSslOptions);
}
builder.maxRedirects(settings.clusterMaximumRedirects());
builder.topologyRefreshOptions(topologyRefresh);
builder.validateClusterNodeMembership(true);
return builder.build();
}
}
@@ -1,213 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings;
import dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings;
import dev.caskeleton.adapter.outbound.cache.redis.security.DestroyableRedisCredentialsProvider;
import dev.caskeleton.adapter.outbound.cache.redis.security.RedisCredentialMaterialProvider;
import dev.caskeleton.adapter.outbound.cache.redis.security.RedisSecretReference;
import dev.caskeleton.adapter.outbound.cache.redis.security.VersionedRedisCredentialMaterial;
import io.lettuce.core.RedisURI;
import io.lettuce.core.SslVerifyMode;
import java.time.Clock;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.function.Function;
/** Converts validated topology settings into deterministic credential-bearing Lettuce URIs. */
final class RedisLettuceUriFactory {
private final RedisCredentialMaterialProvider materialProvider;
private final Clock clock;
RedisLettuceUriFactory(RedisCredentialMaterialProvider materialProvider, Clock clock) {
this.materialProvider =
Objects.requireNonNull(materialProvider, "materialProvider must be non-null");
this.clock = Objects.requireNonNull(clock, "clock must be non-null");
}
RedisLettuceUris create(
RedisDeploymentSettings deployment, RedisClientRuntimeSettings clientSettings) {
Objects.requireNonNull(deployment, "deployment must be non-null");
Objects.requireNonNull(clientSettings, "clientSettings must be non-null");
return switch (deployment) {
case RedisDeploymentSettings.Standalone standalone -> standalone(standalone, clientSettings);
case RedisDeploymentSettings.Sentinel sentinel -> sentinelDiscovery(sentinel, clientSettings);
case RedisDeploymentSettings.Cluster cluster -> cluster(cluster, clientSettings);
};
}
/**
* Applies an operation to newly created credential-owning URIs.
*
* <p>A successful operation assumes ownership of the supplied URI set. If the operation fails,
* this factory destroys every credential before propagating the failure.
*/
<T> T mapOwnedUris(
RedisDeploymentSettings deployment,
RedisClientRuntimeSettings clientSettings,
Function<RedisLettuceUris, T> operation) {
Objects.requireNonNull(operation, "operation must be non-null");
RedisLettuceUris uris = create(deployment, clientSettings);
try {
return operation.apply(uris);
} catch (RuntimeException exception) {
uris.close();
throw exception;
}
}
RedisLettuceUris.SentinelDiscovery createSentinelDiscovery(
RedisDeploymentSettings.Sentinel deployment, RedisClientRuntimeSettings clientSettings) {
Objects.requireNonNull(deployment, "deployment must be non-null");
Objects.requireNonNull(clientSettings, "clientSettings must be non-null");
return sentinelDiscovery(deployment, clientSettings);
}
RedisLettuceUris.SentinelData createSentinelData(
RedisDeploymentSettings.Sentinel deployment,
RedisSentinelMasterDiscovery.DataEndpoint endpoint,
RedisClientRuntimeSettings clientSettings) {
Objects.requireNonNull(deployment, "deployment must be non-null");
Objects.requireNonNull(endpoint, "approved endpoint must be non-null");
Objects.requireNonNull(clientSettings, "clientSettings must be non-null");
validateFullTls(deployment.dataTls(), "Redis Sentinel data-node TLS");
return withPassword(
deployment.dataAuthentication(),
credentials ->
new RedisLettuceUris.SentinelData(
dataUri(
new RedisDeploymentSettings.Endpoint(endpoint.host(), endpoint.port()),
deployment.database(),
credentials,
deployment.dataTls(),
clientSettings)));
}
<T> T mapOwnedSentinelData(
RedisDeploymentSettings.Sentinel deployment,
RedisSentinelMasterDiscovery.DataEndpoint endpoint,
RedisClientRuntimeSettings clientSettings,
Function<RedisLettuceUris.SentinelData, T> operation) {
Objects.requireNonNull(operation, "operation must be non-null");
RedisLettuceUris.SentinelData uris = createSentinelData(deployment, endpoint, clientSettings);
try {
return operation.apply(uris);
} catch (RuntimeException exception) {
uris.close();
throw exception;
}
}
private RedisLettuceUris.Standalone standalone(
RedisDeploymentSettings.Standalone deployment, RedisClientRuntimeSettings clientSettings) {
if (deployment.endpoints().size() != 1) {
throw new IllegalArgumentException(
"Redis standalone deployment must contain exactly one data endpoint");
}
RedisDeploymentSettings.Endpoint endpoint = deployment.endpoints().getFirst();
RedisURI dataUri =
withPassword(
deployment.dataAuthentication(),
credentials ->
dataUri(
endpoint,
deployment.database(),
credentials,
deployment.dataTls(),
clientSettings));
return new RedisLettuceUris.Standalone(dataUri);
}
private RedisLettuceUris.SentinelDiscovery sentinelDiscovery(
RedisDeploymentSettings.Sentinel deployment, RedisClientRuntimeSettings clientSettings) {
validateFullTls(deployment.sentinelTls(), "Redis Sentinel discovery TLS");
return withPassword(
deployment.sentinelAuthentication(),
sentinelCredentials -> {
List<RedisURI> discoveryUris = new ArrayList<>();
for (RedisDeploymentSettings.Endpoint endpoint : deployment.sentinelEndpoints()) {
discoveryUris.add(
dataUri(
endpoint, 0, sentinelCredentials, deployment.sentinelTls(), clientSettings));
}
return new RedisLettuceUris.SentinelDiscovery(discoveryUris);
});
}
private RedisLettuceUris.Cluster cluster(
RedisDeploymentSettings.Cluster deployment, RedisClientRuntimeSettings clientSettings) {
if (deployment.database() != 0) {
throw new IllegalArgumentException("Redis Cluster data URI must use database 0");
}
return withPassword(
deployment.dataAuthentication(),
credentials -> {
List<RedisURI> seedUris =
deployment.seedEndpoints().stream()
.map(
endpoint ->
dataUri(endpoint, 0, credentials, deployment.dataTls(), clientSettings))
.toList();
return new RedisLettuceUris.Cluster(seedUris);
});
}
private RedisURI dataUri(
RedisDeploymentSettings.Endpoint endpoint,
int database,
DestroyableRedisCredentialsProvider credentials,
RedisDeploymentSettings.Tls tls,
RedisClientRuntimeSettings clientSettings) {
validateFullTls(tls, "Redis data URI TLS");
return RedisURI.Builder.redis(endpoint.host(), endpoint.port())
.withAuthentication(credentials)
.withDatabase(database)
.withClientName(clientSettings.clientName())
.withTimeout(clientSettings.commandTimeout())
.withSsl(true)
.withVerifyPeer(SslVerifyMode.FULL)
.build();
}
private void validateFullTls(RedisDeploymentSettings.Tls tls, String field) {
Objects.requireNonNull(tls, field + " must be non-null");
if (!tls.enabled() || !tls.verifyHostname()) {
throw new IllegalArgumentException(field + " must use TLS with FULL hostname verification");
}
RedisSecretReference.parse(tls.trustBundleReference());
}
private <T> T withPassword(
RedisDeploymentSettings.Authentication authentication,
Function<DestroyableRedisCredentialsProvider, T> operation) {
RedisSecretReference reference = RedisSecretReference.parse(authentication.passwordReference());
try (VersionedRedisCredentialMaterial material = resolve(reference)) {
if (material == null) {
throw new IllegalStateException("Redis credential material resolution returned no value");
}
if (material.isExpiredAt(clock.instant())) {
throw new IllegalStateException("Redis credential material is expired");
}
return material.useSecret(
password -> {
DestroyableRedisCredentialsProvider credentials =
DestroyableRedisCredentialsProvider.from(authentication.username(), password);
try {
return operation.apply(credentials);
} catch (RuntimeException exception) {
credentials.destroy();
throw exception;
}
});
}
}
private VersionedRedisCredentialMaterial resolve(RedisSecretReference reference) {
try {
return materialProvider.resolve(reference);
} catch (RuntimeException ignored) {
throw new IllegalStateException("Redis credential material resolution failed");
}
}
}
@@ -1,119 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import io.lettuce.core.RedisURI;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
import javax.security.auth.Destroyable;
/** Topology-specific, credential-bearing Lettuce URI configuration. */
sealed interface RedisLettuceUris extends AutoCloseable
permits RedisLettuceUris.Standalone,
RedisLettuceUris.SentinelDiscovery,
RedisLettuceUris.SentinelData,
RedisLettuceUris.Cluster {
@Override
void close();
final class Standalone implements RedisLettuceUris {
private final RedisURI dataUri;
private final AtomicBoolean closed = new AtomicBoolean();
Standalone(RedisURI dataUri) {
this.dataUri = Objects.requireNonNull(dataUri, "dataUri must be non-null");
}
RedisURI dataUri() {
return dataUri;
}
@Override
public void close() {
if (closed.compareAndSet(false, true)) {
destroyCredentials(List.of(dataUri));
}
}
}
final class SentinelDiscovery implements RedisLettuceUris {
private final List<RedisURI> discoveryUris;
private final AtomicBoolean closed = new AtomicBoolean();
SentinelDiscovery(List<RedisURI> discoveryUris) {
this.discoveryUris = List.copyOf(discoveryUris);
}
List<RedisURI> discoveryUris() {
return discoveryUris;
}
@Override
public void close() {
if (closed.compareAndSet(false, true)) {
destroyCredentials(discoveryUris);
}
}
}
final class SentinelData implements RedisLettuceUris {
private final RedisURI dataUri;
private final AtomicBoolean closed = new AtomicBoolean();
SentinelData(RedisURI dataUri) {
this.dataUri = Objects.requireNonNull(dataUri, "dataUri must be non-null");
}
RedisURI dataUri() {
return dataUri;
}
@Override
public void close() {
if (closed.compareAndSet(false, true)) {
destroyCredentials(List.of(dataUri));
}
}
}
final class Cluster implements RedisLettuceUris {
private final List<RedisURI> seedUris;
private final AtomicBoolean closed = new AtomicBoolean();
Cluster(List<RedisURI> seedUris) {
this.seedUris = List.copyOf(seedUris);
}
List<RedisURI> seedUris() {
return seedUris;
}
@Override
public void close() {
if (closed.compareAndSet(false, true)) {
destroyCredentials(seedUris);
}
}
}
static void destroyCredentials(List<RedisURI> uris) {
var destroyed = Collections.newSetFromMap(new IdentityHashMap<Destroyable, Boolean>());
for (RedisURI uri : uris) {
if (uri.getCredentialsProvider() instanceof Destroyable destroyable
&& destroyed.add(destroyable)) {
try {
destroyable.destroy();
} catch (javax.security.auth.DestroyFailedException ignored) {
// The adapter-owned providers do not throw; remain fail-safe for alternate
// implementations.
}
}
}
}
}
@@ -1,56 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.time.Duration;
import java.util.List;
import java.util.Objects;
/** Non-blocking bounded list helpers; this is not a durable messaging abstraction. */
final class RedisListPrimitives {
private final RedisPrimitiveCatalog catalog;
private final RedisPrimitiveExecutor executor;
private final RedisPrimitiveDescriptor admission;
RedisListPrimitives(RedisPrimitiveCatalog catalog, RedisPrimitiveCommands commands) {
this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null");
this.executor = new RedisPrimitiveExecutor(catalog, commands);
this.admission = catalog.descriptor(RedisPrimitiveId.LIST_ADMIT);
}
RedisPrimitiveKey key(String slot, String identity) {
return catalog.keyFactory(RedisPrimitiveId.LIST_ADMIT).key(slot, identity);
}
RedisPrimitiveValue value(String value) {
return RedisPrimitiveValue.utf8(value, admission.maximumValueBytes());
}
RedisPrimitiveMutationResult admit(
RedisPrimitiveKey key, RedisPrimitiveValue value, Duration initialTimeToLive) {
return executor.mutate(
RedisPrimitiveId.LIST_ADMIT,
List.of(key),
new RedisPrimitiveInvocation.CapacityArguments(
value,
RedisPrimitiveLimit.of(admission.maximumElements(), admission),
initialTimeToLive));
}
RedisPrimitiveReply pop(RedisPrimitiveKey key) {
return executor.execute(
RedisPrimitiveId.LIST_POP, List.of(key), RedisPrimitiveInvocation.NoArguments.INSTANCE);
}
RedisPrimitiveMutationResult trimNewest(RedisPrimitiveKey key, int retainCount) {
RedisPrimitiveDescriptor descriptor =
catalog.descriptor(RedisPrimitiveId.LIST_TRIM_FIXED_BOUNDS);
RedisPrimitiveLimit retain = RedisPrimitiveLimit.of(retainCount, descriptor);
return executor.mutate(
RedisPrimitiveId.LIST_TRIM_FIXED_BOUNDS,
List.of(key),
new RedisPrimitiveInvocation.AtomicArguments(
List.of(
RedisPrimitiveValue.utf8(Integer.toString(retain.value()), 128),
RedisPrimitiveValue.utf8(Integer.toString(descriptor.maximumElements()), 128))));
}
}
@@ -1,51 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.time.Duration;
import java.util.Objects;
/**
* Finite accounting, expiry, reconciliation and subscriber bounds for the optional cache-only L1.
*
* <p>The weight budget is a conservative admission/eviction accounting proxy, not a JVM heap
* reservation or proof of an exact object-layout byte count.
*/
record RedisLocalCachePolicy(
int maximumEntries,
long maximumWeightBytes,
long maximumEntryWeightBytes,
Duration localTimeToLive,
Duration generationRecheckInterval,
int invalidationQueueCapacity) {
private static final int MAXIMUM_ENTRIES = 1_000_000;
private static final long MAXIMUM_WEIGHT_BYTES = 1_073_741_824L;
private static final Duration MAXIMUM_LOCAL_TTL = Duration.ofHours(1);
private static final int MAXIMUM_QUEUE_CAPACITY = 65_536;
RedisLocalCachePolicy {
if (maximumEntries < 1 || maximumEntries > MAXIMUM_ENTRIES) {
throw new IllegalArgumentException("maximumEntries must be in 1..1000000");
}
if (maximumWeightBytes < 1 || maximumWeightBytes > MAXIMUM_WEIGHT_BYTES) {
throw new IllegalArgumentException("maximumWeightBytes must be in 1..1073741824");
}
if (maximumEntryWeightBytes < 1 || maximumEntryWeightBytes > maximumWeightBytes) {
throw new IllegalArgumentException(
"maximumEntryWeightBytes must be positive and not exceed maximumWeightBytes");
}
localTimeToLive = positive(localTimeToLive, MAXIMUM_LOCAL_TTL, "localTimeToLive");
generationRecheckInterval =
positive(generationRecheckInterval, localTimeToLive, "generationRecheckInterval");
if (invalidationQueueCapacity < 1 || invalidationQueueCapacity > MAXIMUM_QUEUE_CAPACITY) {
throw new IllegalArgumentException("invalidationQueueCapacity must be in 1..65536");
}
}
private static Duration positive(Duration value, Duration maximum, String field) {
Objects.requireNonNull(value, field + " must be non-null");
if (value.isZero() || value.isNegative() || value.compareTo(maximum) > 0) {
throw new IllegalArgumentException(field + " must be positive and bounded");
}
return value;
}
}
@@ -1,526 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.application.cache.AuthoritativeAbsence;
import dev.caskeleton.application.cache.CacheInvalidationOutcome;
import dev.caskeleton.application.cache.CacheLookup;
import dev.caskeleton.application.cache.CacheObservationEvent;
import dev.caskeleton.application.cache.CacheObservationPort;
import dev.caskeleton.application.cache.CacheRecordMetadata;
import dev.caskeleton.application.cache.CacheRecordOutcome;
import dev.caskeleton.application.cache.CacheRegionPort;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.DateTimeException;
import java.time.Duration;
import java.time.Instant;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.function.Consumer;
/**
* Optional bounded L1 decorator for semantic cache values only.
*
* <p>Pub/Sub messages are best-effort eviction hints. A local entry never outlives either its own
* TTL or the L2 envelope hard expiry, and the region generation is periodically reconciled.
* Disconnect/queue overflow flushes every local entry and requires a successful generation read
* before L1 can admit data again.
*/
final class RedisLocalCacheRegion implements CacheRegionPort<String, String>, AutoCloseable {
private static final long ENTRY_OVERHEAD_BYTES = 128;
private final String cacheName;
private final RedisCacheL2Region l2;
private final RedisLocalCachePolicy policy;
private final Clock clock;
private final CacheObservationPort observations;
private final String invalidationChannel;
private final RedisCacheInvalidationMessage.Codec messageCodec;
private final Consumer<String> publisher;
private final RedisCacheInvalidationSubscriber invalidationSubscriber;
private final LinkedHashMap<String, LocalEntry> entries = new LinkedHashMap<>(16, 0.75f, true);
private long localWeightBytes;
private String observedGeneration;
private Instant nextGenerationRecheck = Instant.MIN;
private boolean forceGenerationRecheck = true;
private boolean generationProbeInProgress;
private long invalidationEpoch;
RedisLocalCacheRegion(
String cacheName,
RedisCacheL2Region l2,
RedisLocalCachePolicy policy,
Clock clock,
CacheObservationPort observations,
String invalidationChannel,
RedisCacheInvalidationMessage.Codec messageCodec,
Consumer<String> publisher) {
this.cacheName = boundedCacheName(cacheName);
this.l2 = Objects.requireNonNull(l2, "l2 must be non-null");
this.policy = Objects.requireNonNull(policy, "policy must be non-null");
this.clock = Objects.requireNonNull(clock, "clock must be non-null");
this.observations = Objects.requireNonNull(observations, "observations must be non-null");
this.invalidationChannel =
Objects.requireNonNull(invalidationChannel, "invalidationChannel must be non-null");
this.messageCodec = Objects.requireNonNull(messageCodec, "messageCodec must be non-null");
this.publisher = Objects.requireNonNull(publisher, "publisher must be non-null");
this.invalidationSubscriber =
new RedisCacheInvalidationSubscriber(
policy.invalidationQueueCapacity(),
new RedisCacheInvalidationSubscriber.Target() {
@Override
public void apply(RedisCacheInvalidationMessage message) {
applyInvalidationHint(message);
}
@Override
public void disconnected() {
subscriberDisconnected();
}
@Override
public void overflow() {
subscriberOverflow();
}
@Override
public void malformedMessage() {
observeMaintenance(
CacheObservationEvent.MaintenanceAction.SUBSCRIBER_EVENT,
CacheObservationEvent.MaintenanceResult.DROPPED,
CacheObservationEvent.MaintenanceCause.MALFORMED_MESSAGE,
0);
}
});
}
RedisCacheInvalidationSubscriber invalidationSubscriber() {
return invalidationSubscriber;
}
String invalidationChannel() {
return invalidationChannel;
}
RedisCacheInvalidationMessage.Codec invalidationMessageCodec() {
return messageCodec;
}
@Override
public CacheLookup<String> lookup(String key) {
invalidationSubscriber.drain();
Instant now = clock.instant();
long localTierPermit = reconcileGenerationIfRequired(now);
String identity = l2.localEntryIdentity(key);
if (localTierPermit >= 0) {
CacheLookup.Hit<String> local = localHit(identity, now, localTierPermit);
if (local != null) {
return local;
}
} else {
observeLookup(
CacheObservationEvent.Tier.LOCAL_L1,
CacheObservationEvent.LookupResult.BYPASS,
Duration.ZERO);
}
CacheLookup<String> lookup = l2.lookup(key);
observeLookup(CacheObservationEvent.Tier.REDIS_L2, lookupResult(lookup), Duration.ZERO);
if (localTierPermit >= 0 && lookup instanceof CacheLookup.Hit<String> hit) {
admit(identity, hit, now, localTierPermit);
}
return lookup;
}
@Override
public CacheRecordOutcome record(String key, String value, CacheRecordMetadata metadata) {
CacheRecordOutcome outcome = l2.record(key, value, metadata);
if (outcome == CacheRecordOutcome.RECORDED) {
invalidateLocalIdentity(
l2.localEntryIdentity(key), CacheObservationEvent.MaintenanceCause.INVALIDATION);
publish(RedisCacheInvalidationMessage.key(l2.localEntryIdentity(key)));
}
return outcome;
}
@Override
public CacheRecordOutcome recordAbsent(
String key, AuthoritativeAbsence reason, CacheRecordMetadata metadata) {
CacheRecordOutcome outcome = l2.recordAbsent(key, reason, metadata);
if (outcome == CacheRecordOutcome.RECORDED) {
invalidateLocalIdentity(
l2.localEntryIdentity(key), CacheObservationEvent.MaintenanceCause.INVALIDATION);
publish(RedisCacheInvalidationMessage.key(l2.localEntryIdentity(key)));
}
return outcome;
}
@Override
public CacheInvalidationOutcome invalidate(String key) {
String identity = l2.localEntryIdentity(key);
CacheInvalidationOutcome outcome = l2.invalidate(key);
invalidateLocalIdentity(identity, CacheObservationEvent.MaintenanceCause.INVALIDATION);
if (outcome == CacheInvalidationOutcome.INVALIDATED
|| outcome == CacheInvalidationOutcome.INDETERMINATE) {
publish(RedisCacheInvalidationMessage.key(identity));
}
return outcome;
}
@Override
public CacheInvalidationOutcome invalidateRegion() {
CacheInvalidationOutcome outcome = l2.invalidateRegion();
invalidateAllLocal(
CacheObservationEvent.MaintenanceCause.INVALIDATION,
CacheObservationEvent.MaintenanceAction.FLUSH,
CacheObservationEvent.MaintenanceResult.FLUSHED);
if (outcome == CacheInvalidationOutcome.INVALIDATED
|| outcome == CacheInvalidationOutcome.INDETERMINATE) {
try {
publish(RedisCacheInvalidationMessage.region(l2.currentRegionGeneration()));
} catch (RuntimeException ignored) {
observeMaintenance(
CacheObservationEvent.MaintenanceAction.SUBSCRIBER_EVENT,
CacheObservationEvent.MaintenanceResult.ERROR,
CacheObservationEvent.MaintenanceCause.RECONCILIATION_FAILURE,
0);
}
}
return outcome;
}
synchronized int localEntryCount() {
return entries.size();
}
synchronized long localWeightBytes() {
return localWeightBytes;
}
@Override
public void close() {
invalidateAllLocal(
CacheObservationEvent.MaintenanceCause.INVALIDATION,
CacheObservationEvent.MaintenanceAction.FLUSH,
CacheObservationEvent.MaintenanceResult.FLUSHED);
messageCodec.close();
}
private synchronized CacheLookup.Hit<String> localHit(
String identity, Instant now, long permitEpoch) {
if (!permitCurrent(permitEpoch)) {
return null;
}
LocalEntry entry = entries.get(identity);
if (entry == null) {
observeLookup(
CacheObservationEvent.Tier.LOCAL_L1,
CacheObservationEvent.LookupResult.MISS,
Duration.ZERO);
return null;
}
if (!now.isBefore(entry.localExpiresAt()) || !now.isBefore(entry.hit().hardExpiresAt())) {
remove(identity);
observeMaintenance(
CacheObservationEvent.MaintenanceAction.EVICT,
CacheObservationEvent.MaintenanceResult.SUCCESS,
CacheObservationEvent.MaintenanceCause.TTL,
1);
observeLookup(
CacheObservationEvent.Tier.LOCAL_L1,
CacheObservationEvent.LookupResult.MISS,
Duration.ZERO);
return null;
}
CacheLookup.Hit<String> hit = entry.hit();
CacheLookup.Hit<String> current =
new CacheLookup.Hit<>(
hit.value(),
now.isBefore(hit.softExpiresAt())
? CacheLookup.Freshness.FRESH
: CacheLookup.Freshness.STALE,
hit.sourceRevision(),
hit.softExpiresAt(),
hit.hardExpiresAt(),
hit.observationToken(),
hit.writeCondition());
observeLookup(
CacheObservationEvent.Tier.LOCAL_L1,
CacheObservationEvent.LookupResult.HIT,
nonNegativeDuration(entry.admittedAt(), now));
return current;
}
private synchronized void admit(
String identity, CacheLookup.Hit<String> hit, Instant observedAt, long permitEpoch) {
if (!permitCurrent(permitEpoch)) {
return;
}
if (!observedAt.isBefore(hit.hardExpiresAt())) {
return;
}
long weight = conservativeEntryWeightBytes(identity, hit.value());
if (weight > policy.maximumEntryWeightBytes() || weight > policy.maximumWeightBytes()) {
observeMaintenance(
CacheObservationEvent.MaintenanceAction.EVICT,
CacheObservationEvent.MaintenanceResult.DROPPED,
CacheObservationEvent.MaintenanceCause.WEIGHT,
0);
return;
}
LocalEntry old = entries.remove(identity);
if (old != null) {
localWeightBytes -= old.weightBytes();
}
while (!entries.isEmpty()
&& (entries.size() >= policy.maximumEntries()
|| localWeightBytes + weight > policy.maximumWeightBytes())) {
boolean cardinality = entries.size() >= policy.maximumEntries();
Iterator<Map.Entry<String, LocalEntry>> iterator = entries.entrySet().iterator();
Map.Entry<String, LocalEntry> eldest = iterator.next();
localWeightBytes -= eldest.getValue().weightBytes();
iterator.remove();
observeMaintenance(
CacheObservationEvent.MaintenanceAction.EVICT,
CacheObservationEvent.MaintenanceResult.SUCCESS,
cardinality
? CacheObservationEvent.MaintenanceCause.CARDINALITY
: CacheObservationEvent.MaintenanceCause.WEIGHT,
1);
}
Instant localExpiresAt =
earlier(hit.hardExpiresAt(), plus(observedAt, policy.localTimeToLive()));
entries.put(identity, new LocalEntry(hit, observedAt, localExpiresAt, weight));
localWeightBytes += weight;
}
private long reconcileGenerationIfRequired(Instant now) {
long probeEpoch;
synchronized (this) {
if (!forceGenerationRecheck && now.isBefore(nextGenerationRecheck)) {
return invalidationEpoch;
}
if (generationProbeInProgress) {
return -1;
}
generationProbeInProgress = true;
probeEpoch = invalidationEpoch;
}
String current;
try {
current = l2.currentRegionGeneration();
} catch (RuntimeException exception) {
synchronized (this) {
generationProbeInProgress = false;
invalidationEpoch++;
forceGenerationRecheck = true;
nextGenerationRecheck = now;
flushInsideLock(
CacheObservationEvent.MaintenanceCause.RECONCILIATION_FAILURE,
CacheObservationEvent.MaintenanceAction.RECONCILE,
CacheObservationEvent.MaintenanceResult.ERROR);
return -1;
}
}
synchronized (this) {
generationProbeInProgress = false;
if (probeEpoch != invalidationEpoch) {
forceGenerationRecheck = true;
nextGenerationRecheck = now;
return -1;
}
boolean changed = observedGeneration != null && !observedGeneration.equals(current);
if (changed) {
invalidationEpoch++;
flushInsideLock(
CacheObservationEvent.MaintenanceCause.GENERATION_CHANGED,
CacheObservationEvent.MaintenanceAction.RECONCILE,
CacheObservationEvent.MaintenanceResult.FLUSHED);
} else {
observeMaintenance(
CacheObservationEvent.MaintenanceAction.RECONCILE,
CacheObservationEvent.MaintenanceResult.UNCHANGED,
CacheObservationEvent.MaintenanceCause.INVALIDATION,
0);
}
observedGeneration = current;
forceGenerationRecheck = false;
nextGenerationRecheck = plus(now, policy.generationRecheckInterval());
return invalidationEpoch;
}
}
private void applyInvalidationHint(RedisCacheInvalidationMessage message) {
if (message instanceof RedisCacheInvalidationMessage.Key key) {
invalidateLocalIdentity(key.value(), CacheObservationEvent.MaintenanceCause.INVALIDATION);
return;
}
invalidateAllLocal(
CacheObservationEvent.MaintenanceCause.INVALIDATION,
CacheObservationEvent.MaintenanceAction.SUBSCRIBER_EVENT,
CacheObservationEvent.MaintenanceResult.FLUSHED);
}
private void subscriberDisconnected() {
invalidateAllLocal(
CacheObservationEvent.MaintenanceCause.SUBSCRIBER_DISCONNECTED,
CacheObservationEvent.MaintenanceAction.FLUSH,
CacheObservationEvent.MaintenanceResult.FLUSHED);
}
private void subscriberOverflow() {
invalidateAllLocal(
CacheObservationEvent.MaintenanceCause.SUBSCRIBER_OVERFLOW,
CacheObservationEvent.MaintenanceAction.FLUSH,
CacheObservationEvent.MaintenanceResult.FLUSHED);
}
private synchronized void invalidateLocalIdentity(
String identity, CacheObservationEvent.MaintenanceCause cause) {
invalidationEpoch++;
LocalEntry removed = remove(identity);
if (removed != null) {
observeMaintenance(
CacheObservationEvent.MaintenanceAction.EVICT,
CacheObservationEvent.MaintenanceResult.SUCCESS,
cause,
1);
}
}
private LocalEntry remove(String identity) {
LocalEntry removed = entries.remove(identity);
if (removed != null) {
localWeightBytes -= removed.weightBytes();
}
return removed;
}
private synchronized void invalidateAllLocal(
CacheObservationEvent.MaintenanceCause cause,
CacheObservationEvent.MaintenanceAction action,
CacheObservationEvent.MaintenanceResult result) {
invalidationEpoch++;
forceGenerationRecheck = true;
flushInsideLock(cause, action, result);
}
private void flushInsideLock(
CacheObservationEvent.MaintenanceCause cause,
CacheObservationEvent.MaintenanceAction action,
CacheObservationEvent.MaintenanceResult result) {
int affected = entries.size();
entries.clear();
localWeightBytes = 0;
observeMaintenance(action, result, cause, affected);
}
private boolean permitCurrent(long permitEpoch) {
return permitEpoch == invalidationEpoch && !forceGenerationRecheck;
}
private void publish(RedisCacheInvalidationMessage message) {
try {
publisher.accept(messageCodec.encode(message));
observeMaintenance(
CacheObservationEvent.MaintenanceAction.SUBSCRIBER_EVENT,
CacheObservationEvent.MaintenanceResult.SUCCESS,
CacheObservationEvent.MaintenanceCause.INVALIDATION,
0);
} catch (RuntimeException ignored) {
observeMaintenance(
CacheObservationEvent.MaintenanceAction.SUBSCRIBER_EVENT,
CacheObservationEvent.MaintenanceResult.ERROR,
CacheObservationEvent.MaintenanceCause.RECONCILIATION_FAILURE,
0);
}
}
private void observeLookup(
CacheObservationEvent.Tier tier,
CacheObservationEvent.LookupResult result,
Duration entryAge) {
observe(new CacheObservationEvent.Lookup(cacheName, tier, result, entryAge));
}
private void observeMaintenance(
CacheObservationEvent.MaintenanceAction action,
CacheObservationEvent.MaintenanceResult result,
CacheObservationEvent.MaintenanceCause cause,
int affectedEntries) {
observe(
new CacheObservationEvent.LocalMaintenance(
cacheName, action, result, cause, affectedEntries));
}
private void observe(CacheObservationEvent event) {
try {
observations.observe(event);
} catch (RuntimeException ignored) {
// Metrics/logging must never change cache semantics.
}
}
private static CacheObservationEvent.LookupResult lookupResult(CacheLookup<String> lookup) {
if (lookup instanceof CacheLookup.Hit<?> || lookup instanceof CacheLookup.NegativeHit<?>) {
return CacheObservationEvent.LookupResult.HIT;
}
if (lookup instanceof CacheLookup.Miss<?>) {
return CacheObservationEvent.LookupResult.MISS;
}
return CacheObservationEvent.LookupResult.ERROR;
}
/**
* Accounts UTF-8 identity/value bytes plus a fixed conservative allowance for the entry, lookup
* metadata, timestamps and map-node references. It is not an exact JVM heap measurement.
*/
static long conservativeEntryWeightBytes(String identity, String value) {
return Math.addExact(
ENTRY_OVERHEAD_BYTES,
Math.addExact(
identity.getBytes(StandardCharsets.UTF_8).length,
value.getBytes(StandardCharsets.UTF_8).length));
}
private static Instant earlier(Instant first, Instant second) {
return first.isBefore(second) ? first : second;
}
private static Instant plus(Instant value, Duration duration) {
try {
return value.plus(duration);
} catch (ArithmeticException | DateTimeException exception) {
throw new IllegalStateException(
"local cache expiry exceeds supported instant range", exception);
}
}
private static Duration nonNegativeDuration(Instant from, Instant to) {
return to.isBefore(from) ? Duration.ZERO : Duration.between(from, to);
}
private static String boundedCacheName(String value) {
if (value == null || !value.matches("[a-z][a-z0-9-]{0,62}")) {
throw new IllegalArgumentException(
"cacheName must be a code-owned lower-case slug with 1..63 characters");
}
return value;
}
private record LocalEntry(
CacheLookup.Hit<String> hit, Instant admittedAt, Instant localExpiresAt, long weightBytes) {
private LocalEntry {
Objects.requireNonNull(hit, "hit must be non-null");
Objects.requireNonNull(admittedAt, "admittedAt must be non-null");
Objects.requireNonNull(localExpiresAt, "localExpiresAt must be non-null");
if (weightBytes < 1) {
throw new IllegalArgumentException("weightBytes must be positive");
}
}
}
}
@@ -1,45 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.ConstructorBinding;
/** Typed, default-off settings for the cache-only local L1 tier. */
@ConfigurationProperties(prefix = "app.cache.redis.l1")
public record RedisLocalCacheSettings(
boolean enabled,
int maximumEntries,
long maximumWeightBytes,
long maximumEntryWeightBytes,
Duration timeToLive,
Duration generationRecheckInterval,
int invalidationQueueCapacity) {
@ConstructorBinding
public RedisLocalCacheSettings {
maximumEntries = maximumEntries == 0 ? 10_000 : maximumEntries;
maximumWeightBytes = maximumWeightBytes == 0 ? 67_108_864 : maximumWeightBytes;
maximumEntryWeightBytes = maximumEntryWeightBytes == 0 ? 1_048_576 : maximumEntryWeightBytes;
timeToLive = timeToLive == null ? Duration.ofSeconds(30) : timeToLive;
generationRecheckInterval =
generationRecheckInterval == null ? Duration.ofSeconds(5) : generationRecheckInterval;
invalidationQueueCapacity = invalidationQueueCapacity == 0 ? 1024 : invalidationQueueCapacity;
new RedisLocalCachePolicy(
maximumEntries,
maximumWeightBytes,
maximumEntryWeightBytes,
timeToLive,
generationRecheckInterval,
invalidationQueueCapacity);
}
RedisLocalCachePolicy policy() {
return new RedisLocalCachePolicy(
maximumEntries,
maximumWeightBytes,
maximumEntryWeightBytes,
timeToLive,
generationRecheckInterval,
invalidationQueueCapacity);
}
}
@@ -1,34 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
/** Executes an exact catalog script through EVALSHA, with EVAL allowed only after NOSCRIPT. */
final class RedisLuaProgramExecutor implements RedisProgramExecutor {
private final RedisProgramCatalog catalog;
private final RedisBinaryCommands commands;
RedisLuaProgramExecutor(RedisProgramCatalog catalog, RedisBinaryCommands commands) {
this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null");
this.commands = Objects.requireNonNull(commands, "commands must be non-null");
}
@Override
public String execute(RedisCatalogProgramInvocation invocation) {
Objects.requireNonNull(invocation, "invocation must be non-null");
RedisProgramDescriptor descriptor = invocation.descriptor();
if (catalog.descriptor(descriptor.id()) != descriptor) {
throw new IllegalArgumentException("Redis program descriptor is not owned by this catalog");
}
byte[] result = RedisScriptRecovery.evalValue(commands, invocation);
if (result == null || result.length == 0 || result.length > 128) {
throw new IllegalStateException("Redis program returned an invalid status payload");
}
String status = new String(result, StandardCharsets.US_ASCII);
if (!descriptor.statuses().contains(status)) {
throw new RedisProgramCompatibilityException(descriptor.id(), status);
}
return status;
}
}
@@ -1,621 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyBuilder;
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyDigest;
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyNamespace;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.Arrays;
import java.util.Base64;
import java.util.HexFormat;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.LongSupplier;
/**
* Executes the closed Redis session Lua set with pseudonymous keys and bounded fail-closed replies.
*/
final class RedisLuaVersionedSessionStore implements VersionedRedisSessionStore, AutoCloseable {
private static final SecureRandom RANDOM = new SecureRandom();
private static final Base64.Encoder BASE64 = Base64.getEncoder();
private static final Base64.Decoder BASE64_DECODER = Base64.getDecoder();
private static final HexFormat HEX = HexFormat.of();
private final RedisStructuredCommands commands;
private final RedisProgramCatalog catalog;
private final RedisKeyNamespace liveNamespace;
private final RedisKeyNamespace tombstoneNamespace;
private final int hashKeyVersion;
private final byte[] hmacSecret;
private final AtomicBoolean closed = new AtomicBoolean();
private final RedisCapabilityObserver observer;
RedisLuaVersionedSessionStore(
RedisStructuredCommands commands,
String application,
String environment,
int hashKeyVersion,
int keyVersion,
byte[] hmacSecret) {
this(
commands,
application,
environment,
hashKeyVersion,
keyVersion,
hmacSecret,
NoOpRedisCapabilityObservationPort.instance(),
System::nanoTime);
}
RedisLuaVersionedSessionStore(
RedisStructuredCommands commands,
String application,
String environment,
int hashKeyVersion,
int keyVersion,
byte[] hmacSecret,
RedisCapabilityObservationPort observations,
LongSupplier ticker) {
this.commands = Objects.requireNonNull(commands, "commands must be non-null");
this.catalog = RedisProgramCatalog.sessionV1();
this.liveNamespace =
new RedisKeyNamespace(
application,
environment,
"session",
"repository",
hashKeyVersion,
keyVersion,
"live",
512);
this.tombstoneNamespace =
new RedisKeyNamespace(
application,
environment,
"session",
"repository",
hashKeyVersion,
keyVersion,
"tombstone",
512);
this.hashKeyVersion = hashKeyVersion;
this.hmacSecret =
Arrays.copyOf(
Objects.requireNonNull(hmacSecret, "hmacSecret must be non-null"), hmacSecret.length);
if (this.hmacSecret.length < 32) {
throw new IllegalArgumentException("session key HMAC secret requires at least 32 bytes");
}
this.observer = new RedisCapabilityObserver(observations, ticker);
}
@Override
public SessionMutationAttempt newMutationAttempt() {
ensureOpen();
byte[] random = new byte[24];
RANDOM.nextBytes(random);
return new SessionMutationAttempt(
Base64.getUrlEncoder().withoutPadding().encodeToString(random));
}
@Override
public SessionCreateOutcome create(SessionCreateCommand command) {
return observer.observe(
RedisCapabilityObservationEvent.Capability.SESSION,
RedisCapabilityObservationEvent.Role.SESSION,
RedisCapabilityObservationEvent.Operation.SESSION_CREATE,
() -> createOpen(command),
RedisLuaVersionedSessionStore::classifyCreate);
}
private SessionCreateOutcome createOpen(SessionCreateCommand command) {
Objects.requireNonNull(command, "command must be non-null");
try {
String status =
status(
execute(
RedisProgramId.SESSION_CREATE_V1,
keys(command.sessionId()),
List.of(
base64(command.payload()),
ascii(command.newRevision()),
ascii(command.absoluteExpiresAt()),
ascii(command.lastAccessedAt()),
ascii(command.idleTimeout().toMillis()),
ascii(command.attempt().operationId()),
ascii(digest(command.payload())))));
return SessionCreateOutcome.valueOf(status);
} catch (RedisCommandFailureException failure) {
return mutationFailure(
failure, SessionCreateOutcome.INDETERMINATE, SessionCreateOutcome.UNAVAILABLE);
}
}
@Override
public SessionInspectionOutcome inspect(SessionInspectionCommand command) {
return observer.observe(
RedisCapabilityObservationEvent.Capability.SESSION,
RedisCapabilityObservationEvent.Role.SESSION,
RedisCapabilityObservationEvent.Operation.SESSION_INSPECT,
() -> inspectOpen(command),
RedisLuaVersionedSessionStore::classifyInspection);
}
private SessionInspectionOutcome inspectOpen(SessionInspectionCommand command) {
Objects.requireNonNull(command, "command must be non-null");
try {
List<byte[]> reply =
execute(
RedisProgramId.SESSION_INSPECT_V1,
keys(command.sessionId()),
List.of(ascii(command.now())));
String status = status(reply);
return switch (status) {
case "LIVE" -> live(reply);
case "TOMBSTONED" -> new SessionInspectionOutcome.Tombstoned();
case "ABSENT" -> new SessionInspectionOutcome.Absent();
case "ABSOLUTE_EXPIRED" -> new SessionInspectionOutcome.AbsoluteExpired();
default -> throw incompatible(RedisProgramId.SESSION_INSPECT_V1, status);
};
} catch (RedisCommandFailureException failure) {
return new SessionInspectionOutcome.Unavailable();
}
}
@Override
public SessionSaveOutcome saveIfLive(SessionSaveCommand command) {
return observer.observe(
RedisCapabilityObservationEvent.Capability.SESSION,
RedisCapabilityObservationEvent.Role.SESSION,
RedisCapabilityObservationEvent.Operation.SESSION_SAVE,
() -> saveIfLiveOpen(command),
RedisLuaVersionedSessionStore::classifySave);
}
private SessionSaveOutcome saveIfLiveOpen(SessionSaveCommand command) {
Objects.requireNonNull(command, "command must be non-null");
try {
String status =
status(
execute(
RedisProgramId.SESSION_SAVE_IF_LIVE_V1,
keys(command.sessionId()),
List.of(
base64(command.payload()),
ascii(command.expectedRevision()),
ascii(command.newRevision()),
ascii(command.absoluteExpiresAt()),
ascii(command.lastAccessedAt()),
ascii(command.idleTimeout().toMillis()),
ascii(command.attempt().operationId()),
ascii(digest(command.payload())))));
return SessionSaveOutcome.valueOf(status);
} catch (RedisCommandFailureException failure) {
return mutationFailure(
failure, SessionSaveOutcome.INDETERMINATE, SessionSaveOutcome.UNAVAILABLE);
}
}
@Override
public SessionTouchOutcome touchIfLive(SessionTouchCommand command) {
return observer.observe(
RedisCapabilityObservationEvent.Capability.SESSION,
RedisCapabilityObservationEvent.Role.SESSION,
RedisCapabilityObservationEvent.Operation.SESSION_TOUCH,
() -> touchIfLiveOpen(command),
RedisLuaVersionedSessionStore::classifyTouch);
}
private SessionTouchOutcome touchIfLiveOpen(SessionTouchCommand command) {
Objects.requireNonNull(command, "command must be non-null");
try {
String status =
status(
execute(
RedisProgramId.SESSION_TOUCH_IF_LIVE_V1,
keys(command.sessionId()),
List.of(
ascii(command.expectedRevision()),
ascii(command.now()),
ascii(command.absoluteExpiresAt()),
ascii(command.idleTimeout().toMillis()),
ascii(command.touchInterval().toMillis()),
ascii(command.attempt().operationId()))));
return SessionTouchOutcome.valueOf(status);
} catch (RedisCommandFailureException failure) {
return mutationFailure(
failure, SessionTouchOutcome.INDETERMINATE, SessionTouchOutcome.UNAVAILABLE);
}
}
@Override
public SessionRevokeOutcome tombstoneAndDelete(SessionRevokeCommand command) {
return observer.observe(
RedisCapabilityObservationEvent.Capability.SESSION,
RedisCapabilityObservationEvent.Role.SESSION,
RedisCapabilityObservationEvent.Operation.SESSION_REVOKE,
() -> tombstoneAndDeleteOpen(command),
RedisLuaVersionedSessionStore::classifyRevoke);
}
private SessionRevokeOutcome tombstoneAndDeleteOpen(SessionRevokeCommand command) {
Objects.requireNonNull(command, "command must be non-null");
try {
String status =
status(
execute(
RedisProgramId.SESSION_TOMBSTONE_AND_DELETE_V1,
keys(command.sessionId()),
List.of(
ascii(command.expectedRevision()),
ascii(command.tombstoneTimeToLive().toMillis()),
ascii(command.attempt().operationId()))));
return SessionRevokeOutcome.valueOf(status);
} catch (RedisCommandFailureException failure) {
return mutationFailure(
failure, SessionRevokeOutcome.INDETERMINATE, SessionRevokeOutcome.UNAVAILABLE);
}
}
@Override
public SessionRotateOutcome rotate(SessionRotateCommand command) {
return observer.observe(
RedisCapabilityObservationEvent.Capability.SESSION,
RedisCapabilityObservationEvent.Role.SESSION,
RedisCapabilityObservationEvent.Operation.SESSION_ROTATE,
() -> rotateOpen(command),
RedisLuaVersionedSessionStore::classifyRotate);
}
private SessionRotateOutcome rotateOpen(SessionRotateCommand command) {
Objects.requireNonNull(command, "command must be non-null");
try {
RedisKeyPair oldKeys = physicalKeys(command.oldSessionId());
RedisKeyPair newKeys = physicalKeys(command.newSessionId());
String status =
status(
execute(
RedisProgramId.SESSION_ROTATE_V1,
List.of(oldKeys.live(), oldKeys.tombstone(), newKeys.live(), newKeys.tombstone()),
List.of(
base64(command.payload()),
ascii(command.expectedRevision()),
ascii(command.newRevision()),
ascii(command.absoluteExpiresAt()),
ascii(command.lastAccessedAt()),
ascii(command.idleTimeout().toMillis()),
ascii(command.tombstoneTimeToLive().toMillis()),
ascii(command.attempt().operationId()),
ascii(digest(command.payload())),
ascii(newKeys.resourceDigest()))));
return SessionRotateOutcome.valueOf(status);
} catch (RedisCommandFailureException failure) {
return mutationFailure(
failure, SessionRotateOutcome.INDETERMINATE, SessionRotateOutcome.UNAVAILABLE);
}
}
private List<byte[]> execute(RedisProgramId program, List<byte[]> keys, List<byte[]> arguments) {
ensureOpen();
RedisProgramDescriptor descriptor = catalog.descriptor(program);
if (keys.size() != descriptor.keyCount() || arguments.size() != descriptor.argumentCount()) {
throw new IllegalArgumentException("Redis session invocation shape is invalid");
}
validateFields(keys, descriptor.maximumKeyBytes(), "key", false);
validateFields(arguments, descriptor.maximumArgumentBytes(), "argument", false);
List<byte[]> reply =
RedisScriptRecovery.evalMulti(
commands,
catalog.capabilityInvocation(new ProgramInvocation(program, keys, arguments)));
if (reply == null || reply.size() != descriptor.replyFieldCount()) {
throw incompatible(program, "<malformed-reply>");
}
validateFields(reply, descriptor.maximumReplyFieldBytes(), "reply", true);
String status = status(reply);
if (!descriptor.statuses().contains(status)) {
throw incompatible(program, status);
}
return copy(reply);
}
private SessionInspectionOutcome.Live live(List<byte[]> reply) {
if (reply.size() != 5) {
throw incompatible(RedisProgramId.SESSION_INSPECT_V1, "<malformed-live-reply>");
}
try {
byte[] payload = BASE64_DECODER.decode(asciiText(reply.get(1)));
return new SessionInspectionOutcome.Live(
payload,
positiveLong(reply.get(2)),
Instant.ofEpochMilli(positiveLong(reply.get(3))),
Instant.ofEpochMilli(positiveLong(reply.get(4))));
} catch (IllegalArgumentException exception) {
throw incompatible(RedisProgramId.SESSION_INSPECT_V1, "<malformed-live-reply>");
}
}
private List<byte[]> keys(String sessionId) {
RedisKeyPair keys = physicalKeys(sessionId);
return List.of(keys.live(), keys.tombstone());
}
static final class ProgramInvocation implements RedisCatalogProgramMaterial {
private final RedisProgramId programId;
private final List<byte[]> keys;
private final List<byte[]> arguments;
private ProgramInvocation(RedisProgramId programId, List<byte[]> keys, List<byte[]> arguments) {
this.programId = Objects.requireNonNull(programId, "programId must be non-null");
this.keys = copy(keys);
this.arguments = copy(arguments);
}
@Override
public RedisProgramId programId() {
return programId;
}
@Override
public RedisCatalogProgramInvocation.ReplyShape replyShape() {
return RedisCatalogProgramInvocation.ReplyShape.MULTI;
}
@Override
public List<byte[]> copyKeys() {
return copy(keys);
}
@Override
public List<byte[]> copyArguments() {
return copy(arguments);
}
}
private RedisKeyPair physicalKeys(String sessionId) {
ensureOpen();
RedisKeyDigest keyDigest =
RedisKeyDigest.sensitive(
hashKeyVersion, hmacSecret, List.of(sessionId.getBytes(StandardCharsets.US_ASCII)));
return new RedisKeyPair(
ascii(RedisKeyBuilder.build(liveNamespace, keyDigest)),
ascii(RedisKeyBuilder.build(tombstoneNamespace, keyDigest)),
keyDigest.resourceDigest());
}
private static String status(List<byte[]> reply) {
String value = asciiText(reply.getFirst());
if (!value.matches("[A-Z][A-Z_]{1,63}")) {
throw incompatible(null, "<malformed-status>");
}
return value;
}
private static long positiveLong(byte[] value) {
String text = asciiText(value);
if (!text.matches("[1-9][0-9]{0,18}")) {
throw new IllegalArgumentException("expected positive decimal");
}
return Long.parseLong(text);
}
private static String asciiText(byte[] value) {
for (byte character : value) {
if (character < 0x20 || character > 0x7e) {
throw new IllegalArgumentException("expected printable ASCII");
}
}
return new String(value, StandardCharsets.US_ASCII);
}
private static void validateFields(
List<byte[]> fields, int maximumBytes, String label, boolean allowEmpty) {
for (byte[] field : fields) {
if (field == null || (!allowEmpty && field.length < 1) || field.length > maximumBytes) {
throw new IllegalArgumentException("Redis session " + label + " field is out of bounds");
}
}
}
private static <T> T mutationFailure(
RedisCommandFailureException failure, T indeterminate, T unavailable) {
return failure.certainty() == RedisCommandFailureException.Certainty.INDETERMINATE
? indeterminate
: unavailable;
}
private static RedisCapabilityObserver.Classification classifyCreate(
SessionCreateOutcome outcome) {
return classifyMutation(
outcome == SessionCreateOutcome.CREATED
|| outcome == SessionCreateOutcome.ALREADY_CREATED_SAME_OPERATION,
outcome == SessionCreateOutcome.INDETERMINATE,
outcome == SessionCreateOutcome.UNAVAILABLE,
outcome == SessionCreateOutcome.EXISTS_CONFLICT
|| outcome == SessionCreateOutcome.TOMBSTONED);
}
static RedisCapabilityObserver.Classification classifyInspection(
SessionInspectionOutcome outcome) {
return switch (outcome) {
case SessionInspectionOutcome.Live ignored ->
definite(RedisCapabilityObservationEvent.Outcome.HIT);
case SessionInspectionOutcome.Absent ignored ->
definite(RedisCapabilityObservationEvent.Outcome.MISS);
case SessionInspectionOutcome.Tombstoned ignored ->
definite(RedisCapabilityObservationEvent.Outcome.TOMBSTONED);
case SessionInspectionOutcome.AbsoluteExpired ignored ->
definite(RedisCapabilityObservationEvent.Outcome.ABSOLUTE_EXPIRED);
case SessionInspectionOutcome.Unavailable ignored -> unavailable();
};
}
private static RedisCapabilityObserver.Classification classifySave(SessionSaveOutcome outcome) {
return classifyMutation(
outcome == SessionSaveOutcome.SAVED
|| outcome == SessionSaveOutcome.ALREADY_SAVED_SAME_OPERATION,
outcome == SessionSaveOutcome.INDETERMINATE,
outcome == SessionSaveOutcome.UNAVAILABLE,
outcome == SessionSaveOutcome.STALE_REVISION
|| outcome == SessionSaveOutcome.MUTATION_CONFLICT
|| outcome == SessionSaveOutcome.TOMBSTONED);
}
private static RedisCapabilityObserver.Classification classifyTouch(SessionTouchOutcome outcome) {
return classifyMutation(
outcome == SessionTouchOutcome.TOUCHED
|| outcome == SessionTouchOutcome.ALREADY_TOUCHED_SAME_OPERATION
|| outcome == SessionTouchOutcome.TOUCH_NOT_DUE,
outcome == SessionTouchOutcome.INDETERMINATE,
outcome == SessionTouchOutcome.UNAVAILABLE,
outcome == SessionTouchOutcome.STALE_REVISION
|| outcome == SessionTouchOutcome.MUTATION_CONFLICT
|| outcome == SessionTouchOutcome.TOMBSTONED);
}
private static RedisCapabilityObserver.Classification classifyRevoke(
SessionRevokeOutcome outcome) {
return classifyMutation(
outcome == SessionRevokeOutcome.REVOKED_AND_DELETED
|| outcome == SessionRevokeOutcome.TOMBSTONED_ABSENT
|| outcome == SessionRevokeOutcome.ALREADY_REVOKED_SAME_OPERATION,
outcome == SessionRevokeOutcome.INDETERMINATE,
outcome == SessionRevokeOutcome.UNAVAILABLE,
outcome == SessionRevokeOutcome.STALE_REVISION
|| outcome == SessionRevokeOutcome.OPERATION_CONFLICT);
}
private static RedisCapabilityObserver.Classification classifyRotate(
SessionRotateOutcome outcome) {
return classifyMutation(
outcome == SessionRotateOutcome.ROTATED
|| outcome == SessionRotateOutcome.ALREADY_ROTATED_SAME_OPERATION,
outcome == SessionRotateOutcome.INDETERMINATE,
outcome == SessionRotateOutcome.UNAVAILABLE,
outcome == SessionRotateOutcome.STALE_REVISION
|| outcome == SessionRotateOutcome.OLD_TOMBSTONED
|| outcome == SessionRotateOutcome.NEW_ID_CONFLICT);
}
private static RedisCapabilityObserver.Classification classifyMutation(
boolean success, boolean indeterminate, boolean unavailable, boolean conflict) {
if (success) {
return definite(RedisCapabilityObservationEvent.Outcome.SUCCESS);
}
if (indeterminate) {
return new RedisCapabilityObserver.Classification(
RedisCapabilityObservationEvent.Outcome.INDETERMINATE,
RedisCapabilityObservationEvent.Certainty.INDETERMINATE);
}
if (unavailable) {
return unavailable();
}
return definite(
conflict
? RedisCapabilityObservationEvent.Outcome.CONFLICT
: RedisCapabilityObservationEvent.Outcome.MISS);
}
private static RedisCapabilityObserver.Classification definite(
RedisCapabilityObservationEvent.Outcome outcome) {
return new RedisCapabilityObserver.Classification(
outcome, RedisCapabilityObservationEvent.Certainty.DEFINITE);
}
private static RedisCapabilityObserver.Classification unavailable() {
return new RedisCapabilityObserver.Classification(
RedisCapabilityObservationEvent.Outcome.UNAVAILABLE,
RedisCapabilityObservationEvent.Certainty.NOT_APPLIED);
}
private static List<byte[]> copy(List<byte[]> values) {
return values.stream().map(byte[]::clone).toList();
}
private static byte[] base64(byte[] value) {
return BASE64.encode(value);
}
private static byte[] ascii(long value) {
return ascii(Long.toString(value));
}
private static byte[] ascii(Instant value) {
return ascii(value.toEpochMilli());
}
private static byte[] ascii(String value) {
return value.getBytes(StandardCharsets.US_ASCII);
}
private static String digest(byte[] value) {
try {
return HEX.formatHex(MessageDigest.getInstance("SHA-256").digest(value));
} catch (GeneralSecurityException exception) {
throw new IllegalStateException(
"SHA-256 unavailable for Redis session payload digest", exception);
}
}
private void ensureOpen() {
if (closed.get()) {
throw new IllegalStateException("Redis session key material is closed");
}
}
@Override
public void close() {
if (closed.compareAndSet(false, true)) {
Arrays.fill(hmacSecret, (byte) 0);
}
}
boolean destroyed() {
return closed.get()
&& java.util.stream.IntStream.range(0, hmacSecret.length)
.allMatch(index -> hmacSecret[index] == 0);
}
private static RedisSessionProgramCompatibilityException incompatible(
RedisProgramId program, String detail) {
return new RedisSessionProgramCompatibilityException(
program == null ? "<unknown>" : program.externalId(), detail);
}
private static final class RedisKeyPair {
private final byte[] live;
private final byte[] tombstone;
private final String resourceDigest;
private RedisKeyPair(byte[] live, byte[] tombstone, String resourceDigest) {
this.live = live.clone();
this.tombstone = tombstone.clone();
this.resourceDigest = resourceDigest;
}
private byte[] live() {
return live.clone();
}
private byte[] tombstone() {
return tombstone.clone();
}
private String resourceDigest() {
return resourceDigest;
}
}
}
final class RedisSessionProgramCompatibilityException extends RuntimeException {
RedisSessionProgramCompatibilityException(String program, String detail) {
super("Redis session program reply is incompatible: " + program + " " + detail);
}
}
@@ -1,17 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings;
import io.lettuce.core.ClientOptions;
import io.lettuce.core.RedisURI;
import io.lettuce.core.cluster.ClusterClientOptions;
import java.util.List;
/** Injectable native-client creation seam for deterministic, network-free topology tests. */
interface RedisNativeClientFactory {
RedisNativeClientHandle openStandalone(
RedisURI uri, ClientOptions options, RedisClientRuntimeSettings settings);
RedisNativeClientHandle openCluster(
List<RedisURI> seedUris, ClusterClientOptions options, RedisClientRuntimeSettings settings);
}
@@ -1,11 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.time.Duration;
/** Package-private lifecycle handle that prevents native command APIs from escaping. */
interface RedisNativeClientHandle {
Class<?> nativeClientType();
void close(Duration timeout);
}
@@ -1,7 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
/** Internal signal used only to authorize the bounded EVAL fallback. */
final class RedisNoScriptException extends RuntimeException {
private static final long serialVersionUID = 1L;
}
@@ -1,12 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
/** Closed key material created only inside the semantic capability that owns the key. */
sealed interface RedisOwnedPhysicalKeyMaterial
permits LettuceRedisRuntime.LegacyKeyMaterial,
RedisRoleCommandRouter.LegacyKeyMaterial,
RedisStringCacheRegion.CacheKeyMaterial,
RedisCacheConsistencyStore.ConsistencyKeyMaterial,
RedisSemanticReadinessProbe.ProbeKeyMaterial {
byte[] copyEncodedKey();
}
@@ -1,73 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Objects;
/** Opaque adapter-private physical key. Command ports never accept caller-owned key bytes. */
final class RedisPhysicalKey {
private static final int MAXIMUM_KEY_BYTES = 1_024;
private final byte[] encoded;
private RedisPhysicalKey(byte[] encoded) {
Objects.requireNonNull(encoded, "Redis physical key must be non-null");
if (encoded.length < 1 || encoded.length > MAXIMUM_KEY_BYTES) {
throw new IllegalArgumentException("Redis physical key is out of bounds");
}
this.encoded = encoded.clone();
}
static RedisPhysicalKey owned(RedisOwnedPhysicalKeyMaterial material) {
return new RedisPhysicalKey(
Objects.requireNonNull(material, "key material must be non-null").copyEncodedKey());
}
static RedisPhysicalKey primitive(RedisPrimitiveKey key) {
Objects.requireNonNull(key, "primitive key must be non-null");
String encoded =
"ca:primitive:"
+ key.family()
+ ":v"
+ key.version()
+ ":{"
+ key.slot()
+ "}:"
+ key.identity();
return new RedisPhysicalKey(encoded.getBytes(StandardCharsets.UTF_8));
}
int encodedLength() {
return encoded.length;
}
private byte[] copyEncoded() {
return encoded.clone();
}
@Override
public boolean equals(Object other) {
return other instanceof RedisPhysicalKey candidate && Arrays.equals(encoded, candidate.encoded);
}
@Override
public int hashCode() {
return Arrays.hashCode(encoded);
}
@Override
public String toString() {
return "RedisPhysicalKey[redacted]";
}
/** Sole terminal unwrap; callers cannot supply or replace key bytes through this API. */
static final class WireCodec {
private WireCodec() {}
static byte[] copy(RedisPhysicalKey key) {
return Objects.requireNonNull(key, "key must be non-null").copyEncoded();
}
}
}
@@ -1,204 +0,0 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole;
import java.time.Duration;
import java.util.Collection;
import java.util.EnumMap;
import java.util.Map;
final class RedisPrimitiveCatalog {
private static final int SCHEMA_REVISION = 1;
private final Map<RedisPrimitiveId, RedisPrimitiveDescriptor> descriptors;
private RedisPrimitiveCatalog(Map<RedisPrimitiveId, RedisPrimitiveDescriptor> descriptors) {
this.descriptors = Map.copyOf(descriptors);
}
static RedisPrimitiveCatalog standard() {
Map<RedisPrimitiveId, RedisPrimitiveDescriptor> values = new EnumMap<>(RedisPrimitiveId.class);
for (RedisPrimitiveId id : RedisPrimitiveId.values()) {
values.put(id, compileDescriptor(id));
}
return new RedisPrimitiveCatalog(values);
}
RedisPrimitiveDescriptor descriptor(RedisPrimitiveId id) {
RedisPrimitiveDescriptor descriptor = descriptors.get(id);
if (descriptor == null) {
throw new IllegalArgumentException("unknown primitive id");
}
return descriptor;
}
Collection<RedisPrimitiveDescriptor> descriptors() {
return descriptors.values();
}
RedisPrimitiveKeyFactory keyFactory(RedisPrimitiveId id) {
return RedisPrimitiveKeyFactory.canonical(this, id);
}
int schemaRevision() {
return SCHEMA_REVISION;
}
RedisStringValuePrimitives strings(RedisPrimitiveCommands commands) {
return new RedisStringValuePrimitives(this, commands);
}
RedisCounterPrimitives counters(RedisPrimitiveCommands commands) {
return new RedisCounterPrimitives(this, commands);
}
RedisHashPrimitives hashes(RedisPrimitiveCommands commands) {
return new RedisHashPrimitives(this, commands);
}
RedisSetPrimitives sets(RedisPrimitiveCommands commands) {
return new RedisSetPrimitives(this, commands);
}
RedisSortedSetPrimitives sortedSets(RedisPrimitiveCommands commands) {
return new RedisSortedSetPrimitives(this, commands);
}
RedisListPrimitives lists(RedisPrimitiveCommands commands) {
return new RedisListPrimitives(this, commands);
}
RedisBitmapPrimitives bitmaps(RedisPrimitiveCommands commands) {
return new RedisBitmapPrimitives(this, commands);
}
RedisHyperLogLogPrimitives hyperLogLogs(RedisPrimitiveCommands commands) {
return new RedisHyperLogLogPrimitives(this, commands);
}
RedisGeoPrimitives geo(RedisPrimitiveCommands commands) {
return new RedisGeoPrimitives(this, commands);
}
private static RedisPrimitiveDescriptor compileDescriptor(RedisPrimitiveId id) {
RedisPrimitiveSemanticClass semantic =
switch (id.structure()) {
case LIST -> RedisPrimitiveSemanticClass.BEST_EFFORT_NOT_MESSAGING;
case BITMAP -> RedisPrimitiveSemanticClass.NON_AUTHORITATIVE_FIXED_DOMAIN_BITMAP;
case HYPERLOGLOG -> RedisPrimitiveSemanticClass.APPROXIMATE_NON_AUTHORITATIVE_HLL;
case GEO -> RedisPrimitiveSemanticClass.PRIVACY_SENSITIVE_NON_AUTHORITATIVE_GEO;
default -> RedisPrimitiveSemanticClass.EXACT;
};
RedisRole role =
id.structure() == RedisPrimitiveStructure.COUNTER
? RedisRole.COORDINATION
: RedisRole.CACHE;
boolean bulk =
switch (id) {
case STRING_MGET, HLL_MERGE_SAME_SLOT -> true;
default -> false;
};
boolean read =
switch (id) {
case STRING_GET,
STRING_MGET,
COUNTER_READ,
HASH_GET,
HASH_MGET,
HASH_SCAN_PAGE,
SET_CONTAINS,
SET_CARDINALITY,
SET_SCAN_PAGE,
ZSET_COUNT,
ZSET_RANK_PAGE,
ZSET_SCORE_PAGE,
BITMAP_GET,
BITMAP_COUNT_FIXED_RANGE,
HLL_COUNT,
GEO_SEARCH ->
true;
default -> false;
};
RedisProgramId programId =
switch (id) {
case STRING_GET -> RedisProgramId.BOUNDED_GET_V1;
case STRING_MGET -> RedisProgramId.BOUNDED_MGET_V1;
case STRING_COMPARE_SET -> RedisProgramId.COMPARE_AND_SET_WITH_TTL_V1;
case STRING_COMPARE_DELETE -> RedisProgramId.COMPARE_AND_DELETE;
case COUNTER_INCREMENT_INITIAL_TTL -> RedisProgramId.INCREMENT_WITH_INITIAL_TTL_V1;
case HASH_SET_FIELDS -> RedisProgramId.BOUNDED_HASH_FIELD_ADMISSION_V1;
case HASH_SCAN_PAGE -> RedisProgramId.BOUNDED_HASH_SCAN_PAGE_V1;
case HASH_REVISION_CAS -> RedisProgramId.HASH_REVISION_CAS_V1;
case SET_ADMIT -> RedisProgramId.BOUNDED_SET_ADMISSION_V1;
case SET_SCAN_PAGE -> RedisProgramId.BOUNDED_SET_SCAN_PAGE_V1;
case ZSET_ADD -> RedisProgramId.BOUNDED_ZSET_ADMISSION_V1;
case ZSET_TRIM_BOUNDED -> RedisProgramId.ZSET_BOUNDED_TRIM_V1;
case LIST_ADMIT -> RedisProgramId.BOUNDED_LIST_ADMISSION_V1;
case LIST_TRIM_FIXED_BOUNDS -> RedisProgramId.GUARDED_LIST_TRIM_V1;
case GEO_ADD -> RedisProgramId.BOUNDED_GEO_ADMISSION_V1;
default -> null;
};
RedisPrimitiveDescriptor.TtlPolicy ttl =
read
? RedisPrimitiveDescriptor.TtlPolicy.PRESERVE_EXISTING
: id == RedisPrimitiveId.STRING_COMPARE_DELETE
? RedisPrimitiveDescriptor.TtlPolicy.PRESERVE_EXISTING
: id == RedisPrimitiveId.ZSET_TRIM_BOUNDED
|| id == RedisPrimitiveId.LIST_TRIM_FIXED_BOUNDS
? RedisPrimitiveDescriptor.TtlPolicy.REQUIRE_PRECREATED_EXPIRING_KEY
: programId != null
? RedisPrimitiveDescriptor.TtlPolicy.ATOMIC_INITIAL_TTL
: switch (id) {
case STRING_SET_PX, STRING_SET_NX_PX, STRING_SET_XX_PX ->
RedisPrimitiveDescriptor.TtlPolicy.REQUIRED_PX;
case BITMAP_SET, HLL_ADD, HLL_MERGE_SAME_SLOT ->
RedisPrimitiveDescriptor.TtlPolicy.PERSISTENT_ONLY;
default -> RedisPrimitiveDescriptor.TtlPolicy.PRESERVE_EXISTING;
};
return new RedisPrimitiveDescriptor(
id,
id.structure(),
semantic,
role,
family(id.structure()),
1,
512,
16_000,
1_024,
4_096,
switch (id) {
case STRING_MGET, HLL_MERGE_SAME_SLOT -> 4;
default -> 1;
},
256,
16_384,
49_152,
ttl,
bulk
? RedisPrimitiveDescriptor.SlotRule.SAME_SLOT
: RedisPrimitiveDescriptor.SlotRule.SINGLE_KEY,
Duration.ofSeconds(2),
read
? RedisPrimitiveDescriptor.RetrySafety.SAFE_READ
: RedisPrimitiveDescriptor.RetrySafety.NON_REPLAY_SAFE_MUTATION,
read
? RedisPrimitiveDescriptor.TimeoutCertainty.NOT_APPLIED_FOR_READ
: RedisPrimitiveDescriptor.TimeoutCertainty.INDETERMINATE_FOR_MUTATION,
"redis.primitive." + id.name().toLowerCase(java.util.Locale.ROOT).replace('_', '.'),
programId);
}
private static String family(RedisPrimitiveStructure structure) {
return switch (structure) {
case STRING -> "string-value";
case COUNTER -> "counter";
case HASH -> "hash";
case SET -> "set";
case SORTED_SET -> "sorted-set";
case LIST -> "list";
case BITMAP -> "bitmap";
case HYPERLOGLOG -> "hyperloglog";
case GEO -> "geo";
};
}
}

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