feat: redis, fileserver, httpclient 런타임 시점 구현 추가

This commit is contained in:
donghyeon-ka
2026-07-28 14:26:54 +09:00
parent 7363b2aa1e
commit b3add0162d
257 changed files with 30430 additions and 1357 deletions
+14
View File
@@ -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
+3
View File
@@ -10,5 +10,8 @@ build/
.classpath
.settings/
# jqwik property-test runtime state
.jqwik-database
# OS
.DS_Store
+15 -9
View File
@@ -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` 일 때 적용, 기본값은 기존 하드코딩 동작 보존):
+2 -2
View File
@@ -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`.
-5
View File
@@ -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
+2 -2
View File
@@ -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`.
+4 -8
View File
@@ -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'
}
+3 -4
View File
@@ -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
+5 -2
View File
@@ -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
+16
View File
@@ -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`)를
+6 -4
View File
@@ -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'
}
+6 -5
View File
@@ -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
@@ -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());
}
};
}
}
@@ -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());
}
}
@@ -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) {}
}
@@ -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();
}
}
+2 -2
View File
@@ -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
+19 -7
View File
@@ -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.
+110 -26
View File
@@ -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
```
+25 -5
View File
@@ -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
@@ -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;
}
}
@@ -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
}
}
@@ -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);
}
@@ -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",
@@ -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 {}
}
@@ -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;
}
}
@@ -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();
}
}
}
}
@@ -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
}
}
@@ -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);
}
}
}
@@ -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;
}
@@ -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);
}
}
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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);
}
@@ -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;
}
}
@@ -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();
}
}
@@ -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;
}
}
@@ -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");
}
}
@@ -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;
}
}
@@ -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);
}
}
}
@@ -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"
}
]
}
@@ -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'
@@ -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'
@@ -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'
@@ -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;
}
}
@@ -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();
}
}
@@ -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;
}
}
}
@@ -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();
}
}
@@ -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);
}
}
}
@@ -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());
});
}
}
@@ -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");
}
}
@@ -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();
}
}
@@ -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");
}
}
+21 -15
View File
@@ -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
+68 -38
View File
@@ -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
+6 -4
View File
@@ -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'
}
+17 -17
View File
@@ -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
@@ -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);
}
}
}
@@ -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;
}
}
@@ -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);
}
}
}
@@ -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);
}
@@ -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;
}
}
@@ -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");
}
}
}
@@ -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);
}
}
}
}
@@ -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++;
}
}
}
@@ -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);
}
}
@@ -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
}
}
@@ -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) {}
}
@@ -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"));
}
}
@@ -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()))
@@ -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();
}
}
}
@@ -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);
}
}
@@ -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");
}
}
+18 -7
View File
@@ -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.
+67 -12
View File
@@ -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 -1
View File
@@ -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
@@ -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");
}
}
@@ -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);
}
}
@@ -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";
@@ -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 () -> {
@@ -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");
}
}
}
@@ -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,
@@ -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();
}
}
}
@@ -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) {}
}
@@ -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(
@@ -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;
}
}
@@ -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}");
}
}
}
@@ -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();
}
}
@@ -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
}
}
@@ -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");
}
}
}
@@ -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 == '~';
}
}
@@ -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,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");
@@ -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