feat: redis, fileserver, httpclient 런타임 시점 구현 추가
This commit is contained in:
@@ -23,6 +23,19 @@ APP_ASYNC_EXECUTOR_QUEUE_CAPACITY=200
|
||||
|
||||
# ----- Optional integration adapters (default: all disabled) -----
|
||||
APP_CACHE_REDIS_ENABLED=false
|
||||
APP_CACHE_REDIS_CLIENT_MODE=managed
|
||||
APP_CACHE_REDIS_HOST=localhost
|
||||
APP_CACHE_REDIS_PORT=6379
|
||||
APP_CACHE_REDIS_PASSWORD=
|
||||
APP_CACHE_REDIS_KEY_HMAC_SECRET=
|
||||
APP_CACHE_REDIS_COMMAND_TIMEOUT=2s
|
||||
APP_CACHE_REDIS_MAXIMUM_QUEUED_COMMANDS=8
|
||||
APP_CACHE_REDIS_MAXIMUM_IN_FLIGHT_BYTES=16777216
|
||||
APP_CACHE_REDIS_NAMESPACE_ENVIRONMENT=local
|
||||
APP_CACHE_REDIS_SEMANTIC_REGION=default
|
||||
APP_CACHE_REDIS_MAXIMUM_VALUE_BYTES=1048576
|
||||
APP_CACHE_DEFAULT_TTL=300s
|
||||
APP_CACHE_NEGATIVE_TTL=60s
|
||||
APP_MESSAGING_BROKER=
|
||||
APP_MESSAGING_KAFKA_BROKERS=
|
||||
APP_NOTIFICATION_SLACK_PROVIDER=
|
||||
@@ -32,6 +45,7 @@ APP_NOTIFICATION_EMAIL_PROVIDER=
|
||||
APP_OUTBOUND_HTTP_CONNECT_TIMEOUT=2s
|
||||
APP_OUTBOUND_HTTP_READ_TIMEOUT=5s
|
||||
APP_OUTBOUND_HTTP_GLOBAL_CALL_TIMEOUT=10s
|
||||
APP_OUTBOUND_HTTP_MAXIMUM_IN_FLIGHT_CALLS=128
|
||||
APP_OUTBOUND_HTTP_RETRY_ENABLED=false
|
||||
APP_OUTBOUND_HTTP_RETRY_MAX_ATTEMPTS=3
|
||||
APP_OUTBOUND_HTTP_RETRY_INITIAL_BACKOFF=100ms
|
||||
|
||||
@@ -10,5 +10,8 @@ build/
|
||||
.classpath
|
||||
.settings/
|
||||
|
||||
# jqwik property-test runtime state
|
||||
.jqwik-database
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
+15
-9
@@ -77,12 +77,12 @@ vendor/build나 container base image까지 byte-for-byte 같음을 주장하지
|
||||
|
||||
### `verifyCleanArchitectureDependencies`
|
||||
|
||||
- **하는 일.** `allowedProjectDependencies` 맵에 모듈별로 허용된 의존 대상을 선언하고, 실제 Gradle
|
||||
프로젝트 의존(`api` / `implementation` / `compileOnly` / `runtimeOnly`)이 그 범위를 벗어나면 빌드를
|
||||
실패시킵니다.
|
||||
- **이 맵이 의존 방향의 SSOT 입니다.** 새 모듈이나 새 의존 edge 를 추가하면 이 맵과 ArchUnit
|
||||
규칙(`CleanArchitectureTest`)을 함께 갱신해야 합니다. 모르는 코드를 검사하지 못하는 게이트는
|
||||
보호 기능을 못 합니다.
|
||||
- **하는 일.** [config/architecture/modules.json](config/architecture/modules.json)의
|
||||
`allowed_dependencies`를 읽고, 실제 Gradle 프로젝트 의존(`api` / `implementation` /
|
||||
`compileOnly` / `runtimeOnly`)이 그 범위를 벗어나면 빌드를 실패시킵니다.
|
||||
- **JSON registry가 의존 방향의 SSOT 입니다.** 새 모듈이나 새 production 의존 edge를 추가하면
|
||||
registry와 ArchUnit 규칙(`CleanArchitectureTest`)을 함께 갱신해야 합니다. settings와 gate는
|
||||
같은 registry를 읽고, 등록되지 않은 leaf나 허용되지 않은 edge를 fail-closed로 거부합니다.
|
||||
|
||||
### `verifyOneTypePerFile` (code-conventions I6)
|
||||
|
||||
@@ -121,15 +121,17 @@ vendor/build나 container base image까지 byte-for-byte 같음을 주장하지
|
||||
지점입니다. 그래서 그 표면을 snapshot 으로 떠 두고, 미승인 변경에 빌드를 실패시킵니다.
|
||||
- **승인 방법.** reviewer 가 `./gradlew verifyPublicPathSnapshot -PapprovePublicPathChange` 로
|
||||
snapshot 을 의도적으로 다시 생성합니다. 공개 경로 변경은 보안 리뷰 대상으로 보고 수동 승인 후
|
||||
반영합니다.
|
||||
재생성된 snapshot 을 함께 커밋합니다.
|
||||
- **결정 — 무엇을 snapshot 했나 (프로젝트 선택).** 초기안은 기동 시
|
||||
`SecurityFilterChain.getFilters()` 를 introspection 하는 방식이었습니다. 하지만 그 reflection
|
||||
은 Spring 버전마다 깨지기 쉽습니다(`permitAll` matcher 가
|
||||
`RequestMatcherDelegatingAuthorizationManager` 의 private 필드에 숨어 있음). 그래서 `permitAll()`
|
||||
을 실제로 먹이는 결정적 SSOT 인 `SECURITY_PUBLIC_PATHS` 자체를 snapshot 합니다. 탐지 목표(공개 경로
|
||||
변경은 무조건 게이트를 실패시킨다)는 같고, 메커니즘은 더 견고합니다.
|
||||
- **snapshot 위치.** `docs/security/public-paths-snapshot.txt`. `docs/` 는 gitignore 대상이라, fresh
|
||||
checkout 에서는 snapshot 이 없으므로 "처음엔 만들고 통과"한 뒤 이후 변경부터 감시합니다.
|
||||
- **snapshot 위치.** `docs/security/public-paths-snapshot.txt`. 이 파일은 커밋된 필수 보안
|
||||
baseline 입니다. CI 는 Gradle 실행 전에 파일이 비어 있지 않고 Git에 추적되는지 검사하므로 fresh
|
||||
checkout 에서 누락되거나 untracked 상태면 즉시 실패합니다. 승인된 변경만 위 명령으로 재생성한 뒤
|
||||
보안 리뷰와 함께 커밋합니다.
|
||||
|
||||
### `verifyTrivyignore`
|
||||
|
||||
@@ -226,6 +228,8 @@ vendor/build나 container base image까지 byte-for-byte 같음을 주장하지
|
||||
fail-fast sentinel 이 포트를 충족합니다(Layer 3).
|
||||
|
||||
- **`APP_CACHE_REDIS_ENABLED`** — Redis 캐시 어댑터 on/off. `true` | `false`.
|
||||
- **`APP_CACHE_REDIS_CLIENT_MODE`** — `managed`는 내장 Lettuce runtime, `external`은 프로젝트가
|
||||
제공한 `RedisClient` bean을 사용합니다.
|
||||
- **`APP_MESSAGING_BROKER`** — 활성 메시지 브로커 id(예: `kafka`). 빈 값 = 메시징 비활성(사용 시
|
||||
fail-fast).
|
||||
- **`APP_MESSAGING_KAFKA_BROKERS`** — `host:port` CSV. `APP_MESSAGING_BROKER=kafka` 일 때만 필수,
|
||||
@@ -243,6 +247,8 @@ fail-fast sentinel 이 포트를 충족합니다(Layer 3).
|
||||
- **`APP_OUTBOUND_HTTP_READ_TIMEOUT`** — socket read timeout. duration(예: `5s`), 필수, non-zero.
|
||||
- **`APP_OUTBOUND_HTTP_GLOBAL_CALL_TIMEOUT`** — retry 를 포함한 end-to-end 마감 예산. duration(예:
|
||||
`10s`), 필수, non-zero.
|
||||
- **`APP_OUTBOUND_HTTP_MAXIMUM_IN_FLIGHT_CALLS`** — client별 살아 있는 logical-call worker 상한.
|
||||
기본값 `128`, 허용 범위 `1..10000`.
|
||||
- **`APP_OUTBOUND_HTTP_RETRY_ENABLED`** — retry 데코레이터 on/off. `true` 로 켜면 `MeterRegistry` 빈이
|
||||
있어야 하며(D3 가드), 없으면 기동 실패.
|
||||
- retry 튜닝(아래 3개는 `retry-enabled=true` 일 때 적용, 기본값은 기존 하드코딩 동작 보존):
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
|
||||
- Module ID: `adapter-inbound-graphql`
|
||||
- Gradle path: `:adapter:inbound:graphql`
|
||||
- Focused test: `./gradlew :adapter:inbound:graphql:test --console=plain`
|
||||
- Focused test (derived from Gradle path): `./gradlew :adapter:inbound:graphql:test --console=plain`
|
||||
- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0.
|
||||
- Registry SSOT: `.harness/project/modules.yaml`.
|
||||
- Registry SSOT: `src/config/architecture/modules.json`.
|
||||
|
||||
Package root: `dev.caskeleton.adapter.inbound.graphql`.
|
||||
|
||||
|
||||
@@ -11,15 +11,10 @@
|
||||
description = 'Inbound adapter: GraphQL API (Spring for GraphQL, skeleton machinery)'
|
||||
|
||||
dependencies {
|
||||
implementation project(':application-core')
|
||||
implementation project(':domain-core')
|
||||
implementation project(':shared-contract')
|
||||
|
||||
implementation 'org.springframework.boot:spring-boot-starter-graphql'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'
|
||||
|
||||
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
|
||||
|
||||
// GraphQlTester (spring-graphql-test, BOM-managed) — the health test assembles the schema +
|
||||
// controller through a real AnnotatedControllerConfigurer and drives it with an
|
||||
|
||||
@@ -5,10 +5,6 @@ biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspa
|
||||
ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,runtimeClasspath,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
|
||||
@@ -121,7 +117,6 @@ org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClass
|
||||
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
|
||||
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor
|
||||
org.springframework.boot:spring-boot-graphql-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-graphql:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
|
||||
- Module ID: `adapter-inbound-grpc`
|
||||
- Gradle path: `:adapter:inbound:grpc`
|
||||
- Focused test: `./gradlew :adapter:inbound:grpc:test --console=plain`
|
||||
- Focused test (derived from Gradle path): `./gradlew :adapter:inbound:grpc:test --console=plain`
|
||||
- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0.
|
||||
- Registry SSOT: `.harness/project/modules.yaml`.
|
||||
- Registry SSOT: `src/config/architecture/modules.json`.
|
||||
|
||||
Package root: `dev.caskeleton.adapter.inbound.grpc`.
|
||||
|
||||
|
||||
@@ -17,20 +17,16 @@ dependencyManagement {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation project(':application-core')
|
||||
implementation project(':domain-core')
|
||||
implementation project(':shared-contract')
|
||||
|
||||
implementation 'org.springframework.boot:spring-boot-starter'
|
||||
|
||||
implementation 'io.grpc:grpc-netty-shaded'
|
||||
implementation 'io.grpc:grpc-protobuf'
|
||||
implementation 'io.grpc:grpc-stub'
|
||||
implementation 'io.grpc:grpc-services' // health + reflection (grpc.health.v1 / reflection)
|
||||
|
||||
// grpc-java generated stubs reference javax.annotation.Generated; kept compileOnly for parity
|
||||
// with the feature module (the skeleton itself generates no stubs).
|
||||
compileOnly 'org.apache.tomcat:annotations-api:6.0.53'
|
||||
|
||||
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
|
||||
|
||||
// The boot test directly builds generated health/reflection protobuf messages. grpc-services
|
||||
// does not expose protobuf-java on its compile API, so keep the narrower test-only declaration.
|
||||
testImplementation 'io.grpc:grpc-protobuf'
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClass
|
||||
com.github.spotbugs:spotbugs:4.10.2=spotbugs
|
||||
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
|
||||
com.google.android:annotations:4.1.1.4=runtimeClasspath,testRuntimeClasspath
|
||||
com.google.api.grpc:proto-google-common-protos:2.41.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.google.api.grpc:proto-google-common-protos:2.41.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
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
|
||||
@@ -37,7 +37,7 @@ com.google.j2objc:j2objc-annotations:2.8=runtimeClasspath,testRuntimeClasspath
|
||||
com.google.j2objc:j2objc-annotations:3.0.0=compileClasspath,testCompileClasspath
|
||||
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.protobuf:protobuf-java-util:3.25.5=runtimeClasspath,testRuntimeClasspath
|
||||
com.google.protobuf:protobuf-java:3.25.5=annotationProcessor,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
|
||||
com.google.protobuf:protobuf-java:3.25.5=annotationProcessor,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
|
||||
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
|
||||
com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath
|
||||
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
|
||||
@@ -54,7 +54,7 @@ io.grpc:grpc-context:1.68.1=runtimeClasspath,testRuntimeClasspath
|
||||
io.grpc:grpc-core:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.grpc:grpc-netty-shaded:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.grpc:grpc-protobuf-lite:1.68.1=runtimeClasspath,testRuntimeClasspath
|
||||
io.grpc:grpc-protobuf:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.grpc:grpc-protobuf:1.68.1=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.grpc:grpc-services:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.grpc:grpc-stub:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.grpc:grpc-util:1.68.1=runtimeClasspath,testRuntimeClasspath
|
||||
@@ -88,7 +88,6 @@ org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
|
||||
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat:annotations-api:6.0.53=compileClasspath
|
||||
org.apache.xbean:xbean-reflect:3.7=checkstyle
|
||||
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
|
||||
org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
|
||||
- Module ID: `adapter-inbound-web`
|
||||
- Gradle path: `:adapter:inbound:web`
|
||||
- Focused test: `./gradlew :adapter:inbound:web:test --console=plain`
|
||||
- Focused test (derived from Gradle path): `./gradlew :adapter:inbound:web:test --console=plain`
|
||||
- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0.
|
||||
- Registry SSOT: `.harness/project/modules.yaml`.
|
||||
- Registry SSOT: `src/config/architecture/modules.json`.
|
||||
|
||||
Package root: `dev.caskeleton.adapter.inbound.web`.
|
||||
|
||||
@@ -18,6 +18,9 @@ Package root: `dev.caskeleton.adapter.inbound.web`.
|
||||
- Request/response DTOs.
|
||||
- Request DTO to application command mapping.
|
||||
- Authentication, validation, error mapping, filters, and web/security settings.
|
||||
- Sanitized request correlation context exposed through application-owned `CorrelationIdPort`.
|
||||
- Transport-owned OpenAPI customization that keeps `ApiError.details` as `type: object` without
|
||||
leaking Swagger dependencies into `shared-contract`.
|
||||
|
||||
## Allowed
|
||||
|
||||
|
||||
@@ -10,6 +10,16 @@
|
||||
|
||||
---
|
||||
|
||||
## OpenAPI contract stabilization
|
||||
|
||||
Springdoc 3 represents an untyped Java `Object` as an unconstrained OAS 3.1 schema (`{}`).
|
||||
`OpenApiContractConfig` owns the transport-specific correction for the shared `ApiError.details`
|
||||
field and publishes it as `type: object`. This preserves the committed HTTP contract without adding
|
||||
Swagger annotations or dependencies to `shared-contract`. Real-server OpenAPI tests import this
|
||||
production configuration and compare the result with the committed snapshot.
|
||||
|
||||
---
|
||||
|
||||
## auth — 인증 (OIDC resource server)
|
||||
|
||||
### SecurityConfig
|
||||
@@ -323,6 +333,12 @@
|
||||
같은 논리 ID 의 envelope 형태(camelCase)와 HTTP 헤더 형태(kebab-case)는 D19 projection 이며,
|
||||
변환 단일 지점은 `ResponseMetaFactory`.
|
||||
|
||||
### MdcCorrelationIdPortAdapter
|
||||
- `RequestLoggingFilter`가 무해화하고 MDC `correlation_id`에 넣은 값을 application-core의
|
||||
`CorrelationIdPort`로 투영한다.
|
||||
- absent/blank는 `Optional.empty()`로 반환한다. application/sample 계층은 SLF4J/MDC를 직접
|
||||
참조하지 않고 event-id fallback 정책만 소유한다.
|
||||
|
||||
### HeaderSanitizer
|
||||
- 인바운드 헤더 값을 MDC/로그 도달 전에 무해화(D14, OWASP-LOG-C3/C5, CWE-117). 스켈레톤은 구조화 JSON 로깅을
|
||||
가정하므로 위협은 CR/LF/제어문자를 통한 로그 라인 위조 — 값은 보존하되 `\r`/`\n`/ASCII 제어문자(`< 0x20`)를
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
// HTTP / web adapters. Depends on application, domain, and shared operational contracts.
|
||||
// HTTP / web adapters. Depends on application and shared operational contracts.
|
||||
dependencies {
|
||||
implementation project(':domain-core')
|
||||
implementation project(':application-core')
|
||||
implementation project(':shared-contract')
|
||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-security'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||
implementation 'org.openapitools:jackson-databind-nullable:0.2.6'
|
||||
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
|
||||
implementation('org.openapitools:jackson-databind-nullable:0.2.6') {
|
||||
exclude group: 'com.fasterxml.jackson.core', module: 'jackson-databind'
|
||||
}
|
||||
// feature-api-contract-baseline D10: OpenAPI producer. springdoc exposes the
|
||||
// running app's machine-readable contract at /v3/api-docs (OAS 3.1, generated —
|
||||
// never a hand-maintained stale schema). The release-blocking drift gate is
|
||||
// owned by feature-contract-verification-test-suite (planned).
|
||||
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-api:2.8.6'
|
||||
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0'
|
||||
}
|
||||
|
||||
@@ -50,9 +50,9 @@ io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnota
|
||||
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
|
||||
io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.swagger.core.v3:swagger-annotations-jakarta:2.2.29=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.swagger.core.v3:swagger-core-jakarta:2.2.29=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.swagger.core.v3:swagger-models-jakarta:2.2.29=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.swagger.core.v3:swagger-annotations-jakarta:2.2.38=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.swagger.core.v3:swagger-core-jakarta:2.2.38=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.swagger.core.v3:swagger-models-jakarta:2.2.38=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.activation:jakarta.activation-api:2.1.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.validation:jakarta.validation-api:3.1.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
@@ -125,9 +125,10 @@ org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,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.springdoc:springdoc-openapi-starter-common:2.8.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springdoc:springdoc-openapi-starter-webmvc-api:2.8.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springdoc:springdoc-openapi-starter-common:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0=compileClasspath,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=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-converter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package dev.caskeleton.adapter.inbound.web.config;
|
||||
|
||||
import io.swagger.v3.oas.models.Components;
|
||||
import io.swagger.v3.oas.models.media.ObjectSchema;
|
||||
import io.swagger.v3.oas.models.media.Schema;
|
||||
import java.util.Map;
|
||||
import org.springdoc.core.customizers.OpenApiCustomizer;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Keeps transport-owned OpenAPI schema details stable across springdoc library upgrades.
|
||||
*
|
||||
* <p>{@code ApiError.details} is represented by {@code Object} in the shared response contract.
|
||||
* Springdoc 3 renders an untyped Java {@code Object} as an unconstrained OAS 3.1 schema. The public
|
||||
* HTTP contract remains object-shaped, so the web adapter restores that transport-specific type
|
||||
* without adding Swagger dependencies or annotations to {@code shared-contract}.
|
||||
*/
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
public class OpenApiContractConfig {
|
||||
|
||||
@Bean
|
||||
OpenApiCustomizer apiErrorDetailsObjectSchemaCustomizer() {
|
||||
return openApi -> {
|
||||
Components components = openApi.getComponents();
|
||||
Map<String, Schema> schemas = components == null ? null : components.getSchemas();
|
||||
Schema<?> apiError = schemas == null ? null : schemas.get("ApiError");
|
||||
Map<String, Schema> properties = apiError == null ? null : apiError.getProperties();
|
||||
if (properties != null && properties.containsKey("details")) {
|
||||
properties.put("details", new ObjectSchema());
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package dev.caskeleton.adapter.inbound.web.observability;
|
||||
|
||||
import dev.caskeleton.application.observability.CorrelationIdPort;
|
||||
import java.util.Optional;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** Reads the current request's sanitized correlation identifier from the inbound web MDC. */
|
||||
@Component
|
||||
public class MdcCorrelationIdPortAdapter implements CorrelationIdPort {
|
||||
|
||||
@Override
|
||||
public Optional<String> currentCorrelationId() {
|
||||
return Optional.ofNullable(MDC.get(MdcKeys.CORRELATION_ID)).filter(value -> !value.isBlank());
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package dev.caskeleton.adapter.inbound.web.config;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.openapitools.jackson.nullable.JsonNullable;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
class JacksonNullableConfigTest {
|
||||
|
||||
private final ObjectMapper mapper =
|
||||
JsonMapper.builder().addModule(new JacksonNullableConfig().jsonNullableModule()).build();
|
||||
|
||||
@Test
|
||||
void readsPresentValue() throws Exception {
|
||||
Payload payload = mapper.readValue("{\"value\":\"configured\"}", Payload.class);
|
||||
|
||||
assertThat(payload.value().isPresent()).isTrue();
|
||||
assertThat(payload.value().get()).isEqualTo("configured");
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsExplicitNullAsPresentNull() throws Exception {
|
||||
Payload payload = mapper.readValue("{\"value\":null}", Payload.class);
|
||||
|
||||
assertThat(payload.value().isPresent()).isTrue();
|
||||
assertThat(payload.value().get()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsMissingPropertyAsUndefined() throws Exception {
|
||||
Payload payload = mapper.readValue("{}", Payload.class);
|
||||
|
||||
assertThat(payload.value()).isNotNull();
|
||||
assertThat(payload.value().isPresent()).isFalse();
|
||||
}
|
||||
|
||||
private record Payload(JsonNullable<String> value) {}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package dev.caskeleton.adapter.inbound.web.observability;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.slf4j.MDC;
|
||||
|
||||
class MdcCorrelationIdPortAdapterTest {
|
||||
|
||||
private final MdcCorrelationIdPortAdapter adapter = new MdcCorrelationIdPortAdapter();
|
||||
|
||||
@AfterEach
|
||||
void clearMdc() {
|
||||
MDC.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsSanitizedRequestCorrelationId() {
|
||||
MDC.put(MdcKeys.CORRELATION_ID, "corr-123");
|
||||
|
||||
assertThat(adapter.currentCorrelationId()).contains("corr-123");
|
||||
}
|
||||
|
||||
@Test
|
||||
void treatsBlankMdcValueAsAbsent() {
|
||||
MDC.put(MdcKeys.CORRELATION_ID, " ");
|
||||
|
||||
assertThat(adapter.currentCorrelationId()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void reportsAbsenceWhenRequestContextIsMissing() {
|
||||
assertThat(adapter.currentCorrelationId()).isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,9 @@
|
||||
|
||||
- Module ID: `adapter-inbound-websocket`
|
||||
- Gradle path: `:adapter:inbound:websocket`
|
||||
- Focused test: `./gradlew :adapter:inbound:websocket:test --console=plain`
|
||||
- Focused test (derived from Gradle path): `./gradlew :adapter:inbound:websocket:test --console=plain`
|
||||
- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0.
|
||||
- Registry SSOT: `.harness/project/modules.yaml`.
|
||||
- Registry SSOT: `src/config/architecture/modules.json`.
|
||||
|
||||
Package root: `dev.caskeleton.adapter.inbound.websocket`.
|
||||
|
||||
|
||||
@@ -11,13 +11,9 @@
|
||||
description = 'Inbound adapter: WebSocket (STOMP over SockJS, skeleton machinery)'
|
||||
|
||||
dependencies {
|
||||
implementation project(':application-core')
|
||||
implementation project(':domain-core')
|
||||
implementation project(':shared-contract')
|
||||
|
||||
implementation 'org.springframework.boot:spring-boot-starter-websocket'
|
||||
implementation 'com.fasterxml.jackson.core:jackson-databind'
|
||||
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'
|
||||
|
||||
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
|
||||
}
|
||||
|
||||
@@ -5,10 +5,6 @@ biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspa
|
||||
ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,runtimeClasspath,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
|
||||
|
||||
@@ -4,25 +4,37 @@
|
||||
|
||||
- Module ID: `adapter-outbound-cache-redis`
|
||||
- Gradle path: `:adapter:outbound:cache-redis`
|
||||
- Focused test: `./gradlew :adapter:outbound:cache-redis:test --console=plain`
|
||||
- Focused test (derived from Gradle path): `./gradlew :adapter:outbound:cache-redis:test --console=plain`
|
||||
- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0.
|
||||
- Registry SSOT: `.harness/project/modules.yaml`.
|
||||
- Registry SSOT: `src/config/architecture/modules.json`.
|
||||
|
||||
Package root: `dev.caskeleton.adapter.outbound.cache`.
|
||||
|
||||
## Responsibility
|
||||
|
||||
- Implement cache stores, routing, Redis capability, and fail-open technical behavior behind ports.
|
||||
- Own cache binding settings and Redis client adaptation.
|
||||
- Implement semantic cache ports from `application-core` without exposing Redis concepts to core.
|
||||
- Own canonical physical keys, digesting, codec/envelope, program catalog, typed Redis atomic
|
||||
facades, runtime client adaptation, and capability-specific failure semantics.
|
||||
- Keep the legacy cache router isolated while consumers migrate to semantic ports.
|
||||
- Reuse `adapter:outbound:support` for shared outbound concerns.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Allowed dependency edges come only from `.harness/project/modules.yaml`.
|
||||
- Allowed dependency edges come only from the module's
|
||||
`src/config/architecture/modules.json` entry.
|
||||
- No inbound transport, persistence entity/repository, bootstrap, or sample dependency.
|
||||
- Cache adapters do not decide business freshness, entitlement, or domain fallback rules.
|
||||
- Application/domain code must not receive raw Redis keys, commands, Lua/Function names, SDK
|
||||
objects, topology, or connection types.
|
||||
- Cache fail-open behavior must not be reused for session, idempotency, strict quota, lease, or
|
||||
fencing.
|
||||
- The standalone runtime/cache service lane is R1 evidence only. Sentinel/Cluster, TLS/ACL,
|
||||
persistence/restart, eviction and fault evidence are required separately for R2.
|
||||
|
||||
## Tests
|
||||
|
||||
Use fake Redis clients and contract tests for routing/fail-open behavior. Do not use a real network in
|
||||
focused tests; configuration changes include binding/validation coverage.
|
||||
Focused tests use fakes for contract, key, catalog, and typed-facade behavior. R1/R2 promotion
|
||||
requires a separate real Redis service lane; it may never be silently skipped when selected.
|
||||
|
||||
`redisServiceTest` is the explicit standalone lane. It fails when its host/port properties are
|
||||
missing; the default unit task excludes its `redis-service` tag.
|
||||
|
||||
@@ -1,37 +1,121 @@
|
||||
# adapter:outbound:cache-redis — 설계 결정 참조
|
||||
|
||||
캐시 아웃바운드 어댑터 모듈. 패키지 루트: `dev.caskeleton.adapter.outbound.cache`(`core` 서브
|
||||
패키지에 라우팅/SPI 추상화, `redis` 서브패키지에 Redis 바인딩). `:adapter:outbound:support` 에
|
||||
의존해 공유 correlation / fail-open 의존성 로깅을 재사용한다.
|
||||
캐시/Redis 기술 capability 아웃바운드 모듈. 패키지 루트:
|
||||
`dev.caskeleton.adapter.outbound.cache`. `application-core`의 provider-neutral cache contract를
|
||||
구현할 수 있는 경계와 Redis physical key/atomic-program 기반을 소유한다.
|
||||
|
||||
허용/금지 의존 정책은 `src/build.gradle` 의
|
||||
`allowedProjectDependencies['adapter:outbound:cache-redis']` 항목이 SSOT 다(이 모듈은 아직 별도
|
||||
CLAUDE.md 를 두지 않았다). 이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를 모아둔 참조용
|
||||
기록이다.
|
||||
허용/금지 의존 정책은 `src/config/architecture/modules.json`의
|
||||
`adapter-outbound-cache-redis` 항목이 SSOT다. 상세 목표와 미구현 단계는
|
||||
`docs/superpowers/specs/2026-07-26-redis-production-capability-design.md`에 있다.
|
||||
|
||||
## 모듈 개요
|
||||
## 현재 readiness
|
||||
|
||||
application-core 포트 뒤에 두는 **선택형** 캐시 어댑터다. `@ConditionalOnProperty`
|
||||
(`APP_CACHE_REDIS_ENABLED`)로 게이팅되고 기본 비활성이다. `core` 서브패키지는 라우팅/SPI 추상화만
|
||||
갖고, `redis` 서브패키지가 이 모듈이 기본 제공하는 유일한 구체 백엔드(`RedisCacheAdapterConfig`
|
||||
/ `RedisCacheStore`)다. 다른 벤더 백엔드가 필요하면 `CacheBackend` SPI 를 구현해 빈으로 추가한다.
|
||||
현재 standalone runtime과 semantic string cache는 R1이다. 모듈이 Lettuce connection lifecycle,
|
||||
finite command timeout, reconnect replay 차단, finite request queue/admission, positive/negative
|
||||
TTL, digest-protected bounded binary envelope, HMAC physical key,
|
||||
invalidation, Lua `EVALSHA -> NOSCRIPT -> EVAL` 실행기를 제공한다.
|
||||
`app.cache.redis.client-mode=external`이면 프로젝트가 제공한 `RedisClient` 호환 경로를 사용하고
|
||||
managed connection을 생성하지 않는다.
|
||||
|
||||
## 중앙 fail-open 합성
|
||||
명시적으로 Redis 7.4 image를 띄워 실행하는 standalone lane이 실제 expiry와
|
||||
compare-and-delete Lua 실행을 검증하지만 Sentinel/Cluster,
|
||||
TLS/ACL/credential rotation, restart/fault/eviction evidence, health/metrics가 없으므로 R2가 아니다.
|
||||
|
||||
`FailOpenCacheStore` 데코레이터는 `CacheRouterConfig` 가 모든 `CacheBackend` 에 **중앙에서**
|
||||
적용한다 — 백엔드 설정이 실수로 fail-open 정책을 빠뜨릴 수 없다. 백엔드 실패는 cache-miss 로
|
||||
다운그레이드돼 외부 장애가 5xx 로 번지지 않는다. 바인딩되지 않은 논리 이름은 설정 오류이며
|
||||
라우터에서 fail-fast 한다(Layer 3).
|
||||
## Application cache contract
|
||||
|
||||
## 기여 계약은 `CacheBackend`, SPI 는 `CacheStore`
|
||||
`application-core`의 `CacheRegionPort<K,V>`는 다음을 분리한다.
|
||||
|
||||
기여(contribution) 타입을 `CacheStore` 가 아닌 `CacheBackend` 로 둔 건 의도적이다 — 임의의
|
||||
`CacheStore` 빈이 실수로 라우팅되지 않게 하고, 타입이 IDE 탐색 가능하며 중복 id 는 startup 을
|
||||
실패시킨다. `CacheStore.get()` 의 `Optional.empty()` 는 miss 를 뜻한다(SDK 타입이 어댑터 밖으로
|
||||
새지 않게 — B7).
|
||||
- fresh/stale positive hit;
|
||||
- authoritative negative hit;
|
||||
- normal absent/expired/invalidated miss;
|
||||
- incompatible schema;
|
||||
- unavailable/overloaded와 operation certainty;
|
||||
- recorded/conditional/degraded/indeterminate mutation;
|
||||
- invalidated/already-absent/degraded/indeterminate invalidation.
|
||||
|
||||
## 라우팅 바인딩
|
||||
TTL, jitter, codec, topology와 Redis SDK 타입은 이 port에 들어가지 않는다. 실제 product의
|
||||
use case는 `CacheRegionPort`를 상속한 semantic subtype을 정의해야 한다.
|
||||
|
||||
논리 캐시 이름 → 백엔드는 `app.cache.bindings.<name>=<backendId>` 로 선택하며, `backendId` 는
|
||||
`CacheBackend#backendId()` 에서 온다. 백엔드는 `@ConditionalOnProperty` 게이팅 config(예:
|
||||
`RedisCacheAdapterConfig`)가 `CacheBackend` 빈으로 기여한다.
|
||||
## Physical key
|
||||
|
||||
`RedisKeyBuilder`만 다음 canonical shape를 만든다.
|
||||
|
||||
```text
|
||||
ca:<app>:<env>:<capability>:<region>:hv<hashVersion>:kv<keyVersion>:{<slot>}:<digest>:<kind>
|
||||
```
|
||||
|
||||
민감한 사용자/tenant/composite 값은 raw key에 넣지 않는다. length-prefixed canonical bytes를
|
||||
HMAC-SHA-256으로 digest한다. random opaque identifier는 SHA-256을 사용할 수 있다. builder는 slug,
|
||||
version, 정확히 하나인 hash tag와 전체 UTF-8 byte bound를 검증한다.
|
||||
|
||||
## Atomic program foundation
|
||||
|
||||
`redis/program-set.json`은 세 Lua resource의 exact digest, signature, status, complexity와 timeout
|
||||
certainty를 기록한다. `RedisAtomicPrimitives`는 compare-delete, compare-expire,
|
||||
set-if-absent-with-TTL을 typed result로 노출하고 unknown status를 compatibility failure로
|
||||
처리한다. owner/value/operation/TTL은 Redis 호출 전에 제한된다.
|
||||
Generic descriptor/catalog/executor와 typed primitive facade는 package-private collaborator다.
|
||||
Spring composition에는 raw Redis key/value/TTL을 받는 bean을 노출하지 않으며, 이후 semantic
|
||||
port adapter가 내부에서만 이 facade를 사용한다.
|
||||
따라서 이 program set은 현재 internal R0 foundation이며, 실제 도메인 capability가 바로 소비할
|
||||
수 있는 production bean이나 application port가 아니다.
|
||||
|
||||
`RedisLuaProgramExecutor`가 catalog source로 SHA-1 script identity를 계산하여 `EVALSHA`를 먼저
|
||||
호출하고 정확히 `NOSCRIPT`일 때만 compiled script를 `EVAL`한다. signature/argument bounds는
|
||||
client 호출 전에 다시 검증하고 descriptor catalog membership 및 반환 status membership을
|
||||
확인한다. unit lane은 강제 `NOSCRIPT` fallback을 검증하고 standalone real-service lane은
|
||||
compare-and-delete의 실제 atomic execution을 검증한다.
|
||||
|
||||
## Managed runtime과 semantic region
|
||||
|
||||
`app.cache.redis.enabled=true`이고 `client-mode=managed`(기본값)이면 `LettuceRedisRuntime`이
|
||||
단일 binary connection을 생성하고 종료 시 connection/client를 닫는다. 프로젝트가
|
||||
`RedisClient`를 직접 제공하는 경우에는 `client-mode=external`을 명시해야 한다. 이 선택을
|
||||
명시함으로써 Spring configuration 처리 순서에 따라 managed/custom client 선택이 달라지지 않는다.
|
||||
Managed runtime은 reconnect 시 pending command를 replay하지 않고, disconnected command를
|
||||
pre-send 거부하며, request queue와 동시 outstanding command를 같은 finite bound로 제한한다.
|
||||
`RedisStringCacheRegion`은 `CacheRegionPort<String,String>` bean으로 제공되며 다음 결과를
|
||||
구분한다.
|
||||
|
||||
- positive hit, authoritative negative hit, normal miss;
|
||||
- unknown/corrupt/retired envelope와 fail-fast future envelope;
|
||||
- read unavailable/overloaded와 mutation not-applied/indeterminate;
|
||||
- invalidated와 already absent.
|
||||
|
||||
opaque source revision에는 대소 비교 의미가 없으므로
|
||||
`ONLY_IF_SOURCE_REVISION_NEWER`는 임의 lexical comparison을 하지 않고
|
||||
`NOT_RECORDED_PROVIDER_POLICY`를 반환한다.
|
||||
|
||||
Envelope는 source revision의 application invariant(1..128 characters)를 decode 때도 다시
|
||||
검사하고 canonical bytes의 SHA-256 digest가 맞지 않으면 corrupt schema result로 격리한다.
|
||||
|
||||
추가 runtime setting은 `app.cache.redis.maximum-queued-commands=8`(범위 `1..4096`)과
|
||||
`app.cache.redis.maximum-in-flight-bytes=16777216`이다. command count와 retained
|
||||
request/response byte budget을 모두 통과해야 Lettuce 호출을 시작하며,
|
||||
`queue-count × (maximum-value-bytes + overhead)`도 byte bound 이하여야 한다. 이 관계는
|
||||
timeout 완료 뒤 driver가 응답 decode 전까지 command args를 유지하는 경우도 유한하게 제한한다.
|
||||
timeout 직후에는 runtime admission population과 Lettuce retained population이 겹칠 수 있으므로
|
||||
최악 상한은 대략 `maximum-in-flight-bytes + queue-count × per-command-bound`이고, 설정 검증은
|
||||
두 번째 항이 첫 번째 항을 넘지 않게 해 최대 약 2배 population으로 제한한다.
|
||||
|
||||
read는 raw `GET`을 사용하지 않는다. 고정 Lua read가 `GETRANGE(0, maximum-envelope-bytes)`로
|
||||
Redis가 wire에 내보내는 bulk reply 자체를 `maximum-envelope-bytes + 1` 이하로 자르고, 초과하면
|
||||
작은 오류 응답으로 바꾼다. 따라서 다른 writer가 같은 물리 키를 오염시켜도 전체 대용량 value를
|
||||
Netty/codec에 먼저 할당하지 않는다. managed runtime을 활성화할 때 host가 누락되면
|
||||
`localhost`로 암묵 fallback하지 않고 startup을 실패시킨다.
|
||||
|
||||
## Legacy path
|
||||
|
||||
기존 `CacheStoreRouter`, `RedisCacheStore`, `FailOpenCacheStore`는 호환성을 위해 남아 있다. 이
|
||||
경로는 `Optional.empty()`로 miss와 backend failure를 합친다. managed runtime을 사용할 때
|
||||
legacy `put`에도 positive TTL을 적용하지만, 사용자 제공 legacy client의 TTL은 보장할 수 없으므로
|
||||
새 semantic cache port 구현의 기준으로 사용하지 않는다.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./gradlew :application-core:check :adapter:outbound:cache-redis:check --console=plain
|
||||
./gradlew :adapter:outbound:cache-redis:redisServiceTest \
|
||||
-Dredis.test.host=127.0.0.1 -Dredis.test.port=6379 --console=plain
|
||||
```
|
||||
|
||||
@@ -1,14 +1,34 @@
|
||||
plugins { id 'groovy' }
|
||||
dependencies {
|
||||
implementation project(':domain-core')
|
||||
implementation project(':application-core')
|
||||
implementation project(':shared-contract')
|
||||
implementation project(':adapter:outbound:support')
|
||||
|
||||
implementation 'org.springframework.boot:spring-boot-autoconfigure'
|
||||
implementation 'io.lettuce:lettuce-core'
|
||||
implementation 'org.slf4j:slf4j-api'
|
||||
|
||||
testImplementation 'org.spockframework:spock-core:2.4-groovy-5.0'
|
||||
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
|
||||
}
|
||||
tasks.withType(GroovyCompile).configureEach { groovyOptions.encoding = 'UTF-8'; options.encoding = 'UTF-8' }
|
||||
tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' }
|
||||
|
||||
tasks.named('test') {
|
||||
useJUnitPlatform {
|
||||
excludeTags 'redis-service'
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register('redisServiceTest', Test) {
|
||||
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')
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
# Manual edits can break the build and are not advised.
|
||||
# This file is expected to be part of source control.
|
||||
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=testCompileClasspath
|
||||
ch.qos.logback:logback-classic:1.5.21=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
ch.qos.logback:logback-core:1.5.21=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
ch.qos.logback:logback-classic:1.5.21=testCompileClasspath,testRuntimeClasspath
|
||||
ch.qos.logback:logback-core:1.5.21=testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath
|
||||
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
|
||||
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
|
||||
@@ -41,11 +41,21 @@ commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testComp
|
||||
info.picocli:picocli:4.7.7=checkstyle
|
||||
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor
|
||||
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
|
||||
io.leangen.geantyref:geantyref:1.3.16=testRuntimeClasspath
|
||||
io.lettuce:lettuce-core:6.8.1.RELEASE=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.netty:netty-buffer:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.netty:netty-codec-base:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.netty:netty-codec-dns:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.netty:netty-common:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.netty:netty-handler:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.netty:netty-resolver-dns:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.netty:netty-resolver:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.netty:netty-transport-native-unix-common:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.netty:netty-transport:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.projectreactor:reactor-core:3.8.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.annotation:jakarta.annotation-api:3.0.0=runtimeClasspath,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
|
||||
@@ -59,13 +69,11 @@ org.apache.bcel:bcel:6.12.0=spotbugs
|
||||
org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs
|
||||
org.apache.commons:commons-text:1.15.0=spotbugs
|
||||
org.apache.commons:commons-text:1.3=checkstyle
|
||||
org.apache.groovy:groovy-bom:5.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.groovy:groovy:5.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
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=runtimeClasspath,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=runtimeClasspath,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
|
||||
@@ -90,7 +98,7 @@ 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=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
|
||||
@@ -109,14 +117,14 @@ org.ow2.asm:asm-util:9.10.1=spotbugs
|
||||
org.ow2.asm:asm:9.10.1=spotbugs
|
||||
org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
|
||||
org.reactivestreams:reactive-streams:1.0.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.reflections:reflections:0.10.2=checkstyle
|
||||
org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:jul-to-slf4j:2.0.17=runtimeClasspath,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.spockframework:spock-bom:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.spockframework:spock-core:2.4-groovy-5.0=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=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
|
||||
@@ -125,13 +133,13 @@ org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,t
|
||||
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=runtimeClasspath,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=runtimeClasspath,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
|
||||
@@ -149,7 +157,8 @@ org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
|
||||
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
|
||||
org.yaml:snakeyaml:2.5=runtimeClasspath,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
|
||||
|
||||
+292
@@ -0,0 +1,292 @@
|
||||
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 java.net.SocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
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 byte[] BOUNDED_GET_SCRIPT =
|
||||
"""
|
||||
local limit = tonumber(ARGV[1])
|
||||
local value = redis.call('GETRANGE', KEYS[1], 0, limit)
|
||||
if #value > limit then
|
||||
return redis.error_reply('CA_VALUE_TOO_LARGE')
|
||||
end
|
||||
if #value == 0 and redis.call('EXISTS', KEYS[1]) == 0 then
|
||||
return false
|
||||
end
|
||||
return value
|
||||
"""
|
||||
.getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
private 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,
|
||||
RedisRuntimeSettings settings) {
|
||||
this.client = client;
|
||||
this.connection = connection;
|
||||
this.commands = connection.sync();
|
||||
this.legacyTtl = settings.positiveTtl();
|
||||
this.shutdownTimeout = settings.commandTimeout();
|
||||
this.commandAdmission =
|
||||
new RedisCommandAdmission(
|
||||
settings.maximumQueuedCommands(), settings.maximumInFlightBytes());
|
||||
this.maximumReadableValueBytes = settings.maximumValueBytes() + 1024 + 32;
|
||||
this.maximumCommandBytes = settings.maximumValueBytes() + 2048;
|
||||
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) {
|
||||
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) {
|
||||
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.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(key.getBytes(StandardCharsets.UTF_8));
|
||||
return value == null
|
||||
? Optional.empty()
|
||||
: Optional.of(new String(value, StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(String key, String value) {
|
||||
set(key.getBytes(StandardCharsets.UTF_8), value.getBytes(StandardCharsets.UTF_8), legacyTtl);
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] get(byte[] key) {
|
||||
byte[] limit = Integer.toString(maximumReadableValueBytes).getBytes(StandardCharsets.US_ASCII);
|
||||
try {
|
||||
byte[] value =
|
||||
execute(
|
||||
false,
|
||||
reservationBytes(
|
||||
maximumReadableValueBytes,
|
||||
List.of(BOUNDED_GET_SCRIPT),
|
||||
List.of(key),
|
||||
List.of(limit)),
|
||||
() ->
|
||||
commands.eval(
|
||||
BOUNDED_GET_SCRIPT,
|
||||
ScriptOutputType.VALUE,
|
||||
new byte[][] {key.clone()},
|
||||
limit));
|
||||
return value == null ? null : value.clone();
|
||||
} catch (RedisCommandExecutionException exception) {
|
||||
if (exception.getMessage() != null
|
||||
&& exception.getMessage().contains(VALUE_TOO_LARGE_ERROR)) {
|
||||
throw new RedisValueTooLargeException();
|
||||
}
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void set(byte[] key, byte[] value, Duration timeToLive) {
|
||||
String result =
|
||||
execute(
|
||||
true,
|
||||
reservationBytes(64, List.of(key, value)),
|
||||
() ->
|
||||
commands.set(
|
||||
key.clone(), value.clone(), SetArgs.Builder.px(timeToLive.toMillis())));
|
||||
if (!"OK".equals(result)) {
|
||||
throw new IllegalStateException("Redis SET did not acknowledge the mutation");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public long delete(byte[] key) {
|
||||
return execute(true, reservationBytes(32, List.of(key)), () -> commands.del(key.clone()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] evalSha(String sha1, List<byte[]> keys, List<byte[]> arguments) {
|
||||
try {
|
||||
return execute(
|
||||
true,
|
||||
reservationBytes(256, keys, arguments),
|
||||
() ->
|
||||
commands.evalsha(
|
||||
sha1,
|
||||
ScriptOutputType.VALUE,
|
||||
keys.toArray(byte[][]::new),
|
||||
arguments.toArray(byte[][]::new)));
|
||||
} catch (io.lettuce.core.RedisNoScriptException exception) {
|
||||
throw new RedisNoScriptException();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] eval(byte[] script, List<byte[]> keys, List<byte[]> arguments) {
|
||||
return execute(
|
||||
true,
|
||||
reservationBytes(256, List.of(script), keys, arguments),
|
||||
() ->
|
||||
commands.eval(
|
||||
script.clone(),
|
||||
ScriptOutputType.VALUE,
|
||||
keys.toArray(byte[][]::new),
|
||||
arguments.toArray(byte[][]::new)));
|
||||
}
|
||||
|
||||
@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 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);
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
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 = 1_048_576;
|
||||
private static final long MAXIMUM_TTL_MILLIS = Duration.ofDays(30).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);
|
||||
}
|
||||
|
||||
private String execute(RedisProgramId id, List<byte[]> keys, List<byte[]> arguments) {
|
||||
RedisProgramDescriptor descriptor = catalog.descriptor(id);
|
||||
if (keys.size() != descriptor.keyCount() || arguments.size() != descriptor.argumentCount()) {
|
||||
throw new IllegalStateException("typed Redis program signature drift for " + id.externalId());
|
||||
}
|
||||
return executor.execute(descriptor, List.copyOf(keys), List.copyOf(arguments));
|
||||
}
|
||||
|
||||
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[] 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
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
|
||||
/** Minimal binary Redis command surface owned entirely by this adapter. */
|
||||
interface RedisBinaryCommands {
|
||||
|
||||
byte[] get(byte[] key);
|
||||
|
||||
void set(byte[] key, byte[] value, Duration timeToLive);
|
||||
|
||||
long delete(byte[] key);
|
||||
|
||||
byte[] evalSha(String sha1, List<byte[]> keys, List<byte[]> arguments);
|
||||
|
||||
byte[] eval(byte[] script, List<byte[]> keys, List<byte[]> arguments);
|
||||
}
|
||||
+52
-1
@@ -1,7 +1,11 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.cache.core.CacheBackend;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyNamespace;
|
||||
import dev.caskeleton.application.cache.CacheRegionPort;
|
||||
import 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;
|
||||
|
||||
@@ -18,9 +22,56 @@ import org.springframework.context.annotation.Configuration;
|
||||
* 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
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@EnableConfigurationProperties(RedisRuntimeSettings.class)
|
||||
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
|
||||
@ConditionalOnBean(LettuceRedisRuntime.class)
|
||||
@ConditionalOnProperty(
|
||||
name = "app.cache.redis.enabled",
|
||||
havingValue = "true",
|
||||
matchIfMissing = false)
|
||||
CacheRegionPort<String, String> redisStringCacheRegion(
|
||||
LettuceRedisRuntime runtime, RedisRuntimeSettings settings) {
|
||||
RedisKeyNamespace namespace =
|
||||
new RedisKeyNamespace(
|
||||
settings.namespaceApplication(),
|
||||
settings.namespaceEnvironment(),
|
||||
"cache",
|
||||
settings.semanticRegion(),
|
||||
1,
|
||||
1,
|
||||
"entry",
|
||||
512);
|
||||
return new RedisStringCacheRegion(
|
||||
new RedisCacheRegionPolicy(
|
||||
namespace,
|
||||
settings.hmacSecret(),
|
||||
settings.positiveTtl(),
|
||||
settings.negativeTtl(),
|
||||
settings.maximumValueBytes()),
|
||||
runtime);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnProperty(
|
||||
name = "app.cache.redis.enabled",
|
||||
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import dev.caskeleton.application.cache.AuthoritativeAbsence;
|
||||
import dev.caskeleton.application.cache.CacheLookup;
|
||||
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.util.Arrays;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Strict versioned binary envelope for positive and authoritative-negative cache entries. */
|
||||
final class RedisCacheEnvelopeCodec {
|
||||
|
||||
private static final int MAGIC = 0x43414348;
|
||||
private static final byte VERSION = 1;
|
||||
private static final byte POSITIVE = 1;
|
||||
private static final byte NEGATIVE = 2;
|
||||
private static final int CONTENT_HEADER_BYTES =
|
||||
Integer.BYTES + Byte.BYTES + Byte.BYTES + Short.BYTES + Integer.BYTES;
|
||||
private static final int DIGEST_BYTES = 32;
|
||||
|
||||
private RedisCacheEnvelopeCodec() {}
|
||||
|
||||
static byte[] positive(String value, String sourceRevision, int maximumValueBytes) {
|
||||
return encode(
|
||||
POSITIVE,
|
||||
utf8(Objects.requireNonNull(value, "value must be non-null")),
|
||||
sourceRevision,
|
||||
maximumValueBytes);
|
||||
}
|
||||
|
||||
static byte[] negative(
|
||||
AuthoritativeAbsence reason, String sourceRevision, int maximumValueBytes) {
|
||||
Objects.requireNonNull(reason, "reason must be non-null");
|
||||
return encode(NEGATIVE, utf8(reason.name()), sourceRevision, maximumValueBytes);
|
||||
}
|
||||
|
||||
static Decoded decode(byte[] envelope, int maximumValueBytes) {
|
||||
if (envelope == null
|
||||
|| envelope.length < CONTENT_HEADER_BYTES + DIGEST_BYTES
|
||||
|| envelope.length > maximumValueBytes + 1024 + DIGEST_BYTES) {
|
||||
return incompatible(CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE);
|
||||
}
|
||||
try {
|
||||
ByteBuffer buffer = ByteBuffer.wrap(envelope, 0, envelope.length - DIGEST_BYTES);
|
||||
if (buffer.getInt() != MAGIC) {
|
||||
return incompatible(CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE);
|
||||
}
|
||||
byte version = buffer.get();
|
||||
if (version > VERSION) {
|
||||
return incompatible(CacheLookup.SchemaCategory.FUTURE_VERSION);
|
||||
}
|
||||
if (version < VERSION) {
|
||||
return incompatible(CacheLookup.SchemaCategory.RETIRED_VERSION);
|
||||
}
|
||||
byte[] expectedDigest = sha256(Arrays.copyOf(envelope, envelope.length - DIGEST_BYTES));
|
||||
byte[] actualDigest =
|
||||
Arrays.copyOfRange(envelope, envelope.length - DIGEST_BYTES, envelope.length);
|
||||
if (!MessageDigest.isEqual(expectedDigest, actualDigest)) {
|
||||
return incompatible(CacheLookup.SchemaCategory.CORRUPT_ENVELOPE);
|
||||
}
|
||||
byte type = buffer.get();
|
||||
int revisionSize = Short.toUnsignedInt(buffer.getShort());
|
||||
int payloadSize = buffer.getInt();
|
||||
if (revisionSize < 1
|
||||
|| revisionSize > 512
|
||||
|| payloadSize < 1
|
||||
|| payloadSize > maximumValueBytes
|
||||
|| buffer.remaining() != revisionSize + payloadSize) {
|
||||
return incompatible(CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE);
|
||||
}
|
||||
byte[] revision = new byte[revisionSize];
|
||||
byte[] payload = new byte[payloadSize];
|
||||
buffer.get(revision);
|
||||
buffer.get(payload);
|
||||
String sourceRevision = strictUtf8(revision);
|
||||
if (!validSourceRevision(sourceRevision)) {
|
||||
return incompatible(CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE);
|
||||
}
|
||||
if (type == POSITIVE) {
|
||||
return new Positive(strictUtf8(payload), sourceRevision);
|
||||
}
|
||||
if (type == NEGATIVE) {
|
||||
return new Negative(AuthoritativeAbsence.valueOf(strictUtf8(payload)));
|
||||
}
|
||||
return incompatible(CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE);
|
||||
} catch (IllegalArgumentException | CharacterCodingException exception) {
|
||||
return incompatible(CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] encode(
|
||||
byte type, byte[] payload, String sourceRevision, int maximumValueBytes) {
|
||||
byte[] revision =
|
||||
utf8(Objects.requireNonNull(sourceRevision, "sourceRevision must be non-null"));
|
||||
if (!validSourceRevision(sourceRevision) || revision.length > 512) {
|
||||
throw new IllegalArgumentException(
|
||||
"sourceRevision must contain 1..128 characters and at most 512 UTF-8 bytes");
|
||||
}
|
||||
if (payload.length < 1 || payload.length > maximumValueBytes) {
|
||||
throw new IllegalArgumentException("cache payload exceeds configured maximum bytes");
|
||||
}
|
||||
byte[] content =
|
||||
ByteBuffer.allocate(CONTENT_HEADER_BYTES + revision.length + payload.length)
|
||||
.putInt(MAGIC)
|
||||
.put(VERSION)
|
||||
.put(type)
|
||||
.putShort((short) revision.length)
|
||||
.putInt(payload.length)
|
||||
.put(revision)
|
||||
.put(payload)
|
||||
.array();
|
||||
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 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);
|
||||
}
|
||||
|
||||
sealed interface Decoded permits Positive, Negative, Incompatible {}
|
||||
|
||||
record Positive(String value, String sourceRevision) implements Decoded {}
|
||||
|
||||
record Negative(AuthoritativeAbsence reason) implements Decoded {}
|
||||
|
||||
record Incompatible(CacheLookup.SchemaCategory category) implements Decoded {}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyNamespace;
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Immutable key, TTL and envelope bounds for one semantic string cache region. */
|
||||
final class RedisCacheRegionPolicy {
|
||||
|
||||
private final RedisKeyNamespace namespace;
|
||||
private final byte[] hmacSecret;
|
||||
private final Duration positiveTtl;
|
||||
private final Duration negativeTtl;
|
||||
private final int maximumValueBytes;
|
||||
|
||||
RedisCacheRegionPolicy(
|
||||
RedisKeyNamespace namespace,
|
||||
byte[] hmacSecret,
|
||||
Duration positiveTtl,
|
||||
Duration negativeTtl,
|
||||
int maximumValueBytes) {
|
||||
this.namespace = Objects.requireNonNull(namespace, "namespace must be non-null");
|
||||
Objects.requireNonNull(hmacSecret, "hmacSecret must be non-null");
|
||||
if (hmacSecret.length < 32) {
|
||||
throw new IllegalArgumentException("hmacSecret must contain at least 32 bytes");
|
||||
}
|
||||
this.hmacSecret = hmacSecret.clone();
|
||||
this.positiveTtl = positive(positiveTtl, "positiveTtl");
|
||||
this.negativeTtl = positive(negativeTtl, "negativeTtl");
|
||||
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() {
|
||||
return hmacSecret.clone();
|
||||
}
|
||||
|
||||
Duration positiveTtl() {
|
||||
return positiveTtl;
|
||||
}
|
||||
|
||||
Duration negativeTtl() {
|
||||
return negativeTtl;
|
||||
}
|
||||
|
||||
int maximumValueBytes() {
|
||||
return maximumValueBytes;
|
||||
}
|
||||
|
||||
private static Duration positive(Duration value, String field) {
|
||||
Objects.requireNonNull(value, field + " must be non-null");
|
||||
if (value.isZero() || value.isNegative() || value.compareTo(Duration.ofDays(30)) > 0) {
|
||||
throw new IllegalArgumentException(field + " must be positive and at most 30 days");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
/** 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;
|
||||
|
||||
RedisCommandFailureException(Kind kind, Certainty certainty, String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
this.kind = kind;
|
||||
this.certainty = certainty;
|
||||
}
|
||||
|
||||
Kind kind() {
|
||||
return kind;
|
||||
}
|
||||
|
||||
Certainty certainty() {
|
||||
return certainty;
|
||||
}
|
||||
|
||||
enum Kind {
|
||||
UNAVAILABLE,
|
||||
OVERLOADED
|
||||
}
|
||||
|
||||
enum Certainty {
|
||||
NOT_APPLIED,
|
||||
INDETERMINATE
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Executes an exact catalog script through EVALSHA, with EVAL allowed only after NOSCRIPT. */
|
||||
final class RedisLuaProgramExecutor implements RedisProgramExecutor {
|
||||
|
||||
private static final HexFormat HEX = HexFormat.of();
|
||||
|
||||
private final RedisProgramCatalog catalog;
|
||||
private final RedisBinaryCommands commands;
|
||||
|
||||
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(
|
||||
RedisProgramDescriptor descriptor, List<byte[]> keys, List<byte[]> arguments) {
|
||||
Objects.requireNonNull(descriptor, "descriptor must be non-null");
|
||||
if (catalog.descriptor(descriptor.id()) != descriptor) {
|
||||
throw new IllegalArgumentException("Redis program descriptor is not owned by this catalog");
|
||||
}
|
||||
validate(descriptor, keys, arguments);
|
||||
byte[] result;
|
||||
try {
|
||||
result = commands.evalSha(sha1(descriptor.scriptBytes()), keys, arguments);
|
||||
} catch (RedisNoScriptException noScript) {
|
||||
result = commands.eval(descriptor.scriptBytes(), keys, arguments);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
private static void validate(
|
||||
RedisProgramDescriptor descriptor, List<byte[]> keys, List<byte[]> arguments) {
|
||||
Objects.requireNonNull(keys, "keys must be non-null");
|
||||
Objects.requireNonNull(arguments, "arguments must be non-null");
|
||||
if (keys.size() != descriptor.keyCount() || arguments.size() != descriptor.argumentCount()) {
|
||||
throw new IllegalArgumentException("Redis program signature does not match descriptor");
|
||||
}
|
||||
for (byte[] key : keys) {
|
||||
bounded(key, descriptor.maximumKeyBytes(), "key");
|
||||
}
|
||||
for (byte[] argument : arguments) {
|
||||
bounded(argument, descriptor.maximumArgumentBytes(), "argument");
|
||||
}
|
||||
}
|
||||
|
||||
private static void bounded(byte[] value, int maximumBytes, String field) {
|
||||
if (value == null || value.length < 1 || value.length > maximumBytes) {
|
||||
throw new IllegalArgumentException("Redis program " + field + " is out of bounds");
|
||||
}
|
||||
}
|
||||
|
||||
private static String sha1(byte[] script) {
|
||||
try {
|
||||
return HEX.formatHex(MessageDigest.getInstance("SHA-1").digest(script));
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("SHA-1 unavailable for Redis script identity", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
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;
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Collection;
|
||||
import java.util.EnumMap;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/** Closed catalog that binds typed program IDs to immutable versioned Lua resources. */
|
||||
final class RedisProgramCatalog {
|
||||
|
||||
private static final HexFormat HEX = HexFormat.of();
|
||||
|
||||
private final Map<RedisProgramId, RedisProgramDescriptor> descriptors;
|
||||
|
||||
private RedisProgramCatalog(Map<RedisProgramId, RedisProgramDescriptor> descriptors) {
|
||||
this.descriptors = Map.copyOf(descriptors);
|
||||
}
|
||||
|
||||
static RedisProgramCatalog foundation() {
|
||||
Map<RedisProgramId, RedisProgramDescriptor> descriptors = new EnumMap<>(RedisProgramId.class);
|
||||
descriptors.put(
|
||||
RedisProgramId.COMPARE_AND_DELETE,
|
||||
descriptor(
|
||||
RedisProgramId.COMPARE_AND_DELETE,
|
||||
1,
|
||||
1,
|
||||
512,
|
||||
128,
|
||||
Set.of("DELETED", "ABSENT", "NOT_OWNER", "WRONG_TYPE", "INVALID")));
|
||||
descriptors.put(
|
||||
RedisProgramId.COMPARE_AND_EXPIRE,
|
||||
descriptor(
|
||||
RedisProgramId.COMPARE_AND_EXPIRE,
|
||||
1,
|
||||
2,
|
||||
512,
|
||||
128,
|
||||
Set.of("RENEWED", "ABSENT", "NOT_OWNER", "WRONG_TYPE", "INVALID")));
|
||||
descriptors.put(
|
||||
RedisProgramId.SET_IF_ABSENT_WITH_TTL,
|
||||
descriptor(
|
||||
RedisProgramId.SET_IF_ABSENT_WITH_TTL,
|
||||
1,
|
||||
3,
|
||||
512,
|
||||
1_048_576,
|
||||
Set.of("SET", "EXISTS", "WRONG_TYPE", "INVALID")));
|
||||
return new RedisProgramCatalog(descriptors);
|
||||
}
|
||||
|
||||
RedisProgramDescriptor descriptor(RedisProgramId id) {
|
||||
RedisProgramDescriptor descriptor = descriptors.get(id);
|
||||
if (descriptor == null) {
|
||||
throw new IllegalArgumentException("unknown Redis program id: " + id);
|
||||
}
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
Collection<RedisProgramDescriptor> descriptors() {
|
||||
return descriptors.values();
|
||||
}
|
||||
|
||||
private static RedisProgramDescriptor descriptor(
|
||||
RedisProgramId id,
|
||||
int keyCount,
|
||||
int argumentCount,
|
||||
int maximumKeyBytes,
|
||||
int maximumArgumentBytes,
|
||||
Set<String> statuses) {
|
||||
byte[] script = readResource(id.scriptResource());
|
||||
return new RedisProgramDescriptor(
|
||||
id,
|
||||
HEX.formatHex(sha256(script)),
|
||||
script,
|
||||
keyCount,
|
||||
argumentCount,
|
||||
maximumKeyBytes,
|
||||
maximumArgumentBytes,
|
||||
statuses);
|
||||
}
|
||||
|
||||
private static byte[] readResource(String resource) {
|
||||
ClassLoader loader = RedisProgramCatalog.class.getClassLoader();
|
||||
try (InputStream input = loader.getResourceAsStream(resource)) {
|
||||
if (input == null) {
|
||||
throw new IllegalStateException("missing Redis program resource: " + resource);
|
||||
}
|
||||
return input.readAllBytes();
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("cannot read Redis program resource: " + resource, exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] sha256(byte[] input) {
|
||||
try {
|
||||
return MessageDigest.getInstance("SHA-256").digest(input);
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("SHA-256 unavailable", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
/** Raised when runtime program output is not part of the compiled program contract. */
|
||||
public final class RedisProgramCompatibilityException extends RuntimeException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
RedisProgramCompatibilityException(RedisProgramId id, String status) {
|
||||
super("Redis program " + id.externalId() + " returned unknown status: " + status);
|
||||
}
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/** Immutable signature and exact source digest for one versioned atomic program. */
|
||||
final class RedisProgramDescriptor {
|
||||
|
||||
private final RedisProgramId id;
|
||||
private final String sha256;
|
||||
private final byte[] scriptBytes;
|
||||
private final int keyCount;
|
||||
private final int argumentCount;
|
||||
private final int maximumKeyBytes;
|
||||
private final int maximumArgumentBytes;
|
||||
private final Set<String> statuses;
|
||||
|
||||
RedisProgramDescriptor(
|
||||
RedisProgramId id,
|
||||
String sha256,
|
||||
byte[] scriptBytes,
|
||||
int keyCount,
|
||||
int argumentCount,
|
||||
int maximumKeyBytes,
|
||||
int maximumArgumentBytes,
|
||||
Set<String> statuses) {
|
||||
this.id = Objects.requireNonNull(id, "id must be non-null");
|
||||
if (sha256 == null || !sha256.matches("[0-9a-f]{64}")) {
|
||||
throw new IllegalArgumentException("sha256 must be 64 lowercase hexadecimal characters");
|
||||
}
|
||||
this.sha256 = sha256;
|
||||
Objects.requireNonNull(scriptBytes, "scriptBytes must be non-null");
|
||||
if (scriptBytes.length == 0) {
|
||||
throw new IllegalArgumentException("scriptBytes must be non-empty");
|
||||
}
|
||||
this.scriptBytes = scriptBytes.clone();
|
||||
if (keyCount < 1 || argumentCount < 1) {
|
||||
throw new IllegalArgumentException("program key and argument counts must be positive");
|
||||
}
|
||||
this.keyCount = keyCount;
|
||||
this.argumentCount = argumentCount;
|
||||
if (maximumKeyBytes < 1 || maximumArgumentBytes < 1) {
|
||||
throw new IllegalArgumentException("program byte bounds must be positive");
|
||||
}
|
||||
this.maximumKeyBytes = maximumKeyBytes;
|
||||
this.maximumArgumentBytes = maximumArgumentBytes;
|
||||
this.statuses = Set.copyOf(statuses);
|
||||
if (this.statuses.isEmpty()) {
|
||||
throw new IllegalArgumentException("program statuses must be non-empty");
|
||||
}
|
||||
}
|
||||
|
||||
RedisProgramId id() {
|
||||
return id;
|
||||
}
|
||||
|
||||
String sha256() {
|
||||
return sha256;
|
||||
}
|
||||
|
||||
byte[] scriptBytes() {
|
||||
return scriptBytes.clone();
|
||||
}
|
||||
|
||||
int keyCount() {
|
||||
return keyCount;
|
||||
}
|
||||
|
||||
int argumentCount() {
|
||||
return argumentCount;
|
||||
}
|
||||
|
||||
int maximumKeyBytes() {
|
||||
return maximumKeyBytes;
|
||||
}
|
||||
|
||||
int maximumArgumentBytes() {
|
||||
return maximumArgumentBytes;
|
||||
}
|
||||
|
||||
Set<String> statuses() {
|
||||
return statuses;
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Adapter-internal execution seam. Implementations may use Functions or EVALSHA, but application
|
||||
* code must only depend on semantic ports and typed facades.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
interface RedisProgramExecutor {
|
||||
|
||||
String execute(RedisProgramDescriptor descriptor, List<byte[]> keys, List<byte[]> arguments);
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
/** Versioned Redis atomic programs available in the foundation catalog. */
|
||||
enum RedisProgramId {
|
||||
COMPARE_AND_DELETE("compare-and-delete-v1", "redis/scripts/compare-and-delete-v1.lua"),
|
||||
COMPARE_AND_EXPIRE("compare-and-expire-v1", "redis/scripts/compare-and-expire-v1.lua"),
|
||||
SET_IF_ABSENT_WITH_TTL(
|
||||
"set-if-absent-with-ttl-v1", "redis/scripts/set-if-absent-with-ttl-v1.lua");
|
||||
|
||||
private final String externalId;
|
||||
private final String scriptResource;
|
||||
|
||||
RedisProgramId(String externalId, String scriptResource) {
|
||||
this.externalId = externalId;
|
||||
this.scriptResource = scriptResource;
|
||||
}
|
||||
|
||||
String externalId() {
|
||||
return externalId;
|
||||
}
|
||||
|
||||
String scriptResource() {
|
||||
return scriptResource;
|
||||
}
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Base64;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.boot.context.properties.bind.ConstructorBinding;
|
||||
|
||||
/** Typed standalone Redis runtime and semantic cache settings. */
|
||||
@ConfigurationProperties(prefix = "app.cache.redis")
|
||||
public record RedisRuntimeSettings(
|
||||
boolean enabled,
|
||||
ClientMode clientMode,
|
||||
String host,
|
||||
int port,
|
||||
String password,
|
||||
String keyHmacSecret,
|
||||
Duration commandTimeout,
|
||||
Duration positiveTtl,
|
||||
Duration negativeTtl,
|
||||
String namespaceApplication,
|
||||
String namespaceEnvironment,
|
||||
String semanticRegion,
|
||||
int maximumValueBytes,
|
||||
int maximumQueuedCommands,
|
||||
int maximumInFlightBytes) {
|
||||
|
||||
private static final Duration MAXIMUM_TIMEOUT = Duration.ofSeconds(30);
|
||||
private static final Duration MAXIMUM_TTL = Duration.ofDays(30);
|
||||
|
||||
@ConstructorBinding
|
||||
public RedisRuntimeSettings {
|
||||
clientMode = clientMode == null ? ClientMode.MANAGED : clientMode;
|
||||
String configuredHost = host == null ? "" : host.trim();
|
||||
if (enabled && clientMode == ClientMode.MANAGED && configuredHost.isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
"Redis host must be configured when managed Redis is enabled");
|
||||
}
|
||||
host = configuredHost.isEmpty() ? "localhost" : configuredHost;
|
||||
port = port == 0 ? 6379 : port;
|
||||
password = password == null ? "" : password;
|
||||
keyHmacSecret = keyHmacSecret == null ? "" : keyHmacSecret;
|
||||
commandTimeout = commandTimeout == null ? Duration.ofSeconds(2) : commandTimeout;
|
||||
positiveTtl = positiveTtl == null ? Duration.ofMinutes(5) : positiveTtl;
|
||||
negativeTtl = negativeTtl == null ? Duration.ofSeconds(60) : negativeTtl;
|
||||
namespaceApplication = defaultText(namespaceApplication, "ca-skeleton");
|
||||
namespaceEnvironment = defaultText(namespaceEnvironment, "local");
|
||||
semanticRegion = defaultText(semanticRegion, "default");
|
||||
maximumValueBytes = maximumValueBytes == 0 ? 1_048_576 : maximumValueBytes;
|
||||
maximumQueuedCommands = maximumQueuedCommands == 0 ? 8 : maximumQueuedCommands;
|
||||
maximumInFlightBytes = maximumInFlightBytes == 0 ? 16_777_216 : maximumInFlightBytes;
|
||||
if (host.length() > 253
|
||||
|| host.chars().anyMatch(Character::isWhitespace)
|
||||
|| host.contains("/")
|
||||
|| host.contains("\\")) {
|
||||
throw new IllegalArgumentException("Redis host is invalid");
|
||||
}
|
||||
if (port < 1 || port > 65_535) {
|
||||
throw new IllegalArgumentException("Redis port must be in 1..65535");
|
||||
}
|
||||
positive(commandTimeout, MAXIMUM_TIMEOUT, "Redis command timeout");
|
||||
positive(positiveTtl, MAXIMUM_TTL, "Redis positive TTL");
|
||||
positive(negativeTtl, MAXIMUM_TTL, "Redis negative TTL");
|
||||
slug(namespaceApplication, "Redis namespace application");
|
||||
slug(namespaceEnvironment, "Redis namespace environment");
|
||||
slug(semanticRegion, "Redis semantic region");
|
||||
if (maximumValueBytes < 1 || maximumValueBytes > 16_777_216) {
|
||||
throw new IllegalArgumentException("Redis maximum value bytes must be in 1..16777216");
|
||||
}
|
||||
if (maximumQueuedCommands < 1 || maximumQueuedCommands > 4096) {
|
||||
throw new IllegalArgumentException("Redis maximum queued commands must be in 1..4096");
|
||||
}
|
||||
if (maximumInFlightBytes < maximumValueBytes + 1024 || maximumInFlightBytes > 268_435_456) {
|
||||
throw new IllegalArgumentException(
|
||||
"Redis maximum in-flight bytes must cover one maximum value and be <= 268435456");
|
||||
}
|
||||
long maximumRetainedCommandBytes = (long) maximumQueuedCommands * (maximumValueBytes + 2048L);
|
||||
if (maximumRetainedCommandBytes > maximumInFlightBytes) {
|
||||
throw new IllegalArgumentException(
|
||||
"Redis queued-command count and maximum value exceed the in-flight byte bound");
|
||||
}
|
||||
}
|
||||
|
||||
RedisRuntimeSettings(
|
||||
boolean enabled,
|
||||
ClientMode clientMode,
|
||||
String host,
|
||||
int port,
|
||||
String password,
|
||||
String keyHmacSecret,
|
||||
Duration commandTimeout,
|
||||
Duration positiveTtl,
|
||||
Duration negativeTtl,
|
||||
String namespaceApplication,
|
||||
String namespaceEnvironment,
|
||||
String semanticRegion,
|
||||
int maximumValueBytes) {
|
||||
this(
|
||||
enabled,
|
||||
clientMode,
|
||||
host,
|
||||
port,
|
||||
password,
|
||||
keyHmacSecret,
|
||||
commandTimeout,
|
||||
positiveTtl,
|
||||
negativeTtl,
|
||||
namespaceApplication,
|
||||
namespaceEnvironment,
|
||||
semanticRegion,
|
||||
maximumValueBytes,
|
||||
8,
|
||||
16_777_216);
|
||||
}
|
||||
|
||||
/** Selects the module-owned Lettuce runtime or an explicitly supplied {@link RedisClient}. */
|
||||
public enum ClientMode {
|
||||
MANAGED,
|
||||
EXTERNAL
|
||||
}
|
||||
|
||||
byte[] hmacSecret() {
|
||||
byte[] decoded;
|
||||
try {
|
||||
decoded = Base64.getDecoder().decode(keyHmacSecret);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new IllegalArgumentException("Redis key HMAC secret must be valid Base64", exception);
|
||||
}
|
||||
if (decoded.length < 32) {
|
||||
throw new IllegalArgumentException("Redis key HMAC secret must contain at least 32 bytes");
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
private static void positive(Duration value, Duration maximum, String field) {
|
||||
if (value.isZero() || value.isNegative() || value.compareTo(maximum) > 0) {
|
||||
throw new IllegalArgumentException(field + " must be positive and bounded");
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.RedisCacheEnvelopeCodec.Incompatible;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.RedisCacheEnvelopeCodec.Negative;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.RedisCacheEnvelopeCodec.Positive;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyBuilder;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyDigest;
|
||||
import dev.caskeleton.application.cache.AuthoritativeAbsence;
|
||||
import dev.caskeleton.application.cache.CacheInvalidationOutcome;
|
||||
import dev.caskeleton.application.cache.CacheLookup;
|
||||
import dev.caskeleton.application.cache.CacheRecordIntent;
|
||||
import dev.caskeleton.application.cache.CacheRecordMetadata;
|
||||
import dev.caskeleton.application.cache.CacheRecordOutcome;
|
||||
import dev.caskeleton.application.cache.CacheRegionPort;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Semantic string-cache reference adapter using versioned envelopes and finite TTLs. */
|
||||
final class RedisStringCacheRegion implements CacheRegionPort<String, String> {
|
||||
|
||||
private final RedisCacheRegionPolicy policy;
|
||||
private final RedisBinaryCommands commands;
|
||||
|
||||
RedisStringCacheRegion(RedisCacheRegionPolicy policy, RedisBinaryCommands commands) {
|
||||
this.policy = Objects.requireNonNull(policy, "policy must be non-null");
|
||||
this.commands = Objects.requireNonNull(commands, "commands must be non-null");
|
||||
}
|
||||
|
||||
@Override
|
||||
public CacheLookup<String> lookup(String key) {
|
||||
byte[] physicalKey = physicalKey(key);
|
||||
byte[] envelope;
|
||||
try {
|
||||
envelope = commands.get(physicalKey);
|
||||
} catch (RedisValueTooLargeException exception) {
|
||||
return new CacheLookup.IncompatibleSchema<>(
|
||||
CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE,
|
||||
CacheLookup.SchemaPolicy.QUARANTINE_AND_RELOAD);
|
||||
} catch (RedisCommandFailureException exception) {
|
||||
return new CacheLookup.Unavailable<>(
|
||||
exception.kind() == RedisCommandFailureException.Kind.OVERLOADED
|
||||
? CacheLookup.UnavailabilityReason.OVERLOADED
|
||||
: CacheLookup.UnavailabilityReason.UNAVAILABLE,
|
||||
certainty(exception));
|
||||
}
|
||||
if (envelope == null) {
|
||||
return new CacheLookup.Miss<>(CacheLookup.MissReason.ABSENT);
|
||||
}
|
||||
var decoded = RedisCacheEnvelopeCodec.decode(envelope, policy.maximumValueBytes());
|
||||
if (decoded instanceof Positive positive) {
|
||||
return new CacheLookup.Hit<>(
|
||||
positive.value(), CacheLookup.Freshness.FRESH, positive.sourceRevision());
|
||||
}
|
||||
if (decoded instanceof Negative negative) {
|
||||
return new CacheLookup.NegativeHit<>(negative.reason());
|
||||
}
|
||||
Incompatible incompatible = (Incompatible) decoded;
|
||||
return new CacheLookup.IncompatibleSchema<>(
|
||||
incompatible.category(),
|
||||
incompatible.category() == CacheLookup.SchemaCategory.FUTURE_VERSION
|
||||
? CacheLookup.SchemaPolicy.FAIL_FAST
|
||||
: CacheLookup.SchemaPolicy.QUARANTINE_AND_RELOAD);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CacheRecordOutcome record(String key, String value, CacheRecordMetadata metadata) {
|
||||
Objects.requireNonNull(metadata, "metadata must be non-null");
|
||||
if (metadata.intent() == CacheRecordIntent.ONLY_IF_SOURCE_REVISION_NEWER) {
|
||||
return CacheRecordOutcome.NOT_RECORDED_PROVIDER_POLICY;
|
||||
}
|
||||
byte[] physicalKey = physicalKey(key);
|
||||
byte[] envelope =
|
||||
RedisCacheEnvelopeCodec.positive(
|
||||
value, metadata.sourceRevision(), policy.maximumValueBytes());
|
||||
return set(physicalKey, envelope, policy.positiveTtl());
|
||||
}
|
||||
|
||||
@Override
|
||||
public CacheRecordOutcome recordAbsent(
|
||||
String key, AuthoritativeAbsence reason, CacheRecordMetadata metadata) {
|
||||
Objects.requireNonNull(metadata, "metadata must be non-null");
|
||||
if (metadata.intent() == CacheRecordIntent.ONLY_IF_SOURCE_REVISION_NEWER) {
|
||||
return CacheRecordOutcome.NOT_RECORDED_PROVIDER_POLICY;
|
||||
}
|
||||
byte[] physicalKey = physicalKey(key);
|
||||
byte[] envelope =
|
||||
RedisCacheEnvelopeCodec.negative(
|
||||
reason, metadata.sourceRevision(), policy.maximumValueBytes());
|
||||
return set(physicalKey, envelope, policy.negativeTtl());
|
||||
}
|
||||
|
||||
@Override
|
||||
public CacheInvalidationOutcome invalidate(String key) {
|
||||
byte[] physicalKey = physicalKey(key);
|
||||
try {
|
||||
return commands.delete(physicalKey) > 0
|
||||
? CacheInvalidationOutcome.INVALIDATED
|
||||
: CacheInvalidationOutcome.ALREADY_ABSENT;
|
||||
} catch (RedisCommandFailureException exception) {
|
||||
return exception.certainty() == RedisCommandFailureException.Certainty.NOT_APPLIED
|
||||
? CacheInvalidationOutcome.DEGRADED_UNAVAILABLE
|
||||
: CacheInvalidationOutcome.INDETERMINATE;
|
||||
}
|
||||
}
|
||||
|
||||
private CacheRecordOutcome set(
|
||||
byte[] physicalKey, byte[] envelope, java.time.Duration timeToLive) {
|
||||
try {
|
||||
commands.set(physicalKey, envelope, timeToLive);
|
||||
return CacheRecordOutcome.RECORDED;
|
||||
} catch (RedisCommandFailureException exception) {
|
||||
return exception.certainty() == RedisCommandFailureException.Certainty.NOT_APPLIED
|
||||
? CacheRecordOutcome.DEGRADED_UNAVAILABLE
|
||||
: CacheRecordOutcome.INDETERMINATE;
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] physicalKey(String key) {
|
||||
if (key == null || key.isBlank()) {
|
||||
throw new IllegalArgumentException("semantic cache key must be non-blank");
|
||||
}
|
||||
RedisKeyDigest digest =
|
||||
RedisKeyDigest.sensitive(
|
||||
policy.namespace().hashKeyVersion(),
|
||||
policy.hmacSecret(),
|
||||
List.of(key.getBytes(StandardCharsets.UTF_8)));
|
||||
return RedisKeyBuilder.build(policy.namespace(), digest).getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static CacheLookup.OperationCertainty certainty(RedisCommandFailureException exception) {
|
||||
return exception.certainty() == RedisCommandFailureException.Certainty.NOT_APPLIED
|
||||
? CacheLookup.OperationCertainty.NOT_APPLIED
|
||||
: CacheLookup.OperationCertainty.INDETERMINATE;
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
/** Signals that Redis contains a value larger than this runtime is allowed to receive. */
|
||||
final class RedisValueTooLargeException extends RuntimeException {
|
||||
|
||||
RedisValueTooLargeException() {
|
||||
super("Redis value exceeds the configured receive bound");
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis.key;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Sole physical key constructor for the Redis capability foundation. */
|
||||
public final class RedisKeyBuilder {
|
||||
|
||||
private RedisKeyBuilder() {}
|
||||
|
||||
public static String build(RedisKeyNamespace namespace, RedisKeyDigest digest) {
|
||||
Objects.requireNonNull(namespace, "namespace must be non-null");
|
||||
Objects.requireNonNull(digest, "digest must be non-null");
|
||||
if (namespace.hashKeyVersion() != digest.hashKeyVersion()) {
|
||||
throw new IllegalArgumentException("namespace and digest hash key version must match");
|
||||
}
|
||||
String key =
|
||||
"ca:%s:%s:%s:%s:hv%d:kv%d:{%s}:%s:%s"
|
||||
.formatted(
|
||||
namespace.application(),
|
||||
namespace.environment(),
|
||||
namespace.capability(),
|
||||
namespace.region(),
|
||||
namespace.hashKeyVersion(),
|
||||
namespace.keyVersion(),
|
||||
digest.slotTag(),
|
||||
digest.resourceDigest(),
|
||||
namespace.kind());
|
||||
int byteSize = key.getBytes(StandardCharsets.UTF_8).length;
|
||||
if (byteSize > namespace.maximumKeyBytes()) {
|
||||
throw new IllegalArgumentException(
|
||||
"physical Redis key exceeds maximum bytes: "
|
||||
+ byteSize
|
||||
+ " > "
|
||||
+ namespace.maximumKeyBytes());
|
||||
}
|
||||
return key;
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis.key;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Arrays;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
/** Precomputed digest and cluster slot tag; raw resource identifiers are never retained. */
|
||||
public record RedisKeyDigest(int hashKeyVersion, String slotTag, String resourceDigest) {
|
||||
|
||||
private static final int MAXIMUM_COMPONENT_BYTES = 4_096;
|
||||
private static final int MAXIMUM_CANONICAL_BYTES = 16_384;
|
||||
private static final HexFormat HEX = HexFormat.of();
|
||||
|
||||
public RedisKeyDigest {
|
||||
if (hashKeyVersion < 1 || hashKeyVersion > 9_999) {
|
||||
throw new IllegalArgumentException("hashKeyVersion must be in 1..9999");
|
||||
}
|
||||
if (slotTag == null || !slotTag.matches("[0-9a-f]{8}")) {
|
||||
throw new IllegalArgumentException("slotTag must be 8 lowercase hexadecimal characters");
|
||||
}
|
||||
if (resourceDigest == null || !resourceDigest.matches("[0-9a-f]{64}")) {
|
||||
throw new IllegalArgumentException(
|
||||
"resourceDigest must be 64 lowercase hexadecimal characters");
|
||||
}
|
||||
}
|
||||
|
||||
public static RedisKeyDigest opaque(int hashKeyVersion, List<byte[]> components) {
|
||||
return fromBytes(hashKeyVersion, sha256(canonicalComponents(components)));
|
||||
}
|
||||
|
||||
public static RedisKeyDigest sensitive(
|
||||
int hashKeyVersion, byte[] secret, List<byte[]> components) {
|
||||
Objects.requireNonNull(secret, "secret must be non-null");
|
||||
if (secret.length < 32) {
|
||||
throw new IllegalArgumentException("HMAC secret must contain at least 32 bytes");
|
||||
}
|
||||
byte[] secretCopy = secret.clone();
|
||||
try {
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(secretCopy, "HmacSHA256"));
|
||||
return fromBytes(hashKeyVersion, mac.doFinal(canonicalComponents(components)));
|
||||
} catch (GeneralSecurityException exception) {
|
||||
throw new IllegalStateException("HmacSHA256 unavailable", exception);
|
||||
} finally {
|
||||
Arrays.fill(secretCopy, (byte) 0);
|
||||
}
|
||||
}
|
||||
|
||||
private static RedisKeyDigest fromBytes(int hashKeyVersion, byte[] digest) {
|
||||
String hexadecimal = HEX.formatHex(digest);
|
||||
return new RedisKeyDigest(hashKeyVersion, hexadecimal.substring(0, 8), hexadecimal);
|
||||
}
|
||||
|
||||
private static byte[] canonicalComponents(List<byte[]> components) {
|
||||
Objects.requireNonNull(components, "components must be non-null");
|
||||
if (components.isEmpty()) {
|
||||
throw new IllegalArgumentException("at least one digest component is required");
|
||||
}
|
||||
int size = 0;
|
||||
for (byte[] component : components) {
|
||||
Objects.requireNonNull(component, "digest component must be non-null");
|
||||
if (component.length > MAXIMUM_COMPONENT_BYTES) {
|
||||
throw new IllegalArgumentException("digest component exceeds maximum bytes");
|
||||
}
|
||||
size = Math.addExact(size, Integer.BYTES + component.length);
|
||||
if (size > MAXIMUM_CANONICAL_BYTES) {
|
||||
throw new IllegalArgumentException("canonical digest input exceeds maximum bytes");
|
||||
}
|
||||
}
|
||||
ByteBuffer buffer = ByteBuffer.allocate(size);
|
||||
for (byte[] component : components) {
|
||||
buffer.putInt(component.length);
|
||||
buffer.put(component);
|
||||
}
|
||||
return buffer.array();
|
||||
}
|
||||
|
||||
private static byte[] sha256(byte[] input) {
|
||||
try {
|
||||
return MessageDigest.getInstance("SHA-256").digest(input);
|
||||
} catch (GeneralSecurityException exception) {
|
||||
throw new IllegalStateException("SHA-256 unavailable", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis.key;
|
||||
|
||||
/** Validated non-sensitive namespace segments for one physical Redis key family. */
|
||||
public record RedisKeyNamespace(
|
||||
String application,
|
||||
String environment,
|
||||
String capability,
|
||||
String region,
|
||||
int hashKeyVersion,
|
||||
int keyVersion,
|
||||
String kind,
|
||||
int maximumKeyBytes) {
|
||||
|
||||
public RedisKeyNamespace {
|
||||
validateSlug(application, "application");
|
||||
validateSlug(environment, "environment");
|
||||
validateSlug(capability, "capability");
|
||||
validateSlug(region, "region");
|
||||
validateSlug(kind, "kind");
|
||||
if (hashKeyVersion < 1 || hashKeyVersion > 9_999) {
|
||||
throw new IllegalArgumentException("hashKeyVersion must be in 1..9999");
|
||||
}
|
||||
if (keyVersion < 1 || keyVersion > 9_999) {
|
||||
throw new IllegalArgumentException("keyVersion must be in 1..9999");
|
||||
}
|
||||
if (maximumKeyBytes < 1 || maximumKeyBytes > 4_096) {
|
||||
throw new IllegalArgumentException("maximumKeyBytes must be in 1..4096 bytes");
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateSlug(String value, String field) {
|
||||
if (value == null || !value.matches("[a-z][a-z0-9-]{0,62}")) {
|
||||
throw new IllegalArgumentException(field + " must match [a-z][a-z0-9-]{0,62}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"programSet": "ca-redis-programs-v1-foundation",
|
||||
"minimumRedisVersion": "7.2",
|
||||
"resultSchemaVersion": 1,
|
||||
"readiness": "R0",
|
||||
"programs": [
|
||||
{
|
||||
"id": "compare-and-delete-v1",
|
||||
"scriptResource": "redis/scripts/compare-and-delete-v1.lua",
|
||||
"sha256": "d0fa9beaa37353ec96be36e3158e06b33165b15489c67e9ca8e4800dac09b25a",
|
||||
"keyCount": 1,
|
||||
"argumentCount": 1,
|
||||
"statuses": ["DELETED", "ABSENT", "NOT_OWNER", "WRONG_TYPE", "INVALID"],
|
||||
"complexity": "O(1)",
|
||||
"timeoutCertainty": "INDETERMINATE"
|
||||
},
|
||||
{
|
||||
"id": "compare-and-expire-v1",
|
||||
"scriptResource": "redis/scripts/compare-and-expire-v1.lua",
|
||||
"sha256": "5665fe349f2800c061ff3c86ec33ff11cb6706ee35c605b8e68db21cd08bd7e0",
|
||||
"keyCount": 1,
|
||||
"argumentCount": 2,
|
||||
"statuses": ["RENEWED", "ABSENT", "NOT_OWNER", "WRONG_TYPE", "INVALID"],
|
||||
"complexity": "O(1)",
|
||||
"timeoutCertainty": "INDETERMINATE"
|
||||
},
|
||||
{
|
||||
"id": "set-if-absent-with-ttl-v1",
|
||||
"scriptResource": "redis/scripts/set-if-absent-with-ttl-v1.lua",
|
||||
"sha256": "777014f7a23435b5701e2d0d286aef60a2f5d54a7836e94122527b28098dc010",
|
||||
"keyCount": 1,
|
||||
"argumentCount": 3,
|
||||
"statuses": ["SET", "EXISTS", "WRONG_TYPE", "INVALID"],
|
||||
"complexity": "O(1)",
|
||||
"timeoutCertainty": "INDETERMINATE"
|
||||
}
|
||||
]
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
local function key_type(key)
|
||||
local result = redis.call('TYPE', key)
|
||||
if type(result) == 'table' then
|
||||
return result['ok']
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
if #KEYS ~= 1 or #ARGV ~= 1 or string.len(ARGV[1]) == 0 or string.len(ARGV[1]) > 128 then
|
||||
return 'INVALID'
|
||||
end
|
||||
|
||||
local current_type = key_type(KEYS[1])
|
||||
if current_type == 'none' then
|
||||
return 'ABSENT'
|
||||
end
|
||||
if current_type ~= 'string' then
|
||||
return 'WRONG_TYPE'
|
||||
end
|
||||
if redis.call('GET', KEYS[1]) ~= ARGV[1] then
|
||||
return 'NOT_OWNER'
|
||||
end
|
||||
|
||||
redis.call('DEL', KEYS[1])
|
||||
return 'DELETED'
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
local function key_type(key)
|
||||
local result = redis.call('TYPE', key)
|
||||
if type(result) == 'table' then
|
||||
return result['ok']
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
local ttl = tonumber(ARGV[2])
|
||||
if #KEYS ~= 1 or #ARGV ~= 2 or string.len(ARGV[1]) == 0 or string.len(ARGV[1]) > 128
|
||||
or ttl == nil or ttl < 1 then
|
||||
return 'INVALID'
|
||||
end
|
||||
|
||||
local current_type = key_type(KEYS[1])
|
||||
if current_type == 'none' then
|
||||
return 'ABSENT'
|
||||
end
|
||||
if current_type ~= 'string' then
|
||||
return 'WRONG_TYPE'
|
||||
end
|
||||
if redis.call('GET', KEYS[1]) ~= ARGV[1] then
|
||||
return 'NOT_OWNER'
|
||||
end
|
||||
|
||||
redis.call('PEXPIRE', KEYS[1], ttl)
|
||||
return 'RENEWED'
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
local function key_type(key)
|
||||
local result = redis.call('TYPE', key)
|
||||
if type(result) == 'table' then
|
||||
return result['ok']
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
local ttl = tonumber(ARGV[2])
|
||||
if #KEYS ~= 1 or #ARGV ~= 3 or string.len(ARGV[1]) == 0
|
||||
or ttl == nil or ttl < 1 or string.len(ARGV[3]) == 0 or string.len(ARGV[3]) > 128 then
|
||||
return 'INVALID'
|
||||
end
|
||||
|
||||
local current_type = key_type(KEYS[1])
|
||||
if current_type ~= 'none' and current_type ~= 'string' then
|
||||
return 'WRONG_TYPE'
|
||||
end
|
||||
|
||||
local applied = redis.call('SET', KEYS[1], ARGV[1], 'PX', ttl, 'NX')
|
||||
if applied then
|
||||
return 'SET'
|
||||
end
|
||||
return 'EXISTS'
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import static java.nio.charset.StandardCharsets.UTF_8;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
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.CacheLookup;
|
||||
import dev.caskeleton.application.cache.CacheRecordIntent;
|
||||
import dev.caskeleton.application.cache.CacheRecordMetadata;
|
||||
import dev.caskeleton.application.cache.CacheRecordOutcome;
|
||||
import io.lettuce.core.api.StatefulRedisConnection;
|
||||
import io.lettuce.core.codec.ByteArrayCodec;
|
||||
import java.time.Duration;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
@Tag("redis-service")
|
||||
class LettuceRedisRuntimeServiceTest {
|
||||
|
||||
@Test
|
||||
void executesRealTtlExpiryAndCatalogLuaAgainstStandaloneRedis() throws InterruptedException {
|
||||
RedisRuntimeSettings settings = settings();
|
||||
LettuceRedisRuntime runtime = LettuceRedisRuntime.connect(settings);
|
||||
try {
|
||||
RedisStringCacheRegion region =
|
||||
new RedisStringCacheRegion(
|
||||
new RedisCacheRegionPolicy(
|
||||
new RedisKeyNamespace(
|
||||
"ca-skeleton", "test", "cache", "service", 1, 1, "entry", 512),
|
||||
settings.hmacSecret(),
|
||||
Duration.ofMillis(150),
|
||||
Duration.ofSeconds(1),
|
||||
1024),
|
||||
runtime);
|
||||
|
||||
assertThat(
|
||||
region.record(
|
||||
"service-key",
|
||||
"service-value",
|
||||
new CacheRecordMetadata("revision-1", CacheRecordIntent.UPSERT)))
|
||||
.isEqualTo(CacheRecordOutcome.RECORDED);
|
||||
assertThat(region.lookup("service-key"))
|
||||
.isEqualTo(
|
||||
new CacheLookup.Hit<>("service-value", CacheLookup.Freshness.FRESH, "revision-1"));
|
||||
awaitMiss(region, "service-key");
|
||||
|
||||
byte[] leaseKey = "ca:test:lease:{service}".getBytes(UTF_8);
|
||||
runtime.set(leaseKey, "owner-1".getBytes(UTF_8), Duration.ofSeconds(5));
|
||||
RedisProgramCatalog catalog = RedisProgramCatalog.foundation();
|
||||
RedisProgramDescriptor compareDelete =
|
||||
catalog.descriptors().stream()
|
||||
.filter(descriptor -> descriptor.argumentCount() == 1)
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
RedisLuaProgramExecutor executor = new RedisLuaProgramExecutor(catalog, runtime);
|
||||
|
||||
assertThat(
|
||||
executor.execute(
|
||||
compareDelete, List.of(leaseKey), List.of("owner-1".getBytes(UTF_8))))
|
||||
.isEqualTo("DELETED");
|
||||
assertThat(runtime.get(leaseKey)).isNull();
|
||||
} finally {
|
||||
runtime.close();
|
||||
}
|
||||
|
||||
assertThatThrownBy(() -> runtime.get("closed".getBytes(UTF_8)))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("closed");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAnOversizedBulkValueBeforeReturningItToTheSemanticDecoder() {
|
||||
RedisRuntimeSettings settings = settings();
|
||||
RedisKeyNamespace namespace =
|
||||
new RedisKeyNamespace("ca-skeleton", "test", "cache", "service", 1, 1, "entry", 512);
|
||||
byte[] semanticKey = "oversized-service-key".getBytes(UTF_8);
|
||||
byte[] physicalKey =
|
||||
RedisKeyBuilder.build(
|
||||
namespace,
|
||||
RedisKeyDigest.sensitive(
|
||||
namespace.hashKeyVersion(), settings.hmacSecret(), List.of(semanticKey)))
|
||||
.getBytes(UTF_8);
|
||||
byte[] oversizedValue = new byte[settings.maximumValueBytes() + 4096];
|
||||
Arrays.fill(oversizedValue, (byte) 'x');
|
||||
|
||||
io.lettuce.core.RedisClient unboundedClient =
|
||||
io.lettuce.core.RedisClient.create(LettuceRedisRuntime.redisUri(settings));
|
||||
try (StatefulRedisConnection<byte[], byte[]> unboundedConnection =
|
||||
unboundedClient.connect(ByteArrayCodec.INSTANCE);
|
||||
LettuceRedisRuntime runtime = LettuceRedisRuntime.connect(settings)) {
|
||||
unboundedConnection.sync().set(physicalKey, oversizedValue);
|
||||
|
||||
assertThatThrownBy(() -> runtime.get(physicalKey))
|
||||
.isInstanceOf(RedisValueTooLargeException.class);
|
||||
|
||||
RedisStringCacheRegion region =
|
||||
new RedisStringCacheRegion(
|
||||
new RedisCacheRegionPolicy(
|
||||
namespace,
|
||||
settings.hmacSecret(),
|
||||
Duration.ofMinutes(5),
|
||||
Duration.ofSeconds(1),
|
||||
settings.maximumValueBytes()),
|
||||
runtime);
|
||||
assertThat(region.lookup(new String(semanticKey, UTF_8)))
|
||||
.isEqualTo(
|
||||
new CacheLookup.IncompatibleSchema<>(
|
||||
CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE,
|
||||
CacheLookup.SchemaPolicy.QUARANTINE_AND_RELOAD));
|
||||
} finally {
|
||||
unboundedClient.shutdown(Duration.ZERO, settings.commandTimeout());
|
||||
}
|
||||
}
|
||||
|
||||
private static void awaitMiss(RedisStringCacheRegion region, String key)
|
||||
throws InterruptedException {
|
||||
long deadline = System.nanoTime() + Duration.ofSeconds(3).toNanos();
|
||||
CacheLookup<String> result;
|
||||
do {
|
||||
result = region.lookup(key);
|
||||
if (result instanceof CacheLookup.Miss<String>) {
|
||||
assertThat(result).isEqualTo(new CacheLookup.Miss<>(CacheLookup.MissReason.ABSENT));
|
||||
return;
|
||||
}
|
||||
Thread.sleep(20);
|
||||
} while (System.nanoTime() < deadline);
|
||||
throw new AssertionError(
|
||||
"Redis key did not expire within the qualification deadline: " + result);
|
||||
}
|
||||
|
||||
private static RedisRuntimeSettings settings() {
|
||||
String host = requiredProperty("redis.test.host");
|
||||
int port = Integer.parseInt(requiredProperty("redis.test.port"));
|
||||
return new RedisRuntimeSettings(
|
||||
true,
|
||||
RedisRuntimeSettings.ClientMode.MANAGED,
|
||||
host,
|
||||
port,
|
||||
"",
|
||||
Base64.getEncoder().encodeToString(new byte[32]),
|
||||
Duration.ofSeconds(2),
|
||||
Duration.ofMinutes(5),
|
||||
Duration.ofSeconds(30),
|
||||
"ca-skeleton",
|
||||
"test",
|
||||
"service",
|
||||
1024);
|
||||
}
|
||||
|
||||
private static String requiredProperty(String name) {
|
||||
String value = System.getProperty(name);
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new AssertionError("real Redis lane requires -D" + name);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import io.lettuce.core.ClientOptions;
|
||||
import io.lettuce.core.RedisURI;
|
||||
import java.time.Duration;
|
||||
import java.util.Base64;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class LettuceRedisRuntimeTest {
|
||||
|
||||
@Test
|
||||
void buildsAnExactFiniteStandaloneRedisUri() {
|
||||
RedisRuntimeSettings settings =
|
||||
new RedisRuntimeSettings(
|
||||
true,
|
||||
RedisRuntimeSettings.ClientMode.MANAGED,
|
||||
"127.0.0.1",
|
||||
6380,
|
||||
"secret-value",
|
||||
Base64.getEncoder().encodeToString(new byte[32]),
|
||||
Duration.ofSeconds(2),
|
||||
Duration.ofMinutes(5),
|
||||
Duration.ofSeconds(30),
|
||||
"ca-skeleton",
|
||||
"test",
|
||||
"worklog",
|
||||
1024);
|
||||
|
||||
RedisURI uri = LettuceRedisRuntime.redisUri(settings);
|
||||
|
||||
assertThat(uri.getHost()).isEqualTo("127.0.0.1");
|
||||
assertThat(uri.getPort()).isEqualTo(6380);
|
||||
assertThat(uri.getTimeout()).isEqualTo(Duration.ofSeconds(2));
|
||||
assertThat(uri.toString()).doesNotContain("secret-value");
|
||||
}
|
||||
|
||||
@Test
|
||||
void disablesReconnectReplayAndBoundsEveryOutstandingCommand() {
|
||||
RedisRuntimeSettings settings =
|
||||
new RedisRuntimeSettings(
|
||||
true,
|
||||
RedisRuntimeSettings.ClientMode.MANAGED,
|
||||
"127.0.0.1",
|
||||
6380,
|
||||
"",
|
||||
Base64.getEncoder().encodeToString(new byte[32]),
|
||||
Duration.ofSeconds(2),
|
||||
Duration.ofMinutes(5),
|
||||
Duration.ofSeconds(30),
|
||||
"ca-skeleton",
|
||||
"test",
|
||||
"worklog",
|
||||
1024,
|
||||
17,
|
||||
65_536);
|
||||
|
||||
ClientOptions options = LettuceRedisRuntime.clientOptions(settings);
|
||||
|
||||
assertThat(options.isAutoReconnect()).isTrue();
|
||||
assertThat(options.getReplayFilter().test(null)).isTrue();
|
||||
assertThat(options.getDisconnectedBehavior())
|
||||
.isEqualTo(ClientOptions.DisconnectedBehavior.REJECT_COMMANDS);
|
||||
assertThat(options.getRequestQueueSize()).isEqualTo(17);
|
||||
assertThat(options.getTimeoutOptions().isTimeoutCommands()).isTrue();
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import static java.nio.charset.StandardCharsets.UTF_8;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class RedisAtomicPrimitivesTest {
|
||||
|
||||
@Test
|
||||
void mapsCompareDeleteStatusThroughTheTypedFacade() {
|
||||
CapturingExecutor executor = new CapturingExecutor("NOT_OWNER");
|
||||
RedisAtomicPrimitives primitives =
|
||||
new RedisAtomicPrimitives(RedisProgramCatalog.foundation(), executor);
|
||||
|
||||
RedisAtomicPrimitives.CompareDeleteResult result =
|
||||
primitives.compareAndDelete("lease-key", "owner-1".getBytes(UTF_8));
|
||||
|
||||
assertThat(result).isEqualTo(RedisAtomicPrimitives.CompareDeleteResult.NOT_OWNER);
|
||||
assertThat(executor.programId).isEqualTo(RedisProgramId.COMPARE_AND_DELETE);
|
||||
assertThat(executor.keys).containsExactly("lease-key".getBytes(UTF_8));
|
||||
assertThat(executor.arguments).containsExactly("owner-1".getBytes(UTF_8));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validatesTtlAndArgumentBoundsBeforeCallingRedis() {
|
||||
CapturingExecutor executor = new CapturingExecutor("RENEWED");
|
||||
RedisAtomicPrimitives primitives =
|
||||
new RedisAtomicPrimitives(RedisProgramCatalog.foundation(), executor);
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
primitives.compareAndExpire("lease-key", "owner-1".getBytes(UTF_8), Duration.ZERO))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("TTL");
|
||||
assertThat(executor.calls).hasValue(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnknownProgramStatusAsCompatibilityFailure() {
|
||||
RedisAtomicPrimitives primitives =
|
||||
new RedisAtomicPrimitives(
|
||||
RedisProgramCatalog.foundation(), new CapturingExecutor("NEW_SERVER_STATUS"));
|
||||
|
||||
assertThatThrownBy(() -> primitives.compareAndDelete("lease-key", "owner-1".getBytes(UTF_8)))
|
||||
.isInstanceOf(RedisProgramCompatibilityException.class)
|
||||
.hasMessageContaining("NEW_SERVER_STATUS");
|
||||
}
|
||||
|
||||
private static final class CapturingExecutor implements RedisProgramExecutor {
|
||||
|
||||
private final String status;
|
||||
private final AtomicInteger calls = new AtomicInteger();
|
||||
private RedisProgramId programId;
|
||||
private List<byte[]> keys;
|
||||
private List<byte[]> arguments;
|
||||
|
||||
private CapturingExecutor(String status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String execute(
|
||||
RedisProgramDescriptor descriptor, List<byte[]> keys, List<byte[]> arguments) {
|
||||
calls.incrementAndGet();
|
||||
programId = descriptor.id();
|
||||
this.keys = keys;
|
||||
this.arguments = arguments;
|
||||
return status;
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class RedisCommandAdmissionTest {
|
||||
|
||||
@Test
|
||||
void rejectsWhenEitherCommandCountOrRetainedBytesAreSaturatedAndReleasesExactlyOnce() {
|
||||
RedisCommandAdmission admission = new RedisCommandAdmission(2, 100);
|
||||
RedisCommandAdmission.Lease first = admission.tryAcquire(80);
|
||||
|
||||
assertThat(first).isNotNull();
|
||||
assertThat(admission.tryAcquire(21)).isNull();
|
||||
|
||||
first.close();
|
||||
first.close();
|
||||
RedisCommandAdmission.Lease second = admission.tryAcquire(100);
|
||||
assertThat(second).isNotNull();
|
||||
assertThat(admission.tryAcquire(1)).isNull();
|
||||
second.close();
|
||||
}
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import static java.nio.charset.StandardCharsets.UTF_8;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class RedisLuaProgramExecutorTest {
|
||||
|
||||
@Test
|
||||
void fallsBackToEvalOnlyWhenEvalShaReportsNoScript() {
|
||||
FakeCommands commands = new FakeCommands();
|
||||
commands.noScript = true;
|
||||
RedisProgramCatalog catalog = RedisProgramCatalog.foundation();
|
||||
RedisLuaProgramExecutor executor = new RedisLuaProgramExecutor(catalog, commands);
|
||||
RedisProgramDescriptor descriptor = singleArgumentDescriptor(catalog);
|
||||
|
||||
String status =
|
||||
executor.execute(
|
||||
descriptor, List.of("key".getBytes(UTF_8)), List.of("owner".getBytes(UTF_8)));
|
||||
|
||||
assertThat(status).isEqualTo("DELETED");
|
||||
assertThat(commands.evalShaCalls).hasValue(1);
|
||||
assertThat(commands.evalCalls).hasValue(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotEvalAgainWhenCachedScriptExecutes() {
|
||||
FakeCommands commands = new FakeCommands();
|
||||
RedisProgramCatalog catalog = RedisProgramCatalog.foundation();
|
||||
RedisLuaProgramExecutor executor = new RedisLuaProgramExecutor(catalog, commands);
|
||||
RedisProgramDescriptor descriptor = singleArgumentDescriptor(catalog);
|
||||
|
||||
executor.execute(descriptor, List.of("key".getBytes(UTF_8)), List.of("owner".getBytes(UTF_8)));
|
||||
|
||||
assertThat(commands.evalShaCalls).hasValue(1);
|
||||
assertThat(commands.evalCalls).hasValue(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDescriptorsOutsideItsClosedCatalogBeforeExecutingAnything() {
|
||||
FakeCommands commands = new FakeCommands();
|
||||
RedisProgramCatalog ownedCatalog = RedisProgramCatalog.foundation();
|
||||
RedisLuaProgramExecutor executor = new RedisLuaProgramExecutor(ownedCatalog, commands);
|
||||
RedisProgramDescriptor foreignDescriptor =
|
||||
singleArgumentDescriptor(RedisProgramCatalog.foundation());
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
executor.execute(
|
||||
foreignDescriptor,
|
||||
List.of("key".getBytes(UTF_8)),
|
||||
List.of("owner".getBytes(UTF_8))))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("not owned");
|
||||
assertThat(commands.evalShaCalls).hasValue(0);
|
||||
assertThat(commands.evalCalls).hasValue(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsStatusesOutsideTheCompiledProgramContract() {
|
||||
FakeCommands commands = new FakeCommands();
|
||||
commands.result = "UNDECLARED";
|
||||
RedisProgramCatalog catalog = RedisProgramCatalog.foundation();
|
||||
RedisLuaProgramExecutor executor = new RedisLuaProgramExecutor(catalog, commands);
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
executor.execute(
|
||||
singleArgumentDescriptor(catalog),
|
||||
List.of("key".getBytes(UTF_8)),
|
||||
List.of("owner".getBytes(UTF_8))))
|
||||
.isInstanceOf(RedisProgramCompatibilityException.class)
|
||||
.hasMessageContaining("UNDECLARED");
|
||||
}
|
||||
|
||||
private static RedisProgramDescriptor singleArgumentDescriptor(RedisProgramCatalog catalog) {
|
||||
return catalog.descriptors().stream()
|
||||
.filter(descriptor -> descriptor.keyCount() == 1 && descriptor.argumentCount() == 1)
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
}
|
||||
|
||||
private static final class FakeCommands implements RedisBinaryCommands {
|
||||
|
||||
private final AtomicInteger evalShaCalls = new AtomicInteger();
|
||||
private final AtomicInteger evalCalls = new AtomicInteger();
|
||||
private boolean noScript;
|
||||
private String result = "DELETED";
|
||||
|
||||
@Override
|
||||
public byte[] get(byte[] key) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void set(byte[] key, byte[] value, Duration timeToLive) {}
|
||||
|
||||
@Override
|
||||
public long delete(byte[] key) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] evalSha(String sha1, List<byte[]> keys, List<byte[]> arguments) {
|
||||
evalShaCalls.incrementAndGet();
|
||||
if (noScript) {
|
||||
throw new RedisNoScriptException();
|
||||
}
|
||||
return result.getBytes(UTF_8);
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] eval(byte[] script, List<byte[]> keys, List<byte[]> arguments) {
|
||||
evalCalls.incrementAndGet();
|
||||
return result.getBytes(UTF_8);
|
||||
}
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.jayway.jsonpath.JsonPath;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class RedisProgramCatalogTest {
|
||||
|
||||
@Test
|
||||
void loadsEveryFoundationProgramWithAnExactDigestAndBoundedSignature() {
|
||||
RedisProgramCatalog catalog = RedisProgramCatalog.foundation();
|
||||
|
||||
assertThat(catalog.descriptors()).hasSize(3);
|
||||
assertThat(catalog.descriptor(RedisProgramId.COMPARE_AND_DELETE).keyCount()).isEqualTo(1);
|
||||
assertThat(catalog.descriptor(RedisProgramId.COMPARE_AND_DELETE).argumentCount()).isEqualTo(1);
|
||||
assertThat(catalog.descriptor(RedisProgramId.COMPARE_AND_EXPIRE).argumentCount()).isEqualTo(2);
|
||||
assertThat(catalog.descriptor(RedisProgramId.SET_IF_ABSENT_WITH_TTL).argumentCount())
|
||||
.isEqualTo(3);
|
||||
|
||||
catalog
|
||||
.descriptors()
|
||||
.forEach(
|
||||
descriptor -> {
|
||||
assertThat(descriptor.sha256()).matches("[0-9a-f]{64}");
|
||||
assertThat(descriptor.scriptBytes()).isNotEmpty();
|
||||
assertThat(new String(descriptor.scriptBytes(), StandardCharsets.UTF_8))
|
||||
.contains("redis.call");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsDefensiveScriptCopies() {
|
||||
RedisProgramDescriptor descriptor =
|
||||
RedisProgramCatalog.foundation().descriptor(RedisProgramId.COMPARE_AND_DELETE);
|
||||
byte[] first = descriptor.scriptBytes();
|
||||
first[0] = 0;
|
||||
|
||||
assertThat(descriptor.scriptBytes()[0]).isNotZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void machineReadableManifestMatchesTheCompiledCatalog() throws IOException {
|
||||
String manifest;
|
||||
try (InputStream input =
|
||||
RedisProgramCatalogTest.class
|
||||
.getClassLoader()
|
||||
.getResourceAsStream("redis/program-set.json")) {
|
||||
assertThat(input).isNotNull();
|
||||
manifest = new String(input.readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
assertThat(JsonPath.<String>read(manifest, "$.readiness")).isEqualTo("R0");
|
||||
List<Map<String, Object>> programs = JsonPath.read(manifest, "$.programs");
|
||||
RedisProgramCatalog catalog = RedisProgramCatalog.foundation();
|
||||
assertThat(programs).hasSameSizeAs(catalog.descriptors());
|
||||
programs.forEach(
|
||||
program -> {
|
||||
RedisProgramId id =
|
||||
catalog.descriptors().stream()
|
||||
.map(RedisProgramDescriptor::id)
|
||||
.filter(candidate -> candidate.externalId().equals(program.get("id")))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
assertThat(program.get("sha256")).isEqualTo(catalog.descriptor(id).sha256());
|
||||
assertThat(Set.copyOf((List<?>) program.get("statuses")))
|
||||
.isEqualTo(catalog.descriptor(id).statuses());
|
||||
});
|
||||
}
|
||||
}
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Base64;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class RedisRuntimeSettingsTest {
|
||||
|
||||
@Test
|
||||
void validatesFiniteTimeoutTtlPortAndStableHmacSecret() {
|
||||
RedisRuntimeSettings settings =
|
||||
new RedisRuntimeSettings(
|
||||
true,
|
||||
RedisRuntimeSettings.ClientMode.MANAGED,
|
||||
"localhost",
|
||||
6379,
|
||||
"",
|
||||
Base64.getEncoder().encodeToString(new byte[32]),
|
||||
Duration.ofSeconds(2),
|
||||
Duration.ofMinutes(5),
|
||||
Duration.ofSeconds(30),
|
||||
"ca-skeleton",
|
||||
"test",
|
||||
"worklog",
|
||||
1024);
|
||||
|
||||
assertThat(settings.port()).isEqualTo(6379);
|
||||
assertThat(settings.hmacSecret()).hasSize(32);
|
||||
assertThat(settings.positiveTtl()).isEqualTo(Duration.ofMinutes(5));
|
||||
assertThat(settings.maximumQueuedCommands()).isEqualTo(8);
|
||||
assertThat(settings.maximumInFlightBytes()).isEqualTo(16_777_216);
|
||||
}
|
||||
|
||||
@Test
|
||||
void enabledRuntimeRejectsShortOrMissingHmacSecret() {
|
||||
RedisRuntimeSettings settings =
|
||||
new RedisRuntimeSettings(
|
||||
true,
|
||||
RedisRuntimeSettings.ClientMode.MANAGED,
|
||||
"localhost",
|
||||
6379,
|
||||
"",
|
||||
"c2hvcnQ=",
|
||||
Duration.ofSeconds(2),
|
||||
Duration.ofMinutes(5),
|
||||
Duration.ofSeconds(30),
|
||||
"ca-skeleton",
|
||||
"test",
|
||||
"worklog",
|
||||
1024);
|
||||
|
||||
assertThatThrownBy(settings::hmacSecret)
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("HMAC");
|
||||
}
|
||||
|
||||
@Test
|
||||
void enabledManagedRuntimeRejectsAMissingHostInsteadOfSilentlyUsingLocalhost() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new RedisRuntimeSettings(
|
||||
true,
|
||||
RedisRuntimeSettings.ClientMode.MANAGED,
|
||||
" ",
|
||||
6379,
|
||||
"",
|
||||
Base64.getEncoder().encodeToString(new byte[32]),
|
||||
Duration.ofSeconds(2),
|
||||
Duration.ofMinutes(5),
|
||||
Duration.ofSeconds(30),
|
||||
"ca-skeleton",
|
||||
"test",
|
||||
"worklog",
|
||||
1024))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("host");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnboundedDurationsAndInvalidPort() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new RedisRuntimeSettings(
|
||||
true,
|
||||
RedisRuntimeSettings.ClientMode.MANAGED,
|
||||
"localhost",
|
||||
-1,
|
||||
"",
|
||||
"",
|
||||
Duration.ZERO,
|
||||
Duration.ofMinutes(5),
|
||||
Duration.ofSeconds(30),
|
||||
"ca-skeleton",
|
||||
"test",
|
||||
"worklog",
|
||||
1024))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAnUnboundedCommandQueue() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new RedisRuntimeSettings(
|
||||
true,
|
||||
RedisRuntimeSettings.ClientMode.MANAGED,
|
||||
"localhost",
|
||||
6379,
|
||||
"",
|
||||
Base64.getEncoder().encodeToString(new byte[32]),
|
||||
Duration.ofSeconds(2),
|
||||
Duration.ofMinutes(5),
|
||||
Duration.ofSeconds(30),
|
||||
"ca-skeleton",
|
||||
"test",
|
||||
"worklog",
|
||||
1024,
|
||||
4097,
|
||||
16_777_216))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("queued");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsByteCapacityThatCannotHoldOneMaximumValue() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new RedisRuntimeSettings(
|
||||
true,
|
||||
RedisRuntimeSettings.ClientMode.MANAGED,
|
||||
"localhost",
|
||||
6379,
|
||||
"",
|
||||
Base64.getEncoder().encodeToString(new byte[32]),
|
||||
Duration.ofSeconds(2),
|
||||
Duration.ofMinutes(5),
|
||||
Duration.ofSeconds(30),
|
||||
"ca-skeleton",
|
||||
"test",
|
||||
"worklog",
|
||||
1_048_576,
|
||||
16,
|
||||
1_048_576))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("in-flight bytes");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsQueueAndValueBoundsWhoseWorstRetainedPayloadExceedsTheByteBudget() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new RedisRuntimeSettings(
|
||||
true,
|
||||
RedisRuntimeSettings.ClientMode.MANAGED,
|
||||
"localhost",
|
||||
6379,
|
||||
"",
|
||||
Base64.getEncoder().encodeToString(new byte[32]),
|
||||
Duration.ofSeconds(2),
|
||||
Duration.ofMinutes(5),
|
||||
Duration.ofSeconds(30),
|
||||
"ca-skeleton",
|
||||
"test",
|
||||
"worklog",
|
||||
1_048_576,
|
||||
64,
|
||||
16_777_216))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("queued-command count");
|
||||
}
|
||||
}
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import static java.nio.charset.StandardCharsets.UTF_8;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyNamespace;
|
||||
import dev.caskeleton.application.cache.AuthoritativeAbsence;
|
||||
import dev.caskeleton.application.cache.CacheInvalidationOutcome;
|
||||
import dev.caskeleton.application.cache.CacheLookup;
|
||||
import dev.caskeleton.application.cache.CacheRecordIntent;
|
||||
import dev.caskeleton.application.cache.CacheRecordMetadata;
|
||||
import dev.caskeleton.application.cache.CacheRecordOutcome;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class RedisStringCacheRegionTest {
|
||||
|
||||
private FakeCommands commands;
|
||||
private RedisStringCacheRegion region;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
commands = new FakeCommands();
|
||||
region =
|
||||
new RedisStringCacheRegion(
|
||||
new RedisCacheRegionPolicy(
|
||||
new RedisKeyNamespace(
|
||||
"ca-skeleton", "test", "cache", "worklog", 1, 1, "entry", 512),
|
||||
new byte[32],
|
||||
Duration.ofMinutes(5),
|
||||
Duration.ofSeconds(30),
|
||||
1024),
|
||||
commands);
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordsAndReadsPositiveEntryWithBoundedTtl() {
|
||||
CacheRecordOutcome outcome =
|
||||
region.record(
|
||||
"tenant-1:work-1",
|
||||
"cached-value",
|
||||
new CacheRecordMetadata("revision-1", CacheRecordIntent.UPSERT));
|
||||
|
||||
assertThat(outcome).isEqualTo(CacheRecordOutcome.RECORDED);
|
||||
assertThat(commands.lastTtl).isEqualTo(Duration.ofMinutes(5));
|
||||
assertThat(new String(commands.lastKey, UTF_8)).doesNotContain("tenant-1");
|
||||
assertThat(region.lookup("tenant-1:work-1"))
|
||||
.isEqualTo(
|
||||
new CacheLookup.Hit<>("cached-value", CacheLookup.Freshness.FRESH, "revision-1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordsAndReadsAuthoritativeNegativeEntryWithShorterTtl() {
|
||||
CacheRecordOutcome outcome =
|
||||
region.recordAbsent(
|
||||
"tenant-1:missing",
|
||||
AuthoritativeAbsence.NOT_FOUND,
|
||||
new CacheRecordMetadata("revision-2", CacheRecordIntent.UPSERT));
|
||||
|
||||
assertThat(outcome).isEqualTo(CacheRecordOutcome.RECORDED);
|
||||
assertThat(commands.lastTtl).isEqualTo(Duration.ofSeconds(30));
|
||||
assertThat(region.lookup("tenant-1:missing"))
|
||||
.isEqualTo(new CacheLookup.NegativeHit<>(AuthoritativeAbsence.NOT_FOUND));
|
||||
}
|
||||
|
||||
@Test
|
||||
void distinguishesMissIncompatibleEnvelopeAndProviderFailure() {
|
||||
assertThat(region.lookup("absent"))
|
||||
.isEqualTo(new CacheLookup.Miss<>(CacheLookup.MissReason.ABSENT));
|
||||
|
||||
commands.value = new byte[] {0, 1, 2};
|
||||
assertThat(region.lookup("invalid"))
|
||||
.isEqualTo(
|
||||
new CacheLookup.IncompatibleSchema<>(
|
||||
CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE,
|
||||
CacheLookup.SchemaPolicy.QUARANTINE_AND_RELOAD));
|
||||
|
||||
commands.failure = new IllegalStateException("connection unavailable");
|
||||
assertThatThrownBy(() -> region.lookup("programming-error"))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("connection unavailable");
|
||||
|
||||
commands.failure =
|
||||
new RedisCommandFailureException(
|
||||
RedisCommandFailureException.Kind.UNAVAILABLE,
|
||||
RedisCommandFailureException.Certainty.NOT_APPLIED,
|
||||
"connection unavailable",
|
||||
null);
|
||||
assertThat(region.lookup("unavailable"))
|
||||
.isEqualTo(
|
||||
new CacheLookup.Unavailable<>(
|
||||
CacheLookup.UnavailabilityReason.UNAVAILABLE,
|
||||
CacheLookup.OperationCertainty.NOT_APPLIED));
|
||||
}
|
||||
|
||||
@Test
|
||||
void refusesOpaqueNewerRevisionIntentAndMapsMutationCertainty() {
|
||||
CacheRecordOutcome rejected =
|
||||
region.record(
|
||||
"key",
|
||||
"value",
|
||||
new CacheRecordMetadata(
|
||||
"opaque-revision", CacheRecordIntent.ONLY_IF_SOURCE_REVISION_NEWER));
|
||||
|
||||
assertThat(rejected).isEqualTo(CacheRecordOutcome.NOT_RECORDED_PROVIDER_POLICY);
|
||||
|
||||
commands.failure =
|
||||
new RedisCommandFailureException(
|
||||
RedisCommandFailureException.Kind.UNAVAILABLE,
|
||||
RedisCommandFailureException.Certainty.INDETERMINATE,
|
||||
"timeout",
|
||||
null);
|
||||
assertThat(
|
||||
region.record(
|
||||
"key", "value", new CacheRecordMetadata("revision-1", CacheRecordIntent.UPSERT)))
|
||||
.isEqualTo(CacheRecordOutcome.INDETERMINATE);
|
||||
assertThat(region.invalidate("key")).isEqualTo(CacheInvalidationOutcome.INDETERMINATE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapsKnownPreSendMutationFailureToDegradedUnavailable() {
|
||||
commands.failure =
|
||||
new RedisCommandFailureException(
|
||||
RedisCommandFailureException.Kind.UNAVAILABLE,
|
||||
RedisCommandFailureException.Certainty.NOT_APPLIED,
|
||||
"disconnected",
|
||||
null);
|
||||
|
||||
assertThat(
|
||||
region.record(
|
||||
"key", "value", new CacheRecordMetadata("revision-1", CacheRecordIntent.UPSERT)))
|
||||
.isEqualTo(CacheRecordOutcome.DEGRADED_UNAVAILABLE);
|
||||
assertThat(region.invalidate("key")).isEqualTo(CacheInvalidationOutcome.DEGRADED_UNAVAILABLE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInvalidRevisionFutureVersionAndBitCorruptionThroughTypedSchemaResults() {
|
||||
commands.value = rawEnvelope((byte) 1, "r".repeat(129), "value");
|
||||
assertThat(region.lookup("invalid-revision"))
|
||||
.isEqualTo(
|
||||
new CacheLookup.IncompatibleSchema<>(
|
||||
CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE,
|
||||
CacheLookup.SchemaPolicy.QUARANTINE_AND_RELOAD));
|
||||
|
||||
commands.value = rawEnvelope((byte) 2, "revision-1", "value");
|
||||
assertThat(region.lookup("future"))
|
||||
.isEqualTo(
|
||||
new CacheLookup.IncompatibleSchema<>(
|
||||
CacheLookup.SchemaCategory.FUTURE_VERSION, CacheLookup.SchemaPolicy.FAIL_FAST));
|
||||
|
||||
commands.value = RedisCacheEnvelopeCodec.positive("value", "revision-1", 1024);
|
||||
commands.value[commands.value.length - 33] ^= 1;
|
||||
assertThat(region.lookup("corrupt"))
|
||||
.isEqualTo(
|
||||
new CacheLookup.IncompatibleSchema<>(
|
||||
CacheLookup.SchemaCategory.CORRUPT_ENVELOPE,
|
||||
CacheLookup.SchemaPolicy.QUARANTINE_AND_RELOAD));
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidatesExistingAndMissingEntriesSeparately() {
|
||||
region.record("key", "value", new CacheRecordMetadata("revision-1", CacheRecordIntent.UPSERT));
|
||||
|
||||
assertThat(region.invalidate("key")).isEqualTo(CacheInvalidationOutcome.INVALIDATED);
|
||||
assertThat(region.invalidate("key")).isEqualTo(CacheInvalidationOutcome.ALREADY_ABSENT);
|
||||
}
|
||||
|
||||
private static final class FakeCommands implements RedisBinaryCommands {
|
||||
|
||||
private byte[] lastKey;
|
||||
private byte[] value;
|
||||
private Duration lastTtl;
|
||||
private RuntimeException failure;
|
||||
|
||||
@Override
|
||||
public byte[] get(byte[] key) {
|
||||
failIfConfigured();
|
||||
return value == null ? null : value.clone();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void set(byte[] key, byte[] value, Duration timeToLive) {
|
||||
failIfConfigured();
|
||||
lastKey = key.clone();
|
||||
this.value = value.clone();
|
||||
lastTtl = timeToLive;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long delete(byte[] key) {
|
||||
failIfConfigured();
|
||||
if (value == null) {
|
||||
return 0;
|
||||
}
|
||||
value = null;
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] evalSha(String sha1, List<byte[]> keys, List<byte[]> arguments) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] eval(byte[] script, List<byte[]> keys, List<byte[]> arguments) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
private void failIfConfigured() {
|
||||
if (failure != null) {
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] rawEnvelope(byte version, String revision, String value) {
|
||||
byte[] revisionBytes = revision.getBytes(UTF_8);
|
||||
byte[] valueBytes = value.getBytes(UTF_8);
|
||||
byte[] content =
|
||||
ByteBuffer.allocate(12 + revisionBytes.length + valueBytes.length)
|
||||
.putInt(0x43414348)
|
||||
.put(version)
|
||||
.put((byte) 1)
|
||||
.putShort((short) revisionBytes.length)
|
||||
.putInt(valueBytes.length)
|
||||
.put(revisionBytes)
|
||||
.put(valueBytes)
|
||||
.array();
|
||||
byte[] digest;
|
||||
try {
|
||||
digest = java.security.MessageDigest.getInstance("SHA-256").digest(content);
|
||||
} catch (java.security.NoSuchAlgorithmException exception) {
|
||||
throw new AssertionError(exception);
|
||||
}
|
||||
return ByteBuffer.allocate(content.length + digest.length).put(content).put(digest).array();
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis.key;
|
||||
|
||||
import static java.nio.charset.StandardCharsets.UTF_8;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class RedisKeyBuilderTest {
|
||||
|
||||
private static final byte[] HMAC_SECRET =
|
||||
"test-only-hmac-material-with-at-least-32-bytes".getBytes(UTF_8);
|
||||
|
||||
@Test
|
||||
void buildsNamespacedKeyWithoutLeakingSensitiveComponents() {
|
||||
RedisKeyNamespace namespace =
|
||||
new RedisKeyNamespace("worklog-api", "prod", "cache", "summary", 2, 1, "entry", 256);
|
||||
RedisKeyDigest digest =
|
||||
RedisKeyDigest.sensitive(
|
||||
2,
|
||||
HMAC_SECRET,
|
||||
List.of("tenant@example.com".getBytes(UTF_8), "worklog-42".getBytes(UTF_8)));
|
||||
|
||||
String key = RedisKeyBuilder.build(namespace, digest);
|
||||
|
||||
assertThat(key)
|
||||
.startsWith("ca:worklog-api:prod:cache:summary:hv2:kv1:{")
|
||||
.endsWith(":entry")
|
||||
.doesNotContain("tenant@example.com")
|
||||
.doesNotContain("worklog-42");
|
||||
assertThat(key.chars().filter(character -> character == '{').count()).isEqualTo(1);
|
||||
assertThat(key.chars().filter(character -> character == '}').count()).isEqualTo(1);
|
||||
assertThat(key.getBytes(UTF_8).length).isLessThanOrEqualTo(256);
|
||||
}
|
||||
|
||||
@Test
|
||||
void lengthPrefixedDigestPreventsComponentBoundaryAmbiguity() {
|
||||
RedisKeyDigest first =
|
||||
RedisKeyDigest.sensitive(
|
||||
1, HMAC_SECRET, List.of("ab".getBytes(UTF_8), "c".getBytes(UTF_8)));
|
||||
RedisKeyDigest second =
|
||||
RedisKeyDigest.sensitive(
|
||||
1, HMAC_SECRET, List.of("a".getBytes(UTF_8), "bc".getBytes(UTF_8)));
|
||||
|
||||
assertThat(first.resourceDigest()).isNotEqualTo(second.resourceDigest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDigestVersionMismatchAndOversizedPhysicalKey() {
|
||||
RedisKeyDigest digest = RedisKeyDigest.opaque(1, List.of("id".getBytes(UTF_8)));
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
RedisKeyBuilder.build(
|
||||
new RedisKeyNamespace(
|
||||
"worklog-api", "prod", "cache", "summary", 2, 1, "entry", 256),
|
||||
digest))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("version");
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
RedisKeyBuilder.build(
|
||||
new RedisKeyNamespace(
|
||||
"worklog-api", "prod", "cache", "summary", 1, 1, "entry", 32),
|
||||
digest))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("bytes");
|
||||
}
|
||||
}
|
||||
@@ -4,27 +4,29 @@
|
||||
|
||||
- Module ID: `adapter-outbound-fileserver`
|
||||
- Gradle path: `:adapter:outbound:fileserver`
|
||||
- Focused test: `./gradlew :adapter:outbound:fileserver:test --console=plain`
|
||||
- Focused test (derived from Gradle path): `./gradlew :adapter:outbound:fileserver:test --console=plain`
|
||||
- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0.
|
||||
- Registry SSOT: `.harness/project/modules.yaml`.
|
||||
- Registry SSOT: `src/config/architecture/modules.json`.
|
||||
|
||||
Package root: `dev.caskeleton.adapter.outbound.fileserver`. Driven (outbound) adapter implementing
|
||||
`dev.caskeleton.application.fileexport.FileExportPort` (application-core). Design rationale lives in
|
||||
[README.md](README.md).
|
||||
`dev.caskeleton.application.filepublication.FilePublicationPort` (application-core). The legacy
|
||||
`FileExportPort` remains temporarily for compatibility. Design rationale lives in [README.md](README.md).
|
||||
|
||||
## Responsibility
|
||||
|
||||
- Export tabular data as CSV files behind `FileExportPort`, written under
|
||||
`ca-skeleton.fileserver.base-directory` (stand-in for NFS/SFTP). Single implementation
|
||||
(`FilesystemCsvExportAdapter`); pure JDK filesystem IO, no external service.
|
||||
- Opt-in: `FileExportConfig` gates the single `FileExportPort` bean with
|
||||
`@ConditionalOnProperty(ca-skeleton.fileserver.enabled=true)`, default off. The adapter is a plain
|
||||
class; the config assembles it as a bean.
|
||||
- Publish typed tabular data through a bounded producer/sink contract behind
|
||||
`FilePublicationPort`.
|
||||
- Own CSV encoding, schema validation, formula policy, staging, checksum/counts, file force, and
|
||||
local exclusive-publication semantics.
|
||||
- Return opaque references and explicit publication/durability guarantees; do not expose paths.
|
||||
- Opt-in: `FileExportConfig` gates publication with
|
||||
`ca-skeleton.fileserver.enabled=true`; the legacy bean additionally requires
|
||||
`ca-skeleton.fileserver.legacy-enabled=true` and a separate root. Both default off.
|
||||
|
||||
## Allowed
|
||||
|
||||
- Project deps: `:application-core`, `:shared-contract` — SSOT is the
|
||||
`adapter-outbound-fileserver` entry in `.harness/project/modules.yaml`; `src/build.gradle`
|
||||
`adapter-outbound-fileserver` entry in `src/config/architecture/modules.json`; `src/build.gradle`
|
||||
enforces it. No
|
||||
`:domain-core`, no sibling adapters.
|
||||
- External: NONE (pure filesystem). `spring-boot-starter`, `spring-boot-configuration-processor`
|
||||
@@ -34,14 +36,18 @@ Package root: `dev.caskeleton.adapter.outbound.fileserver`. Driven (outbound) ad
|
||||
|
||||
- Inbound adapters, sibling outbound adapters, persistence, `app-bootstrap`, `sample-portfolio`
|
||||
(ArchUnit `OUTBOUND_ADAPTERS_*` family rules).
|
||||
- Leaking a framework/domain type across `FileExportPort` — the port takes/returns only `String` /
|
||||
`List<String>` / `List<List<String>>` / `ExportedFile`.
|
||||
- Leaking filesystem, stream, framework, or provider types across `FilePublicationPort`.
|
||||
- Advertising local R1 as crash-recoverable R2. Durable operation journal, reconciliation, SFTP,
|
||||
and NFS/HA evidence are not fully implemented. The local journal only supports single-node
|
||||
terminal restoration and sealed-artifact resume; it is not cross-node fencing or R2 evidence.
|
||||
- Adding a second production provider without an explicit selector and startup ambiguity tests.
|
||||
- Fully-qualified inline type references; more than one public top-level type per file.
|
||||
|
||||
## Tests
|
||||
|
||||
`FilesystemCsvExportAdapterTest` (temp-dir CSV write/verify: header + rows, RFC-4180 escaping,
|
||||
null-field, overwrite, path-traversal + blank-name rejection).
|
||||
`FilePublicationContractTest`, `LocalFilePublicationAdapterTest`,
|
||||
`LocalPublicationJournalTest`, `LocalFilePublicationRecoveryTest`, `FilePublicationConfigTest`, and
|
||||
the legacy `FilesystemCsvExportAdapterTest`.
|
||||
|
||||
```bash
|
||||
cd src
|
||||
|
||||
@@ -1,62 +1,92 @@
|
||||
# adapter:outbound:fileserver — design-decision reference
|
||||
|
||||
File-server export outbound (driven) adapter. Package root:
|
||||
`dev.caskeleton.adapter.outbound.fileserver`. Implements the `application-core` port
|
||||
`dev.caskeleton.application.fileexport.FileExportPort` behind an opt-in `@ConditionalOnProperty`
|
||||
selector, mirroring the existing outbound adapters (notification / cache-redis / httpclient /
|
||||
objectstorage).
|
||||
File-server publication outbound (driven) adapter. Package root:
|
||||
`dev.caskeleton.adapter.outbound.fileserver`. It implements the framework-free
|
||||
`application-core` `FilePublicationPort` and temporarily retains the legacy `FileExportPort`.
|
||||
Publication and the legacy compatibility port have separate opt-in selectors.
|
||||
|
||||
The allowed/forbidden dependency policy is owned by `src/build.gradle`'s
|
||||
`allowedProjectDependencies['adapter:outbound:fileserver']` (SSOT). Module rules live in
|
||||
[CLAUDE.md](CLAUDE.md); this document records the **design rationale** lifted out of the code
|
||||
comments.
|
||||
|
||||
## Module overview
|
||||
## Implemented capability
|
||||
|
||||
An **opt-in** file-export adapter placed behind an application-core port. A single
|
||||
`FilesystemCsvExportAdapter` writes CSV files under `ca-skeleton.fileserver.base-directory` — a
|
||||
stand-in for an NFS mount, shared file server, or SFTP drop. There is no external service and no
|
||||
external dependency (pure JDK filesystem IO), so the local profile just works and the lockfile only
|
||||
pins the shared Spring Boot / tooling graph.
|
||||
`LocalFilePublicationAdapter` is a local-filesystem R1 provider. The application supplies a typed
|
||||
schema and streams rows once through a producer/sink callback. The adapter encodes each row without
|
||||
materializing the whole export, enforces row/encoded-byte/per-cell limits, applies the configured
|
||||
spreadsheet-formula policy, computes SHA-256 and counts, forces the staged file, and publishes it
|
||||
with an exclusive atomic hard-link create. After publication it forces both staging and final
|
||||
directories before recording the terminal journal. Its receipt contains an opaque reference rather than a
|
||||
server path. A private, forced operation journal records request fingerprints and `WRITING`, `SEALED`,
|
||||
and `PUBLISHED` state. On a single local filesystem, a restarted adapter can restore a verified
|
||||
terminal receipt or finish a verified sealed staging artifact without invoking the producer again.
|
||||
A sealed journal plus a verified final target reconstructs the only supported hard-link protocol
|
||||
as `UNIQUE_ATOMIC_CREATE` after re-forcing the final directory.
|
||||
Corrupt/unreadable operation state is exposed only as provider-neutral
|
||||
`PUBLISH_INDETERMINATE`, never as an adapter-internal exception.
|
||||
The private control directory and journal shards reject symbolic links before read/write so a
|
||||
pre-existing internal link cannot redirect journal bytes outside the configured base.
|
||||
Once a `SEALED` record exists, publish conflicts and unsupported atomic publication preserve the
|
||||
verified staging artifact for explicit retry/reconciliation instead of deleting the only recovery
|
||||
evidence.
|
||||
Operation-scoped JVM and OS file locks serialize cooperating callers on the same local filesystem.
|
||||
|
||||
Selector: `ca-skeleton.fileserver.enabled=true` (default `false`). Unlike objectstorage there is a
|
||||
single implementation, so no backend switch is needed; the `enabled` flag keeps the module from
|
||||
activating unexpectedly when merely present on the classpath. `FileExportConfig` gates the single
|
||||
`FileExportPort` bean on that flag.
|
||||
Selector: `ca-skeleton.fileserver.enabled=true` (default `false`) enables only the new
|
||||
`FilePublicationPort`. The overwrite-capable compatibility port additionally requires
|
||||
`ca-skeleton.fileserver.legacy-enabled=true` and writes under its own legacy root. This module is not
|
||||
currently a default `app-bootstrap` dependency, so a consuming application must intentionally add
|
||||
the leaf as well as enable it.
|
||||
|
||||
## The port contract (framework/domain-neutral)
|
||||
## Publication contract
|
||||
|
||||
`FileExportPort` is a minimal, domain-neutral surface:
|
||||
The current contract is:
|
||||
|
||||
- `ExportedFile exportCsv(String fileName, List<String> header, List<List<String>> rows)`.
|
||||
- `FilePublishReceipt publish(FilePublishRequest, TabularRowProducer)`.
|
||||
|
||||
The caller supplies a bare file name, an optional header row, and the data rows as lists of
|
||||
already-stringified field values. The adapter owns file placement, RFC-4180 escaping, and byte
|
||||
encoding, and returns an `ExportedFile` receipt (`fileName`, absolute `path`, `byteSize`,
|
||||
`rowCount`). No framework or domain type crosses the port — the application layer stays decoupled
|
||||
from the CSV format and the destination filesystem. A fork that needs a real domain export maps its
|
||||
rows to `List<List<String>>` at the call site (or adds a typed convenience method in its own layer).
|
||||
The request uses `FileDestinationId`, `FilePublishOperationId`, `LogicalFileName`,
|
||||
`SourceRevision`, and `ExportSchema`; it has no `Path`, `File`, Spring, stream, or provider type.
|
||||
`TabularCell` preserves value types until encoding. The producer writes rows to a bounded sink and
|
||||
can call `checkpoint()` for cooperative interruption checks.
|
||||
|
||||
## CSV escaping
|
||||
`FileExportPort.exportCsv(...)` remains for compatibility only. It materializes all rows, writes
|
||||
directly to a separate final-path root, permits overwrite, accepts only a bare file name, and
|
||||
returns an absolute path. It must not be used as
|
||||
R2 durability or cluster-safety evidence.
|
||||
|
||||
Every field is escaped per RFC-4180: a field containing a comma, double-quote, carriage return, or
|
||||
line feed is wrapped in double-quotes with embedded quotes doubled. A `null` field is written as an
|
||||
empty field. Rows are separated by `\n` and the file is UTF-8 encoded. Overwriting an existing file
|
||||
at the same name replaces it.
|
||||
## Settings
|
||||
|
||||
## IO-failure handling
|
||||
- `ca-skeleton.fileserver.enabled=false`
|
||||
- `ca-skeleton.fileserver.legacy-enabled=false`
|
||||
- `ca-skeleton.fileserver.base-directory=./.data/fileserver`
|
||||
- `ca-skeleton.fileserver.legacy-base-directory=./.data/fileserver-legacy`
|
||||
- `ca-skeleton.fileserver.destination-id=local-export`
|
||||
- `ca-skeleton.fileserver.maximum-rows=1000000`
|
||||
- `ca-skeleton.fileserver.maximum-encoded-bytes=1073741824`
|
||||
|
||||
Filesystem IO failures are wrapped in the shared-contract `DependencyFailureException`
|
||||
(`dependencyName="fileserver"`) so a fork's web error handler classifies them uniformly with the
|
||||
other outbound dependencies. A blank file name or one that escapes the base directory (path
|
||||
traversal) is `IllegalArgumentException` (a caller bug, not a dependency failure) — the adapter
|
||||
normalises the resolved path and checks it still starts with the base directory.
|
||||
The provider always fails closed if the filesystem cannot supply exclusive hard-link creation.
|
||||
There is no copy-to-final or overwrite-capable rename fallback.
|
||||
|
||||
## Guarantee boundary
|
||||
|
||||
This remains local R1, not Fileserver R2. It now provides single-node request fingerprinting,
|
||||
forced operation-journal replacement, terminal receipt restoration, and bounded sealed-artifact
|
||||
reconciliation. It does not yet provide cross-node fencing, exhaustive crash-point qualification,
|
||||
reference/manifest indexes, background reconciliation/reaping, SFTP, NFS mount identity,
|
||||
multi-node cleanup, or quota reservation. See
|
||||
`docs/superpowers/specs/2026-07-26-fileserver-production-capability-design.md` for the remaining
|
||||
phases.
|
||||
|
||||
## Tests
|
||||
|
||||
- `FilesystemCsvExportAdapterTest` — `@TempDir` write/verify: header + rows, CSV escaping of a field
|
||||
containing a comma / quote / newline, null-field handling, header-only and headerless exports,
|
||||
overwrite, and path-traversal + blank-name rejection.
|
||||
- `FilePublicationContractTest`: framework-free values and invalid contract inputs.
|
||||
- `LocalFilePublicationAdapterTest`: streaming, limits, type checks, formula mitigation, receipt,
|
||||
publication, and staging cleanup.
|
||||
- `LocalPublicationJournalTest`: canonical request fingerprint and strict journal integrity.
|
||||
- `LocalFilePublicationRecoveryTest`: restart receipt restoration, sealed resume, conflict, and
|
||||
artifact-integrity handling.
|
||||
- `FilePublicationConfigTest`: opt-in binding and both port beans.
|
||||
- `FilesystemCsvExportAdapterTest`: legacy compatibility path.
|
||||
|
||||
```bash
|
||||
cd src
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
// Driven adapter: file server / filesystem exports behind application-core's FileExportPort. Writes
|
||||
// delimited (CSV) files to a configured base directory — a stand-in for an NFS mount, shared file
|
||||
// server, or SFTP drop. Pure JDK filesystem IO, so there are NO external dependencies: the lockfile
|
||||
// only pins the shared Spring Boot / tooling graph. Opt-in via @ConditionalOnProperty
|
||||
// (ca-skeleton.fileserver.enabled), off by default so the module never activates unexpectedly.
|
||||
// server, or SFTP drop. Its IO path uses only the JDK; Spring Boot autoconfigure and the SLF4J API
|
||||
// provide conditional composition and diagnostics without an external file-client SDK. Opt-in via
|
||||
// @ConditionalOnProperty (ca-skeleton.fileserver.enabled), off by default so the module never
|
||||
// activates unexpectedly.
|
||||
description = 'Outbound adapter: file server exports (filesystem/CSV)'
|
||||
|
||||
dependencies {
|
||||
implementation project(':application-core')
|
||||
implementation project(':shared-contract')
|
||||
|
||||
implementation 'org.springframework.boot:spring-boot-starter'
|
||||
implementation 'org.springframework.boot:spring-boot-autoconfigure'
|
||||
implementation 'org.slf4j:slf4j-api'
|
||||
|
||||
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
|
||||
}
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
# 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=compileClasspath,testCompileClasspath
|
||||
ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=testCompileClasspath
|
||||
ch.qos.logback:logback-classic:1.5.21=testCompileClasspath,testRuntimeClasspath
|
||||
ch.qos.logback:logback-core:1.5.21=testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath
|
||||
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
|
||||
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
|
||||
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
|
||||
com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,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,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,compileClasspath,spotbugs,testCompileClasspath
|
||||
com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs,testCompileClasspath
|
||||
com.google.code.gson:gson:2.13.2=spotbugs
|
||||
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath
|
||||
com.google.errorprone:error_prone_annotations:2.38.0=testCompileClasspath
|
||||
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
|
||||
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
|
||||
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
@@ -44,7 +44,7 @@ io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotatio
|
||||
io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,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
|
||||
@@ -60,9 +60,9 @@ 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=compileClasspath,runtimeClasspath,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=compileClasspath,runtimeClasspath,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
|
||||
@@ -95,10 +95,10 @@ org.mockito:mockito-core:5.20.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.objenesis:objenesis:3.3=testRuntimeClasspath
|
||||
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath
|
||||
org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath
|
||||
org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath
|
||||
org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,testCompileClasspath
|
||||
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
|
||||
@@ -108,7 +108,7 @@ org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
|
||||
org.reflections:reflections:0.10.2=checkstyle
|
||||
org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:jul-to-slf4j:2.0.17=testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
|
||||
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
@@ -121,13 +121,13 @@ org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,t
|
||||
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=compileClasspath,runtimeClasspath,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=compileClasspath,runtimeClasspath,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
|
||||
@@ -145,7 +145,7 @@ org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
|
||||
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
|
||||
org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.yaml:snakeyaml:2.5=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
|
||||
|
||||
+46
-2
@@ -1,6 +1,11 @@
|
||||
package dev.caskeleton.adapter.outbound.fileserver;
|
||||
|
||||
import dev.caskeleton.application.fileexport.FileExportPort;
|
||||
import dev.caskeleton.application.filepublication.FileDestinationId;
|
||||
import dev.caskeleton.application.filepublication.FilePublicationPort;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
@@ -18,8 +23,47 @@ import org.springframework.context.annotation.Configuration;
|
||||
public class FileExportConfig {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnProperty(prefix = "ca-skeleton.fileserver", name = "enabled", havingValue = "true")
|
||||
@ConditionalOnProperty(
|
||||
prefix = "ca-skeleton.fileserver",
|
||||
name = {"enabled", "legacy-enabled"},
|
||||
havingValue = "true")
|
||||
public FileExportPort filesystemCsvExportPort(FileExportProperties properties) {
|
||||
return new FilesystemCsvExportAdapter(properties.getBaseDirectory());
|
||||
Path publicationRoot = configuredRoot(properties.getBaseDirectory(), "base-directory");
|
||||
Path legacyRoot = configuredRoot(properties.getLegacyBaseDirectory(), "legacy-base-directory");
|
||||
Path canonicalPublicationRoot = canonicalDirectory(publicationRoot);
|
||||
Path canonicalLegacyRoot = canonicalDirectory(legacyRoot);
|
||||
if (canonicalPublicationRoot.startsWith(canonicalLegacyRoot)
|
||||
|| canonicalLegacyRoot.startsWith(canonicalPublicationRoot)) {
|
||||
throw new IllegalStateException("fileserver publication and legacy roots must not overlap");
|
||||
}
|
||||
return new FilesystemCsvExportAdapter(legacyRoot.toString());
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnProperty(prefix = "ca-skeleton.fileserver", name = "enabled", havingValue = "true")
|
||||
public FilePublicationPort localFilePublicationPort(FileExportProperties properties) {
|
||||
return new LocalFilePublicationAdapter(
|
||||
new LocalFilePublicationPolicy(
|
||||
new FileDestinationId(properties.getDestinationId()),
|
||||
configuredRoot(properties.getBaseDirectory(), "base-directory"),
|
||||
properties.getMaximumRows(),
|
||||
properties.getMaximumEncodedBytes()));
|
||||
}
|
||||
|
||||
private static Path configuredRoot(String value, String property) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException(
|
||||
"ca-skeleton.fileserver." + property + " must be non-blank");
|
||||
}
|
||||
return Path.of(value).toAbsolutePath().normalize();
|
||||
}
|
||||
|
||||
private static Path canonicalDirectory(Path root) {
|
||||
try {
|
||||
Files.createDirectories(root);
|
||||
return root.toRealPath();
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("fileserver root cannot be canonicalized", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+55
@@ -16,9 +16,24 @@ public class FileExportProperties {
|
||||
*/
|
||||
private boolean enabled = false;
|
||||
|
||||
/** Whether to expose the overwrite-capable legacy port. Always requires {@link #enabled}. */
|
||||
private boolean legacyEnabled = false;
|
||||
|
||||
/** Base directory that export files are written under (stand-in for an NFS/SFTP drop). */
|
||||
private String baseDirectory = "./.data/fileserver";
|
||||
|
||||
/** Separate root for the legacy overwrite-capable port; never shares publication control data. */
|
||||
private String legacyBaseDirectory = "./.data/fileserver-legacy";
|
||||
|
||||
/** Logical destination identifier exposed to application-core; never a path or host. */
|
||||
private String destinationId = "local-export";
|
||||
|
||||
/** Hard upper bound for data rows accepted by one streaming publication. */
|
||||
private long maximumRows = 1_000_000;
|
||||
|
||||
/** Hard upper bound for encoded bytes, including the CSV header. */
|
||||
private long maximumEncodedBytes = 1_073_741_824;
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
@@ -27,6 +42,14 @@ public class FileExportProperties {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public boolean isLegacyEnabled() {
|
||||
return legacyEnabled;
|
||||
}
|
||||
|
||||
public void setLegacyEnabled(boolean legacyEnabled) {
|
||||
this.legacyEnabled = legacyEnabled;
|
||||
}
|
||||
|
||||
public String getBaseDirectory() {
|
||||
return baseDirectory;
|
||||
}
|
||||
@@ -34,4 +57,36 @@ public class FileExportProperties {
|
||||
public void setBaseDirectory(String baseDirectory) {
|
||||
this.baseDirectory = baseDirectory;
|
||||
}
|
||||
|
||||
public String getLegacyBaseDirectory() {
|
||||
return legacyBaseDirectory;
|
||||
}
|
||||
|
||||
public void setLegacyBaseDirectory(String legacyBaseDirectory) {
|
||||
this.legacyBaseDirectory = legacyBaseDirectory;
|
||||
}
|
||||
|
||||
public String getDestinationId() {
|
||||
return destinationId;
|
||||
}
|
||||
|
||||
public void setDestinationId(String destinationId) {
|
||||
this.destinationId = destinationId;
|
||||
}
|
||||
|
||||
public long getMaximumRows() {
|
||||
return maximumRows;
|
||||
}
|
||||
|
||||
public void setMaximumRows(long maximumRows) {
|
||||
this.maximumRows = maximumRows;
|
||||
}
|
||||
|
||||
public long getMaximumEncodedBytes() {
|
||||
return maximumEncodedBytes;
|
||||
}
|
||||
|
||||
public void setMaximumEncodedBytes(long maximumEncodedBytes) {
|
||||
this.maximumEncodedBytes = maximumEncodedBytes;
|
||||
}
|
||||
}
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package dev.caskeleton.adapter.outbound.fileserver;
|
||||
|
||||
import dev.caskeleton.application.filepublication.ExportSchema.Column;
|
||||
import dev.caskeleton.application.filepublication.FilePublishRequest;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Canonical fingerprint for retry/recovery intent comparison. */
|
||||
final class FilePublishRequestFingerprint {
|
||||
|
||||
private static final HexFormat HEX = HexFormat.of();
|
||||
|
||||
private FilePublishRequestFingerprint() {}
|
||||
|
||||
static String calculate(FilePublishRequest request) {
|
||||
Objects.requireNonNull(request, "request must be non-null");
|
||||
MessageDigest digest = sha256();
|
||||
update(digest, request.operationId().value());
|
||||
update(digest, request.destinationId().value());
|
||||
update(digest, request.logicalFileName().value());
|
||||
update(digest, request.sourceRevision().value());
|
||||
update(digest, request.formatProfileId());
|
||||
update(digest, request.schema().schemaId());
|
||||
update(digest, request.schema().version());
|
||||
update(digest, request.schema().columns().size());
|
||||
for (Column column : request.schema().columns()) {
|
||||
update(digest, column.name());
|
||||
update(digest, column.cellType().name());
|
||||
update(digest, column.nullable() ? 1 : 0);
|
||||
update(digest, column.formulaPolicy().name());
|
||||
update(digest, column.maximumUtf8Bytes());
|
||||
}
|
||||
return HEX.formatHex(digest.digest());
|
||||
}
|
||||
|
||||
private static void update(MessageDigest digest, String value) {
|
||||
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
|
||||
digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(bytes.length).array());
|
||||
digest.update(bytes);
|
||||
}
|
||||
|
||||
private static void update(MessageDigest digest, int value) {
|
||||
digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(value).array());
|
||||
}
|
||||
|
||||
private static MessageDigest sha256() {
|
||||
try {
|
||||
return MessageDigest.getInstance("SHA-256");
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("SHA-256 unavailable", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-1
@@ -97,7 +97,14 @@ public class FilesystemCsvExportAdapter implements FileExportPort {
|
||||
if (fileName == null || fileName.isBlank()) {
|
||||
throw new IllegalArgumentException("fileName must be non-null and non-blank");
|
||||
}
|
||||
Path resolved = baseDir.resolve(fileName).normalize();
|
||||
Path relative = Path.of(fileName);
|
||||
if (relative.isAbsolute()
|
||||
|| relative.getNameCount() != 1
|
||||
|| ".".equals(fileName)
|
||||
|| "..".equals(fileName)) {
|
||||
throw new IllegalArgumentException("fileName must be a bare file name");
|
||||
}
|
||||
Path resolved = baseDir.resolve(relative).normalize();
|
||||
if (!resolved.startsWith(baseDir) || resolved.equals(baseDir)) {
|
||||
throw new IllegalArgumentException("illegal export file name (path traversal): " + fileName);
|
||||
}
|
||||
|
||||
+437
@@ -0,0 +1,437 @@
|
||||
package dev.caskeleton.adapter.outbound.fileserver;
|
||||
|
||||
import dev.caskeleton.application.filepublication.FilePublicationException;
|
||||
import dev.caskeleton.application.filepublication.FilePublicationPort;
|
||||
import dev.caskeleton.application.filepublication.FilePublishReceipt;
|
||||
import dev.caskeleton.application.filepublication.FilePublishReceipt.DurabilityGuarantee;
|
||||
import dev.caskeleton.application.filepublication.FilePublishReceipt.PublicationGuarantee;
|
||||
import dev.caskeleton.application.filepublication.FilePublishRequest;
|
||||
import dev.caskeleton.application.filepublication.FileVersion;
|
||||
import dev.caskeleton.application.filepublication.PublishedFileReference;
|
||||
import dev.caskeleton.application.filepublication.TabularRowProducer;
|
||||
import dev.caskeleton.application.filepublication.TabularRowSink;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.channels.Channels;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.FileAlreadyExistsException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.nio.file.attribute.PosixFilePermission;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Instant;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Local staged CSV provider with single-node operation recovery. It does not claim cross-node
|
||||
* fencing, background reconciliation, or R2 qualification.
|
||||
*/
|
||||
public final class LocalFilePublicationAdapter implements FilePublicationPort {
|
||||
|
||||
private static final String FORMAT_PROFILE = "csv-rfc4180-v1";
|
||||
private static final HexFormat HEX = HexFormat.of();
|
||||
|
||||
private final LocalFilePublicationPolicy policy;
|
||||
private final Path baseDirectory;
|
||||
private final Path stagingDirectory;
|
||||
private final LocalPublicationJournal journal;
|
||||
private final AtomicLinkPublisher atomicLinkPublisher;
|
||||
|
||||
public LocalFilePublicationAdapter(LocalFilePublicationPolicy policy) {
|
||||
this(policy, (target, staging) -> Files.createLink(target, staging));
|
||||
}
|
||||
|
||||
LocalFilePublicationAdapter(
|
||||
LocalFilePublicationPolicy policy, AtomicLinkPublisher atomicLinkPublisher) {
|
||||
this.policy = Objects.requireNonNull(policy, "policy must be non-null");
|
||||
this.atomicLinkPublisher =
|
||||
Objects.requireNonNull(atomicLinkPublisher, "atomicLinkPublisher must be non-null");
|
||||
this.baseDirectory = policy.baseDirectory();
|
||||
this.stagingDirectory = baseDirectory.resolve(".staging");
|
||||
initializeDirectories();
|
||||
try {
|
||||
this.journal = new LocalPublicationJournal(baseDirectory);
|
||||
} catch (LocalPublicationJournalException exception) {
|
||||
throw new FilePublicationException(
|
||||
FilePublicationException.Reason.UNAVAILABLE,
|
||||
"file publication control journal is unavailable",
|
||||
exception);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public FilePublishReceipt publish(FilePublishRequest request, TabularRowProducer producer) {
|
||||
validateRequest(request);
|
||||
Objects.requireNonNull(producer, "producer must be non-null");
|
||||
LocalPublicationJournal.OperationLock operationLock;
|
||||
try {
|
||||
operationLock = journal.acquire(request.operationId().value());
|
||||
} catch (LocalPublicationJournalException exception) {
|
||||
throw new FilePublicationException(
|
||||
FilePublicationException.Reason.UNAVAILABLE,
|
||||
"file publication operation lock is unavailable",
|
||||
exception);
|
||||
}
|
||||
try (operationLock) {
|
||||
return publishLocked(request, producer);
|
||||
} catch (LocalPublicationJournalException exception) {
|
||||
throw indeterminate(exception);
|
||||
}
|
||||
}
|
||||
|
||||
private FilePublishReceipt publishLocked(
|
||||
FilePublishRequest request, TabularRowProducer producer) {
|
||||
String operationToken = operationToken(request.operationId().value());
|
||||
String publishedFileName = request.logicalFileName().value() + "--" + operationToken + ".csv";
|
||||
Path staging = stagingDirectory.resolve("." + operationToken + ".part");
|
||||
Path target = baseDirectory.resolve(publishedFileName);
|
||||
String requestFingerprint = FilePublishRequestFingerprint.calculate(request);
|
||||
Optional<LocalPublicationJournalRecord> prior;
|
||||
try {
|
||||
prior = journal.find(request.operationId().value());
|
||||
} catch (LocalPublicationJournalException exception) {
|
||||
throw indeterminate(exception);
|
||||
}
|
||||
if (prior.isPresent()) {
|
||||
return recover(prior.get(), request, requestFingerprint, staging, target);
|
||||
}
|
||||
if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new FilePublicationException(
|
||||
FilePublicationException.Reason.CONFLICT,
|
||||
"file publication target already exists for operation");
|
||||
}
|
||||
|
||||
LocalPublicationJournalRecord writing =
|
||||
LocalPublicationJournalRecord.writing(
|
||||
request.operationId().value(),
|
||||
requestFingerprint,
|
||||
publishedFileName,
|
||||
staging.getFileName().toString());
|
||||
storeBeforeCommit(writing);
|
||||
StreamingCsvEncoder.Stats stats;
|
||||
try (FileChannel channel =
|
||||
FileChannel.open(
|
||||
staging,
|
||||
StandardOpenOption.CREATE_NEW,
|
||||
StandardOpenOption.WRITE,
|
||||
LinkOption.NOFOLLOW_LINKS);
|
||||
OutputStream output = Channels.newOutputStream(channel)) {
|
||||
restrictPermissions(staging);
|
||||
StreamingCsvEncoder encoder =
|
||||
new StreamingCsvEncoder(
|
||||
request.schema(),
|
||||
output,
|
||||
sha256(),
|
||||
policy.maximumRows(),
|
||||
policy.maximumEncodedBytes());
|
||||
encoder.writeHeader();
|
||||
TabularRowSink sink =
|
||||
new TabularRowSink() {
|
||||
@Override
|
||||
public void write(dev.caskeleton.application.filepublication.TabularRow row) {
|
||||
encoder.write(row);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void checkpoint() {
|
||||
encoder.checkpoint();
|
||||
}
|
||||
};
|
||||
producer.produce(sink);
|
||||
encoder.checkpoint();
|
||||
output.flush();
|
||||
channel.force(true);
|
||||
stats = encoder.finish();
|
||||
} catch (RuntimeException exception) {
|
||||
deleteStaging(staging, exception);
|
||||
journal.delete(request.operationId().value(), exception);
|
||||
throw exception;
|
||||
} catch (FileAlreadyExistsException exception) {
|
||||
throw new FilePublicationException(
|
||||
FilePublicationException.Reason.CONFLICT,
|
||||
"file publication operation is already staging",
|
||||
exception);
|
||||
} catch (IOException exception) {
|
||||
deleteStaging(staging, exception);
|
||||
journal.delete(request.operationId().value(), exception);
|
||||
throw new FilePublicationException(
|
||||
FilePublicationException.Reason.UNAVAILABLE,
|
||||
"file publication staging failed",
|
||||
exception);
|
||||
}
|
||||
|
||||
LocalPublicationJournalRecord sealed =
|
||||
LocalPublicationJournalRecord.sealed(
|
||||
request.operationId().value(),
|
||||
requestFingerprint,
|
||||
publishedFileName,
|
||||
staging.getFileName().toString(),
|
||||
stats.byteSize(),
|
||||
stats.rowCount(),
|
||||
request.schema().columns().size(),
|
||||
stats.sha256(),
|
||||
stats.formulaMitigatedCount());
|
||||
storeBeforeCommit(sealed);
|
||||
PublicationGuarantee guarantee = publishStaging(staging, target);
|
||||
verifyArtifact(target, sealed);
|
||||
Instant publishedAt = Instant.now();
|
||||
storeTerminal(sealed.published(publishedAt, guarantee.name()));
|
||||
return receipt(request, sealed, guarantee, publishedAt);
|
||||
}
|
||||
|
||||
private FilePublishReceipt recover(
|
||||
LocalPublicationJournalRecord record,
|
||||
FilePublishRequest request,
|
||||
String requestFingerprint,
|
||||
Path staging,
|
||||
Path target) {
|
||||
if (!record.requestFingerprint().equals(requestFingerprint)) {
|
||||
throw new FilePublicationException(
|
||||
FilePublicationException.Reason.CONFLICT,
|
||||
"file publication operation was already used for a different request");
|
||||
}
|
||||
if (!record.stageFileName().equals(staging.getFileName().toString())
|
||||
|| !record.publishedFileName().equals(target.getFileName().toString())) {
|
||||
throw indeterminate(new IOException("journal locator mismatch"));
|
||||
}
|
||||
if (record.state() == LocalPublicationJournalRecord.State.WRITING) {
|
||||
throw indeterminate(new IOException("publication interrupted before sealing"));
|
||||
}
|
||||
PublicationGuarantee guarantee;
|
||||
if (record.state() == LocalPublicationJournalRecord.State.PUBLISHED) {
|
||||
verifyArtifact(target, record);
|
||||
guarantee = publicationGuarantee(record.publicationGuarantee());
|
||||
return receipt(request, record, guarantee, Instant.parse(record.publishedAt()));
|
||||
}
|
||||
|
||||
if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) {
|
||||
verifyArtifact(target, record);
|
||||
try {
|
||||
forceDirectory(baseDirectory);
|
||||
} catch (IOException exception) {
|
||||
throw indeterminate(exception);
|
||||
}
|
||||
guarantee = PublicationGuarantee.UNIQUE_ATOMIC_CREATE;
|
||||
} else if (Files.exists(staging, LinkOption.NOFOLLOW_LINKS)) {
|
||||
verifyArtifact(staging, record);
|
||||
guarantee = publishStaging(staging, target);
|
||||
verifyArtifact(target, record);
|
||||
} else {
|
||||
throw indeterminate(new IOException("sealed publication artifact is unavailable"));
|
||||
}
|
||||
Instant publishedAt = Instant.now();
|
||||
storeTerminal(record.published(publishedAt, guarantee.name()));
|
||||
return receipt(request, record, guarantee, publishedAt);
|
||||
}
|
||||
|
||||
private FilePublishReceipt receipt(
|
||||
FilePublishRequest request,
|
||||
LocalPublicationJournalRecord record,
|
||||
PublicationGuarantee guarantee,
|
||||
Instant publishedAt) {
|
||||
String operationToken = operationToken(request.operationId().value());
|
||||
return new FilePublishReceipt(
|
||||
request.operationId(),
|
||||
new PublishedFileReference(
|
||||
"filepub:" + policy.destinationId().value() + ":" + operationToken),
|
||||
request.destinationId(),
|
||||
record.publishedFileName(),
|
||||
new FileVersion(record.sha256()),
|
||||
request.formatProfileId(),
|
||||
"text/csv",
|
||||
"UTF-8",
|
||||
record.byteSize(),
|
||||
record.rowCount(),
|
||||
record.columnCount(),
|
||||
record.sha256(),
|
||||
publishedAt,
|
||||
guarantee,
|
||||
DurabilityGuarantee.PROCESS_LOCAL_SYNC,
|
||||
record.formulaMitigatedCount());
|
||||
}
|
||||
|
||||
private static PublicationGuarantee publicationGuarantee(String name) {
|
||||
try {
|
||||
return PublicationGuarantee.valueOf(name);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new FilePublicationException(
|
||||
FilePublicationException.Reason.PUBLISH_INDETERMINATE,
|
||||
"file publication journal contains an unknown guarantee",
|
||||
exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static void verifyArtifact(Path artifact, LocalPublicationJournalRecord record) {
|
||||
try {
|
||||
if (!Files.isRegularFile(artifact, LinkOption.NOFOLLOW_LINKS)
|
||||
|| Files.size(artifact) != record.byteSize()
|
||||
|| !digest(artifact).equals(record.sha256())) {
|
||||
throw indeterminate(new IOException("publication artifact integrity mismatch"));
|
||||
}
|
||||
} catch (IOException exception) {
|
||||
throw indeterminate(exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static String digest(Path artifact) throws IOException {
|
||||
MessageDigest digest = sha256();
|
||||
try (var input = Files.newInputStream(artifact, LinkOption.NOFOLLOW_LINKS)) {
|
||||
byte[] buffer = new byte[8192];
|
||||
int read;
|
||||
while ((read = input.read(buffer)) != -1) {
|
||||
digest.update(buffer, 0, read);
|
||||
}
|
||||
}
|
||||
return HEX.formatHex(digest.digest());
|
||||
}
|
||||
|
||||
private void validateRequest(FilePublishRequest request) {
|
||||
Objects.requireNonNull(request, "request must be non-null");
|
||||
if (!policy.destinationId().equals(request.destinationId())) {
|
||||
throw new FilePublicationException(
|
||||
FilePublicationException.Reason.INVALID_REQUEST,
|
||||
"file publication destination is not bound to this provider");
|
||||
}
|
||||
if (!FORMAT_PROFILE.equals(request.formatProfileId())) {
|
||||
throw new FilePublicationException(
|
||||
FilePublicationException.Reason.INVALID_REQUEST,
|
||||
"unsupported file publication format profile");
|
||||
}
|
||||
}
|
||||
|
||||
private PublicationGuarantee publishStaging(Path staging, Path target) {
|
||||
try {
|
||||
atomicLinkPublisher.publish(target, staging);
|
||||
forceDirectory(baseDirectory);
|
||||
Files.delete(staging);
|
||||
forceDirectory(stagingDirectory);
|
||||
return PublicationGuarantee.UNIQUE_ATOMIC_CREATE;
|
||||
} catch (FileAlreadyExistsException exception) {
|
||||
throw new FilePublicationException(
|
||||
FilePublicationException.Reason.CONFLICT,
|
||||
"file publication target already exists",
|
||||
exception);
|
||||
} catch (IOException exception) {
|
||||
if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw indeterminate(exception);
|
||||
}
|
||||
throw unavailableAtomicPublication(exception);
|
||||
} catch (UnsupportedOperationException exception) {
|
||||
throw unavailableAtomicPublication(exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static FilePublicationException unavailableAtomicPublication(Exception exception) {
|
||||
return new FilePublicationException(
|
||||
FilePublicationException.Reason.UNAVAILABLE,
|
||||
"exclusive atomic file publication is unavailable",
|
||||
exception);
|
||||
}
|
||||
|
||||
private static void forceDirectory(Path directory) throws IOException {
|
||||
try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) {
|
||||
channel.force(true);
|
||||
}
|
||||
}
|
||||
|
||||
private void initializeDirectories() {
|
||||
try {
|
||||
Files.createDirectories(baseDirectory);
|
||||
if (Files.isSymbolicLink(baseDirectory)
|
||||
|| !Files.isDirectory(baseDirectory, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new FilePublicationException(
|
||||
FilePublicationException.Reason.INVALID_REQUEST,
|
||||
"file publication base directory must be a real directory");
|
||||
}
|
||||
Files.createDirectories(stagingDirectory);
|
||||
if (Files.isSymbolicLink(stagingDirectory)
|
||||
|| !Files.isDirectory(stagingDirectory, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new FilePublicationException(
|
||||
FilePublicationException.Reason.INVALID_REQUEST,
|
||||
"file publication staging directory must be a real directory");
|
||||
}
|
||||
restrictPermissions(stagingDirectory);
|
||||
} catch (IOException exception) {
|
||||
throw new FilePublicationException(
|
||||
FilePublicationException.Reason.UNAVAILABLE,
|
||||
"file publication directories are unavailable",
|
||||
exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static void restrictPermissions(Path path) throws IOException {
|
||||
try {
|
||||
Files.setPosixFilePermissions(
|
||||
path,
|
||||
Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)
|
||||
? Set.of(
|
||||
PosixFilePermission.OWNER_READ,
|
||||
PosixFilePermission.OWNER_WRITE,
|
||||
PosixFilePermission.OWNER_EXECUTE)
|
||||
: Set.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE));
|
||||
} catch (UnsupportedOperationException ignored) {
|
||||
// Non-POSIX filesystems are allowed only as R1; deployment evidence owns permission claims.
|
||||
}
|
||||
}
|
||||
|
||||
private static MessageDigest sha256() {
|
||||
try {
|
||||
return MessageDigest.getInstance("SHA-256");
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("SHA-256 unavailable", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static String operationToken(String operationId) {
|
||||
MessageDigest digest = sha256();
|
||||
return HEX.formatHex(digest.digest(operationId.getBytes(StandardCharsets.UTF_8)))
|
||||
.substring(0, 24);
|
||||
}
|
||||
|
||||
private void storeBeforeCommit(LocalPublicationJournalRecord record) {
|
||||
try {
|
||||
journal.store(record);
|
||||
} catch (LocalPublicationJournalException exception) {
|
||||
throw new FilePublicationException(
|
||||
FilePublicationException.Reason.UNAVAILABLE,
|
||||
"file publication control journal update failed before commit",
|
||||
exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void storeTerminal(LocalPublicationJournalRecord record) {
|
||||
try {
|
||||
journal.store(record);
|
||||
} catch (LocalPublicationJournalException exception) {
|
||||
throw indeterminate(exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static FilePublicationException indeterminate(Throwable exception) {
|
||||
return new FilePublicationException(
|
||||
FilePublicationException.Reason.PUBLISH_INDETERMINATE,
|
||||
"file publication commit outcome is indeterminate",
|
||||
exception);
|
||||
}
|
||||
|
||||
private static void deleteStaging(Path staging, Throwable original) {
|
||||
try {
|
||||
Files.deleteIfExists(staging);
|
||||
} catch (IOException cleanupFailure) {
|
||||
original.addSuppressed(cleanupFailure);
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
interface AtomicLinkPublisher {
|
||||
|
||||
void publish(Path target, Path staging) throws IOException;
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package dev.caskeleton.adapter.outbound.fileserver;
|
||||
|
||||
import dev.caskeleton.application.filepublication.FileDestinationId;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Immutable operational bounds for the local R1 provider. */
|
||||
public record LocalFilePublicationPolicy(
|
||||
FileDestinationId destinationId,
|
||||
Path baseDirectory,
|
||||
long maximumRows,
|
||||
long maximumEncodedBytes) {
|
||||
|
||||
public LocalFilePublicationPolicy {
|
||||
Objects.requireNonNull(destinationId, "destinationId must be non-null");
|
||||
Objects.requireNonNull(baseDirectory, "baseDirectory must be non-null");
|
||||
baseDirectory = baseDirectory.toAbsolutePath().normalize();
|
||||
if (maximumRows < 1) {
|
||||
throw new IllegalArgumentException("maximumRows must be >= 1");
|
||||
}
|
||||
if (maximumEncodedBytes < 1) {
|
||||
throw new IllegalArgumentException("maximumEncodedBytes must be >= 1");
|
||||
}
|
||||
}
|
||||
}
|
||||
+294
@@ -0,0 +1,294 @@
|
||||
package dev.caskeleton.adapter.outbound.fileserver;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.FileLock;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.FileAlreadyExistsException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.nio.file.attribute.PosixFilePermission;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.Semaphore;
|
||||
|
||||
/** Forced, atomically replaced operation journal for the local provider. */
|
||||
final class LocalPublicationJournal {
|
||||
|
||||
private static final int MAXIMUM_RECORD_BYTES = 16_384;
|
||||
private static final int JVM_LOCK_STRIPE_COUNT = 256;
|
||||
private static final HexFormat HEX = HexFormat.of();
|
||||
private static final Semaphore[] JVM_LOCK_STRIPES = createLockStripes();
|
||||
|
||||
private final Path controlDirectory;
|
||||
private final Path operationsDirectory;
|
||||
|
||||
LocalPublicationJournal(Path baseDirectory) {
|
||||
controlDirectory = baseDirectory.resolve(".ca-fileserver");
|
||||
operationsDirectory = controlDirectory.resolve("operations");
|
||||
initialize();
|
||||
}
|
||||
|
||||
Optional<LocalPublicationJournalRecord> find(String operationId) {
|
||||
Path record = recordPath(operationId);
|
||||
validateShardIfPresent(record.getParent());
|
||||
if (!Files.exists(record, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
if (!Files.isRegularFile(record, LinkOption.NOFOLLOW_LINKS)
|
||||
|| Files.size(record) > MAXIMUM_RECORD_BYTES) {
|
||||
throw new LocalPublicationJournalException("local publication journal is corrupt");
|
||||
}
|
||||
LocalPublicationJournalRecord decoded =
|
||||
LocalPublicationJournalCodec.decode(Files.readAllBytes(record));
|
||||
if (!decoded.operationId().equals(operationId)) {
|
||||
throw new LocalPublicationJournalException("local publication journal identity mismatch");
|
||||
}
|
||||
return Optional.of(decoded);
|
||||
} catch (IOException exception) {
|
||||
throw new LocalPublicationJournalException(
|
||||
"local publication journal cannot be read", exception);
|
||||
}
|
||||
}
|
||||
|
||||
OperationLock acquire(String operationId) {
|
||||
Path lockPath = lockPath(operationId);
|
||||
Semaphore jvmLock =
|
||||
JVM_LOCK_STRIPES[
|
||||
Math.floorMod(lockPath.toAbsolutePath().normalize().hashCode(), JVM_LOCK_STRIPE_COUNT)];
|
||||
jvmLock.acquireUninterruptibly();
|
||||
FileChannel channel = null;
|
||||
try {
|
||||
createRealDirectory(lockPath.getParent());
|
||||
restrictDirectory(lockPath.getParent());
|
||||
if (Files.exists(lockPath, LinkOption.NOFOLLOW_LINKS)
|
||||
&& !Files.isRegularFile(lockPath, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new LocalPublicationJournalException("local publication operation lock is invalid");
|
||||
}
|
||||
channel =
|
||||
FileChannel.open(
|
||||
lockPath,
|
||||
StandardOpenOption.CREATE,
|
||||
StandardOpenOption.WRITE,
|
||||
LinkOption.NOFOLLOW_LINKS);
|
||||
restrictFile(lockPath);
|
||||
return new OperationLock(channel.lock(), channel, jvmLock);
|
||||
} catch (IOException | RuntimeException exception) {
|
||||
closeAfterAcquireFailure(channel, exception);
|
||||
jvmLock.release();
|
||||
if (exception instanceof LocalPublicationJournalException journalException) {
|
||||
throw journalException;
|
||||
}
|
||||
throw new LocalPublicationJournalException(
|
||||
"local publication operation lock is unavailable", exception);
|
||||
}
|
||||
}
|
||||
|
||||
void store(LocalPublicationJournalRecord record) {
|
||||
byte[] encoded = LocalPublicationJournalCodec.encode(record);
|
||||
Path target = recordPath(record.operationId());
|
||||
Path parent = target.getParent();
|
||||
Path temporary = parent.resolve("." + target.getFileName() + "." + UUID.randomUUID() + ".tmp");
|
||||
try {
|
||||
createRealDirectory(parent);
|
||||
restrictDirectory(parent);
|
||||
try (FileChannel channel =
|
||||
FileChannel.open(
|
||||
temporary,
|
||||
StandardOpenOption.CREATE_NEW,
|
||||
StandardOpenOption.WRITE,
|
||||
LinkOption.NOFOLLOW_LINKS)) {
|
||||
restrictFile(temporary);
|
||||
ByteBuffer buffer = ByteBuffer.wrap(encoded);
|
||||
while (buffer.hasRemaining()) {
|
||||
channel.write(buffer);
|
||||
}
|
||||
channel.force(true);
|
||||
}
|
||||
Files.move(
|
||||
temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
forceDirectory(parent);
|
||||
} catch (AtomicMoveNotSupportedException exception) {
|
||||
deleteTemporary(temporary, exception);
|
||||
throw new LocalPublicationJournalException(
|
||||
"atomic local publication journal update is unavailable", exception);
|
||||
} catch (IOException exception) {
|
||||
deleteTemporary(temporary, exception);
|
||||
throw new LocalPublicationJournalException(
|
||||
"local publication journal cannot be stored", exception);
|
||||
}
|
||||
}
|
||||
|
||||
void delete(String operationId, Throwable original) {
|
||||
Path target = recordPath(operationId);
|
||||
try {
|
||||
validateShardIfPresent(target.getParent());
|
||||
if (Files.deleteIfExists(target)) {
|
||||
forceDirectory(target.getParent());
|
||||
}
|
||||
} catch (IOException cleanupFailure) {
|
||||
original.addSuppressed(cleanupFailure);
|
||||
}
|
||||
}
|
||||
|
||||
Path recordPath(String operationId) {
|
||||
String token = token(operationId);
|
||||
return operationsDirectory.resolve(token.substring(0, 2)).resolve(token + ".json");
|
||||
}
|
||||
|
||||
private Path lockPath(String operationId) {
|
||||
String token = token(operationId);
|
||||
return operationsDirectory.resolve(token.substring(0, 2)).resolve(token + ".lock");
|
||||
}
|
||||
|
||||
private void initialize() {
|
||||
try {
|
||||
createRealDirectory(controlDirectory);
|
||||
restrictDirectory(controlDirectory);
|
||||
createRealDirectory(operationsDirectory);
|
||||
restrictDirectory(operationsDirectory);
|
||||
} catch (IOException exception) {
|
||||
throw new LocalPublicationJournalException(
|
||||
"local publication control directory is unavailable", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static void createRealDirectory(Path directory) throws IOException {
|
||||
try {
|
||||
Files.createDirectory(directory);
|
||||
} catch (FileAlreadyExistsException ignored) {
|
||||
// Validate the existing entry without following a symbolic link.
|
||||
}
|
||||
validateRealDirectory(directory);
|
||||
}
|
||||
|
||||
private static void validateShardIfPresent(Path directory) {
|
||||
if (Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) {
|
||||
validateRealDirectory(directory);
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateRealDirectory(Path directory) {
|
||||
if (Files.isSymbolicLink(directory)
|
||||
|| !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new LocalPublicationJournalException("local publication control directory is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
private static void restrictDirectory(Path path) throws IOException {
|
||||
try {
|
||||
Files.setPosixFilePermissions(
|
||||
path,
|
||||
Set.of(
|
||||
PosixFilePermission.OWNER_READ,
|
||||
PosixFilePermission.OWNER_WRITE,
|
||||
PosixFilePermission.OWNER_EXECUTE));
|
||||
} catch (UnsupportedOperationException ignored) {
|
||||
// Deployment qualification owns non-POSIX permission evidence.
|
||||
}
|
||||
}
|
||||
|
||||
private static void restrictFile(Path path) throws IOException {
|
||||
try {
|
||||
Files.setPosixFilePermissions(
|
||||
path, Set.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE));
|
||||
} catch (UnsupportedOperationException ignored) {
|
||||
// Deployment qualification owns non-POSIX permission evidence.
|
||||
}
|
||||
}
|
||||
|
||||
private static void forceDirectory(Path directory) throws IOException {
|
||||
try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) {
|
||||
channel.force(true);
|
||||
}
|
||||
}
|
||||
|
||||
private static String token(String operationId) {
|
||||
try {
|
||||
return HEX.formatHex(
|
||||
MessageDigest.getInstance("SHA-256")
|
||||
.digest(operationId.getBytes(java.nio.charset.StandardCharsets.UTF_8)));
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("SHA-256 unavailable", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static void deleteTemporary(Path temporary, Throwable original) {
|
||||
try {
|
||||
Files.deleteIfExists(temporary);
|
||||
} catch (IOException cleanupFailure) {
|
||||
original.addSuppressed(cleanupFailure);
|
||||
}
|
||||
}
|
||||
|
||||
private static Semaphore[] createLockStripes() {
|
||||
Semaphore[] locks = new Semaphore[JVM_LOCK_STRIPE_COUNT];
|
||||
for (int index = 0; index < locks.length; index++) {
|
||||
locks[index] = new Semaphore(1);
|
||||
}
|
||||
return locks;
|
||||
}
|
||||
|
||||
private static void closeAfterAcquireFailure(FileChannel channel, Throwable original) {
|
||||
if (channel == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
channel.close();
|
||||
} catch (IOException closeFailure) {
|
||||
original.addSuppressed(closeFailure);
|
||||
}
|
||||
}
|
||||
|
||||
static final class OperationLock implements AutoCloseable {
|
||||
|
||||
private final FileLock fileLock;
|
||||
private final FileChannel channel;
|
||||
private final Semaphore jvmLock;
|
||||
private boolean closed;
|
||||
|
||||
private OperationLock(FileLock fileLock, FileChannel channel, Semaphore jvmLock) {
|
||||
this.fileLock = fileLock;
|
||||
this.channel = channel;
|
||||
this.jvmLock = jvmLock;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
closed = true;
|
||||
IOException failure = null;
|
||||
try {
|
||||
fileLock.release();
|
||||
} catch (IOException exception) {
|
||||
failure = exception;
|
||||
}
|
||||
try {
|
||||
channel.close();
|
||||
} catch (IOException exception) {
|
||||
if (failure == null) {
|
||||
failure = exception;
|
||||
} else {
|
||||
failure.addSuppressed(exception);
|
||||
}
|
||||
} finally {
|
||||
jvmLock.release();
|
||||
}
|
||||
if (failure != null) {
|
||||
throw new LocalPublicationJournalException(
|
||||
"local publication operation lock cannot be released", failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
package dev.caskeleton.adapter.outbound.fileserver;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/** Strict bounded codec for the flat local operation-journal JSON document. */
|
||||
final class LocalPublicationJournalCodec {
|
||||
|
||||
private static final Set<String> KEYS =
|
||||
Set.of(
|
||||
"schemaVersion",
|
||||
"state",
|
||||
"operationId",
|
||||
"requestFingerprint",
|
||||
"publishedFileName",
|
||||
"stageFileName",
|
||||
"byteSize",
|
||||
"rowCount",
|
||||
"columnCount",
|
||||
"sha256",
|
||||
"formulaMitigatedCount",
|
||||
"publishedAt",
|
||||
"publicationGuarantee");
|
||||
|
||||
private LocalPublicationJournalCodec() {}
|
||||
|
||||
static byte[] encode(LocalPublicationJournalRecord record) {
|
||||
StringBuilder json = new StringBuilder(768);
|
||||
json.append('{');
|
||||
number(json, "schemaVersion", record.schemaVersion());
|
||||
string(json, "state", record.state().name());
|
||||
string(json, "operationId", record.operationId());
|
||||
string(json, "requestFingerprint", record.requestFingerprint());
|
||||
string(json, "publishedFileName", record.publishedFileName());
|
||||
string(json, "stageFileName", record.stageFileName());
|
||||
number(json, "byteSize", record.byteSize());
|
||||
number(json, "rowCount", record.rowCount());
|
||||
number(json, "columnCount", record.columnCount());
|
||||
string(json, "sha256", record.sha256());
|
||||
number(json, "formulaMitigatedCount", record.formulaMitigatedCount());
|
||||
string(json, "publishedAt", record.publishedAt());
|
||||
string(json, "publicationGuarantee", record.publicationGuarantee());
|
||||
json.append("}\n");
|
||||
return json.toString().getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
static LocalPublicationJournalRecord decode(byte[] bytes) {
|
||||
try {
|
||||
Map<String, String> values = new FlatJsonParser(bytes).parse();
|
||||
if (!values.keySet().equals(KEYS)) {
|
||||
throw new IllegalArgumentException("journal fields do not match schema");
|
||||
}
|
||||
return new LocalPublicationJournalRecord(
|
||||
integer(values, "schemaVersion"),
|
||||
LocalPublicationJournalRecord.State.valueOf(values.get("state")),
|
||||
values.get("operationId"),
|
||||
values.get("requestFingerprint"),
|
||||
values.get("publishedFileName"),
|
||||
values.get("stageFileName"),
|
||||
longValue(values, "byteSize"),
|
||||
longValue(values, "rowCount"),
|
||||
integer(values, "columnCount"),
|
||||
values.get("sha256"),
|
||||
longValue(values, "formulaMitigatedCount"),
|
||||
values.get("publishedAt"),
|
||||
values.get("publicationGuarantee"));
|
||||
} catch (RuntimeException exception) {
|
||||
throw new LocalPublicationJournalException("local publication journal is corrupt", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static int integer(Map<String, String> values, String key) {
|
||||
return Integer.parseInt(values.get(key));
|
||||
}
|
||||
|
||||
private static long longValue(Map<String, String> values, String key) {
|
||||
return Long.parseLong(values.get(key));
|
||||
}
|
||||
|
||||
private static void number(StringBuilder json, String key, long value) {
|
||||
fieldPrefix(json, key);
|
||||
json.append(value);
|
||||
}
|
||||
|
||||
private static void string(StringBuilder json, String key, String value) {
|
||||
fieldPrefix(json, key);
|
||||
appendQuoted(json, value);
|
||||
}
|
||||
|
||||
private static void fieldPrefix(StringBuilder json, String key) {
|
||||
if (json.length() > 1) {
|
||||
json.append(',');
|
||||
}
|
||||
appendQuoted(json, key);
|
||||
json.append(':');
|
||||
}
|
||||
|
||||
private static void appendQuoted(StringBuilder target, String value) {
|
||||
target.append('"');
|
||||
for (int index = 0; index < value.length(); index++) {
|
||||
char character = value.charAt(index);
|
||||
switch (character) {
|
||||
case '"' -> target.append("\\\"");
|
||||
case '\\' -> target.append("\\\\");
|
||||
case '\b' -> target.append("\\b");
|
||||
case '\f' -> target.append("\\f");
|
||||
case '\n' -> target.append("\\n");
|
||||
case '\r' -> target.append("\\r");
|
||||
case '\t' -> target.append("\\t");
|
||||
default -> {
|
||||
if (character < 0x20) {
|
||||
target.append("\\u%04x".formatted((int) character));
|
||||
} else {
|
||||
target.append(character);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
target.append('"');
|
||||
}
|
||||
|
||||
private static final class FlatJsonParser {
|
||||
|
||||
private static final int MAXIMUM_JOURNAL_BYTES = 16_384;
|
||||
|
||||
private final String input;
|
||||
private int cursor;
|
||||
|
||||
private FlatJsonParser(byte[] bytes) {
|
||||
if (bytes.length < 2 || bytes.length > MAXIMUM_JOURNAL_BYTES) {
|
||||
throw new IllegalArgumentException("journal size is out of bounds");
|
||||
}
|
||||
input = new String(bytes, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private Map<String, String> parse() {
|
||||
Map<String, String> values = new LinkedHashMap<>();
|
||||
whitespace();
|
||||
expect('{');
|
||||
whitespace();
|
||||
while (!peek('}')) {
|
||||
String key = quoted();
|
||||
if (!KEYS.contains(key)) {
|
||||
throw new IllegalArgumentException("unknown journal field");
|
||||
}
|
||||
whitespace();
|
||||
expect(':');
|
||||
whitespace();
|
||||
String value = peek('"') ? quoted() : number();
|
||||
if (values.putIfAbsent(key, value) != null) {
|
||||
throw new IllegalArgumentException("duplicate journal field");
|
||||
}
|
||||
whitespace();
|
||||
if (peek(',')) {
|
||||
cursor++;
|
||||
whitespace();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect('}');
|
||||
whitespace();
|
||||
if (cursor != input.length()) {
|
||||
throw new IllegalArgumentException("trailing journal content");
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
private String quoted() {
|
||||
expect('"');
|
||||
StringBuilder value = new StringBuilder();
|
||||
while (cursor < input.length()) {
|
||||
char character = input.charAt(cursor++);
|
||||
if (character == '"') {
|
||||
return value.toString();
|
||||
}
|
||||
if (character != '\\') {
|
||||
if (character < 0x20) {
|
||||
throw new IllegalArgumentException("unescaped control character");
|
||||
}
|
||||
value.append(character);
|
||||
continue;
|
||||
}
|
||||
if (cursor >= input.length()) {
|
||||
throw new IllegalArgumentException("truncated escape");
|
||||
}
|
||||
char escape = input.charAt(cursor++);
|
||||
switch (escape) {
|
||||
case '"' -> value.append('"');
|
||||
case '\\' -> value.append('\\');
|
||||
case 'b' -> value.append('\b');
|
||||
case 'f' -> value.append('\f');
|
||||
case 'n' -> value.append('\n');
|
||||
case 'r' -> value.append('\r');
|
||||
case 't' -> value.append('\t');
|
||||
case 'u' -> value.append(unicode());
|
||||
default -> throw new IllegalArgumentException("invalid escape");
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("unterminated string");
|
||||
}
|
||||
|
||||
private char unicode() {
|
||||
if (cursor + 4 > input.length()) {
|
||||
throw new IllegalArgumentException("truncated unicode escape");
|
||||
}
|
||||
int value = Integer.parseInt(input.substring(cursor, cursor + 4), 16);
|
||||
cursor += 4;
|
||||
return (char) value;
|
||||
}
|
||||
|
||||
private String number() {
|
||||
int start = cursor;
|
||||
if (peek('-')) {
|
||||
cursor++;
|
||||
}
|
||||
while (cursor < input.length() && Character.isDigit(input.charAt(cursor))) {
|
||||
cursor++;
|
||||
}
|
||||
if (cursor == start || (cursor == start + 1 && input.charAt(start) == '-')) {
|
||||
throw new IllegalArgumentException("invalid number");
|
||||
}
|
||||
return input.substring(start, cursor);
|
||||
}
|
||||
|
||||
private void whitespace() {
|
||||
while (cursor < input.length() && Character.isWhitespace(input.charAt(cursor))) {
|
||||
cursor++;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean peek(char expected) {
|
||||
return cursor < input.length() && input.charAt(cursor) == expected;
|
||||
}
|
||||
|
||||
private void expect(char expected) {
|
||||
if (!peek(expected)) {
|
||||
throw new IllegalArgumentException("unexpected journal token");
|
||||
}
|
||||
cursor++;
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package dev.caskeleton.adapter.outbound.fileserver;
|
||||
|
||||
/** Internal control-plane failure that never includes a filesystem path. */
|
||||
final class LocalPublicationJournalException extends RuntimeException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
LocalPublicationJournalException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
LocalPublicationJournalException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
package dev.caskeleton.adapter.outbound.fileserver;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Immutable local control record; internal locators are relative file names only. */
|
||||
record LocalPublicationJournalRecord(
|
||||
int schemaVersion,
|
||||
State state,
|
||||
String operationId,
|
||||
String requestFingerprint,
|
||||
String publishedFileName,
|
||||
String stageFileName,
|
||||
long byteSize,
|
||||
long rowCount,
|
||||
int columnCount,
|
||||
String sha256,
|
||||
long formulaMitigatedCount,
|
||||
String publishedAt,
|
||||
String publicationGuarantee) {
|
||||
|
||||
static final int CURRENT_SCHEMA_VERSION = 1;
|
||||
|
||||
LocalPublicationJournalRecord {
|
||||
if (schemaVersion != CURRENT_SCHEMA_VERSION) {
|
||||
throw new IllegalArgumentException("unsupported local publication journal schema");
|
||||
}
|
||||
Objects.requireNonNull(state, "state must be non-null");
|
||||
requireText(operationId, "operationId", 128);
|
||||
requireDigest(requestFingerprint, "requestFingerprint");
|
||||
requireFileName(publishedFileName, "publishedFileName");
|
||||
requireFileName(stageFileName, "stageFileName");
|
||||
if (byteSize < 0 || rowCount < 0 || columnCount < 0 || formulaMitigatedCount < 0) {
|
||||
throw new IllegalArgumentException("journal counts must be non-negative");
|
||||
}
|
||||
if (state != State.WRITING) {
|
||||
requireDigest(sha256, "sha256");
|
||||
if (columnCount < 1) {
|
||||
throw new IllegalArgumentException("sealed journal columnCount must be positive");
|
||||
}
|
||||
} else if (!sha256.isEmpty()) {
|
||||
throw new IllegalArgumentException("writing journal must not contain a digest");
|
||||
}
|
||||
if (state == State.PUBLISHED) {
|
||||
Instant.parse(publishedAt);
|
||||
if (publicationGuarantee.isBlank()) {
|
||||
throw new IllegalArgumentException("published journal must contain publicationGuarantee");
|
||||
}
|
||||
} else if (!publishedAt.isEmpty() || !publicationGuarantee.isEmpty()) {
|
||||
throw new IllegalArgumentException("non-terminal journal must not contain receipt fields");
|
||||
}
|
||||
}
|
||||
|
||||
static LocalPublicationJournalRecord writing(
|
||||
String operationId,
|
||||
String requestFingerprint,
|
||||
String publishedFileName,
|
||||
String stageFileName) {
|
||||
return new LocalPublicationJournalRecord(
|
||||
CURRENT_SCHEMA_VERSION,
|
||||
State.WRITING,
|
||||
operationId,
|
||||
requestFingerprint,
|
||||
publishedFileName,
|
||||
stageFileName,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
"",
|
||||
0,
|
||||
"",
|
||||
"");
|
||||
}
|
||||
|
||||
static LocalPublicationJournalRecord sealed(
|
||||
String operationId,
|
||||
String requestFingerprint,
|
||||
String publishedFileName,
|
||||
String stageFileName,
|
||||
long byteSize,
|
||||
long rowCount,
|
||||
int columnCount,
|
||||
String sha256,
|
||||
long formulaMitigatedCount) {
|
||||
return new LocalPublicationJournalRecord(
|
||||
CURRENT_SCHEMA_VERSION,
|
||||
State.SEALED,
|
||||
operationId,
|
||||
requestFingerprint,
|
||||
publishedFileName,
|
||||
stageFileName,
|
||||
byteSize,
|
||||
rowCount,
|
||||
columnCount,
|
||||
sha256,
|
||||
formulaMitigatedCount,
|
||||
"",
|
||||
"");
|
||||
}
|
||||
|
||||
LocalPublicationJournalRecord published(Instant time, String guarantee) {
|
||||
return new LocalPublicationJournalRecord(
|
||||
schemaVersion,
|
||||
State.PUBLISHED,
|
||||
operationId,
|
||||
requestFingerprint,
|
||||
publishedFileName,
|
||||
stageFileName,
|
||||
byteSize,
|
||||
rowCount,
|
||||
columnCount,
|
||||
sha256,
|
||||
formulaMitigatedCount,
|
||||
Objects.requireNonNull(time, "time must be non-null").toString(),
|
||||
guarantee);
|
||||
}
|
||||
|
||||
private static void requireDigest(String value, String field) {
|
||||
if (value == null || !value.matches("[0-9a-f]{64}")) {
|
||||
throw new IllegalArgumentException(field + " must be a lowercase SHA-256 digest");
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireFileName(String value, String field) {
|
||||
requireText(value, field, 256);
|
||||
if (value.contains("/") || value.contains("\\") || value.equals(".") || value.equals("..")) {
|
||||
throw new IllegalArgumentException(field + " must be a relative file name");
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireText(String value, String field, int maximumLength) {
|
||||
if (value == null
|
||||
|| value.isBlank()
|
||||
|| value.length() > maximumLength
|
||||
|| value.chars().anyMatch(Character::isISOControl)) {
|
||||
throw new IllegalArgumentException(field + " is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
enum State {
|
||||
WRITING,
|
||||
SEALED,
|
||||
PUBLISHED
|
||||
}
|
||||
}
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
package dev.caskeleton.adapter.outbound.fileserver;
|
||||
|
||||
import dev.caskeleton.application.filepublication.ExportSchema;
|
||||
import dev.caskeleton.application.filepublication.FilePublicationException;
|
||||
import dev.caskeleton.application.filepublication.TabularCell;
|
||||
import dev.caskeleton.application.filepublication.TabularCell.BooleanCell;
|
||||
import dev.caskeleton.application.filepublication.TabularCell.DateCell;
|
||||
import dev.caskeleton.application.filepublication.TabularCell.DecimalCell;
|
||||
import dev.caskeleton.application.filepublication.TabularCell.InstantCell;
|
||||
import dev.caskeleton.application.filepublication.TabularCell.IntegerCell;
|
||||
import dev.caskeleton.application.filepublication.TabularCell.NullCell;
|
||||
import dev.caskeleton.application.filepublication.TabularCell.TextCell;
|
||||
import dev.caskeleton.application.filepublication.TabularRow;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
|
||||
/** Attempt-scoped row-at-a-time RFC-4180 encoder with schema and byte bounds. */
|
||||
final class StreamingCsvEncoder {
|
||||
|
||||
private static final HexFormat HEX = HexFormat.of();
|
||||
|
||||
private final ExportSchema schema;
|
||||
private final OutputStream output;
|
||||
private final MessageDigest digest;
|
||||
private final long maximumRows;
|
||||
private final long maximumBytes;
|
||||
|
||||
private long bytesWritten;
|
||||
private long rowsWritten;
|
||||
private long formulaMitigated;
|
||||
|
||||
StreamingCsvEncoder(
|
||||
ExportSchema schema,
|
||||
OutputStream output,
|
||||
MessageDigest digest,
|
||||
long maximumRows,
|
||||
long maximumBytes) {
|
||||
this.schema = schema;
|
||||
this.output = output;
|
||||
this.digest = digest;
|
||||
this.maximumRows = maximumRows;
|
||||
this.maximumBytes = maximumBytes;
|
||||
}
|
||||
|
||||
void writeHeader() {
|
||||
writeRecord(schema.columns().stream().map(ExportSchema.Column::name).toList());
|
||||
}
|
||||
|
||||
void write(TabularRow row) {
|
||||
checkpoint();
|
||||
if (rowsWritten >= maximumRows) {
|
||||
throw failure(
|
||||
FilePublicationException.Reason.CAPACITY_EXCEEDED, "file publication row limit exceeded");
|
||||
}
|
||||
List<TabularCell> cells = row.cells();
|
||||
if (cells.size() != schema.columns().size()) {
|
||||
throw failure(
|
||||
FilePublicationException.Reason.INVALID_REQUEST,
|
||||
"row column count does not match schema");
|
||||
}
|
||||
String[] values = new String[cells.size()];
|
||||
for (int index = 0; index < cells.size(); index++) {
|
||||
values[index] = encodeCell(cells.get(index), schema.columns().get(index));
|
||||
}
|
||||
writeRecord(List.of(values));
|
||||
rowsWritten++;
|
||||
checkpoint();
|
||||
}
|
||||
|
||||
void checkpoint() {
|
||||
if (Thread.currentThread().isInterrupted()) {
|
||||
throw failure(FilePublicationException.Reason.CANCELLED, "file publication cancelled");
|
||||
}
|
||||
}
|
||||
|
||||
Stats finish() {
|
||||
return new Stats(bytesWritten, rowsWritten, formulaMitigated, HEX.formatHex(digest.digest()));
|
||||
}
|
||||
|
||||
private String encodeCell(TabularCell cell, ExportSchema.Column column) {
|
||||
if (cell instanceof NullCell) {
|
||||
if (!column.nullable()) {
|
||||
throw failure(
|
||||
FilePublicationException.Reason.INVALID_REQUEST,
|
||||
"null cell is not allowed for column " + column.name());
|
||||
}
|
||||
return "";
|
||||
}
|
||||
if (cell.cellType() != column.cellType()) {
|
||||
throw failure(
|
||||
FilePublicationException.Reason.INVALID_REQUEST,
|
||||
"cell type does not match schema column " + column.name());
|
||||
}
|
||||
|
||||
String rendered =
|
||||
switch (cell) {
|
||||
case TextCell text -> encodeText(text.value(), column);
|
||||
case IntegerCell integer -> Long.toString(integer.value());
|
||||
case DecimalCell decimal -> decimal.value().toPlainString();
|
||||
case BooleanCell bool -> Boolean.toString(bool.value());
|
||||
case DateCell date -> date.value().toString();
|
||||
case InstantCell instant -> instant.value().toString();
|
||||
case NullCell ignored -> throw new IllegalStateException("handled above");
|
||||
};
|
||||
|
||||
int bytes = rendered.getBytes(StandardCharsets.UTF_8).length;
|
||||
if (bytes > column.maximumUtf8Bytes()) {
|
||||
throw failure(
|
||||
FilePublicationException.Reason.CAPACITY_EXCEEDED,
|
||||
"cell byte limit exceeded for column " + column.name());
|
||||
}
|
||||
return rendered;
|
||||
}
|
||||
|
||||
private String encodeText(String value, ExportSchema.Column column) {
|
||||
if (!isFormulaCandidate(value)) {
|
||||
return value;
|
||||
}
|
||||
return switch (column.formulaPolicy()) {
|
||||
case ALLOW -> value;
|
||||
case MITIGATE -> {
|
||||
formulaMitigated++;
|
||||
yield "'" + value;
|
||||
}
|
||||
case REJECT ->
|
||||
throw failure(
|
||||
FilePublicationException.Reason.INVALID_REQUEST,
|
||||
"spreadsheet formula text rejected for column " + column.name());
|
||||
};
|
||||
}
|
||||
|
||||
private void writeRecord(List<String> fields) {
|
||||
StringBuilder row = new StringBuilder();
|
||||
for (int index = 0; index < fields.size(); index++) {
|
||||
if (index > 0) {
|
||||
row.append(',');
|
||||
}
|
||||
row.append(escape(fields.get(index)));
|
||||
}
|
||||
row.append('\n');
|
||||
writeBytes(row.toString().getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private void writeBytes(byte[] bytes) {
|
||||
if (bytesWritten > maximumBytes - bytes.length) {
|
||||
throw failure(
|
||||
FilePublicationException.Reason.CAPACITY_EXCEEDED,
|
||||
"file publication byte limit exceeded");
|
||||
}
|
||||
try {
|
||||
output.write(bytes);
|
||||
digest.update(bytes);
|
||||
bytesWritten += bytes.length;
|
||||
} catch (IOException exception) {
|
||||
throw new FilePublicationException(
|
||||
FilePublicationException.Reason.UNAVAILABLE, "file publication write failed", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static String escape(String value) {
|
||||
if (value.contains(",")
|
||||
|| value.contains("\"")
|
||||
|| value.contains("\n")
|
||||
|| value.contains("\r")) {
|
||||
return '"' + value.replace("\"", "\"\"") + '"';
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static boolean isFormulaCandidate(String value) {
|
||||
if (value.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return switch (value.charAt(0)) {
|
||||
case '=', '+', '-', '@', '\t', '\r' -> true;
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
|
||||
private static FilePublicationException failure(
|
||||
FilePublicationException.Reason reason, String message) {
|
||||
return new FilePublicationException(reason, message);
|
||||
}
|
||||
|
||||
record Stats(long byteSize, long rowCount, long formulaMitigatedCount, String sha256) {}
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
package dev.caskeleton.adapter.outbound.fileserver;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.application.fileexport.FileExportPort;
|
||||
import dev.caskeleton.application.filepublication.FilePublicationPort;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
|
||||
class FilePublicationConfigTest {
|
||||
|
||||
@TempDir Path tempDir;
|
||||
|
||||
private final ApplicationContextRunner runner =
|
||||
new ApplicationContextRunner().withUserConfiguration(FileExportConfig.class);
|
||||
|
||||
@Test
|
||||
void remainsDisabledUnlessExplicitlyEnabled() {
|
||||
runner.run(
|
||||
context -> {
|
||||
assertThat(context).doesNotHaveBean(FileExportPort.class);
|
||||
assertThat(context).doesNotHaveBean(FilePublicationPort.class);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void bindsPublicationLimitsAndContributesOnlyTheStreamingPortByDefault() {
|
||||
runner
|
||||
.withPropertyValues(
|
||||
"ca-skeleton.fileserver.enabled=true",
|
||||
"ca-skeleton.fileserver.base-directory=build/test-files",
|
||||
"ca-skeleton.fileserver.destination-id=nightly-export",
|
||||
"ca-skeleton.fileserver.maximum-rows=125",
|
||||
"ca-skeleton.fileserver.maximum-encoded-bytes=4096")
|
||||
.run(
|
||||
context -> {
|
||||
assertThat(context).doesNotHaveBean(FileExportPort.class);
|
||||
assertThat(context).hasSingleBean(FilePublicationPort.class);
|
||||
|
||||
FileExportProperties properties = context.getBean(FileExportProperties.class);
|
||||
assertThat(properties.getDestinationId()).isEqualTo("nightly-export");
|
||||
assertThat(properties.getMaximumRows()).isEqualTo(125);
|
||||
assertThat(properties.getMaximumEncodedBytes()).isEqualTo(4096);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void legacyPortRequiresItsOwnOptInAndSeparateRoot() {
|
||||
runner
|
||||
.withPropertyValues(
|
||||
"ca-skeleton.fileserver.enabled=true",
|
||||
"ca-skeleton.fileserver.base-directory=build/test-files",
|
||||
"ca-skeleton.fileserver.legacy-enabled=true",
|
||||
"ca-skeleton.fileserver.legacy-base-directory=build/test-files-legacy")
|
||||
.run(
|
||||
context -> {
|
||||
assertThat(context).hasSingleBean(FileExportPort.class);
|
||||
assertThat(context).hasSingleBean(FilePublicationPort.class);
|
||||
FileExportProperties properties = context.getBean(FileExportProperties.class);
|
||||
assertThat(properties.getLegacyBaseDirectory()).isEqualTo("build/test-files-legacy");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsBlankPublicationRootAndOverlappingLegacyRoot() {
|
||||
runner
|
||||
.withPropertyValues(
|
||||
"ca-skeleton.fileserver.enabled=true", "ca-skeleton.fileserver.base-directory= ")
|
||||
.run(context -> assertThat(context).hasFailed());
|
||||
|
||||
runner
|
||||
.withPropertyValues(
|
||||
"ca-skeleton.fileserver.enabled=true",
|
||||
"ca-skeleton.fileserver.legacy-enabled=true",
|
||||
"ca-skeleton.fileserver.base-directory=build/shared-files",
|
||||
"ca-skeleton.fileserver.legacy-base-directory=build/shared-files/legacy")
|
||||
.run(
|
||||
context ->
|
||||
assertThat(context)
|
||||
.hasFailed()
|
||||
.getFailure()
|
||||
.hasRootCauseMessage(
|
||||
"fileserver publication and legacy roots must not overlap"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsLegacyRootThatAliasesThePublicationRootThroughASymbolicLink() throws Exception {
|
||||
Path publicationRoot = Files.createDirectory(tempDir.resolve("publication"));
|
||||
Path legacyAlias = tempDir.resolve("legacy-alias");
|
||||
Files.createSymbolicLink(legacyAlias, publicationRoot);
|
||||
|
||||
runner
|
||||
.withPropertyValues(
|
||||
"ca-skeleton.fileserver.enabled=true",
|
||||
"ca-skeleton.fileserver.legacy-enabled=true",
|
||||
"ca-skeleton.fileserver.base-directory=" + publicationRoot,
|
||||
"ca-skeleton.fileserver.legacy-base-directory=" + legacyAlias)
|
||||
.run(
|
||||
context ->
|
||||
assertThat(context)
|
||||
.hasFailed()
|
||||
.getFailure()
|
||||
.hasRootCauseMessage(
|
||||
"fileserver publication and legacy roots must not overlap"));
|
||||
}
|
||||
}
|
||||
+8
@@ -94,6 +94,14 @@ class FilesystemCsvExportAdapterTest {
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void nestedControlLikePathIsRejected() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
adapter.exportCsv(".ca-fileserver/operations/record.json", List.of("a"), List.of()))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void blankFileNameIsRejected() {
|
||||
assertThatThrownBy(() -> adapter.exportCsv(" ", List.of("a"), List.of()))
|
||||
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
package dev.caskeleton.adapter.outbound.fileserver;
|
||||
|
||||
import static java.nio.charset.StandardCharsets.UTF_8;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.application.filepublication.ExportSchema;
|
||||
import dev.caskeleton.application.filepublication.FileDestinationId;
|
||||
import dev.caskeleton.application.filepublication.FilePublicationException;
|
||||
import dev.caskeleton.application.filepublication.FilePublishOperationId;
|
||||
import dev.caskeleton.application.filepublication.FilePublishReceipt;
|
||||
import dev.caskeleton.application.filepublication.FilePublishRequest;
|
||||
import dev.caskeleton.application.filepublication.LogicalFileName;
|
||||
import dev.caskeleton.application.filepublication.SourceRevision;
|
||||
import dev.caskeleton.application.filepublication.TabularCell.IntegerCell;
|
||||
import dev.caskeleton.application.filepublication.TabularCell.TextCell;
|
||||
import dev.caskeleton.application.filepublication.TabularRow;
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.stream.Stream;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class LocalFilePublicationAdapterTest {
|
||||
|
||||
private static final FileDestinationId DESTINATION = new FileDestinationId("local-export");
|
||||
|
||||
@TempDir Path tempDir;
|
||||
|
||||
private LocalFilePublicationAdapter adapter;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
adapter =
|
||||
new LocalFilePublicationAdapter(
|
||||
new LocalFilePublicationPolicy(DESTINATION, tempDir, 10, 1024));
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishesRowsThroughTheSinkAndReturnsAnOpaqueReceipt() throws IOException {
|
||||
AtomicInteger producerCalls = new AtomicInteger();
|
||||
|
||||
FilePublishReceipt receipt =
|
||||
adapter.publish(
|
||||
request(),
|
||||
sink -> {
|
||||
producerCalls.incrementAndGet();
|
||||
sink.write(new TabularRow(List.of(new IntegerCell(1), new TextCell("=cmd"))));
|
||||
});
|
||||
|
||||
assertThat(producerCalls).hasValue(1);
|
||||
assertThat(receipt.reference().value()).doesNotContain(tempDir.toString());
|
||||
assertThat(receipt.sha256()).hasSize(64);
|
||||
assertThat(receipt.dataRowCount()).isEqualTo(1);
|
||||
assertThat(receipt.formulaMitigatedCount()).isEqualTo(1);
|
||||
|
||||
Path published = onlyPublishedCsv();
|
||||
assertThat(Files.readString(published, UTF_8)).isEqualTo("id,note\n1,'=cmd\n");
|
||||
}
|
||||
|
||||
@Test
|
||||
void removesStagingAndLeavesNoFinalArtifactWhenByteLimitIsExceeded() throws IOException {
|
||||
LocalFilePublicationAdapter tinyAdapter =
|
||||
new LocalFilePublicationAdapter(
|
||||
new LocalFilePublicationPolicy(DESTINATION, tempDir, 10, 16));
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
tinyAdapter.publish(
|
||||
request(),
|
||||
sink ->
|
||||
sink.write(
|
||||
new TabularRow(
|
||||
List.of(new IntegerCell(1), new TextCell("x".repeat(100)))))))
|
||||
.isInstanceOf(FilePublicationException.class)
|
||||
.hasMessageContaining("byte");
|
||||
|
||||
assertThat(publishedCsvFiles()).isEmpty();
|
||||
try (Stream<Path> staging = Files.list(tempDir.resolve(".staging"))) {
|
||||
assertThat(staging).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsWrongCellTypeAndDeletesTheStagingFile() throws IOException {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
adapter.publish(
|
||||
request(),
|
||||
sink ->
|
||||
sink.write(
|
||||
new TabularRow(
|
||||
List.of(new TextCell("not-an-id"), new TextCell("ok"))))))
|
||||
.isInstanceOf(FilePublicationException.class)
|
||||
.hasMessageContaining("cell type");
|
||||
|
||||
assertThat(publishedCsvFiles()).isEmpty();
|
||||
try (Stream<Path> staging = Files.list(tempDir.resolve(".staging"))) {
|
||||
assertThat(staging).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void propagatesProducerFailureAndDeletesStaging() throws IOException {
|
||||
IllegalStateException sourceFailure = new IllegalStateException("source unavailable");
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
adapter.publish(
|
||||
request(),
|
||||
sink -> {
|
||||
throw sourceFailure;
|
||||
}))
|
||||
.isSameAs(sourceFailure);
|
||||
|
||||
assertThat(publishedCsvFiles()).isEmpty();
|
||||
try (Stream<Path> staging = Files.list(tempDir.resolve(".staging"))) {
|
||||
assertThat(staging).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void targetConflictAfterSealingPreservesTheOnlyRecoveryArtifact() throws Exception {
|
||||
FilePublishRequest request = request();
|
||||
String token =
|
||||
HexFormat.of()
|
||||
.formatHex(
|
||||
MessageDigest.getInstance("SHA-256")
|
||||
.digest(request.operationId().value().getBytes(UTF_8)))
|
||||
.substring(0, 24);
|
||||
Path target = tempDir.resolve(request.logicalFileName().value() + "--" + token + ".csv");
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
adapter.publish(
|
||||
request,
|
||||
sink -> {
|
||||
sink.write(new TabularRow(List.of(new IntegerCell(1), new TextCell("ok"))));
|
||||
try {
|
||||
Files.writeString(target, "collision", UTF_8);
|
||||
} catch (IOException exception) {
|
||||
throw new UncheckedIOException(exception);
|
||||
}
|
||||
}))
|
||||
.isInstanceOfSatisfying(
|
||||
FilePublicationException.class,
|
||||
exception ->
|
||||
assertThat(exception.reason()).isEqualTo(FilePublicationException.Reason.CONFLICT));
|
||||
|
||||
assertThat(Files.readString(target, UTF_8)).isEqualTo("collision");
|
||||
try (Stream<Path> staging = Files.list(tempDir.resolve(".staging"))) {
|
||||
assertThat(staging.filter(Files::isRegularFile)).hasSize(1);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void unavailableAtomicPrimitiveFailsClosedAndPreservesTheSealedStage() throws Exception {
|
||||
LocalFilePublicationPolicy failClosedPolicy =
|
||||
new LocalFilePublicationPolicy(DESTINATION, tempDir, 10, 1024);
|
||||
LocalFilePublicationAdapter failingAdapter =
|
||||
new LocalFilePublicationAdapter(
|
||||
failClosedPolicy,
|
||||
(target, staging) -> {
|
||||
throw new IOException("hard links unavailable");
|
||||
});
|
||||
FilePublishRequest request = request();
|
||||
String token =
|
||||
HexFormat.of()
|
||||
.formatHex(
|
||||
MessageDigest.getInstance("SHA-256")
|
||||
.digest(request.operationId().value().getBytes(UTF_8)))
|
||||
.substring(0, 24);
|
||||
Path target = tempDir.resolve(request.logicalFileName().value() + "--" + token + ".csv");
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
failingAdapter.publish(
|
||||
request,
|
||||
sink ->
|
||||
sink.write(
|
||||
new TabularRow(List.of(new IntegerCell(1), new TextCell("ok"))))))
|
||||
.isInstanceOfSatisfying(
|
||||
FilePublicationException.class,
|
||||
exception ->
|
||||
assertThat(exception.reason())
|
||||
.isEqualTo(FilePublicationException.Reason.UNAVAILABLE));
|
||||
assertThat(target).doesNotExist();
|
||||
try (Stream<Path> staging = Files.list(tempDir.resolve(".staging"))) {
|
||||
assertThat(staging.filter(Files::isRegularFile)).hasSize(1);
|
||||
}
|
||||
}
|
||||
|
||||
private FilePublishRequest request() {
|
||||
return new FilePublishRequest(
|
||||
new FilePublishOperationId("01J1234567890ABCDEFGHJKMNP"),
|
||||
DESTINATION,
|
||||
new LogicalFileName("worklogs"),
|
||||
new SourceRevision("snapshot-42"),
|
||||
new ExportSchema(
|
||||
"worklog-v1",
|
||||
1,
|
||||
List.of(
|
||||
new ExportSchema.Column(
|
||||
"id",
|
||||
ExportSchema.CellType.INTEGER,
|
||||
false,
|
||||
ExportSchema.FormulaPolicy.REJECT,
|
||||
64),
|
||||
new ExportSchema.Column(
|
||||
"note",
|
||||
ExportSchema.CellType.TEXT,
|
||||
false,
|
||||
ExportSchema.FormulaPolicy.MITIGATE,
|
||||
128))),
|
||||
"csv-rfc4180-v1");
|
||||
}
|
||||
|
||||
private Path onlyPublishedCsv() throws IOException {
|
||||
assertThat(publishedCsvFiles()).hasSize(1);
|
||||
return publishedCsvFiles().getFirst();
|
||||
}
|
||||
|
||||
private List<Path> publishedCsvFiles() throws IOException {
|
||||
try (Stream<Path> files = Files.list(tempDir)) {
|
||||
return files.filter(path -> path.getFileName().toString().endsWith(".csv")).toList();
|
||||
}
|
||||
}
|
||||
}
|
||||
+280
@@ -0,0 +1,280 @@
|
||||
package dev.caskeleton.adapter.outbound.fileserver;
|
||||
|
||||
import static java.nio.charset.StandardCharsets.UTF_8;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.application.filepublication.ExportSchema;
|
||||
import dev.caskeleton.application.filepublication.ExportSchema.CellType;
|
||||
import dev.caskeleton.application.filepublication.ExportSchema.Column;
|
||||
import dev.caskeleton.application.filepublication.ExportSchema.FormulaPolicy;
|
||||
import dev.caskeleton.application.filepublication.FileDestinationId;
|
||||
import dev.caskeleton.application.filepublication.FilePublicationException;
|
||||
import dev.caskeleton.application.filepublication.FilePublishOperationId;
|
||||
import dev.caskeleton.application.filepublication.FilePublishReceipt;
|
||||
import dev.caskeleton.application.filepublication.FilePublishRequest;
|
||||
import dev.caskeleton.application.filepublication.LogicalFileName;
|
||||
import dev.caskeleton.application.filepublication.SourceRevision;
|
||||
import dev.caskeleton.application.filepublication.TabularCell.IntegerCell;
|
||||
import dev.caskeleton.application.filepublication.TabularCell.TextCell;
|
||||
import dev.caskeleton.application.filepublication.TabularRow;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class LocalFilePublicationRecoveryTest {
|
||||
|
||||
private static final FileDestinationId DESTINATION = new FileDestinationId("local-export");
|
||||
|
||||
@TempDir Path tempDir;
|
||||
|
||||
private LocalFilePublicationPolicy policy;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
policy = new LocalFilePublicationPolicy(DESTINATION, tempDir, 10, 1024);
|
||||
}
|
||||
|
||||
@Test
|
||||
void completedOperationIsRestoredAfterRestartWithoutCallingProducer() {
|
||||
LocalFilePublicationAdapter first = new LocalFilePublicationAdapter(policy);
|
||||
FilePublishReceipt original = first.publish(request("source-1"), this::produceOneRow);
|
||||
AtomicInteger producerCalls = new AtomicInteger();
|
||||
|
||||
FilePublishReceipt restored =
|
||||
new LocalFilePublicationAdapter(policy)
|
||||
.publish(
|
||||
request("source-1"),
|
||||
sink -> {
|
||||
producerCalls.incrementAndGet();
|
||||
throw new AssertionError("terminal retry must not invoke producer");
|
||||
});
|
||||
|
||||
assertThat(producerCalls).hasValue(0);
|
||||
assertThat(restored).isEqualTo(original);
|
||||
}
|
||||
|
||||
@Test
|
||||
void reusedOperationIdWithDifferentIntentIsAConflict() {
|
||||
new LocalFilePublicationAdapter(policy).publish(request("source-1"), this::produceOneRow);
|
||||
AtomicInteger producerCalls = new AtomicInteger();
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new LocalFilePublicationAdapter(policy)
|
||||
.publish(request("source-2"), sink -> producerCalls.incrementAndGet()))
|
||||
.isInstanceOfSatisfying(
|
||||
FilePublicationException.class,
|
||||
exception ->
|
||||
assertThat(exception.reason()).isEqualTo(FilePublicationException.Reason.CONFLICT));
|
||||
assertThat(producerCalls).hasValue(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void terminalRecordWithMutatedArtifactIsIndeterminate() throws Exception {
|
||||
FilePublishReceipt receipt =
|
||||
new LocalFilePublicationAdapter(policy).publish(request("source-1"), this::produceOneRow);
|
||||
Files.writeString(tempDir.resolve(receipt.publishedFileName()), "mutated", UTF_8);
|
||||
AtomicInteger producerCalls = new AtomicInteger();
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new LocalFilePublicationAdapter(policy)
|
||||
.publish(request("source-1"), sink -> producerCalls.incrementAndGet()))
|
||||
.isInstanceOfSatisfying(
|
||||
FilePublicationException.class,
|
||||
exception ->
|
||||
assertThat(exception.reason())
|
||||
.isEqualTo(FilePublicationException.Reason.PUBLISH_INDETERMINATE));
|
||||
assertThat(producerCalls).hasValue(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void corruptOperationJournalIsMappedToTheProviderNeutralIndeterminateFailure() throws Exception {
|
||||
FilePublishRequest request = request("source-1");
|
||||
new LocalFilePublicationAdapter(policy).publish(request, this::produceOneRow);
|
||||
LocalPublicationJournal journal = new LocalPublicationJournal(tempDir);
|
||||
Files.writeString(
|
||||
journal.recordPath(request.operationId().value()), "{\"corrupt\":true}", UTF_8);
|
||||
|
||||
assertThatThrownBy(
|
||||
() -> new LocalFilePublicationAdapter(policy).publish(request, this::produceOneRow))
|
||||
.isInstanceOfSatisfying(
|
||||
FilePublicationException.class,
|
||||
exception ->
|
||||
assertThat(exception.reason())
|
||||
.isEqualTo(FilePublicationException.Reason.PUBLISH_INDETERMINATE));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sealedStagingIsPublishedAfterRestartWithoutCallingProducer() throws Exception {
|
||||
FilePublishRequest request = request("source-1");
|
||||
String operationToken = token(request.operationId().value());
|
||||
String stageName = "." + operationToken + ".part";
|
||||
String publishedName = request.logicalFileName().value() + "--" + operationToken + ".csv";
|
||||
byte[] payload = "id,note\n1,ok\n".getBytes(UTF_8);
|
||||
String digest = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(payload));
|
||||
Path stagingDirectory = tempDir.resolve(".staging");
|
||||
Files.createDirectories(stagingDirectory);
|
||||
Files.write(stagingDirectory.resolve(stageName), payload);
|
||||
LocalPublicationJournal journal = new LocalPublicationJournal(tempDir);
|
||||
journal.store(
|
||||
LocalPublicationJournalRecord.sealed(
|
||||
request.operationId().value(),
|
||||
FilePublishRequestFingerprint.calculate(request),
|
||||
publishedName,
|
||||
stageName,
|
||||
payload.length,
|
||||
1,
|
||||
2,
|
||||
digest,
|
||||
0));
|
||||
AtomicInteger producerCalls = new AtomicInteger();
|
||||
|
||||
FilePublishReceipt receipt =
|
||||
new LocalFilePublicationAdapter(policy)
|
||||
.publish(request, sink -> producerCalls.incrementAndGet());
|
||||
|
||||
assertThat(producerCalls).hasValue(0);
|
||||
assertThat(Files.readAllBytes(tempDir.resolve(publishedName))).isEqualTo(payload);
|
||||
assertThat(receipt.sha256()).isEqualTo(digest);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sealedTargetRecoveryReconstructsTheHardLinkCommitWithoutCallingProducer() throws Exception {
|
||||
FilePublishRequest request = request("source-1");
|
||||
String operationToken = token(request.operationId().value());
|
||||
String stageName = "." + operationToken + ".part";
|
||||
String publishedName = request.logicalFileName().value() + "--" + operationToken + ".csv";
|
||||
byte[] payload = "id,note\n1,ok\n".getBytes(UTF_8);
|
||||
String digest = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(payload));
|
||||
Files.write(tempDir.resolve(publishedName), payload);
|
||||
new LocalPublicationJournal(tempDir)
|
||||
.store(
|
||||
LocalPublicationJournalRecord.sealed(
|
||||
request.operationId().value(),
|
||||
FilePublishRequestFingerprint.calculate(request),
|
||||
publishedName,
|
||||
stageName,
|
||||
payload.length,
|
||||
1,
|
||||
2,
|
||||
digest,
|
||||
0));
|
||||
|
||||
FilePublishReceipt receipt =
|
||||
new LocalFilePublicationAdapter(policy)
|
||||
.publish(
|
||||
request,
|
||||
sink -> {
|
||||
throw new AssertionError("sealed target recovery must not invoke producer");
|
||||
});
|
||||
|
||||
assertThat(receipt.publicationGuarantee())
|
||||
.isEqualTo(FilePublishReceipt.PublicationGuarantee.UNIQUE_ATOMIC_CREATE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void concurrentAdaptersSerializeOneOperationAndInvokeTheProducerOnce() throws Exception {
|
||||
LocalFilePublicationAdapter firstAdapter = new LocalFilePublicationAdapter(policy);
|
||||
LocalFilePublicationAdapter secondAdapter = new LocalFilePublicationAdapter(policy);
|
||||
CountDownLatch firstProducerStarted = new CountDownLatch(1);
|
||||
CountDownLatch releaseFirstProducer = new CountDownLatch(1);
|
||||
CountDownLatch secondCallStarted = new CountDownLatch(1);
|
||||
CountDownLatch secondCallFinished = new CountDownLatch(1);
|
||||
AtomicInteger producerCalls = new AtomicInteger();
|
||||
AtomicReference<FilePublishReceipt> firstReceipt = new AtomicReference<>();
|
||||
AtomicReference<FilePublishReceipt> secondReceipt = new AtomicReference<>();
|
||||
AtomicReference<Throwable> failure = new AtomicReference<>();
|
||||
Thread first =
|
||||
Thread.ofPlatform()
|
||||
.start(
|
||||
() -> {
|
||||
try {
|
||||
firstReceipt.set(
|
||||
firstAdapter.publish(
|
||||
request("source-1"),
|
||||
sink -> {
|
||||
producerCalls.incrementAndGet();
|
||||
firstProducerStarted.countDown();
|
||||
try {
|
||||
releaseFirstProducer.await();
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException(exception);
|
||||
}
|
||||
produceOneRow(sink);
|
||||
}));
|
||||
} catch (Throwable throwable) {
|
||||
failure.compareAndSet(null, throwable);
|
||||
}
|
||||
});
|
||||
|
||||
assertThat(firstProducerStarted.await(1, TimeUnit.SECONDS)).isTrue();
|
||||
Thread second =
|
||||
Thread.ofPlatform()
|
||||
.start(
|
||||
() -> {
|
||||
secondCallStarted.countDown();
|
||||
try {
|
||||
secondReceipt.set(
|
||||
secondAdapter.publish(
|
||||
request("source-1"),
|
||||
sink -> {
|
||||
producerCalls.incrementAndGet();
|
||||
produceOneRow(sink);
|
||||
}));
|
||||
} catch (Throwable throwable) {
|
||||
failure.compareAndSet(null, throwable);
|
||||
} finally {
|
||||
secondCallFinished.countDown();
|
||||
}
|
||||
});
|
||||
assertThat(secondCallStarted.await(1, TimeUnit.SECONDS)).isTrue();
|
||||
assertThat(secondCallFinished.await(100, TimeUnit.MILLISECONDS)).isFalse();
|
||||
|
||||
releaseFirstProducer.countDown();
|
||||
first.join(2_000);
|
||||
second.join(2_000);
|
||||
|
||||
assertThat(first.isAlive()).isFalse();
|
||||
assertThat(second.isAlive()).isFalse();
|
||||
assertThat(failure.get()).isNull();
|
||||
assertThat(producerCalls).hasValue(1);
|
||||
assertThat(secondReceipt.get()).isEqualTo(firstReceipt.get());
|
||||
}
|
||||
|
||||
private void produceOneRow(dev.caskeleton.application.filepublication.TabularRowSink sink) {
|
||||
sink.write(new TabularRow(List.of(new IntegerCell(1), new TextCell("ok"))));
|
||||
}
|
||||
|
||||
private static FilePublishRequest request(String sourceRevision) {
|
||||
return new FilePublishRequest(
|
||||
new FilePublishOperationId("operation-1"),
|
||||
DESTINATION,
|
||||
new LogicalFileName("report"),
|
||||
new SourceRevision(sourceRevision),
|
||||
new ExportSchema(
|
||||
"work-log",
|
||||
1,
|
||||
List.of(
|
||||
new Column("id", CellType.INTEGER, false, FormulaPolicy.REJECT, 32),
|
||||
new Column("note", CellType.TEXT, false, FormulaPolicy.MITIGATE, 256))),
|
||||
"csv-rfc4180-v1");
|
||||
}
|
||||
|
||||
private static String token(String operationId) throws Exception {
|
||||
return HexFormat.of()
|
||||
.formatHex(MessageDigest.getInstance("SHA-256").digest(operationId.getBytes(UTF_8)))
|
||||
.substring(0, 24);
|
||||
}
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
package dev.caskeleton.adapter.outbound.fileserver;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.application.filepublication.ExportSchema;
|
||||
import dev.caskeleton.application.filepublication.ExportSchema.CellType;
|
||||
import dev.caskeleton.application.filepublication.ExportSchema.Column;
|
||||
import dev.caskeleton.application.filepublication.ExportSchema.FormulaPolicy;
|
||||
import dev.caskeleton.application.filepublication.FileDestinationId;
|
||||
import dev.caskeleton.application.filepublication.FilePublishOperationId;
|
||||
import dev.caskeleton.application.filepublication.FilePublishRequest;
|
||||
import dev.caskeleton.application.filepublication.LogicalFileName;
|
||||
import dev.caskeleton.application.filepublication.SourceRevision;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class LocalPublicationJournalTest {
|
||||
|
||||
@TempDir Path tempDir;
|
||||
|
||||
@Test
|
||||
void requestFingerprintIsStableAndCoversSourceAndSchema() {
|
||||
String first = FilePublishRequestFingerprint.calculate(request("source-1", 1));
|
||||
String same = FilePublishRequestFingerprint.calculate(request("source-1", 1));
|
||||
String differentSource = FilePublishRequestFingerprint.calculate(request("source-2", 1));
|
||||
String differentSchema = FilePublishRequestFingerprint.calculate(request("source-1", 2));
|
||||
|
||||
assertThat(first).matches("[0-9a-f]{64}").isEqualTo(same);
|
||||
assertThat(differentSource).isNotEqualTo(first);
|
||||
assertThat(differentSchema).isNotEqualTo(first);
|
||||
}
|
||||
|
||||
@Test
|
||||
void journalRoundTripsASealedRecordAndRejectsCorruption() throws Exception {
|
||||
LocalPublicationJournal journal = new LocalPublicationJournal(tempDir);
|
||||
LocalPublicationJournalRecord sealed =
|
||||
LocalPublicationJournalRecord.sealed(
|
||||
"operation-1",
|
||||
"1".repeat(64),
|
||||
"report--token.csv",
|
||||
".token.part",
|
||||
42,
|
||||
3,
|
||||
2,
|
||||
"a".repeat(64),
|
||||
1);
|
||||
|
||||
journal.store(sealed);
|
||||
|
||||
assertThat(journal.find("operation-1")).contains(sealed);
|
||||
|
||||
Path record = journal.recordPath("operation-1");
|
||||
Files.writeString(
|
||||
record, Files.readString(record).replace("\"byteSize\":42", "\"byteSize\":x"));
|
||||
|
||||
assertThatThrownBy(() -> journal.find("operation-1"))
|
||||
.isInstanceOf(LocalPublicationJournalException.class)
|
||||
.hasMessageContaining("corrupt");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsSymlinkedControlDirectoryWithoutWritingOutsideTheBase() throws Exception {
|
||||
Path base = Files.createDirectory(tempDir.resolve("base"));
|
||||
Path outside = Files.createDirectory(tempDir.resolve("outside"));
|
||||
Files.createSymbolicLink(base.resolve(".ca-fileserver"), outside);
|
||||
|
||||
assertThatThrownBy(() -> new LocalPublicationJournal(base))
|
||||
.isInstanceOf(LocalPublicationJournalException.class);
|
||||
assertThat(outside.resolve("operations")).doesNotExist();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsSymlinkedJournalShardWithoutWritingOutsideTheControlDirectory() throws Exception {
|
||||
LocalPublicationJournal journal = new LocalPublicationJournal(tempDir);
|
||||
Path outside = Files.createDirectory(tempDir.resolve("outside"));
|
||||
Path shard = journal.recordPath("operation-1").getParent();
|
||||
Files.createSymbolicLink(shard, outside);
|
||||
LocalPublicationJournalRecord sealed =
|
||||
LocalPublicationJournalRecord.sealed(
|
||||
"operation-1",
|
||||
"1".repeat(64),
|
||||
"report--token.csv",
|
||||
".token.part",
|
||||
42,
|
||||
3,
|
||||
2,
|
||||
"a".repeat(64),
|
||||
1);
|
||||
|
||||
assertThatThrownBy(() -> journal.store(sealed))
|
||||
.isInstanceOf(LocalPublicationJournalException.class);
|
||||
assertThat(outside).isEmptyDirectory();
|
||||
}
|
||||
|
||||
private static FilePublishRequest request(String sourceRevision, int schemaVersion) {
|
||||
return new FilePublishRequest(
|
||||
new FilePublishOperationId("operation-1"),
|
||||
new FileDestinationId("local-export"),
|
||||
new LogicalFileName("report"),
|
||||
new SourceRevision(sourceRevision),
|
||||
new ExportSchema(
|
||||
"work-log",
|
||||
schemaVersion,
|
||||
List.of(
|
||||
new Column("id", CellType.INTEGER, false, FormulaPolicy.REJECT, 32),
|
||||
new Column("note", CellType.TEXT, false, FormulaPolicy.MITIGATE, 256))),
|
||||
"csv-rfc4180-v1");
|
||||
}
|
||||
}
|
||||
@@ -4,27 +4,38 @@
|
||||
|
||||
- Module ID: `adapter-outbound-httpclient`
|
||||
- Gradle path: `:adapter:outbound:httpclient`
|
||||
- Focused test: `./gradlew :adapter:outbound:httpclient:test --console=plain`
|
||||
- Focused test (derived from Gradle path): `./gradlew :adapter:outbound:httpclient:test --console=plain`
|
||||
- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0.
|
||||
- Registry SSOT: `.harness/project/modules.yaml`.
|
||||
- Registry SSOT: `src/config/architecture/modules.json`.
|
||||
|
||||
Package root: `dev.caskeleton.adapter.outbound.httpclient`.
|
||||
|
||||
## Responsibility
|
||||
|
||||
- Own outbound REST client construction, timeouts, retries, circuit breakers, response-size bounds,
|
||||
trace propagation, diagnostics, and shutdown safety.
|
||||
- Own typed destination/operation catalogs, safe target construction, outbound engine construction,
|
||||
deadlines/cancellation, resilience, request/response bounds, egress security, diagnostics, and
|
||||
lifecycle.
|
||||
- Adapt external HTTP calls behind application/domain ports.
|
||||
- Reuse `adapter:outbound:support` for shared outbound concerns.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Allowed dependency edges come only from `.harness/project/modules.yaml`.
|
||||
- Allowed dependency edges come only from the module's
|
||||
`src/config/architecture/modules.json` entry.
|
||||
- No inbound controller/DTO, persistence, bootstrap, or sample dependency.
|
||||
- Retry and circuit-breaker code is technical resilience; business compensation and use-case
|
||||
sequencing stay in application/domain layers.
|
||||
- Application/domain code must not import this module's generic HTTP client, operation descriptor,
|
||||
URI, Spring HTTP, JDK/Apache client, retry, or wire DTO types.
|
||||
- Normal calls use registered fixed destinations and relative operation routes; arbitrary absolute
|
||||
URL/header/credential APIs are forbidden.
|
||||
- The legacy JDK facade, connect/read timeout, and response-size interceptor are not evidence of an
|
||||
Apache pool bound, egress security, wire hard-cancellation, or R2 readiness. Its active monotonic
|
||||
logical-call deadline is R1 evidence only.
|
||||
- Streaming must validate status before body delivery and remains bounded by a selected readiness
|
||||
card before production use.
|
||||
|
||||
## Tests
|
||||
|
||||
Use fake clients/servers or direct collaborator fakes with no real network. Settings receive
|
||||
binding/validation tests; retry/error mapping and resource bounds receive focused unit tests.
|
||||
Focused tests may use loopback servers and collaborator fakes. R2 promotion requires explicit
|
||||
real-network/TLS/pool/cancellation/security lanes and no selected lane may silently skip.
|
||||
|
||||
@@ -4,14 +4,38 @@
|
||||
`dev.caskeleton.adapter.outbound.httpclient`(`resilience`, `diagnostics` 서브패키지 포함).
|
||||
`:adapter:outbound:support` 에 의존해 공유 correlation / fail-open 의존성 로깅을 재사용한다.
|
||||
|
||||
허용/금지 의존 정책은 `src/build.gradle` 의
|
||||
`allowedProjectDependencies['adapter:outbound:httpclient']` 항목이 SSOT 다(이 모듈은 아직 별도
|
||||
CLAUDE.md 를 두지 않았다). 이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를 모아둔 참조용
|
||||
기록이다.
|
||||
허용/금지 의존 정책은 `src/config/architecture/modules.json`의
|
||||
`adapter-outbound-httpclient` 항목이 SSOT다. 작업 규칙은 `CLAUDE.md`, 상세 목표와 잔여 단계는
|
||||
`docs/superpowers/specs/2026-07-27-httpclient-production-capability-design.md`에 있다.
|
||||
|
||||
## 현재 readiness
|
||||
|
||||
현재 구현은 typed operation/target foundation과 migration용 JDK client를 제공하지만 R2가 아니다.
|
||||
caller/configured `CallBudget` 교집합을 실제 logical call과 retry backoff에 적용하고 timeout 시
|
||||
virtual-thread task를 interrupt하는 active logical deadline은 구현됐다. Apache HC5 pool, pool
|
||||
acquisition bound, wire hard-cancellation evidence, canonical zero-binding composition, DNS/SSRF,
|
||||
TLS/auth/proxy, decoded-body bound와 real-network qualification은 아직 없다.
|
||||
|
||||
`application-core`에는 HTTP 타입이 없는 monotonic `CallBudget`만 추가되며 JDK facade의
|
||||
`get`/`exchange`/`stream` overload가 이를 소비한다. 실제 product의
|
||||
`FraudScreeningPort`, `PartnerCatalogPort` 같은 feature-specific port는 해당 product가 소유한다.
|
||||
template이 demo business port를 production core에 추가하지 않는다.
|
||||
|
||||
## Typed operation과 target foundation
|
||||
|
||||
`HttpOperationDescriptor`는 destination/operation ID, policy revision, method, relative route,
|
||||
operation semantics, request/response mode, success status, ordinary retry, 전체 physical attempt와
|
||||
response byte 상한을 immutable하게 고정한다. `HttpOperationCatalog`는 startup-time closed
|
||||
catalog이며 runtime registration API가 없다.
|
||||
|
||||
`FixedHttpDestination`과 `HttpTargetBuilder`는 fixed http(s) authority와 registered relative
|
||||
route만 결합한다. user-info/query/fragment가 있는 base URI, absolute/scheme-relative request
|
||||
target, dot traversal, slash를 포함한 path variable, 사전 percent-encoding은 거부한다.
|
||||
|
||||
## OutboundHttpClient
|
||||
|
||||
단일 명명 의존성(named upstream dependency)용 베이스라인 HTTP 클라이언트.
|
||||
단일 명명 의존성(named upstream dependency)용 migration HTTP 클라이언트다. 새 application
|
||||
use case가 이 기술 타입을 직접 주입하는 것은 금지한다.
|
||||
|
||||
### static `baseline(...)` 팩토리인 이유
|
||||
`public final class` + `private` 생성자 + `public static baseline(...)` 형태다. ArchUnit B7 은
|
||||
@@ -31,9 +55,9 @@ static 팩토리를 쓴다.
|
||||
`InputStream` 을 그대로 전달한다.
|
||||
|
||||
### retry 를 CB **바깥**에 두는 이유
|
||||
`exchange()` 의 decoration 순서는 CB(outer) → retry(inner)다. retry 를 CB 바깥에 둬야 각 retry
|
||||
시도가 CB 슬라이딩 윈도에 **독립적으로** 카운트된다. retry 를 CB 안에 두면 모든 재시도가 CB 호출
|
||||
1건으로 합산돼 실제 실패 빈도가 CB 에 가려진다.
|
||||
`exchange()`의 현재 decoration은 retry가 논리 호출을 감싸고, circuit breaker가 각 physical
|
||||
attempt를 감싼다. 따라서 세 번의 wire attempt는 CB 실패 세 건으로 집계된다. 과거 구현은 CB가
|
||||
retry 전체를 감싸 실패 한 건으로만 기록했으며 회귀 테스트로 수정되었다.
|
||||
|
||||
### size 위반은 분류하지 않고 전파
|
||||
`OutboundResponseSizeExceededException` 은 의도적으로 `DependencyFailureException` 이
|
||||
@@ -44,15 +68,45 @@ static 팩토리를 쓴다.
|
||||
### streaming 경로에 retry 없음
|
||||
이미 소비된 스트림은 안전하게 재발행할 수 없다 — reader 에 이미 전달된 바이트는 잃고, 서버가
|
||||
처음부터 재전송을 보장하지 않는다. 그래서 `stream()` 은 retry 없이 shutdown 게이팅·분류·로깅만
|
||||
적용한다.
|
||||
적용한다. 2xx status를 먼저 확인하고, 4xx/5xx이면 error body를 callback에 넘기지 않고 닫은 뒤
|
||||
분류한다. 성공 streaming은 아직 decoded-byte/idle/deadline bound가 없으므로 R2 streaming이 아니다.
|
||||
|
||||
### legacy request-target 방어
|
||||
|
||||
`exchange()`와 `stream()`은 `/`로 시작하는 relative request target만 허용한다. absolute URI,
|
||||
scheme-relative authority, fragment, dot traversal과 ambiguous encoded slash/dot을 호출 전에
|
||||
거부한다. JDK engine redirect는 `NEVER`로 명시한다. 이 방어는 fixed DNS/address admission이나
|
||||
redirect readiness card를 대체하지 않는다.
|
||||
|
||||
### active logical deadline
|
||||
|
||||
기본 overload는 `globalCallTimeout`으로 budget을 만들고, caller budget overload는 둘의 더 짧은
|
||||
absolute monotonic deadline을 사용한다. blocking RestClient 호출과 Resilience4j retry/backoff는
|
||||
MDC를 복사한 virtual thread 안에서 실행된다. caller는 같은 deadline까지만 기다리고 timeout이면
|
||||
task를 interrupt하며 `DEPENDENCY_TIMEOUT`으로 분류한다. retry ThreadLocal도 worker 안에서
|
||||
설정·정리되고 worker 진입 및 각 physical supplier 직전에 남은 budget을 다시 확인한다. Caller
|
||||
thread interrupt는 interrupt flag를 복구한 `CancellationException`으로 보존하며 dependency
|
||||
장애로 기록하지 않는다.
|
||||
|
||||
이는 JDK provider가 interrupt에 반응하는 범위의 R1 cancellation이다. DNS/TLS/write/body 각
|
||||
단계의 wire handle 종료, connection quarantine와 no-leak을 증명하지 않으므로 R2 hard
|
||||
cancellation 증거가 아니다. Interrupt를 무시하는 provider/callback은 caller 반환 뒤에도 virtual
|
||||
thread에서 남을 수 있다. 그래서 client별 live worker를
|
||||
`maximum-in-flight-calls`(기본 128)로 제한하고, timeout 뒤에도 실제 worker가 종료할 때까지
|
||||
admission slot을 반환하지 않는다. 상한이 차면 새 worker를 만들지 않고 즉시 거부한다.
|
||||
|
||||
shutdown guard는 등록된 client executor의 active `FutureTask`를 모두 cancel하고 새 admission을
|
||||
닫는다. 다만 interrupt를 무시하는 wire/callback을 강제 종료하거나 모든 cleanup 완료까지 기다리는
|
||||
drain/reaper는 아니므로, 이것만으로 R2 hard-cancellation/lifecycle evidence가 되지는 않는다.
|
||||
|
||||
### OutboundHttpShutdownGuard — SmartLifecycle 인 이유
|
||||
`SmartLifecycle` + `getPhase() = Integer.MAX_VALUE`(가장 먼저 stop)로 종료 시 아웃바운드
|
||||
호출자보다 먼저 멈춘다. `ContextClosedEvent` 를 쓰지 않는 이유: SmartLifecycle phase 순서는
|
||||
결정적이고 close 시퀀스가 빈을 파괴하기 전에 동작하지만, `ContextClosedEvent` 는 컨텍스트 종료가
|
||||
시작된 뒤 발생하고 다른 lifecycle 빈과의 순서가 정의되지 않는다. 종료 중에는
|
||||
`DEPENDENCY_CIRCUIT_OPEN`/REJECTED 로 fail-fast 한다(전용 shutdown 코드를 새로 만들지 않고
|
||||
가장 가까운 버킷을 재사용).
|
||||
새 호출을 `DEPENDENCY_CIRCUIT_OPEN`/REJECTED 로 fail-fast하고, pre-check와 worker start 사이
|
||||
경합은 executor registry가 cancellation으로 닫는다(전용 shutdown 코드를 새로 만들지 않고 가장
|
||||
가까운 버킷을 재사용).
|
||||
|
||||
### OutboundHttpTimeoutEnforcer — `static @Bean` BeanPostProcessor
|
||||
raw `RestClient`/`RestClient.Builder` 빈이 등록되면 startup 을 실패시키는 BeanPostProcessor 다.
|
||||
@@ -97,7 +151,8 @@ retry/CB 머신을 담는 홀더. `retryFor()` / `circuitBreakerFor()` 는 해
|
||||
비활성일 때 `null` 을 넘긴다.
|
||||
|
||||
### OutboundRetryPolicy — POST/PATCH 는 항상 non-retryable
|
||||
재시도 조건은 ThreadLocal 호출 컨텍스트 기반의 4가지로, 멱등(idempotent) 메서드만 재시도한다.
|
||||
재시도 조건은 monotonic `CallBudget`을 보유한 ThreadLocal 호출 컨텍스트 기반의 4가지로,
|
||||
멱등(idempotent) 메서드만 재시도한다.
|
||||
POST/PATCH 는 Idempotency-Key 계약이 정의되지 않았으므로 보수적으로 항상 재시도하지 않는다.
|
||||
exponential random backoff(jitter)는 settings 로 구동된다.
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
plugins { id 'groovy' }
|
||||
dependencies {
|
||||
implementation project(':domain-core')
|
||||
implementation project(':application-core')
|
||||
implementation project(':shared-contract')
|
||||
implementation project(':adapter:outbound:support')
|
||||
@@ -12,6 +11,7 @@ dependencies {
|
||||
implementation 'io.github.resilience4j:resilience4j-circuitbreaker:2.2.0'
|
||||
implementation 'io.github.resilience4j:resilience4j-micrometer:2.2.0'
|
||||
implementation 'org.slf4j:slf4j-api'
|
||||
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
|
||||
|
||||
testImplementation 'org.spockframework:spock-core:2.4-groovy-5.0'
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
# Manual edits can break the build and are not advised.
|
||||
# This file is expected to be part of source control.
|
||||
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=testCompileClasspath
|
||||
ch.qos.logback:logback-classic:1.5.21=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
ch.qos.logback:logback-core:1.5.21=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
ch.qos.logback:logback-classic:1.5.21=testCompileClasspath,testRuntimeClasspath
|
||||
ch.qos.logback:logback-core:1.5.21=testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath
|
||||
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
|
||||
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
|
||||
@@ -53,7 +53,7 @@ io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCo
|
||||
io.micrometer:micrometer-core:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.annotation:jakarta.annotation-api:3.0.0=runtimeClasspath,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
|
||||
@@ -71,9 +71,9 @@ org.apache.groovy:groovy-bom:5.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.groovy:groovy:5.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
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=runtimeClasspath,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=runtimeClasspath,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
|
||||
@@ -121,12 +121,13 @@ org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
|
||||
org.reflections:reflections:0.10.2=checkstyle
|
||||
org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:jul-to-slf4j:2.0.17=runtimeClasspath,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.spockframework:spock-bom:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.spockframework:spock-core:2.4-groovy-5.0=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=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
|
||||
@@ -135,13 +136,13 @@ org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,t
|
||||
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=runtimeClasspath,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=runtimeClasspath,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
|
||||
@@ -159,7 +160,7 @@ org.springframework:spring-web:7.0.1=compileClasspath,runtimeClasspath,testCompi
|
||||
org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
|
||||
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
|
||||
org.yaml:snakeyaml:2.5=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.yaml:snakeyaml:2.5=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
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient;
|
||||
|
||||
/** Raised when the absolute monotonic logical-call deadline wins. */
|
||||
public final class OutboundCallDeadlineExceededException extends RuntimeException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
OutboundCallDeadlineExceededException() {
|
||||
super("outbound HTTP call deadline exceeded");
|
||||
}
|
||||
}
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient;
|
||||
|
||||
import dev.caskeleton.application.outbound.CallBudget;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.FutureTask;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import java.util.concurrent.Semaphore;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.Supplier;
|
||||
import org.slf4j.MDC;
|
||||
|
||||
/** Runs one blocking logical call on a cancellable virtual thread under an absolute budget. */
|
||||
final class OutboundCallExecutor {
|
||||
|
||||
private static final int DEFAULT_MAXIMUM_IN_FLIGHT_CALLS = 128;
|
||||
|
||||
private final OutboundHttpShutdownGuard shutdownGuard;
|
||||
private final Semaphore admission;
|
||||
private final Map<FutureTask<?>, Thread> activeTasks = new ConcurrentHashMap<>();
|
||||
private final AtomicBoolean accepting = new AtomicBoolean(true);
|
||||
|
||||
OutboundCallExecutor() {
|
||||
this(new OutboundHttpShutdownGuard(), DEFAULT_MAXIMUM_IN_FLIGHT_CALLS);
|
||||
}
|
||||
|
||||
OutboundCallExecutor(OutboundHttpShutdownGuard shutdownGuard, int maximumInFlightCalls) {
|
||||
this.shutdownGuard = Objects.requireNonNull(shutdownGuard, "shutdownGuard must be non-null");
|
||||
if (maximumInFlightCalls < 1 || maximumInFlightCalls > 10_000) {
|
||||
throw new IllegalArgumentException("maximumInFlightCalls must be in 1..10000");
|
||||
}
|
||||
this.admission = new Semaphore(maximumInFlightCalls);
|
||||
shutdownGuard.registerShutdownAction(this::shutdown);
|
||||
}
|
||||
|
||||
<T> T execute(CallBudget budget, Supplier<T> operation) {
|
||||
Objects.requireNonNull(budget, "budget must be non-null");
|
||||
Objects.requireNonNull(operation, "operation must be non-null");
|
||||
rejectIfShuttingDown();
|
||||
if (budget.isExpiredAt(System.nanoTime())) {
|
||||
throw new OutboundCallDeadlineExceededException();
|
||||
}
|
||||
if (!admission.tryAcquire()) {
|
||||
throw new RejectedExecutionException("outbound HTTP in-flight capacity is exhausted");
|
||||
}
|
||||
if (!accepting.get() || shutdownGuard.isShuttingDown()) {
|
||||
admission.release();
|
||||
throw cancellation("outbound HTTP client is shutting down");
|
||||
}
|
||||
|
||||
Map<String, String> callerMdc = MDC.getCopyOfContextMap();
|
||||
FutureTask<T> task =
|
||||
new FutureTask<>(
|
||||
() -> {
|
||||
installMdc(callerMdc);
|
||||
try {
|
||||
if (budget.isExpiredAt(System.nanoTime())) {
|
||||
throw new OutboundCallDeadlineExceededException();
|
||||
}
|
||||
return operation.get();
|
||||
} finally {
|
||||
MDC.clear();
|
||||
}
|
||||
});
|
||||
Thread worker =
|
||||
Thread.ofVirtual()
|
||||
.name("outbound-http-call")
|
||||
.unstarted(
|
||||
() -> {
|
||||
try {
|
||||
task.run();
|
||||
} finally {
|
||||
activeTasks.remove(task);
|
||||
admission.release();
|
||||
}
|
||||
});
|
||||
activeTasks.put(task, worker);
|
||||
if (!accepting.get() || shutdownGuard.isShuttingDown()) {
|
||||
task.cancel(false);
|
||||
}
|
||||
try {
|
||||
worker.start();
|
||||
} catch (RuntimeException | Error startFailure) {
|
||||
activeTasks.remove(task);
|
||||
admission.release();
|
||||
throw startFailure;
|
||||
}
|
||||
|
||||
long remaining = budget.remainingNanosAt(System.nanoTime());
|
||||
if (remaining == 0) {
|
||||
task.cancel(true);
|
||||
throw new OutboundCallDeadlineExceededException();
|
||||
}
|
||||
try {
|
||||
return task.get(remaining, TimeUnit.NANOSECONDS);
|
||||
} catch (TimeoutException exception) {
|
||||
task.cancel(true);
|
||||
throw new OutboundCallDeadlineExceededException();
|
||||
} catch (InterruptedException exception) {
|
||||
task.cancel(true);
|
||||
Thread.currentThread().interrupt();
|
||||
CancellationException cancelled =
|
||||
new CancellationException("outbound HTTP caller thread was interrupted");
|
||||
cancelled.initCause(exception);
|
||||
throw cancelled;
|
||||
} catch (ExecutionException exception) {
|
||||
throw propagate(exception.getCause());
|
||||
}
|
||||
}
|
||||
|
||||
private void shutdown() {
|
||||
accepting.set(false);
|
||||
activeTasks.keySet().forEach(task -> task.cancel(true));
|
||||
}
|
||||
|
||||
private void rejectIfShuttingDown() {
|
||||
if (!accepting.get() || shutdownGuard.isShuttingDown()) {
|
||||
throw cancellation("outbound HTTP client is shutting down");
|
||||
}
|
||||
}
|
||||
|
||||
private static CancellationException cancellation(String message) {
|
||||
return new CancellationException(message);
|
||||
}
|
||||
|
||||
private static void installMdc(Map<String, String> context) {
|
||||
if (context == null || context.isEmpty()) {
|
||||
MDC.clear();
|
||||
} else {
|
||||
MDC.setContextMap(context);
|
||||
}
|
||||
}
|
||||
|
||||
private static RuntimeException propagate(Throwable failure) {
|
||||
if (failure instanceof RuntimeException runtimeException) {
|
||||
return runtimeException;
|
||||
}
|
||||
if (failure instanceof Error error) {
|
||||
throw error;
|
||||
}
|
||||
return new IllegalStateException("outbound HTTP worker failed", failure);
|
||||
}
|
||||
}
|
||||
+12
@@ -53,6 +53,18 @@ final class OutboundHttpCallObserver {
|
||||
return rejected;
|
||||
}
|
||||
|
||||
/** Maps local admission saturation without pretending that a network connection was attempted. */
|
||||
DependencyFailureException rejectCapacity(Throwable cause) {
|
||||
DependencyFailureException rejected =
|
||||
new DependencyFailureException(
|
||||
OperationalError.DEPENDENCY_CIRCUIT_OPEN,
|
||||
dependencyName,
|
||||
"outbound HTTP local in-flight capacity is exhausted before send",
|
||||
cause);
|
||||
logger.logFailure(dependencyName, "REJECTED", 0L, 0, rejected);
|
||||
return rejected;
|
||||
}
|
||||
|
||||
private static String outcomeFor(DependencyFailureException dfe) {
|
||||
return switch ((OperationalError) dfe.errorCode()) {
|
||||
case DEPENDENCY_TIMEOUT -> "TIMEOUT";
|
||||
|
||||
+122
-38
@@ -3,15 +3,21 @@ package dev.caskeleton.adapter.outbound.httpclient;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpDependencyLogger;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpErrorMapper;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.resilience.OutboundHttpResilience;
|
||||
import dev.caskeleton.application.outbound.CallBudget;
|
||||
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
|
||||
import io.github.resilience4j.retry.Retry;
|
||||
import java.io.InputStream;
|
||||
import java.time.Instant;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Supplier;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import org.springframework.web.client.RestClientResponseException;
|
||||
|
||||
/**
|
||||
* Baseline outbound HTTP client for a single named upstream dependency. Created via the {@code
|
||||
@@ -27,6 +33,7 @@ public final class OutboundHttpClient {
|
||||
private final OutboundHttpResilience resilience;
|
||||
private final OutboundRetryPolicy retryPolicy;
|
||||
private final OutboundHttpCallObserver observer;
|
||||
private final OutboundCallExecutor callExecutor;
|
||||
|
||||
private final RestClient bufferedClient;
|
||||
private final RestClient streamingClient;
|
||||
@@ -46,6 +53,7 @@ public final class OutboundHttpClient {
|
||||
this.resilience = resilience;
|
||||
this.retryPolicy = retryPolicy;
|
||||
this.observer = new OutboundHttpCallObserver(dependencyName, errorMapper, logger);
|
||||
this.callExecutor = new OutboundCallExecutor(shutdownGuard, settings.maximumInFlightCalls());
|
||||
|
||||
var clients = OutboundHttpRestClientFactory.create(dependencyName, baseUrl, settings);
|
||||
this.bufferedClient = clients.buffered();
|
||||
@@ -78,7 +86,12 @@ public final class OutboundHttpClient {
|
||||
|
||||
/** Shortcut: GET with buffered deserialization. */
|
||||
public <T> T get(String uri, Class<T> responseType) {
|
||||
return exchange(HttpMethod.GET, uri, null, responseType);
|
||||
return get(uri, responseType, CallBudget.fromNow(settings.globalCallTimeout()));
|
||||
}
|
||||
|
||||
/** GET bounded by the intersection of caller and configured logical-call budgets. */
|
||||
public <T> T get(String uri, Class<T> responseType, CallBudget budget) {
|
||||
return exchange(HttpMethod.GET, uri, null, responseType, budget);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,42 +99,39 @@ public final class OutboundHttpClient {
|
||||
* propagates unclassified (usage-contract violation — large responses must use {@link #stream}).
|
||||
*/
|
||||
public <T> T exchange(HttpMethod method, String uri, Object requestBody, Class<T> responseType) {
|
||||
return exchange(
|
||||
method, uri, requestBody, responseType, CallBudget.fromNow(settings.globalCallTimeout()));
|
||||
}
|
||||
|
||||
/** Buffered exchange bounded by one absolute monotonic logical-call deadline. */
|
||||
public <T> T exchange(
|
||||
HttpMethod method,
|
||||
String uri,
|
||||
Object requestBody,
|
||||
Class<T> responseType,
|
||||
CallBudget callerBudget) {
|
||||
OutboundHttpRestClientFactory.validateRelativeTarget(uri);
|
||||
Objects.requireNonNull(method, "method must be non-null");
|
||||
Objects.requireNonNull(responseType, "responseType must be non-null");
|
||||
Objects.requireNonNull(callerBudget, "callerBudget must be non-null");
|
||||
if (shutdownGuard.isShuttingDown()) {
|
||||
throw observer.rejectShutdown("shutdown in progress — outbound call rejected fail-fast (D8)");
|
||||
}
|
||||
|
||||
Instant deadline = Instant.now().plus(settings.globalCallTimeout());
|
||||
retryPolicy.beginCall(method, deadline);
|
||||
|
||||
// Track attempt count for logging — declared outside try so catch can read it.
|
||||
int[] attemptCount = {0};
|
||||
CallBudget effectiveBudget =
|
||||
callerBudget.intersect(CallBudget.fromNow(settings.globalCallTimeout()));
|
||||
AtomicInteger attemptCount = new AtomicInteger();
|
||||
long startNs = System.nanoTime();
|
||||
try {
|
||||
Supplier<T> supplier = buildSupplier(method, uri, requestBody, responseType);
|
||||
|
||||
// Retry OUTSIDE the CB so each attempt is independently CB-counted
|
||||
// (inside the CB, all retries would count as a single CB call).
|
||||
Optional<CircuitBreaker> cb = resilience.circuitBreakerFor(dependencyName);
|
||||
Optional<Retry> retry = resilience.retryFor(dependencyName);
|
||||
|
||||
Supplier<T> countingSupplier =
|
||||
() -> {
|
||||
attemptCount[0]++;
|
||||
return supplier.get();
|
||||
};
|
||||
|
||||
Supplier<T> decorated = countingSupplier;
|
||||
if (retry.isPresent()) {
|
||||
decorated = Retry.decorateSupplier(retry.get(), decorated);
|
||||
}
|
||||
if (cb.isPresent()) {
|
||||
decorated = CircuitBreaker.decorateSupplier(cb.get(), decorated);
|
||||
}
|
||||
|
||||
T result = decorated.get();
|
||||
T result =
|
||||
callExecutor.execute(
|
||||
effectiveBudget,
|
||||
() ->
|
||||
executeBuffered(
|
||||
method, uri, requestBody, responseType, effectiveBudget, attemptCount));
|
||||
|
||||
// retryAttempt = attemptCount - 1 (0 means the first attempt succeeded).
|
||||
observer.recordSuccess(startNs, Math.max(0, attemptCount[0] - 1));
|
||||
observer.recordSuccess(startNs, Math.max(0, attemptCount.get() - 1));
|
||||
return result;
|
||||
|
||||
} catch (OutboundResponseSizeExceededException sizeEx) {
|
||||
@@ -129,11 +139,14 @@ public final class OutboundHttpClient {
|
||||
// failure).
|
||||
throw sizeEx;
|
||||
|
||||
} catch (Throwable t) {
|
||||
throw observer.recordFailure(t, startNs, Math.max(0, attemptCount[0] - 1));
|
||||
} catch (CancellationException cancelled) {
|
||||
throw cancelled;
|
||||
|
||||
} finally {
|
||||
retryPolicy.endCall();
|
||||
} catch (RejectedExecutionException rejected) {
|
||||
throw observer.rejectCapacity(rejected);
|
||||
|
||||
} catch (Throwable t) {
|
||||
throw observer.recordFailure(t, startNs, Math.max(0, attemptCount.get() - 1));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,27 +155,98 @@ public final class OutboundHttpClient {
|
||||
* (delivered bytes are lost and the server may not support resending).
|
||||
*/
|
||||
public <T> T stream(HttpMethod method, String uri, Function<InputStream, T> reader) {
|
||||
return stream(method, uri, reader, CallBudget.fromNow(settings.globalCallTimeout()));
|
||||
}
|
||||
|
||||
/** Streaming exchange bounded by the caller/configured logical-call deadline intersection. */
|
||||
public <T> T stream(
|
||||
HttpMethod method, String uri, Function<InputStream, T> reader, CallBudget callerBudget) {
|
||||
OutboundHttpRestClientFactory.validateRelativeTarget(uri);
|
||||
Objects.requireNonNull(method, "method must be non-null");
|
||||
Objects.requireNonNull(reader, "reader must be non-null");
|
||||
Objects.requireNonNull(callerBudget, "callerBudget must be non-null");
|
||||
if (shutdownGuard.isShuttingDown()) {
|
||||
throw observer.rejectShutdown(
|
||||
"shutdown in progress — outbound stream call rejected fail-fast (D8)");
|
||||
}
|
||||
|
||||
CallBudget effectiveBudget =
|
||||
callerBudget.intersect(CallBudget.fromNow(settings.globalCallTimeout()));
|
||||
long startNs = System.nanoTime();
|
||||
try {
|
||||
T result =
|
||||
streamingClient
|
||||
.method(method)
|
||||
.uri(uri)
|
||||
.exchange((req, res) -> reader.apply(res.getBody()));
|
||||
callExecutor.execute(
|
||||
effectiveBudget,
|
||||
() ->
|
||||
streamingClient
|
||||
.method(method)
|
||||
.uri(uri)
|
||||
.exchange(
|
||||
(request, response) -> {
|
||||
if (!response.getStatusCode().is2xxSuccessful()) {
|
||||
try (InputStream ignored = response.getBody()) {
|
||||
// Discard without exposing the upstream error body.
|
||||
}
|
||||
throw new RestClientResponseException(
|
||||
"upstream HTTP status rejected before streaming body delivery",
|
||||
response.getStatusCode(),
|
||||
response.getStatusText(),
|
||||
null,
|
||||
new byte[0],
|
||||
StandardCharsets.UTF_8);
|
||||
}
|
||||
return reader.apply(response.getBody());
|
||||
}));
|
||||
|
||||
observer.recordSuccess(startNs, 0);
|
||||
return result;
|
||||
|
||||
} catch (CancellationException cancelled) {
|
||||
throw cancelled;
|
||||
} catch (RejectedExecutionException rejected) {
|
||||
throw observer.rejectCapacity(rejected);
|
||||
} catch (Throwable t) {
|
||||
throw observer.recordFailure(t, startNs, 0);
|
||||
}
|
||||
}
|
||||
|
||||
private <T> T executeBuffered(
|
||||
HttpMethod method,
|
||||
String uri,
|
||||
Object requestBody,
|
||||
Class<T> responseType,
|
||||
CallBudget budget,
|
||||
AtomicInteger attemptCount) {
|
||||
retryPolicy.beginCall(method, budget);
|
||||
try {
|
||||
Supplier<T> supplier = buildSupplier(method, uri, requestBody, responseType);
|
||||
Optional<CircuitBreaker> circuitBreaker = resilience.circuitBreakerFor(dependencyName);
|
||||
Optional<Retry> retry = resilience.retryFor(dependencyName);
|
||||
Supplier<T> physicalAttempt =
|
||||
() -> {
|
||||
attemptCount.incrementAndGet();
|
||||
return supplier.get();
|
||||
};
|
||||
if (circuitBreaker.isPresent()) {
|
||||
physicalAttempt = CircuitBreaker.decorateSupplier(circuitBreaker.get(), physicalAttempt);
|
||||
}
|
||||
Supplier<T> circuitProtectedAttempt = physicalAttempt;
|
||||
Supplier<T> decorated =
|
||||
() -> {
|
||||
if (budget.isExpiredAt(System.nanoTime())) {
|
||||
throw new OutboundCallDeadlineExceededException();
|
||||
}
|
||||
return circuitProtectedAttempt.get();
|
||||
};
|
||||
if (retry.isPresent()) {
|
||||
decorated = Retry.decorateSupplier(retry.get(), decorated);
|
||||
}
|
||||
return decorated.get();
|
||||
} finally {
|
||||
retryPolicy.endCall();
|
||||
}
|
||||
}
|
||||
|
||||
private <T> Supplier<T> buildSupplier(
|
||||
HttpMethod method, String uri, Object requestBody, Class<T> responseType) {
|
||||
return () -> {
|
||||
|
||||
+61
-1
@@ -1,6 +1,8 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.util.Locale;
|
||||
import org.springframework.http.client.JdkClientHttpRequestFactory;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
@@ -17,8 +19,12 @@ final class OutboundHttpRestClientFactory {
|
||||
record Clients(RestClient buffered, RestClient streaming) {}
|
||||
|
||||
static Clients create(String dependencyName, String baseUrl, OutboundHttpSettings settings) {
|
||||
validateBaseUrl(baseUrl);
|
||||
HttpClient httpClient =
|
||||
HttpClient.newBuilder().connectTimeout(settings.connectTimeout()).build();
|
||||
HttpClient.newBuilder()
|
||||
.connectTimeout(settings.connectTimeout())
|
||||
.followRedirects(HttpClient.Redirect.NEVER)
|
||||
.build();
|
||||
JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient);
|
||||
requestFactory.setReadTimeout(settings.readTimeout());
|
||||
|
||||
@@ -43,4 +49,58 @@ final class OutboundHttpRestClientFactory {
|
||||
|
||||
return new Clients(buffered, streaming);
|
||||
}
|
||||
|
||||
static void validateRelativeTarget(String target) {
|
||||
if (target == null
|
||||
|| !target.startsWith("/")
|
||||
|| target.startsWith("//")
|
||||
|| target.indexOf('\\') >= 0
|
||||
|| target.indexOf('#') >= 0
|
||||
|| target.chars().anyMatch(character -> Character.isISOControl(character))) {
|
||||
throw new IllegalArgumentException(
|
||||
"legacy outbound HTTP request target must be a relative path");
|
||||
}
|
||||
URI parsed;
|
||||
try {
|
||||
parsed = URI.create(target);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new IllegalArgumentException(
|
||||
"legacy outbound HTTP request target must be a valid relative path", exception);
|
||||
}
|
||||
String rawPath = parsed.getRawPath();
|
||||
String lowerPath = rawPath == null ? "" : rawPath.toLowerCase(Locale.ROOT);
|
||||
if (parsed.isAbsolute()
|
||||
|| parsed.getRawAuthority() != null
|
||||
|| !parsed.normalize().getRawPath().equals(rawPath)
|
||||
|| lowerPath.contains("%2e")
|
||||
|| lowerPath.contains("%2f")
|
||||
|| lowerPath.contains("%5c")) {
|
||||
throw new IllegalArgumentException(
|
||||
"legacy outbound HTTP request target must be an unambiguous relative path");
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateBaseUrl(String baseUrl) {
|
||||
URI parsed;
|
||||
try {
|
||||
parsed = URI.create(baseUrl);
|
||||
} catch (RuntimeException exception) {
|
||||
throw new IllegalArgumentException("legacy outbound HTTP base URL is invalid", exception);
|
||||
}
|
||||
String scheme = parsed.getScheme();
|
||||
if (!parsed.isAbsolute()
|
||||
|| scheme == null
|
||||
|| parsed.getHost() == null
|
||||
|| (!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme))
|
||||
|| parsed.getRawUserInfo() != null
|
||||
|| parsed.getRawQuery() != null
|
||||
|| parsed.getRawFragment() != null
|
||||
|| parsed.getRawPath().indexOf('\\') >= 0
|
||||
|| parsed.getRawPath().chars().anyMatch(character -> Character.isISOControl(character))
|
||||
|| !parsed.normalize().getRawPath().equals(parsed.getRawPath())) {
|
||||
throw new IllegalArgumentException(
|
||||
"legacy outbound HTTP base URL must be a fixed http(s) authority without "
|
||||
+ "user-info, query, fragment, or ambiguous path");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+39
-2
@@ -13,6 +13,7 @@ import org.springframework.util.unit.DataSize;
|
||||
* @param connectTimeout TCP connect timeout; must be positive
|
||||
* @param readTimeout socket read timeout; must be positive
|
||||
* @param globalCallTimeout end-to-end deadline budget per call including retries; must be positive
|
||||
* @param maximumInFlightCalls maximum live logical-call workers owned by one client
|
||||
* @param retryEnabled whether the Resilience4j retry decorator is active
|
||||
* @param circuitBreakerEnabled whether the Resilience4j circuit-breaker decorator is active
|
||||
* @param responseSizeLimit max in-memory response body size; null defaults to 10 MB; zero/negative
|
||||
@@ -25,6 +26,7 @@ public record OutboundHttpSettings(
|
||||
Duration connectTimeout,
|
||||
Duration readTimeout,
|
||||
Duration globalCallTimeout,
|
||||
Integer maximumInFlightCalls,
|
||||
boolean retryEnabled,
|
||||
boolean circuitBreakerEnabled,
|
||||
DataSize responseSizeLimit,
|
||||
@@ -34,6 +36,8 @@ public record OutboundHttpSettings(
|
||||
/** Registry default for {@code APP_OUTBOUND_HTTP_RESPONSE_SIZE_LIMIT}. */
|
||||
private static final DataSize DEFAULT_RESPONSE_SIZE_LIMIT = DataSize.ofMegabytes(10);
|
||||
|
||||
private static final Duration MAXIMUM_GLOBAL_CALL_TIMEOUT = Duration.ofDays(365);
|
||||
|
||||
private static final int DEFAULT_RETRY_MAX_ATTEMPTS = 3;
|
||||
private static final Duration DEFAULT_RETRY_INITIAL_BACKOFF = Duration.ofMillis(100);
|
||||
private static final double DEFAULT_RETRY_BACKOFF_MULTIPLIER = 2.0;
|
||||
@@ -56,10 +60,20 @@ public record OutboundHttpSettings(
|
||||
"APP_OUTBOUND_HTTP_READ_TIMEOUT (app.outbound.http.read-timeout) must be a "
|
||||
+ "positive duration (spring_duration_shorthand_non_zero, D5)");
|
||||
}
|
||||
if (globalCallTimeout == null || globalCallTimeout.isZero() || globalCallTimeout.isNegative()) {
|
||||
if (globalCallTimeout == null
|
||||
|| globalCallTimeout.isZero()
|
||||
|| globalCallTimeout.isNegative()
|
||||
|| globalCallTimeout.compareTo(MAXIMUM_GLOBAL_CALL_TIMEOUT) > 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"APP_OUTBOUND_HTTP_GLOBAL_CALL_TIMEOUT (app.outbound.http.global-call-timeout) must be a "
|
||||
+ "positive duration (spring_duration_shorthand_non_zero, D5)");
|
||||
+ "positive duration no greater than 365 days");
|
||||
}
|
||||
if (maximumInFlightCalls == null) {
|
||||
maximumInFlightCalls = 128;
|
||||
} else if (maximumInFlightCalls < 1 || maximumInFlightCalls > 10_000) {
|
||||
throw new IllegalArgumentException(
|
||||
"APP_OUTBOUND_HTTP_MAXIMUM_IN_FLIGHT_CALLS "
|
||||
+ "(app.outbound.http.maximum-in-flight-calls) must be in 1..10000");
|
||||
}
|
||||
if (responseSizeLimit == null) {
|
||||
responseSizeLimit = DEFAULT_RESPONSE_SIZE_LIMIT;
|
||||
@@ -77,6 +91,28 @@ public record OutboundHttpSettings(
|
||||
}
|
||||
}
|
||||
|
||||
/** Compatibility constructor preserving the former canonical 8-argument shape. */
|
||||
public OutboundHttpSettings(
|
||||
Duration connectTimeout,
|
||||
Duration readTimeout,
|
||||
Duration globalCallTimeout,
|
||||
boolean retryEnabled,
|
||||
boolean circuitBreakerEnabled,
|
||||
DataSize responseSizeLimit,
|
||||
Retry retry,
|
||||
CircuitBreaker circuitBreaker) {
|
||||
this(
|
||||
connectTimeout,
|
||||
readTimeout,
|
||||
globalCallTimeout,
|
||||
null,
|
||||
retryEnabled,
|
||||
circuitBreakerEnabled,
|
||||
responseSizeLimit,
|
||||
retry,
|
||||
circuitBreaker);
|
||||
}
|
||||
|
||||
/**
|
||||
* Secondary constructor: defaults for resilience tuning; preserves the original 6-arg call sites.
|
||||
*/
|
||||
@@ -91,6 +127,7 @@ public record OutboundHttpSettings(
|
||||
connectTimeout,
|
||||
readTimeout,
|
||||
globalCallTimeout,
|
||||
null,
|
||||
retryEnabled,
|
||||
circuitBreakerEnabled,
|
||||
responseSizeLimit,
|
||||
|
||||
+11
@@ -1,5 +1,7 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import org.springframework.context.SmartLifecycle;
|
||||
|
||||
@@ -12,6 +14,7 @@ public final class OutboundHttpShutdownGuard implements SmartLifecycle {
|
||||
|
||||
private final AtomicBoolean running = new AtomicBoolean(false);
|
||||
private final AtomicBoolean shuttingDown = new AtomicBoolean(false);
|
||||
private final Set<Runnable> shutdownActions = ConcurrentHashMap.newKeySet();
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
@@ -22,6 +25,7 @@ public final class OutboundHttpShutdownGuard implements SmartLifecycle {
|
||||
public void stop() {
|
||||
shuttingDown.set(true);
|
||||
running.set(false);
|
||||
shutdownActions.forEach(Runnable::run);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -46,4 +50,11 @@ public final class OutboundHttpShutdownGuard implements SmartLifecycle {
|
||||
public boolean isShuttingDown() {
|
||||
return shuttingDown.get();
|
||||
}
|
||||
|
||||
void registerShutdownAction(Runnable action) {
|
||||
shutdownActions.add(action);
|
||||
if (shuttingDown.get()) {
|
||||
action.run();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+24
-3
@@ -1,7 +1,9 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpErrorMapper;
|
||||
import dev.caskeleton.application.outbound.CallBudget;
|
||||
import dev.caskeleton.shared.error.DependencyFailureException;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Set;
|
||||
import org.springframework.http.HttpMethod;
|
||||
@@ -36,8 +38,27 @@ public final class OutboundRetryPolicy {
|
||||
}
|
||||
|
||||
/** Must be paired with {@link #endCall()} in try/finally. */
|
||||
public void beginCall(HttpMethod method, CallBudget budget) {
|
||||
callContextHolder.set(new CallContext(method, budget.monotonicDeadlineNanos()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Compatibility bridge for the legacy tests/facade. New call paths must carry {@link CallBudget}
|
||||
* directly.
|
||||
*/
|
||||
@Deprecated
|
||||
public void beginCall(HttpMethod method, Instant deadline) {
|
||||
callContextHolder.set(new CallContext(method, deadline));
|
||||
long remaining;
|
||||
try {
|
||||
remaining = Duration.between(Instant.now(), deadline).toNanos();
|
||||
} catch (ArithmeticException exception) {
|
||||
remaining = Long.MAX_VALUE;
|
||||
}
|
||||
long now = System.nanoTime();
|
||||
long boundedRemaining = Math.max(0, remaining);
|
||||
long monotonicDeadline =
|
||||
boundedRemaining > Long.MAX_VALUE - now ? Long.MAX_VALUE : now + boundedRemaining;
|
||||
callContextHolder.set(new CallContext(method, monotonicDeadline));
|
||||
}
|
||||
|
||||
public void endCall() {
|
||||
@@ -69,8 +90,8 @@ public final class OutboundRetryPolicy {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Instant.now().isBefore(ctx.deadline());
|
||||
return ctx.monotonicDeadlineNanos() - System.nanoTime() > 0;
|
||||
}
|
||||
|
||||
private record CallContext(HttpMethod method, Instant deadline) {}
|
||||
private record CallContext(HttpMethod method, long monotonicDeadlineNanos) {}
|
||||
}
|
||||
|
||||
+3
-1
@@ -1,5 +1,6 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.diagnostics;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.OutboundCallDeadlineExceededException;
|
||||
import dev.caskeleton.shared.error.DependencyFailureException;
|
||||
import dev.caskeleton.shared.error.OperationalError;
|
||||
import io.github.resilience4j.circuitbreaker.CallNotPermittedException;
|
||||
@@ -86,7 +87,8 @@ public final class OutboundHttpErrorMapper {
|
||||
+ ")",
|
||||
failure);
|
||||
}
|
||||
if (current instanceof HttpTimeoutException
|
||||
if (current instanceof OutboundCallDeadlineExceededException
|
||||
|| current instanceof HttpTimeoutException
|
||||
|| current instanceof SocketTimeoutException
|
||||
|| current instanceof TimeoutException) {
|
||||
return new DependencyFailureException(
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.operation;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Validated fixed base authority for the internal operation kernel. */
|
||||
public final class FixedHttpDestination {
|
||||
|
||||
private final HttpDestinationId destinationId;
|
||||
private final URI baseUri;
|
||||
private final boolean requireHttps;
|
||||
|
||||
public FixedHttpDestination(HttpDestinationId destinationId, URI baseUri, boolean requireHttps) {
|
||||
this.destinationId = Objects.requireNonNull(destinationId, "destinationId must be non-null");
|
||||
Objects.requireNonNull(baseUri, "baseUri must be non-null");
|
||||
String scheme = baseUri.getScheme();
|
||||
if (!baseUri.isAbsolute() || scheme == null || baseUri.getHost() == null) {
|
||||
throw new IllegalArgumentException("HTTP base URI must be absolute with a host");
|
||||
}
|
||||
scheme = scheme.toLowerCase(Locale.ROOT);
|
||||
if (!"http".equals(scheme) && !"https".equals(scheme)) {
|
||||
throw new IllegalArgumentException("HTTP base URI scheme must be http or https");
|
||||
}
|
||||
if (requireHttps && !"https".equals(scheme)) {
|
||||
throw new IllegalArgumentException("HTTP destination requires https");
|
||||
}
|
||||
if (baseUri.getRawUserInfo() != null) {
|
||||
throw new IllegalArgumentException("HTTP base URI must not contain user-info");
|
||||
}
|
||||
if (baseUri.getRawQuery() != null) {
|
||||
throw new IllegalArgumentException("HTTP base URI must not contain a query");
|
||||
}
|
||||
if (baseUri.getRawFragment() != null) {
|
||||
throw new IllegalArgumentException("HTTP base URI must not contain a fragment");
|
||||
}
|
||||
String path = baseUri.getRawPath();
|
||||
if (path == null) {
|
||||
path = "";
|
||||
}
|
||||
if (path.indexOf('\\') >= 0
|
||||
|| path.indexOf('%') >= 0
|
||||
|| path.chars().anyMatch(character -> Character.isISOControl(character))
|
||||
|| !URI.create(path.isEmpty() ? "/" : path)
|
||||
.normalize()
|
||||
.getPath()
|
||||
.equals(path.isEmpty() ? "/" : path)) {
|
||||
throw new IllegalArgumentException("HTTP base URI contains an ambiguous path");
|
||||
}
|
||||
this.baseUri = URI.create(scheme + "://" + baseUri.getRawAuthority() + path);
|
||||
this.requireHttps = requireHttps;
|
||||
}
|
||||
|
||||
HttpDestinationId destinationId() {
|
||||
return destinationId;
|
||||
}
|
||||
|
||||
URI baseUri() {
|
||||
return baseUri;
|
||||
}
|
||||
|
||||
boolean requireHttps() {
|
||||
return requireHttps;
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.operation;
|
||||
|
||||
/** Stable low-cardinality destination registry identifier; never a host or caller value. */
|
||||
public record HttpDestinationId(String value) {
|
||||
|
||||
public HttpDestinationId {
|
||||
if (value == null || !value.matches("[a-z][a-z0-9-]{0,62}")) {
|
||||
throw new IllegalArgumentException("HTTP destination id must match [a-z][a-z0-9-]{0,62}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.operation;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Immutable startup-time operation catalog; runtime registration is deliberately absent. */
|
||||
public final class HttpOperationCatalog {
|
||||
|
||||
private final Map<HttpOperationId, HttpOperationDescriptor> descriptors;
|
||||
|
||||
public HttpOperationCatalog(Collection<HttpOperationDescriptor> descriptors) {
|
||||
Objects.requireNonNull(descriptors, "descriptors must be non-null");
|
||||
Map<HttpOperationId, HttpOperationDescriptor> indexed = new LinkedHashMap<>();
|
||||
for (HttpOperationDescriptor descriptor : descriptors) {
|
||||
Objects.requireNonNull(descriptor, "descriptor must be non-null");
|
||||
if (indexed.putIfAbsent(descriptor.operationId(), descriptor) != null) {
|
||||
throw new IllegalArgumentException(
|
||||
"duplicate HTTP operation id: " + descriptor.operationId().value());
|
||||
}
|
||||
}
|
||||
this.descriptors = Map.copyOf(indexed);
|
||||
}
|
||||
|
||||
HttpOperationDescriptor require(HttpOperationId operationId) {
|
||||
HttpOperationDescriptor descriptor = descriptors.get(operationId);
|
||||
if (descriptor == null) {
|
||||
throw new IllegalArgumentException("unregistered HTTP operation: " + operationId.value());
|
||||
}
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
public Collection<HttpOperationDescriptor> descriptors() {
|
||||
return descriptors.values();
|
||||
}
|
||||
}
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.operation;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/** Immutable upper bound for one registered HTTP operation. Callers cannot override its policy. */
|
||||
public final class HttpOperationDescriptor {
|
||||
|
||||
private static final long MAXIMUM_BUFFERED_RESPONSE_BYTES = 1_073_741_824;
|
||||
|
||||
private final HttpOperationId operationId;
|
||||
private final HttpDestinationId destinationId;
|
||||
private final int policyRevision;
|
||||
private final Method method;
|
||||
private final String routeTemplate;
|
||||
private final OperationSemantics semantics;
|
||||
private final RequestMode requestMode;
|
||||
private final ResponseMode responseMode;
|
||||
private final Set<Integer> successStatuses;
|
||||
private final int ordinaryMaximumRetries;
|
||||
private final int maximumPhysicalAttempts;
|
||||
private final long maximumResponseBytes;
|
||||
|
||||
public HttpOperationDescriptor(
|
||||
HttpOperationId operationId,
|
||||
HttpDestinationId destinationId,
|
||||
int policyRevision,
|
||||
Method method,
|
||||
String routeTemplate,
|
||||
OperationSemantics semantics,
|
||||
RequestMode requestMode,
|
||||
ResponseMode responseMode,
|
||||
Set<Integer> successStatuses,
|
||||
int ordinaryMaximumRetries,
|
||||
int maximumPhysicalAttempts,
|
||||
long maximumResponseBytes) {
|
||||
this.operationId = Objects.requireNonNull(operationId, "operationId must be non-null");
|
||||
this.destinationId = Objects.requireNonNull(destinationId, "destinationId must be non-null");
|
||||
this.method = Objects.requireNonNull(method, "method must be non-null");
|
||||
this.semantics = Objects.requireNonNull(semantics, "semantics must be non-null");
|
||||
this.requestMode = Objects.requireNonNull(requestMode, "requestMode must be non-null");
|
||||
this.responseMode = Objects.requireNonNull(responseMode, "responseMode must be non-null");
|
||||
if (policyRevision < 1) {
|
||||
throw new IllegalArgumentException("HTTP operation policyRevision must be >= 1");
|
||||
}
|
||||
this.policyRevision = policyRevision;
|
||||
validateRelativeRoute(routeTemplate);
|
||||
this.routeTemplate = routeTemplate;
|
||||
this.successStatuses = Set.copyOf(successStatuses);
|
||||
if (this.successStatuses.isEmpty()
|
||||
|| this.successStatuses.stream().anyMatch(status -> status < 200 || status > 299)) {
|
||||
throw new IllegalArgumentException(
|
||||
"HTTP operation success statuses must be a non-empty 2xx set");
|
||||
}
|
||||
if (ordinaryMaximumRetries < 0 || ordinaryMaximumRetries > 5) {
|
||||
throw new IllegalArgumentException("ordinaryMaximumRetries must be in 0..5");
|
||||
}
|
||||
if (maximumPhysicalAttempts < 1
|
||||
|| maximumPhysicalAttempts > 8
|
||||
|| maximumPhysicalAttempts < ordinaryMaximumRetries + 1) {
|
||||
throw new IllegalArgumentException(
|
||||
"maximumPhysicalAttempts must cover the initial request and ordinary retries");
|
||||
}
|
||||
this.ordinaryMaximumRetries = ordinaryMaximumRetries;
|
||||
this.maximumPhysicalAttempts = maximumPhysicalAttempts;
|
||||
if (maximumResponseBytes < 1 || maximumResponseBytes > MAXIMUM_BUFFERED_RESPONSE_BYTES) {
|
||||
throw new IllegalArgumentException("maximumResponseBytes exceeds the buffered hard bound");
|
||||
}
|
||||
this.maximumResponseBytes = maximumResponseBytes;
|
||||
if (ordinaryMaximumRetries > 0 && requestMode == RequestMode.SINGLE_USE_STREAM) {
|
||||
throw new IllegalArgumentException(
|
||||
"automatic retry requires a replayable absent, buffered, or reopenable request body");
|
||||
}
|
||||
if (semantics == OperationSemantics.NON_RETRYABLE_MUTATION
|
||||
&& (ordinaryMaximumRetries != 0 || maximumPhysicalAttempts != 1)) {
|
||||
throw new IllegalArgumentException(
|
||||
"non-retryable mutation must allow exactly one physical attempt");
|
||||
}
|
||||
if (semantics == OperationSemantics.SAFE_READ
|
||||
&& method != Method.GET
|
||||
&& method != Method.HEAD) {
|
||||
throw new IllegalArgumentException("safe-read operations must use GET or HEAD");
|
||||
}
|
||||
}
|
||||
|
||||
HttpOperationId operationId() {
|
||||
return operationId;
|
||||
}
|
||||
|
||||
HttpDestinationId destinationId() {
|
||||
return destinationId;
|
||||
}
|
||||
|
||||
int policyRevision() {
|
||||
return policyRevision;
|
||||
}
|
||||
|
||||
Method method() {
|
||||
return method;
|
||||
}
|
||||
|
||||
String routeTemplate() {
|
||||
return routeTemplate;
|
||||
}
|
||||
|
||||
OperationSemantics semantics() {
|
||||
return semantics;
|
||||
}
|
||||
|
||||
RequestMode requestMode() {
|
||||
return requestMode;
|
||||
}
|
||||
|
||||
ResponseMode responseMode() {
|
||||
return responseMode;
|
||||
}
|
||||
|
||||
Set<Integer> successStatuses() {
|
||||
return successStatuses;
|
||||
}
|
||||
|
||||
int ordinaryMaximumRetries() {
|
||||
return ordinaryMaximumRetries;
|
||||
}
|
||||
|
||||
int maximumPhysicalAttempts() {
|
||||
return maximumPhysicalAttempts;
|
||||
}
|
||||
|
||||
long maximumResponseBytes() {
|
||||
return maximumResponseBytes;
|
||||
}
|
||||
|
||||
private static void validateRelativeRoute(String route) {
|
||||
if (route == null
|
||||
|| !route.startsWith("/")
|
||||
|| route.startsWith("//")
|
||||
|| route.contains("://")
|
||||
|| route.indexOf('?') >= 0
|
||||
|| route.indexOf('#') >= 0
|
||||
|| route.indexOf('\\') >= 0
|
||||
|| route.indexOf('%') >= 0
|
||||
|| route.chars().anyMatch(character -> Character.isISOControl(character))) {
|
||||
throw new IllegalArgumentException(
|
||||
"HTTP operation route must be an unambiguous relative path template");
|
||||
}
|
||||
for (String segment : route.split("/", -1)) {
|
||||
if (".".equals(segment) || "..".equals(segment)) {
|
||||
throw new IllegalArgumentException("HTTP operation route must not contain dot segments");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum Method {
|
||||
GET,
|
||||
HEAD,
|
||||
POST,
|
||||
PUT,
|
||||
PATCH,
|
||||
DELETE
|
||||
}
|
||||
|
||||
public enum OperationSemantics {
|
||||
SAFE_READ,
|
||||
IDEMPOTENT_MUTATION,
|
||||
KEYED_MUTATION,
|
||||
NON_RETRYABLE_MUTATION
|
||||
}
|
||||
|
||||
public enum RequestMode {
|
||||
NONE,
|
||||
BUFFERED,
|
||||
REOPENABLE_STREAM,
|
||||
SINGLE_USE_STREAM
|
||||
}
|
||||
|
||||
public enum ResponseMode {
|
||||
BODILESS,
|
||||
BUFFERED,
|
||||
STREAM_CALLBACK
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.operation;
|
||||
|
||||
/** Stable versioned operation registry identifier suitable for metrics and policy joins. */
|
||||
public record HttpOperationId(String value) {
|
||||
|
||||
public HttpOperationId {
|
||||
if (value == null
|
||||
|| value.length() > 128
|
||||
|| !value.matches("[a-z][a-z0-9-]*(\\.[a-z][a-z0-9-]*)+\\.v[1-9][0-9]*")) {
|
||||
throw new IllegalArgumentException(
|
||||
"HTTP operation id must be bounded lowercase dot notation ending in .vN");
|
||||
}
|
||||
}
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.operation;
|
||||
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** Resolves an internal registered relative route against one fixed destination. */
|
||||
public final class HttpTargetBuilder {
|
||||
|
||||
private static final Pattern VARIABLE = Pattern.compile("\\{([a-z][a-zA-Z0-9]{0,31})}");
|
||||
private static final char[] HEX = "0123456789ABCDEF".toCharArray();
|
||||
|
||||
private HttpTargetBuilder() {}
|
||||
|
||||
public static URI resolve(
|
||||
FixedHttpDestination destination,
|
||||
HttpOperationDescriptor operation,
|
||||
Map<String, String> pathVariables) {
|
||||
Objects.requireNonNull(destination, "destination must be non-null");
|
||||
Objects.requireNonNull(operation, "operation must be non-null");
|
||||
Objects.requireNonNull(pathVariables, "pathVariables must be non-null");
|
||||
if (!destination.destinationId().equals(operation.destinationId())) {
|
||||
throw new IllegalArgumentException("HTTP operation destination does not match binding");
|
||||
}
|
||||
|
||||
Set<String> required = new HashSet<>();
|
||||
Matcher matcher = VARIABLE.matcher(operation.routeTemplate());
|
||||
StringBuilder route = new StringBuilder();
|
||||
while (matcher.find()) {
|
||||
String variable = matcher.group(1);
|
||||
if (!required.add(variable)) {
|
||||
throw new IllegalArgumentException("duplicate HTTP path variable: " + variable);
|
||||
}
|
||||
String value = pathVariables.get(variable);
|
||||
if (value == null) {
|
||||
throw new IllegalArgumentException("missing HTTP path variable: " + variable);
|
||||
}
|
||||
matcher.appendReplacement(route, Matcher.quoteReplacement(encodeSegment(value)));
|
||||
}
|
||||
matcher.appendTail(route);
|
||||
if (!required.equals(pathVariables.keySet())) {
|
||||
throw new IllegalArgumentException("unknown HTTP path variable supplied");
|
||||
}
|
||||
|
||||
String basePath = destination.baseUri().getRawPath();
|
||||
if (basePath == null || basePath.isEmpty() || "/".equals(basePath)) {
|
||||
basePath = "";
|
||||
} else if (basePath.endsWith("/")) {
|
||||
basePath = basePath.substring(0, basePath.length() - 1);
|
||||
}
|
||||
String target =
|
||||
destination.baseUri().getScheme()
|
||||
+ "://"
|
||||
+ destination.baseUri().getRawAuthority()
|
||||
+ basePath
|
||||
+ route;
|
||||
return URI.create(target);
|
||||
}
|
||||
|
||||
private static String encodeSegment(String value) {
|
||||
if (value.isBlank()
|
||||
|| ".".equals(value)
|
||||
|| "..".equals(value)
|
||||
|| value.indexOf('/') >= 0
|
||||
|| value.indexOf('\\') >= 0
|
||||
|| value.indexOf('%') >= 0
|
||||
|| value.chars().anyMatch(character -> Character.isISOControl(character))) {
|
||||
throw new IllegalArgumentException("HTTP path variable must be exactly one raw segment");
|
||||
}
|
||||
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
|
||||
StringBuilder encoded = new StringBuilder(bytes.length);
|
||||
for (byte current : bytes) {
|
||||
int unsigned = current & 0xff;
|
||||
if (isUnreserved(unsigned)) {
|
||||
encoded.append((char) unsigned);
|
||||
} else {
|
||||
encoded.append('%');
|
||||
encoded.append(HEX[unsigned >>> 4]);
|
||||
encoded.append(HEX[unsigned & 0x0f]);
|
||||
}
|
||||
}
|
||||
return encoded.toString();
|
||||
}
|
||||
|
||||
private static boolean isUnreserved(int value) {
|
||||
return (value >= 'a' && value <= 'z')
|
||||
|| (value >= 'A' && value <= 'Z')
|
||||
|| (value >= '0' && value <= '9')
|
||||
|| value == '-'
|
||||
|| value == '.'
|
||||
|| value == '_'
|
||||
|| value == '~';
|
||||
}
|
||||
}
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.application.outbound.CallBudget;
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.slf4j.MDC;
|
||||
|
||||
class OutboundCallExecutorTest {
|
||||
|
||||
private final OutboundCallExecutor executor = new OutboundCallExecutor();
|
||||
|
||||
@Test
|
||||
void expiredBudgetDoesNotStartWork() {
|
||||
AtomicBoolean started = new AtomicBoolean();
|
||||
CallBudget expired = CallBudget.after(System.nanoTime() - 2, Duration.ofNanos(1));
|
||||
|
||||
assertThatThrownBy(() -> executor.execute(expired, () -> started.getAndSet(true)))
|
||||
.isInstanceOf(OutboundCallDeadlineExceededException.class);
|
||||
assertThat(started).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void interruptsRunningVirtualThreadWhenDeadlineWins() throws Exception {
|
||||
CountDownLatch started = new CountDownLatch(1);
|
||||
CountDownLatch interrupted = new CountDownLatch(1);
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
executor.execute(
|
||||
CallBudget.fromNow(Duration.ofMillis(80)),
|
||||
() -> {
|
||||
started.countDown();
|
||||
try {
|
||||
Thread.sleep(Duration.ofSeconds(5));
|
||||
} catch (InterruptedException exception) {
|
||||
interrupted.countDown();
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
return "late";
|
||||
}))
|
||||
.isInstanceOf(OutboundCallDeadlineExceededException.class);
|
||||
|
||||
assertThat(started.await(1, TimeUnit.SECONDS)).isTrue();
|
||||
assertThat(interrupted.await(1, TimeUnit.SECONDS)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsCompletedResultBeforeDeadline() {
|
||||
String result = executor.execute(CallBudget.fromNow(Duration.ofSeconds(1)), () -> "completed");
|
||||
|
||||
assertThat(result).isEqualTo("completed");
|
||||
}
|
||||
|
||||
@Test
|
||||
void propagatesAndCleansCallerMdcInTheWorker() {
|
||||
MDC.put("trace_id", "trace-1");
|
||||
try {
|
||||
String trace =
|
||||
executor.execute(CallBudget.fromNow(Duration.ofSeconds(1)), () -> MDC.get("trace_id"));
|
||||
|
||||
assertThat(trace).isEqualTo("trace-1");
|
||||
assertThat(MDC.get("trace_id")).isEqualTo("trace-1");
|
||||
} finally {
|
||||
MDC.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void callerInterruptionRemainsCancellationAndPreservesTheInterruptFlag() throws Exception {
|
||||
CountDownLatch operationStarted = new CountDownLatch(1);
|
||||
CountDownLatch operationInterrupted = new CountDownLatch(1);
|
||||
AtomicReference<Throwable> failure = new AtomicReference<>();
|
||||
AtomicBoolean callerInterruptPreserved = new AtomicBoolean();
|
||||
Thread caller =
|
||||
Thread.ofPlatform()
|
||||
.start(
|
||||
() -> {
|
||||
try {
|
||||
executor.execute(
|
||||
CallBudget.fromNow(Duration.ofSeconds(5)),
|
||||
() -> {
|
||||
operationStarted.countDown();
|
||||
try {
|
||||
Thread.sleep(Duration.ofSeconds(5));
|
||||
} catch (InterruptedException exception) {
|
||||
operationInterrupted.countDown();
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
return "late";
|
||||
});
|
||||
} catch (Throwable throwable) {
|
||||
failure.set(throwable);
|
||||
callerInterruptPreserved.set(Thread.currentThread().isInterrupted());
|
||||
}
|
||||
});
|
||||
|
||||
assertThat(operationStarted.await(1, TimeUnit.SECONDS)).isTrue();
|
||||
caller.interrupt();
|
||||
caller.join(1_000);
|
||||
|
||||
assertThat(caller.isAlive()).isFalse();
|
||||
assertThat(failure.get()).isInstanceOf(CancellationException.class);
|
||||
assertThat(callerInterruptPreserved).isTrue();
|
||||
assertThat(operationInterrupted.await(1, TimeUnit.SECONDS)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonCooperativeTimedOutWorkerKeepsItsBoundedAdmissionUntilItActuallyStops() throws Exception {
|
||||
OutboundHttpShutdownGuard guard = new OutboundHttpShutdownGuard();
|
||||
guard.start();
|
||||
OutboundCallExecutor bounded = new OutboundCallExecutor(guard, 1);
|
||||
CountDownLatch started = new CountDownLatch(1);
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
CountDownLatch exited = new CountDownLatch(1);
|
||||
AtomicReference<Throwable> firstFailure = new AtomicReference<>();
|
||||
Thread firstCaller =
|
||||
Thread.ofPlatform()
|
||||
.start(
|
||||
() -> {
|
||||
try {
|
||||
bounded.execute(
|
||||
CallBudget.fromNow(Duration.ofMillis(80)),
|
||||
() -> {
|
||||
started.countDown();
|
||||
try {
|
||||
while (release.getCount() > 0) {
|
||||
try {
|
||||
release.await();
|
||||
} catch (InterruptedException ignored) {
|
||||
// Deliberately non-cooperative to prove the admission stays owned.
|
||||
}
|
||||
}
|
||||
return "released";
|
||||
} finally {
|
||||
exited.countDown();
|
||||
}
|
||||
});
|
||||
} catch (Throwable throwable) {
|
||||
firstFailure.set(throwable);
|
||||
}
|
||||
});
|
||||
|
||||
assertThat(started.await(1, TimeUnit.SECONDS)).isTrue();
|
||||
firstCaller.join(1_000);
|
||||
assertThat(firstFailure.get()).isInstanceOf(OutboundCallDeadlineExceededException.class);
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
bounded.execute(CallBudget.fromNow(Duration.ofSeconds(1)), () -> "must-not-start"))
|
||||
.isInstanceOf(RejectedExecutionException.class);
|
||||
|
||||
release.countDown();
|
||||
assertThat(exited.await(1, TimeUnit.SECONDS)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shutdownCancelsInFlightWorkAndRejectsNewStarts() throws Exception {
|
||||
OutboundHttpShutdownGuard guard = new OutboundHttpShutdownGuard();
|
||||
guard.start();
|
||||
OutboundCallExecutor guarded = new OutboundCallExecutor(guard, 1);
|
||||
CountDownLatch started = new CountDownLatch(1);
|
||||
CountDownLatch interrupted = new CountDownLatch(1);
|
||||
AtomicReference<Throwable> failure = new AtomicReference<>();
|
||||
Thread caller =
|
||||
Thread.ofPlatform()
|
||||
.start(
|
||||
() -> {
|
||||
try {
|
||||
guarded.execute(
|
||||
CallBudget.fromNow(Duration.ofSeconds(5)),
|
||||
() -> {
|
||||
started.countDown();
|
||||
try {
|
||||
Thread.sleep(Duration.ofSeconds(5));
|
||||
} catch (InterruptedException exception) {
|
||||
interrupted.countDown();
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
return "late";
|
||||
});
|
||||
} catch (Throwable throwable) {
|
||||
failure.set(throwable);
|
||||
}
|
||||
});
|
||||
|
||||
assertThat(started.await(1, TimeUnit.SECONDS)).isTrue();
|
||||
guard.stop();
|
||||
caller.join(1_000);
|
||||
|
||||
assertThat(failure.get()).isInstanceOf(CancellationException.class);
|
||||
assertThat(interrupted.await(1, TimeUnit.SECONDS)).isTrue();
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
guarded.execute(CallBudget.fromNow(Duration.ofSeconds(1)), () -> "must-not-start"))
|
||||
.isInstanceOf(CancellationException.class);
|
||||
}
|
||||
}
|
||||
+12
@@ -12,6 +12,7 @@ import dev.caskeleton.shared.error.OperationalError;
|
||||
import io.github.resilience4j.circuitbreaker.CallNotPermittedException;
|
||||
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -164,6 +165,17 @@ class OutboundHttpCallObserverTest {
|
||||
assertThat(event.getFormattedMessage()).contains("outcome=\"REJECTED\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
void capacityRejectionIsLoggedAsRejectedWithoutAConnectFailureClassification() {
|
||||
DependencyFailureException dfe =
|
||||
observer.rejectCapacity(new RejectedExecutionException("capacity"));
|
||||
|
||||
assertThat(dfe.errorCode()).isEqualTo(OperationalError.DEPENDENCY_CIRCUIT_OPEN);
|
||||
assertThat(dfe.getMessage()).contains("before send");
|
||||
assertThat(logAppender.list)
|
||||
.anyMatch(event -> event.getFormattedMessage().contains("outcome=\"REJECTED\""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectShutdownLogsDuration0AndRetryAttempt0() {
|
||||
observer.rejectShutdown("shutdown test");
|
||||
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.catchThrowableOfType;
|
||||
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpDependencyLogger;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpErrorMapper;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.resilience.OutboundHttpResilience;
|
||||
import dev.caskeleton.application.outbound.CallBudget;
|
||||
import dev.caskeleton.shared.error.DependencyFailureException;
|
||||
import dev.caskeleton.shared.error.OperationalError;
|
||||
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
|
||||
import io.github.resilience4j.retry.RetryRegistry;
|
||||
import java.io.IOException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.util.unit.DataSize;
|
||||
|
||||
class OutboundHttpClientDeadlineTest {
|
||||
|
||||
private HttpServer server;
|
||||
private String baseUrl;
|
||||
|
||||
@BeforeEach
|
||||
void startServer() throws IOException {
|
||||
server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
|
||||
server.start();
|
||||
baseUrl = "http://localhost:" + server.getAddress().getPort();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void stopServer() {
|
||||
server.stop(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shorterCallerBudgetBoundsBlockingRead() {
|
||||
server.createContext(
|
||||
"/slow",
|
||||
exchange -> {
|
||||
try {
|
||||
Thread.sleep(2_000);
|
||||
exchange.sendResponseHeaders(200, -1);
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
} finally {
|
||||
exchange.close();
|
||||
}
|
||||
});
|
||||
OutboundHttpSettings settings = settings(false, false, Duration.ofMillis(100));
|
||||
OutboundHttpClient client = client(settings);
|
||||
long started = System.nanoTime();
|
||||
|
||||
DependencyFailureException failure =
|
||||
catchThrowableOfType(
|
||||
DependencyFailureException.class,
|
||||
() -> client.get("/slow", String.class, CallBudget.fromNow(Duration.ofMillis(100))));
|
||||
|
||||
assertThat(failure.errorCode()).isEqualTo(OperationalError.DEPENDENCY_TIMEOUT);
|
||||
assertThat(Duration.ofNanos(System.nanoTime() - started)).isLessThan(Duration.ofSeconds(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deadlineDuringBackoffPreventsSecondPhysicalAttempt() {
|
||||
AtomicInteger serverCalls = new AtomicInteger();
|
||||
server.createContext(
|
||||
"/retry",
|
||||
exchange -> {
|
||||
serverCalls.incrementAndGet();
|
||||
exchange.sendResponseHeaders(500, -1);
|
||||
exchange.close();
|
||||
});
|
||||
OutboundHttpSettings settings = settings(true, true, Duration.ofSeconds(1));
|
||||
CircuitBreakerRegistry circuitBreakers = CircuitBreakerRegistry.ofDefaults();
|
||||
OutboundHttpClient client = client(settings, circuitBreakers);
|
||||
|
||||
DependencyFailureException failure =
|
||||
catchThrowableOfType(
|
||||
DependencyFailureException.class,
|
||||
() -> client.get("/retry", String.class, CallBudget.fromNow(Duration.ofMillis(400))));
|
||||
|
||||
assertThat(failure.errorCode()).isEqualTo(OperationalError.DEPENDENCY_TIMEOUT);
|
||||
assertThat(serverCalls).hasValue(1);
|
||||
assertThat(circuitBreakers.circuitBreaker("deadline-dep").getMetrics().getNumberOfFailedCalls())
|
||||
.isEqualTo(1);
|
||||
}
|
||||
|
||||
private OutboundHttpClient client(OutboundHttpSettings settings) {
|
||||
return client(settings, CircuitBreakerRegistry.ofDefaults());
|
||||
}
|
||||
|
||||
private OutboundHttpClient client(
|
||||
OutboundHttpSettings settings, CircuitBreakerRegistry circuitBreakers) {
|
||||
OutboundHttpShutdownGuard guard = new OutboundHttpShutdownGuard();
|
||||
guard.start();
|
||||
OutboundHttpErrorMapper mapper = new OutboundHttpErrorMapper();
|
||||
OutboundRetryPolicy retryPolicy = new OutboundRetryPolicy(settings, guard, mapper);
|
||||
OutboundHttpResilience resilience =
|
||||
new OutboundHttpResilience(
|
||||
settings, retryPolicy, RetryRegistry.ofDefaults(), circuitBreakers);
|
||||
return OutboundHttpClient.baseline(
|
||||
"deadline-dep",
|
||||
baseUrl,
|
||||
settings,
|
||||
guard,
|
||||
resilience,
|
||||
retryPolicy,
|
||||
mapper,
|
||||
new OutboundHttpDependencyLogger());
|
||||
}
|
||||
|
||||
private static OutboundHttpSettings settings(
|
||||
boolean retry, boolean circuitBreaker, Duration retryBackoff) {
|
||||
return new OutboundHttpSettings(
|
||||
Duration.ofSeconds(1),
|
||||
Duration.ofSeconds(5),
|
||||
Duration.ofSeconds(5),
|
||||
retry,
|
||||
circuitBreaker,
|
||||
DataSize.ofMegabytes(1),
|
||||
new OutboundHttpSettings.Retry(3, retryBackoff, 1.0),
|
||||
null);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user