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:
co-authored by
Claude Opus 5
parent
1a3b560678
commit
5f10b791d3
@@ -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> 을 추가로 요구한다.
|
||||
```
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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=
|
||||
|
||||
-24
@@ -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);
|
||||
}
|
||||
}
|
||||
-34
@@ -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());
|
||||
}
|
||||
}
|
||||
-12
@@ -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();
|
||||
}
|
||||
-17
@@ -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);
|
||||
}
|
||||
}
|
||||
-24
@@ -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);
|
||||
}
|
||||
-81
@@ -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);
|
||||
}
|
||||
}
|
||||
-50
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
-231
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
-83
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-228
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-346
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
-108
@@ -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";
|
||||
}
|
||||
}
|
||||
-120
@@ -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) {}
|
||||
}
|
||||
-14
@@ -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.
|
||||
}
|
||||
}
|
||||
-316
@@ -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
|
||||
}
|
||||
}
|
||||
-13
@@ -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);
|
||||
}
|
||||
-42
@@ -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]";
|
||||
}
|
||||
}
|
||||
-15
@@ -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);
|
||||
}
|
||||
}
|
||||
-55
@@ -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());
|
||||
}
|
||||
}
|
||||
-19
@@ -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;
|
||||
}
|
||||
}
|
||||
-57
@@ -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));
|
||||
}
|
||||
}
|
||||
-64
@@ -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);
|
||||
}
|
||||
}
|
||||
-158
@@ -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);
|
||||
}
|
||||
}
|
||||
-219
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
-246
@@ -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 {}
|
||||
}
|
||||
-164
@@ -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;
|
||||
}
|
||||
}
|
||||
-64
@@ -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();
|
||||
}
|
||||
}
|
||||
-63
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-18
@@ -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();
|
||||
}
|
||||
-295
@@ -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);
|
||||
}
|
||||
}
|
||||
-250
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-111
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
-48
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
-24
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
-159
@@ -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;
|
||||
}
|
||||
}
|
||||
-166
@@ -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();
|
||||
}
|
||||
}
|
||||
-181
@@ -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, ""));
|
||||
}
|
||||
}
|
||||
-758
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
-175
@@ -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
|
||||
}
|
||||
}
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
@FunctionalInterface
|
||||
interface RedisCapabilityObservationPort {
|
||||
|
||||
void observe(RedisCapabilityObservationEvent.Event event);
|
||||
}
|
||||
-125
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
-321
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
-27
@@ -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();
|
||||
}
|
||||
-43
@@ -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);
|
||||
}
|
||||
}
|
||||
-27
@@ -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;
|
||||
}
|
||||
-45
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-53
@@ -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
|
||||
}
|
||||
}
|
||||
-57
@@ -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());
|
||||
}
|
||||
}
|
||||
-40
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
-63
@@ -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(),
|
||||
"");
|
||||
}
|
||||
}
|
||||
-85
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-118
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
-76
@@ -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);
|
||||
}
|
||||
}
|
||||
-50
@@ -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
|
||||
}
|
||||
}
|
||||
-553
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
-56
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
-394
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
-503
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
-39
@@ -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]";
|
||||
}
|
||||
}
|
||||
-81
@@ -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));
|
||||
}
|
||||
}
|
||||
-98
@@ -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));
|
||||
}
|
||||
}
|
||||
-54
@@ -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");
|
||||
}
|
||||
}
|
||||
-54
@@ -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);
|
||||
}
|
||||
}
|
||||
-77
@@ -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());
|
||||
}
|
||||
}
|
||||
-78
@@ -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);
|
||||
}
|
||||
}
|
||||
-44
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
-74
@@ -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>");
|
||||
}
|
||||
}
|
||||
-10
@@ -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) {}
|
||||
-111
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-77
@@ -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();
|
||||
}
|
||||
}
|
||||
-568
@@ -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();
|
||||
}
|
||||
}
|
||||
-25
@@ -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);
|
||||
}
|
||||
}
|
||||
-27
@@ -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();
|
||||
}
|
||||
}
|
||||
-82
@@ -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);
|
||||
}
|
||||
}
|
||||
-44
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
-81
@@ -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>");
|
||||
}
|
||||
}
|
||||
-10
@@ -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) {}
|
||||
-64
@@ -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();
|
||||
}
|
||||
}
|
||||
-21
@@ -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);
|
||||
}
|
||||
}
|
||||
-7
@@ -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;
|
||||
}
|
||||
-20
@@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
-87
@@ -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();
|
||||
}
|
||||
}
|
||||
-68
@@ -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();
|
||||
}
|
||||
}
|
||||
-213
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
-119
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-56
@@ -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))));
|
||||
}
|
||||
}
|
||||
-51
@@ -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;
|
||||
}
|
||||
}
|
||||
-526
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-45
@@ -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);
|
||||
}
|
||||
}
|
||||
-34
@@ -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;
|
||||
}
|
||||
}
|
||||
-621
@@ -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);
|
||||
}
|
||||
}
|
||||
-17
@@ -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);
|
||||
}
|
||||
-11
@@ -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);
|
||||
}
|
||||
-7
@@ -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;
|
||||
}
|
||||
-12
@@ -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();
|
||||
}
|
||||
-73
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
-204
@@ -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
Reference in New Issue
Block a user