diff --git a/fileserver-superpowers-package/README.md b/fileserver-superpowers-package/README.md deleted file mode 100644 index eda4fb6..0000000 --- a/fileserver-superpowers-package/README.md +++ /dev/null @@ -1,25 +0,0 @@ -# Fileserver Superpowers Package - -## 포함 파일 - -- `fileserver-platform-design.md` — Fileserver 플랫폼 설계 확정안 -- `fileserver-platform-implementation-plan.md` — 33개 TDD 작업으로 분해한 구현 계획 -- `VALIDATION.md` — 문서 정적 검증 결과 -- `validate_fileserver_docs.py` — 검증 재실행 스크립트 - -## 저장소 배치 위치 - -```text -docs/superpowers/specs/2026-08-07-fileserver-platform-design.md -docs/superpowers/plans/2026-08-07-fileserver-platform-implementation-plan.md -``` - -## 실행 순서 - -1. 실제 Backend Skeleton 구조와 root package를 대조한다. -2. 설계서의 모듈 경계를 저장소에 반영한다. -3. 구현 계획 Task 1부터 순서대로 실행한다. -4. 각 Task에서 실패 테스트를 확인한 뒤 구현한다. -5. Milestone A~D마다 전체 검증 Gate를 실행한다. - -실행에는 `superpowers:subagent-driven-development` 방식이 권장된다. diff --git a/fileserver-superpowers-package/VALIDATION.md b/fileserver-superpowers-package/VALIDATION.md deleted file mode 100644 index 0b19a1d..0000000 --- a/fileserver-superpowers-package/VALIDATION.md +++ /dev/null @@ -1,43 +0,0 @@ -# Fileserver Superpowers 문서 검증 - -**결과:** PASS - -## 파일 - -- `fileserver-platform-design.md` — 1893 lines, 59904 bytes, SHA-256 `ee7b21277b254b9606a9ec6e34118a10fba3abbe818b43cbce9ae832102411e6` -- `fileserver-platform-implementation-plan.md` — 3422 lines, 131608 bytes, SHA-256 `9a443852ab3a7e4a2232c1b443d4cb8d3478a4954d70510173d3e0ac1d3d2125` - -## 검증 항목 - -- [x] **fileserver-platform-design.md exists** — /mnt/data/fileserver-platform-design.md -- [x] **fileserver-platform-implementation-plan.md exists** — /mnt/data/fileserver-platform-implementation-plan.md -- [x] **design title** — True -- [x] **plan header** — required Superpowers header -- [x] **design code fences** — count=94 -- [x] **plan code fences** — count=416 -- [x] **design placeholder scan** — hits=[] -- [x] **plan placeholder scan** — hits=[] -- [x] **design section coverage** — missing=[] -- [x] **design topic: MVC** — missing=[] -- [x] **design topic: WebFlux** — missing=[] -- [x] **design topic: local/PVC/NFS** — missing=[] -- [x] **design topic: content/metadata separation** — missing=[] -- [x] **design topic: upload** — missing=[] -- [x] **design topic: download** — missing=[] -- [x] **design topic: publish** — missing=[] -- [x] **design topic: security** — missing=[] -- [x] **design topic: resumable** — missing=[] -- [x] **design topic: observability** — missing=[] -- [x] **blocking core port leakage** — hits=[] -- [x] **task count** — count=33 -- [x] **task numbering** — numbers=[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33] -- [x] **task block completeness** — {} -- [x] **unique create paths** — {} -- [x] **plan scope coverage** — missing=[] -- [x] **no Redis carryover** — search term=redis -- [x] **no deprecated nginx token design** — token mapper removed - -## 검증 범위의 한계 - -- 현재 Backend Skeleton 저장소가 입력되지 않아 Gradle compilation, integration test, Nginx execution, PVC·NFS certification은 실행하지 않았다. -- 본 검증은 설계·계획 문서의 구조, 내부 일관성, 범위 추적성, 미확정 표식과 중복 경로를 확인한 정적 검증이다. diff --git a/fileserver-superpowers-package/fileserver-platform-design.md b/fileserver-superpowers-package/fileserver-platform-design.md deleted file mode 100644 index 7ea0daa..0000000 --- a/fileserver-superpowers-package/fileserver-platform-design.md +++ /dev/null @@ -1,1893 +0,0 @@ -# Fileserver Platform 설계서 - -**문서 상태:** 설계 확정안 -**작성 기준일:** 2026-08-07 -**입력 근거:** `Spring 기반 Fileserver 설계 심층 리서치` -**대상 저장소:** Spring 기반 Backend Skeleton - ---- - -## 1. 요약 - -이 설계는 Fileserver를 단순한 업로드·다운로드 컨트롤러가 아니라 다음 네 계층을 분리한 공통 파일 서비스 플랫폼으로 정의한다. - -1. **Content Store** — byte stream, staging, range read, publish, delete를 담당한다. -2. **Metadata Store** — 파일 상태, 소유·권한 연결 정보, 크기, digest, MIME 판정, version, lease, 만료를 관리한다. -3. **Transfer Adapter** — Spring MVC, Spring WebFlux, Nginx 위임으로 HTTP 전송을 제공한다. -4. **Verification Layer** — checksum, 형식 판정, 악성 파일 검사, quarantine을 담당한다. - -공개 API는 `fileId`와 `uploadId`만 사용한다. `Path`, 실제 파일명, 디렉터리, mount 경로, symlink와 같은 파일시스템 개념은 로컬 저장소 어댑터 밖으로 노출하지 않는다. 파일 내용과 메타데이터는 하나의 ACID transaction으로 묶을 수 없으므로 상태 머신, version, writer lease, reconciliation을 통해 일관성을 유지한다. - -최초 Stable 릴리스는 Linux 로컬 파일시스템과 인증된 Kubernetes PVC RWO를 대상으로 다음을 제공한다. - -- raw 및 multipart 단일 업로드 -- 제한형 다중 파일 업로드 -- streaming append와 SHA-256 검증 -- GET, HEAD, 단일 Range, 조건부 요청 -- 직접 전송과 Nginx 위임 -- logical delete와 비동기 physical cleanup -- MVC와 WebFlux 어댑터 -- 다중 인스턴스용 DB version·lease -- tus 1.0 별도 Stable 모듈 -- NFS·PVC RWX 제한 지원 프로파일 -- IETF resumable upload draft-12 Experimental 모듈 - ---- - -## 2. 목표와 성공 기준 - -### 2.1 목표 - -- 다양한 웹 서비스가 파일 업로드·다운로드를 즉시 사용할 수 있는 공통 기술 모듈을 제공한다. -- 로컬 디스크, PVC, NFS, 향후 Object Storage가 동일한 저장소 의미론을 공유하도록 한다. -- 최대 파일 크기에서도 JVM heap 사용량이 파일 크기에 비례하지 않도록 한다. -- 부분 파일, 경로 탈출, 권한 우회, 검사 전 공개를 구조적으로 차단한다. -- 장애 후 성공 여부가 모호한 작업을 단순 실패와 구분하고 복구할 수 있게 한다. -- 구현자가 설계 중 다시 판단하지 않도록 HTTP 계약, 상태 전이, 오류, 설정, 테스트 완료 조건을 고정한다. - -### 2.2 성공 기준 - -| 영역 | 완료 기준 | -|---|---| -| 공개 식별자 | 외부 API가 `fileId`, `uploadId`만 사용하고 실제 경로를 노출하지 않는다. | -| 업로드 | raw·multipart 스트리밍이 bounded memory로 동작하며 부분 파일은 READY 이전에 읽을 수 없다. | -| 무결성 | 서버가 actual size와 SHA-256을 계산하고 client digest가 있으면 검증한다. | -| publish | atomic move probe가 통과하거나 metadata pointer publish를 사용한다. | -| 다운로드 | `200`, `206`, `304`, `412`, `416`과 관련 header 계약을 일관되게 제공한다. | -| 보안 | traversal, symlink escape, 원본명 저장, 무조건 overwrite, 검사 전 공개를 차단한다. | -| 다중 인스턴스 | upload별 단일 writer lease와 metadata version 충돌 검사가 동작한다. | -| 장애 복구 | process kill, disk full, network interruption 후 READY invariant가 깨지지 않는다. | -| 운영 | temp, orphan, quota, disk usage, transfer, verification metric과 cleanup job을 제공한다. | -| 플랫폼 | Linux local과 지정 PVC 프로파일의 인증 테스트를 통과한다. | - ---- - -## 3. 범위 - -### 3.1 포함 범위 - -- Spring MVC와 Spring WebFlux -- blocking channel SPI와 async publisher SPI -- Linux local disk -- Kubernetes PVC RWO 인증 프로파일 -- 인증된 PVC RWX·NFSv4.1 제한 프로파일 -- Windows NTFS 호환성 CI 프로파일 -- 단일·다중 인스턴스 -- `multipart/form-data`, `application/octet-stream` -- 단일·제한형 다중 파일 업로드 -- streaming upload, cancellation, status, cleanup -- GET, HEAD, byte range, conditional request, cache header -- 애플리케이션 직접 전송, zero-copy capability, Nginx 위임 -- SHA-256, MIME·signature 검사 SPI, AV·CDR SPI -- quota reservation, concurrency limit, storage high-water 보호 -- tus 1.0 -- IETF resumable upload draft-12 Experimental -- 관리자 health, orphan scan, reconcile, cleanup, reverify -- metric, trace, audit, problem detail - -### 3.2 제외 범위 - -- 공개 API의 임의 절대·상대 경로 입력 -- 공개 디렉터리 list·scan -- symlink follow·생성 -- hard link 생성 -- 공개 재귀 삭제 -- webroot 내부 저장 -- 원본 파일명 그대로의 physical filename -- 조건 없는 overwrite -- READY 이전 다운로드 -- 하나의 offset에 대한 동시 append -- proxy가 이미 전달한 비멱등 upload의 자동 재시도 -- NFS lock만을 이용한 다중 인스턴스 정합성 -- 다른 `FileStore` 사이의 atomic move 보장 -- copy 실패 시 자동 rollback 보장 -- 모든 파일 형식의 안전성 판정 -- 임의 ZIP extraction -- Object Storage provider 구현과 signed URL -- FTP, SFTP, SMB client 기능 - ---- - -## 4. 고정 설계 결정 - -| 항목 | 결정 | -|---|---| -| 운영 우선 플랫폼 | Linux | -| Java | Java 21 | -| Spring | 6.2 최신 patch와 7.0 최신 patch를 release matrix에서 검증 | -| MVC | 정식 지원, streaming 전용 `AsyncTaskExecutor` 사용 | -| WebFlux | 정식 지원, event loop에서 blocking filesystem I/O 금지 | -| 공통 저장소 계약 | `Path`가 아니라 create·append·finalize·stat·openRead·delete 의미론 | -| metadata 기준 | 관계형 DB의 metadata가 authoritative | -| publish 기준 | same-FileStore atomic move 또는 metadata pointer publish | -| 공개 식별자 | opaque `FileId`, `UploadId` | -| physical key | 서버가 생성한 `ContentKey` | -| 원본명 | 비신뢰 표시 metadata | -| 기본 업로드 | create-only | -| overwrite | `If-Match` 또는 metadata version 필수 | -| checksum | 서버 계산 SHA-256 필수, client digest 선택 검증 | -| ETag | immutable READY bytes의 SHA-256 strong ETag | -| private cache | `private, no-store` 기본 | -| 재개 업로드 | tus 1.0 Stable, HTTPbis draft-12 Experimental | -| 다중 append | 단일 writer lease, 병렬 업로드는 독립 part 후 concatenate 방식만 | -| 삭제 | logical delete 후 physical cleanup | -| NFS | 외부 DB version·lease와 reconciliation을 전제로 제한 지원 | -| Windows | 초기 non-blocking compatibility profile | - ---- - -## 5. 지원 매트릭스 - -### 5.1 런타임·저장소 - -| 대상 | 지원 수준 | 조건 | -|---|---|---| -| Linux ext4/XFS local | 완전 지원 | startup capability probe 통과 | -| Kubernetes PVC RWO | 조건부 완전 | 지정 CSI·StorageClass·mount option 인증 | -| Kubernetes PVC RWX | 제한 지원 | 실제 backend별 release certification | -| NFSv4.1 | 제한 지원 | DB lease·version, ambiguous completion reconciliation | -| Windows NTFS | 호환성 | nightly test, 운영 지원은 후속 확정 | -| Nginx stable | 완전 지원 | internal location과 Range 계약 인증 | -| 단일 인스턴스 | 완전 지원 | process-local serialization 가능 | -| 다중 인스턴스 | 완전 지원 조건부 | 공유 metadata DB와 writer lease 필수 | - -### 5.2 프로토콜·기능 - -| 기능 | 수준 | 모듈 | -|---|---|---| -| raw upload | Stable | `fileserver-mvc`, `fileserver-webflux` | -| multipart 단일 | Stable | MVC·WebFlux | -| multipart batch | Stable 제한형 | 별도 batch endpoint, 비원자적 결과 배열 | -| direct download | Stable | MVC·WebFlux | -| single Range | Stable | core HTTP contract | -| multi Range | Beta | 개수·overlap·총량 budget 필수 | -| Nginx delegation | Stable | `fileserver-nginx` | -| tus 1.0 | Stable 별도 모듈 | `fileserver-tus` | -| HTTPbis draft-12 | Experimental | `fileserver-resumable-httpbis-draft12` | -| NFS RWX | Limited | 인증 프로파일 | -| Windows | Compatibility | CI profile | - ---- - -## 6. 전체 아키텍처 - -```text -HTTP Client - │ - ├─ Spring MVC Adapter - ├─ Spring WebFlux Adapter - └─ tus / HTTPbis Adapter - │ - ▼ -Application Services - ├─ UploadApplicationService - ├─ FinalizeUploadService - ├─ DownloadApplicationService - ├─ FileLifecycleService - ├─ CleanupApplicationService - └─ ReconciliationService - │ - ├───────────────┐ - ▼ ▼ -Metadata Store Port Content Store Port - │ │ - ▼ ├─ Local Filesystem Adapter -JPA Metadata Adapter └─ Future Object Storage Adapter - │ - ├─ Verification Port - ├─ Authorization Port - ├─ Quota Port - └─ Observability - -Download path -Application authorization - ├─ Direct transfer - └─ Nginx X-Accel-Redirect -``` - -### 6.1 의존 방향 - -- `fileserver-core-api`는 Spring MVC, WebFlux, JPA, NIO 구현 타입에 의존하지 않는다. -- `fileserver-application`은 core port만 사용한다. -- `fileserver-storage-local`은 NIO와 local path를 캡슐화한다. -- `fileserver-metadata-jpa`는 metadata port를 구현한다. -- HTTP adapter는 application service만 호출한다. -- Nginx 모듈은 물리 경로 대신 안전한 internal URI descriptor만 생성한다. -- 검사·권한·quota 정책은 SPI로 주입하며 Fileserver가 비즈니스 규칙을 내장하지 않는다. - -### 6.2 업로드 실행 흐름 - -```text -1. 인증·기술 정책 확인 -2. quota 예약 -3. FileRecord(CREATED)와 UploadSession 생성 -4. ContentStore.createUpload(CREATE_NEW) -5. FileRecord → UPLOADING -6. stream append + actual size + SHA-256 계산 -7. channel close -8. FileRecord → UPLOADED -9. verification 실행 -10. VERIFYING / QUARANTINED / REJECTED -11. publish strategy 실행 -12. physical stat 재검증 -13. metadata pointer, size, digest, MIME, version 기록 -14. FileRecord → READY -15. quota 예약을 committed usage로 전환 -``` - -### 6.3 다운로드 실행 흐름 - -```text -1. FileId 조회 -2. 존재 은닉 정책을 포함한 authorization -3. READY 상태 확인 -4. conditional header 평가 -5. Range parsing·budget 검증 -6. transfer mode 선택 - - DIRECT - - ZERO_COPY capability - - NGINX_DELEGATED -7. 응답 header 확정 -8. bytes 전송 또는 internal redirect -9. 성공·중단·전송량 관측 -``` - ---- - -## 7. 모듈 구조 - -```text -backend-skeleton/ -├── modules/fileserver/ -│ ├── fileserver-core-api/ -│ ├── fileserver-application/ -│ ├── fileserver-metadata-jpa/ -│ ├── fileserver-storage-local/ -│ ├── fileserver-verification/ -│ ├── fileserver-mvc/ -│ ├── fileserver-webflux/ -│ ├── fileserver-nginx/ -│ ├── fileserver-admin/ -│ ├── fileserver-tus/ -│ ├── fileserver-resumable-httpbis-draft12/ -│ ├── fileserver-spring-boot-starter/ -│ └── fileserver-testkit/ -├── infra/fileserver/ -│ ├── local/ -│ ├── nginx/ -│ ├── nfs/ -│ └── kubernetes/ -└── docs/fileserver/ - ├── support-matrix.md - ├── http-contract.md - ├── storage-certification.md - ├── security.md - ├── operations.md - └── upgrade-guide.md -``` - -| 모듈 | 책임 | -|---|---| -| `fileserver-core-api` | ID, 상태, value object, port, 오류, capability | -| `fileserver-application` | upload·download·lifecycle orchestration | -| `fileserver-metadata-jpa` | metadata, lease, quota reservation persistence | -| `fileserver-storage-local` | staging, append, range read, publish, delete, probe | -| `fileserver-verification` | digest, MIME verdict, scanner pipeline | -| `fileserver-mvc` | Servlet multipart/raw/download adapter | -| `fileserver-webflux` | `PartEvent`, `DataBuffer`, reactive transfer adapter | -| `fileserver-nginx` | internal URI와 `X-Accel-Redirect` response strategy | -| `fileserver-admin` | health, orphan, reconcile, cleanup, reverify | -| `fileserver-tus` | tus 1.0 protocol adapter | -| `fileserver-resumable-httpbis-draft12` | versioned Experimental protocol adapter | -| `fileserver-spring-boot-starter` | properties, auto-configuration, startup gate | -| `fileserver-testkit` | contract, filesystem, HTTP, fault, performance harness | - ---- - -## 8. 핵심 공개 모델 - -### 8.1 식별자 - -```java -public record FileId(UUID value) { - public FileId { - Objects.requireNonNull(value, "value"); - } -} - -public record UploadId(UUID value) { - public UploadId { - Objects.requireNonNull(value, "value"); - } -} - -public record ContentKey(String value) { - public ContentKey { - if (value == null || !value.matches("[a-z0-9/_-]{16,200}")) { - throw new IllegalArgumentException("invalid content key"); - } - } -} - -public record StorageNamespace(String value) { - public StorageNamespace { - if (value == null || !value.matches("[a-z][a-z0-9-]{1,62}")) { - throw new IllegalArgumentException("invalid storage namespace"); - } - } -} -``` - -`ContentKey`는 public HTTP contract에 포함하지 않는다. `FileId`는 추측하기 어려운 ID를 사용하지만 비밀 token으로 취급하지 않으며 모든 요청에서 authorization을 수행한다. - -### 8.2 파일 상태 - -```java -public enum FileState { - CREATED, - UPLOADING, - UPLOADED, - VERIFYING, - QUARANTINED, - READY, - REJECTED, - FAILED, - DELETING, - DELETED, - EXPIRED -} -``` - -허용 전이는 `FileStateMachine` 하나에서 관리한다. persistence adapter나 controller가 상태를 직접 대입하지 않는다. - -```java -public interface FileStateMachine { - void requireTransition(FileState current, FileState target); - boolean canTransition(FileState current, FileState target); -} -``` - -### 8.3 ByteRange - -```java -public record ByteRange(long startInclusive, long endInclusive) { - public ByteRange { - if (startInclusive < 0 || endInclusive < startInclusive) { - throw new IllegalArgumentException("invalid byte range"); - } - } - - public long length() { - return Math.addExact(Math.subtractExact(endInclusive, startInclusive), 1); - } -} -``` - -HTTP suffix/open-ended Range는 HTTP adapter의 parser가 현재 representation 길이를 기준으로 위 value object로 정규화한다. - -### 8.4 파일 metadata - -```java -public record FileDescriptor( - FileId fileId, - StorageNamespace namespace, - FileState state, - String originalFilename, - String mediaType, - long size, - String sha256, - String strongEtag, - Instant publishedAt, - long version -) {} -``` - -실제 path, scanner 원문 응답, user metadata 원문은 public descriptor에 포함하지 않는다. - ---- - -## 9. 상태 머신과 invariant - -### 9.1 상태 전이 - -```text -CREATED → UPLOADING -UPLOADING → UPLOADED | FAILED | EXPIRED | DELETING -UPLOADED → VERIFYING | FAILED | DELETING -VERIFYING → READY | QUARANTINED | REJECTED | FAILED -QUARANTINED → VERIFYING | READY | REJECTED | DELETING -READY → DELETING -REJECTED → DELETING -FAILED → UPLOADING | VERIFYING | DELETING | EXPIRED -DELETING → DELETED | FAILED -EXPIRED → DELETING -``` - -`FAILED`에서의 복구 전이는 저장된 `lastErrorCode`와 recovery policy가 허용할 때만 수행한다. - -### 9.2 필수 invariant - -- READY에는 읽을 수 있는 immutable content가 존재한다. -- READY의 size와 SHA-256은 실제 bytes와 일치한다. -- READY가 아닌 레코드는 direct download와 Nginx internal mapping에서 제외된다. -- 하나의 upload에는 하나의 유효 writer lease만 존재한다. -- offset은 durable append가 확인된 byte 수만큼만 증가한다. -- client가 주장한 크기·MIME·파일명은 authoritative 값이 아니다. -- REJECTED, DELETED, EXPIRED는 public API에서 재활성화되지 않는다. -- DB와 storage가 불일치하면 READY를 추정하지 않고 recovery queue로 보낸다. -- logical delete가 성공하면 신규 download authorization은 즉시 차단된다. -- physical cleanup 실패는 DELETING 또는 FAILED 상태와 운영 경보로 남는다. - ---- - -## 10. Metadata Store 설계 - -### 10.1 Port - -```java -public interface FileMetadataStore { - FileRecord insert(FileRecordDraft draft); - Optional find(FileId fileId); - FileRecord transition( - FileId fileId, - long expectedVersion, - FileState expectedState, - FileState targetState, - FileRecordMutation mutation - ); - FileRecord markDeleting(FileId fileId, long expectedVersion); - List findRecoverable(FileRecoveryQuery query); -} - -public interface UploadSessionStore { - UploadSession create(UploadSessionDraft draft); - Optional find(UploadId uploadId); - WriterLease acquireLease( - UploadId uploadId, - String owner, - Instant now, - Duration leaseDuration, - long expectedVersion - ); - UploadSession commitOffset( - UploadId uploadId, - WriterLease lease, - long expectedOffset, - long committedOffset - ); - void releaseLease(UploadId uploadId, WriterLease lease); - List findExpired(Instant cutoff, int limit); -} -``` - -### 10.2 관계형 schema - -| Table | 핵심 컬럼 | -|---|---| -| `fs_file` | `file_id`, `namespace`, `state`, `content_key`, `original_name`, `claimed_media_type`, `verified_media_type`, `expected_size`, `actual_size`, `sha256`, `strong_etag`, `published_at`, `version`, `last_error_code`, timestamps | -| `fs_upload_session` | `upload_id`, `file_id`, `expected_length`, `committed_offset`, `protocol`, `expires_at`, `lease_owner`, `lease_until`, `version` | -| `fs_verification_result` | `file_id`, `verifier`, `verdict`, `details_code`, `started_at`, `completed_at` | -| `fs_quota_reservation` | `reservation_id`, `scope`, `reserved_bytes`, `committed_bytes`, `expires_at`, `status`, `version` | -| `fs_cleanup_item` | `cleanup_id`, `file_id`, `content_key`, `type`, `attempt`, `next_attempt_at`, `status`, `last_error_code` | - -`fs_file.version`과 `fs_upload_session.version`은 optimistic locking에 사용한다. 모든 상태 전이는 `WHERE version = ? AND state = ?` 조건을 포함한다. - -### 10.3 authoritative source - -- 공개 metadata는 `fs_file`을 기준으로 한다. -- physical `stat`은 publish 검증과 reconciliation에 사용한다. -- NFS·PVC의 timestamp는 Last-Modified의 authoritative source로 사용하지 않는다. -- `published_at`을 HTTP Last-Modified로 사용한다. - - -## 11. Content Store Port - -### 11.1 Capability - -```java -public record ContentStoreCapabilities( - boolean rangedRead, - boolean atomicCreate, - boolean atomicPublish, - boolean conditionalWrite, - boolean serverSideCopy, - boolean delegatedDownload, - boolean resumableAppend -) {} -``` - -Capability는 설정값만 읽지 않고 실제 저장소 root에서 startup probe한 결과로 생성한다. - -### 11.2 Blocking SPI - -```java -public interface BlockingContentStore { - UploadHandle createUpload(CreateContentCommand command); - - AppendResult append( - UploadHandle handle, - long expectedOffset, - ReadableByteChannel source, - long contentLength - ); - - StoredContent finalizeUpload( - UploadHandle handle, - FinalizeContentCommand command - ); - - ContentMetadata stat(ContentKey key); - - ReadableByteChannel openRead(ContentKey key, ByteRange range); - - DeleteResult delete(ContentKey key, DeletePrecondition precondition); - - ContentStoreCapabilities capabilities(); -} -``` - -### 11.3 Async SPI - -```java -public interface AsyncContentStore { - CompletionStage createUpload(CreateContentCommand command); - - CompletionStage append( - UploadHandle handle, - long expectedOffset, - Flow.Publisher content - ); - - CompletionStage finalizeUpload( - UploadHandle handle, - FinalizeContentCommand command - ); - - CompletionStage stat(ContentKey key); - - Flow.Publisher openRead(ContentKey key, ByteRange range); - - CompletionStage delete( - ContentKey key, - DeletePrecondition precondition - ); - - ContentStoreCapabilities capabilities(); -} -``` - -공통 SPI에 Spring `Resource`, `DataBuffer`, Reactor 타입을 포함하지 않는다. WebFlux adapter는 `Flow.Publisher`와 `Flux` 사이를 변환하고 pooled buffer의 수명주기를 책임진다. - -### 11.4 Capability 확장 - -```java -public interface CopyCapableContentStore { - CompletionStage copy( - ContentKey source, - ContentKey target, - CopyPrecondition precondition - ); -} - -public interface CapacityAwareContentStore { - StorageCapacity capacity(); -} - -public interface DelegatedDownloadStore { - DelegatedDownloadDescriptor createDelegation( - ContentKey key, - ByteRange range, - Duration ttl - ); -} -``` - -`copy`, capacity, delegation은 최소 Port에 강제하지 않는다. - ---- - -## 12. Local Filesystem Adapter - -### 12.1 저장 레이아웃 - -```text -${root}/ -├── staging/ -│ └── ab/cd/.part -├── content/ -│ └── ab/cd/.bin -├── quarantine/ -│ └── ab/cd/.bin -└── probe/ -``` - -- shard는 server-generated ID의 앞 2 byte씩 사용한다. -- 원본 파일명과 확장자를 physical filename에 사용하지 않는다. -- `staging`, `content`, `quarantine`은 동일 `FileStore`에 위치해야 한다. -- root는 application source, config, webroot와 분리한다. -- startup에서 디렉터리 owner·permission을 검증한다. - -### 12.2 경로 안전 규칙 - -```java -public interface PhysicalPathResolver { - Path stagingPath(UploadId uploadId); - Path contentPath(ContentKey contentKey); - Path quarantinePath(ContentKey contentKey); -} -``` - -`PhysicalPathResolver`는 `fileserver-storage-local` 내부 package-private 구현으로 둔다. 공개 module export 대상이 아니다. - -필수 검사: - -1. absolute 또는 drive-qualified 입력을 받지 않는다. -2. ID에서 만든 고정 component만 resolve한다. -3. normalize 결과가 root 아래인지 확인한다. -4. 모든 open·stat·delete에 `NOFOLLOW_LINKS`를 사용한다. -5. parent component가 symlink인지 확인한다. -6. provider가 지원하면 `SecureDirectoryStream`을 사용한다. -7. open 후 file identity와 expected parent identity를 재검증한다. - -### 12.3 staging 생성 - -- `CREATE_NEW`, `WRITE`, `NOFOLLOW_LINKS`로 연다. -- 충돌 시 새로운 storage key를 재발급하지 않고 invariant violation으로 기록한다. -- file permission은 owner read·write만 허용하는 프로파일을 기본으로 한다. -- append 전에 실제 file length와 metadata offset을 대조한다. - -### 12.4 append - -- 고정 크기 direct buffer pool 또는 heap buffer를 사용하며 파일 전체를 적재하지 않는다. -- 기본 buffer는 128 KiB다. -- `expectedOffset`이 실제 길이 또는 metadata offset과 다르면 append를 수행하지 않는다. -- 실제 수신 byte 수가 정책 최대값을 넘으면 즉시 중단한다. -- append 도중 실제 size와 SHA-256을 streaming 계산한다. -- `contentLength >= 0`이면 실제 append byte와 일치해야 한다. -- cancellation과 exception 시 channel을 닫고 session은 복구 가능한 상태로 남긴다. - -### 12.5 delete - -- symbolic link를 따라가지 않는다. -- logical delete를 먼저 수행한 뒤 cleanup worker가 physical object를 삭제한다. -- large file은 삭제 latency와 filesystem 특성을 metric으로 기록한다. -- 실제 파일이 이미 없으면 idempotent success로 처리하되 reconciliation event를 남긴다. - ---- - -## 13. Storage Capability Probe와 Startup Gate - -### 13.1 Probe 항목 - -| Probe | 통과 기준 | 실패 정책 | -|---|---|---| -| writable root | create·write·close·delete 성공 | startup 실패 | -| `CREATE_NEW` 경쟁 | 두 동시 create 중 정확히 하나 성공 | startup 실패 | -| same `FileStore` | staging·content·quarantine 동일 | startup 실패 | -| atomic move | observer가 partial target을 보지 않고 move 성공 | mode에 따라 실패 또는 pointer publish | -| replace | old 또는 new만 관측 | overwrite capability 비활성 | -| fsync profile | force 후 restart test 결과 저장 | durability 등급 표시 | -| symlink no-follow | target 접근이 차단됨 | startup 실패 | -| open-delete | OS 동작 기록 | lifecycle policy 조정 | -| capacity | usable·total 조회 가능 | admin capability 제한 | - -### 13.2 Publish mode - -```java -public enum PublishMode { - ATOMIC_MOVE_REQUIRED, - ATOMIC_MOVE_PREFERRED, - METADATA_POINTER -} -``` - -- `ATOMIC_MOVE_REQUIRED`: probe 실패 시 startup 실패 -- `ATOMIC_MOVE_PREFERRED`: 가능하면 atomic move, 불가능하면 pointer publish -- `METADATA_POINTER`: immutable physical key를 완성한 뒤 DB pointer를 READY boundary로 사용 - -기본값은 `ATOMIC_MOVE_PREFERRED`다. - -### 13.3 Runtime capability endpoint - -`GET /internal/fileserver/capabilities`는 다음을 제공한다. - -```json -{ - "storageType": "LOCAL", - "publishMode": "ATOMIC_MOVE_PREFERRED", - "rangedRead": true, - "atomicCreate": true, - "atomicPublish": true, - "conditionalWrite": true, - "delegatedDownload": true, - "resumableAppend": true, - "filesystemProfile": "linux-ext4" -} -``` - -physical root와 mount detail은 반환하지 않는다. - ---- - -## 14. Publish와 완료 처리 - -### 14.1 Atomic move strategy - -```text -staging channel close -→ optional `FileChannel.force(true)` -→ verify expected length·digest -→ target parent 준비 -→ `Files.move(staging, target, ATOMIC_MOVE)` -→ target stat -→ DB READY transition -``` - -`REPLACE_EXISTING`은 overwrite precondition이 있는 경로에서만 사용한다. create-only 경로는 target이 이미 있으면 실패한다. - -### 14.2 Metadata pointer strategy - -```text -staging write 완료 -→ immutable content key로 새 physical object 완성 -→ physical stat 검증 -→ DB transaction에서 contentKey pointer와 READY 상태 publish -→ 이전 physical object를 cleanup queue에 등록 -``` - -이 전략은 rename의 원자성 대신 metadata store transaction을 public publish boundary로 사용한다. - -### 14.3 Ambiguous completion - -다음 상황은 `AmbiguousCompletionException`으로 분류한다. - -- NFS rename request가 서버에서 처리되었을 수 있으나 응답이 유실됨 -- write·force 후 연결 또는 mount 응답이 사라짐 -- DB commit 응답을 받지 못해 상태 전이 성공 여부를 알 수 없음 - -처리 순서: - -1. operation ID와 expected physical key를 조회한다. -2. metadata version과 state를 재조회한다. -3. physical stat·size·digest를 확인한다. -4. 명백한 성공이면 성공 결과를 복원한다. -5. 명백한 미실행이면 제한적으로 재실행한다. -6. 판정 불가면 recovery queue와 `retryable=false, reconciliationRequired=true` 오류를 반환한다. - ---- - -## 15. Upload Application 설계 - -### 15.1 공개 command - -```java -public record CreateUploadRequest( - StorageNamespace namespace, - String originalFilename, - String claimedMediaType, - OptionalLong expectedLength, - Optional expectedSha256, - UploadProtocol protocol, - Instant expiresAt -) {} - -public interface UploadApplicationService { - UploadSessionView create(CreateUploadRequest request, RequestContext context); - - AppendUploadResult append( - UploadId uploadId, - long expectedOffset, - ReadableByteChannel content, - long contentLength, - RequestContext context - ); - - FileView finalizeUpload( - UploadId uploadId, - FinalizeUploadRequest request, - RequestContext context - ); - - UploadSessionView status(UploadId uploadId, RequestContext context); - - void cancel(UploadId uploadId, RequestContext context); -} -``` - -Async API는 별도 interface로 동일 의미를 제공한다. - -### 15.2 Create - -- authorization hook 실행 -- expected length가 있으면 정책 최대값 검증 -- quota reservation 생성 -- FileRecord CREATED 생성 -- UploadSession 생성 -- storage staging 생성 -- state를 UPLOADING으로 전이 -- `Location`과 current offset 0 반환 - -DB 생성 후 storage 생성이 실패하면 FileRecord를 FAILED로 전이하고 quota reservation을 해제한다. storage 생성 후 DB 응답이 모호하면 operation ID로 reconciliation한다. - -### 15.3 Append - -- upload 상태·만료 확인 -- writer lease 획득 -- metadata offset, physical length, request offset 일치 검증 -- concurrency, rate, storage high-water gate 확인 -- streaming append -- committed offset 저장 -- lease release - -append 실패 후 offset은 실제 저장이 확인된 길이까지만 증가한다. metadata offset과 physical length가 다르면 자동 append하지 않고 reconciliation으로 보낸다. - -### 15.4 Finalize - -- expected length가 있으면 committed offset과 비교 -- server SHA-256과 client digest 비교 -- state를 UPLOADED로 전이 -- verification pipeline 실행 -- verdict가 ACCEPT이면 publish -- metadata READY 전이 -- quota commit -- REJECT 또는 QUARANTINE이면 public download 금지 - -### 15.5 Multipart batch - -`POST /v1/files:batch`는 다음 계약을 사용한다. - -- 최대 part 수 기본 16 -- 각 파일은 독립 FileRecord·UploadSession -- 요청 전체 ACID 원자성은 보장하지 않는다. -- 일부 실패 시 성공 파일을 rollback하지 않는다. -- `200 OK`와 파일별 결과 배열을 반환한다. -- 총 request byte와 tenant quota를 요청 전·중 모두 검사한다. - -```json -{ - "results": [ - {"clientPartId":"a", "status":"CREATED", "fileId":"..."}, - {"clientPartId":"b", "status":"REJECTED", "problem":{"code":"FILE_TOO_LARGE"}} - ] -} -``` - ---- - -## 16. Verification Layer - -### 16.1 Port - -```java -public interface FileVerifier { - String verifierId(); - CompletionStage verify(VerificationRequest request); -} - -public record VerificationResult( - VerificationVerdict verdict, - String code, - Optional verifiedMediaType, - Map safeMetadata -) {} - -public enum VerificationVerdict { - ACCEPT, - QUARANTINE, - REJECT, - RETRY -} -``` - -### 16.2 기본 pipeline - -```text -Length verifier -→ SHA-256 verifier -→ filename policy -→ media type detector -→ signature/parser verifier -→ optional AV scanner -→ optional CDR -→ final policy combiner -``` - -- client `Content-Type`은 claimed metadata로만 저장한다. -- 단순 magic byte 일치만으로 안전 판정을 내리지 않는다. -- scanner timeout은 READY로 우회하지 않는다. -- 위험 형식은 quarantine 또는 reject한다. -- HTML, SVG 등 scriptable 문서는 기본 attachment이며 inline은 명시적 안전 프로파일에서만 허용한다. - -### 16.3 검사 비동기화 - -- 검사 시간이 짧은 프로파일은 upload request 안에서 완료하여 `201`을 반환할 수 있다. -- AV·CDR처럼 긴 검사는 `202 Accepted`와 VERIFYING 상태를 반환한다. -- READY 전환은 verification worker가 수행한다. -- retryable scanner 장애는 exponential backoff와 최대 시도 횟수를 사용한다. -- 최대 시도 초과는 FAILED 또는 QUARANTINED로 전이한다. - ---- - -## 17. Authorization과 기술 정책 Hook - -```java -public interface FileAccessPolicy { - void authorize(FileOperation operation, FileAccessSubject subject, FileDescriptor descriptor); -} - -public enum FileOperation { - CREATE, - APPEND, - FINALIZE, - READ_METADATA, - DOWNLOAD, - DELETE, - COPY, - MOVE, - ADMIN_REVERIFY, - ADMIN_FORCE_DELETE -} -``` - -Fileserver는 사용자 등급·업무 역할 같은 비즈니스 정책을 내장하지 않는다. 대신 모든 공개 operation에서 위 hook을 반드시 호출하고, starter가 no-op allow-all 구현을 운영 프로파일에서 자동 생성하지 않도록 한다. - -존재 은닉 프로파일에서는 권한 없는 file에 `404`를 반환한다. 내부 audit에는 `ACCESS_DENIED`를 기록하되 fileId·userId 원문을 metric label에 사용하지 않는다. - ---- - -## 18. Quota, Capacity와 Transfer Budget - -### 18.1 Quota Port - -```java -public interface FileQuotaService { - QuotaReservation reserve(QuotaScope scope, long expectedBytes, Duration ttl); - void extend(QuotaReservation reservation, long additionalBytes); - void commit(QuotaReservation reservation, long actualBytes); - void release(QuotaReservation reservation); -} -``` - -expected length가 없으면 프로파일별 initial reservation을 잡고 append 중 증분 예약한다. - -### 18.2 기본 운영 프로파일 - -| 설정 | Standard | Large-file | -|---|---:|---:| -| 최대 파일 | 100 MiB | 5 GiB | -| 최대 request | 116 MiB | 5 GiB + 16 MiB | -| 최대 multipart part | 16 | 16 | -| in-memory part | 512 KiB | 256 KiB | -| stream buffer | 128 KiB | 256 KiB | -| 인스턴스 동시 upload | 16 | 32 | -| 인스턴스 direct download | 64 | 128 | -| scope 동시 upload | 4 | 8 | -| temp soft limit | usable 70% | usable 70% | -| temp hard limit | usable 85% | usable 85% | -| idle read timeout | 45 s | 60 s | -| 미완료 upload TTL | 24 h | 72 h | -| multi Range 최대 개수 | 8 | 8 | - -이 값은 starter 기본값이며 운영 환경은 부하 인증 결과로 재정의한다. - -### 18.3 Admission control - -새 upload는 다음 중 하나가 발생하면 거절한다. - -- quota reservation 실패 -- storage hard high-water 초과 -- instance upload permit 고갈 -- scope 동시성 초과 -- verification queue hard limit 초과 - -soft high-water에서는 대용량 upload를 throttle하거나 `429/503`과 `Retry-After`를 반환한다. - ---- - -## 19. HTTP API - -### 19.1 공개 endpoint - -| Method·Path | 목적 | 성공 | -|---|---|---| -| `POST /v1/files` | multipart 단일 업로드 | `201` READY 또는 `202` VERIFYING | -| `POST /v1/files:raw` | raw streaming 업로드 | `201` 또는 `202` | -| `POST /v1/files:batch` | 제한형 다중 업로드 | `200` 결과 배열 | -| `PUT /v1/files/{fileId}/content` | create-only·조건부 교체 | `201` 또는 `204` | -| `GET /v1/files/{fileId}` | metadata | `200` | -| `GET /v1/files/{fileId}/content` | download | `200`, `206`, `304` | -| `HEAD /v1/files/{fileId}/content` | download metadata | `200`, `304` | -| `DELETE /v1/files/{fileId}` | logical delete | `202` 또는 `204` | -| `POST /v1/files/{fileId}:copy` | 조건부 copy | `202` | -| `POST /v1/files/{fileId}:move` | logical namespace move | `200` 또는 `204` | -| `POST /v1/uploads` | resumable resource 생성 | `201` | -| `HEAD /v1/uploads/{uploadId}` | offset 조회 | protocol별 `200/204` | -| `PATCH /v1/uploads/{uploadId}` | append | `204` | -| `DELETE /v1/uploads/{uploadId}` | cancel | `204` | - -### 19.2 Header 계약 - -| Header | 계약 | -|---|---| -| `Content-Type` | client 값은 claimed type, verified type을 별도 저장 | -| `Content-Length` | 있으면 사전 검증, 없어도 streamed hard limit 적용 | -| `Content-Disposition` | `inline` 또는 `attachment`, `filename` + `filename*` | -| `Accept-Ranges` | byte range 지원 시 `bytes` | -| `Range` | 기본 single, budget이 있는 경우 제한형 multi | -| `Content-Range` | `206` 실제 범위, `416`은 `bytes */size` | -| `ETag` | SHA-256 strong validator | -| `Last-Modified` | `publishedAt` | -| `If-None-Match` | GET·HEAD revalidation, create-only `*` | -| `If-Modified-Since` | ETag 보조 | -| `If-Match` | overwrite·delete lost-update 방지 | -| `If-Range` | validator 일치 시에만 partial | -| `Cache-Control` | private 기본 `private, no-store` | -| `Content-Digest` | 실제 HTTP message content digest | -| `Repr-Digest` | 전체 representation digest 선택 제공 | -| `Location` | 생성된 file·upload resource | -| `Retry-After` | `429`, `503`, 장기 검사의 polling 힌트 | -| `X-Accel-Redirect` | Nginx 내부 응답 전용 | - -### 19.3 상태 코드 - -| Status | 조건 | -|---:|---| -| `200` | metadata, 전체 GET, batch result | -| `201` | file 또는 upload 생성 | -| `202` | 검사 또는 physical cleanup 비동기 | -| `204` | append, cancel, body 없는 update | -| `206` | satisfiable Range | -| `304` | GET·HEAD validator 일치 | -| `400` | 잘못된 header·요청 조합 | -| `401` | 인증 없음 | -| `403/404` | 접근 거부 또는 존재 은닉 | -| `409` | 상태·offset·lease 충돌 | -| `410` | 만료 upload | -| `411` | `require-content-length=true` 프로파일 | -| `412` | precondition 실패 | -| `413` | 크기·quota 정책 위반 | -| `415` | 허용하지 않는 upload media type | -| `416` | 만족 불가능 Range | -| `422` | digest·signature·scanner reject | -| `429` | 동시성·rate limit | -| `503` | storage·scanner unavailable | -| `504` | downstream timeout | -| `507` | 저장공간 부족 | - ---- - -## 20. Range와 Conditional Request - -### 20.1 Range parser - -```java -public interface HttpRangeResolver { - ResolvedRanges resolve(String rangeHeader, long representationLength, RangeBudget budget); -} - -public record RangeBudget( - int maxRanges, - long maxTotalBytes, - boolean mergeOverlaps -) {} -``` - -기본 public 다운로드는 single Range만 허용한다. multi Range를 활성화한 profile에서는 최대 8개, overlap merge 후 총 byte가 representation 길이 이하인 경우만 허용한다. - -### 20.2 응답 결정 순서 - -```text -authorization -→ READY 확인 -→ current ETag·Last-Modified 계산 -→ If-Match / If-Unmodified-Since -→ If-None-Match / If-Modified-Since -→ Range parse -→ If-Range 평가 -→ 200 / 206 / 304 / 412 / 416 결정 -``` - -`If-Range`가 불일치하면 Range를 무시하고 전체 `200`을 반환한다. - -### 20.3 ETag와 digest - -- stored SHA-256을 quoted strong ETag로 사용한다. -- metadata-only 변경은 representation ETag를 바꾸지 않는다. -- `Content-Digest`는 전송 bytes 기준이다. -- full response에서는 stored SHA-256을 재사용할 수 있다. -- partial response에서는 해당 range digest를 streaming 계산하거나 기능을 비활성화한다. -- 전체 representation digest가 필요하면 `Repr-Digest`를 제공한다. - - -## 21. Spring MVC Adapter - -### 21.1 Upload - -- `MultipartFile#getBytes()`를 사용하지 않는다. -- raw upload는 request input stream을 `ReadableByteChannel`로 변환한다. -- multipart는 container threshold와 temp directory를 starter가 명시적으로 설정한다. -- upload request thread가 storage write를 장시간 점유하지 않도록 전용 executor를 사용한다. -- 기본 executor는 bounded queue와 rejection policy를 가진다. -- request cancellation과 client disconnect를 application service에 전달한다. - -### 21.2 Download - -전송 전략은 다음 순서로 선택한다. - -1. Nginx 위임이 활성화되고 threshold 이상이면 delegation -2. local `Path`를 안전하게 반환할 수 있고 zero-copy 조건이 맞으면 zero-copy capability -3. 그 외 `StreamingResponseBody` - -Range 처리는 core HTTP contract가 결정한다. Spring의 자동 Range 지원에만 의존하지 않고 MVC와 WebFlux가 같은 결과를 반환하도록 공통 resolver를 사용한다. `InputStreamResource`는 반복 가능한 Range resource로 사용하지 않는다. - -### 21.3 Executor - -```java -public record MvcTransferExecutorProperties( - int coreThreads, - int maxThreads, - int queueCapacity, - Duration shutdownTimeout -) {} -``` - -기본값: - -```text -coreThreads=8 -maxThreads=32 -queueCapacity=64 -shutdownTimeout=30s -``` - -queue가 가득 차면 무제한 대기하지 않고 `429` 또는 `503`으로 변환한다. - ---- - -## 22. Spring WebFlux Adapter - -### 22.1 Upload - -- raw body는 `Flux`를 순차 소비한다. -- multipart streaming은 `Flux`를 사용한다. -- pooled `DataBuffer`는 전달하거나 명시적으로 release한다. -- blocking local filesystem adapter 호출은 bounded elastic이 아니라 전용 bounded scheduler에서 실행한다. -- async store가 제공되면 event loop를 유지한 채 `Flow.Publisher`로 전달한다. -- cancellation 시 channel, lease, temp resource를 정리한다. - -### 22.2 Download - -- async store는 `Flux`로 변환한다. -- local file zero-copy가 runtime에서 가능하면 capability optimization으로 사용한다. -- Range와 conditional 결정은 MVC와 동일한 core resolver를 사용한다. -- slow client에서 in-flight buffer 수가 설정 상한을 넘지 않도록 한다. - -### 22.3 Blocking 검출 - -CI에서 BlockHound 또는 동등한 검증으로 다음을 차단한다. - -- event loop에서 `Files.*`, `FileChannel`, JDBC 호출 -- synchronous scanner 호출 -- blocking metadata repository 호출 - ---- - -## 23. Nginx 전송 위임 - -### 23.1 구조 - -```text -Client -→ GET /v1/files/{fileId}/content -→ Application authorization + READY gate -→ validated ContentKey를 internal relative URI로 변환 -→ X-Accel-Redirect: /__files/ab/cd/.bin -→ Nginx internal location -→ physical content transfer -``` - -internal URI는 절대 physical path를 포함하지 않는다. `NginxInternalUriMapper`는 검증된 `ContentKey`만 받아 `/__files/` 아래의 상대 URI를 생성한다. 이 header는 Nginx가 내부 redirect로 소비하므로 client 응답에는 노출하지 않는다. 별도 공개 signed URL을 발급하는 기능은 Object Storage 모듈의 책임으로 남긴다. - -### 23.2 정책 - -- 기본 delegation threshold는 16 MiB다. -- private file은 Nginx shared cache를 기본 비활성화한다. -- `internal` location은 외부 직접 요청을 거부한다. -- `X-Accel-Redirect`는 downstream client에 그대로 전달되지 않도록 한다. -- Range, ETag, Content-Disposition, Cache-Control 결과가 direct mode와 동일해야 한다. -- Nginx access log에 physical root와 원본 파일명을 남기지 않는다. -- mapper가 생성한 URI는 `ContentKey`의 허용 문자와 shard 규칙을 다시 검증한다. - -### 23.3 Nginx upload - -| 경로 | 기본 buffering | -|---|---| -| 작은 multipart | on 허용 | -| 대용량 raw | off | -| tus PATCH | off | -| HTTPbis PATCH | off | - -upstream 전송이 시작된 non-idempotent upload에는 `proxy_next_upstream` 재시도를 적용하지 않는다. - ---- - -## 24. 재개 가능한 업로드 - -### 24.1 공통 원칙 - -- upload resource별 single writer lease -- offset은 metadata와 physical length를 함께 검증 -- mismatch 시 body를 쓰지 않고 `409` -- 서버 재시작 후 offset reconciliation -- create 시 quota 예약 -- expiration과 cleanup -- client checksum 검증 -- upload resource는 READY file과 별도 수명주기를 가진다. - -### 24.2 tus 1.0 Stable - -지원 기능: - -- creation -- `HEAD`와 `Upload-Offset` -- `PATCH application/offset+octet-stream` -- checksum extension -- expiration extension -- termination extension -- concatenation extension은 Beta - -성공 append는 `204`와 새 `Upload-Offset`을 반환한다. offset mismatch는 resource를 변경하지 않고 `409`를 반환한다. - -### 24.3 HTTPbis draft-12 Experimental - -- module 이름과 package에 `draft12`를 포함한다. -- feature flag 없이는 bean을 생성하지 않는다. -- media type과 header를 draft version에 고정한다. -- 104 interim response 지원 여부를 runtime capability로 표시한다. -- 최종 RFC 변화에 따른 breaking change를 허용한다. -- Stable core와 endpoint namespace를 분리한다. - -### 24.4 병렬 upload - -하나의 upload offset에 여러 writer를 허용하지 않는다. 병렬 전송은 다음 구조만 제공한다. - -```text -parent upload -├─ part 1 resource -├─ part 2 resource -└─ part N resource -→ 각 part checksum 검증 -→ 순서와 총 길이 검증 -→ concatenate -→ final verification -``` - ---- - -## 25. 파일 관리 기능 - -### 25.1 stat - -공개 `stat`은 DB metadata를 반환한다. physical stat은 내부 일관성 검증에만 사용한다. - -### 25.2 delete - -```text -If-Match 검증 -→ READY/REJECTED/FAILED → DELETING -→ 공개 read 즉시 차단 -→ cleanup item 등록 -→ physical delete -→ quota 반영 -→ DELETED -``` - -### 25.3 copy - -- capability가 없으면 application-level stream copy를 사용한다. -- target은 create-only가 기본이다. -- source와 target metadata는 별도 레코드다. -- copy 실패 시 incomplete target은 cleanup queue로 보낸다. -- 자동 rollback 보장을 선언하지 않는다. - -### 25.4 move - -공개 move는 physical path move가 아니라 logical namespace·ownership metadata 변경이다. physical content는 immutable key를 유지한다. physical move는 admin maintenance에만 사용한다. - -### 25.5 list·scan - -public API에는 제공하지 않는다. admin API는 bounded pagination, prefix allowlist, rate limit, dry-run을 요구한다. - ---- - -## 26. 오류 모델과 Problem Detail - -### 26.1 예외 hierarchy - -```text -FileserverException -├─ FileNotFoundException -├─ FileAlreadyExistsException -├─ InvalidPathException -├─ PathOutsideNamespaceException -├─ FileAccessDeniedException -├─ StorageFullException -├─ QuotaExceededException -├─ FileTooLargeException -├─ UnsupportedMediaTypeException -├─ IntegrityMismatchException -├─ UploadOffsetMismatchException -├─ UploadExpiredException -├─ FileNotReadyException -├─ AtomicPublishUnsupportedException -├─ TransferTimeoutException -├─ PartialWriteException -├─ AmbiguousCompletionException -├─ StorageUnavailableException -├─ ConcurrentFileModificationException -└─ MalwareDetectedException -``` - -모든 예외는 다음 metadata를 가진다. - -```java -public record FileserverFailureContext( - String code, - boolean retryable, - boolean ambiguous, - boolean reconciliationRequired, - Optional fileId, - Optional uploadId, - OptionalLong expectedOffset, - OptionalLong currentOffset, - Optional currentState -) {} -``` - -### 26.2 Problem Detail - -```json -{ - "type": "urn:fileserver:problem:upload-offset-mismatch", - "title": "Upload offset mismatch", - "status": 409, - "code": "UPLOAD_OFFSET_MISMATCH", - "retryable": true, - "uploadId": "...", - "expectedOffset": 1048576, - "currentOffset": 524288, - "traceId": "..." -} -``` - -내부 path, mount, scanner credential, storage token을 포함하지 않는다. - ---- - -## 27. 보안 정책 - -### 27.1 위험 등급 - -| 등급 | 대상 | 정책 | -|---|---|---| -| F1 | ID 기반 create·read·delete, single Range | 기본 허용, auth·size·state gate | -| F2 | 대용량 stream, multi Range, resumable, overwrite, copy | quota·budget·precondition 필수 | -| F3 | list, capacity, orphan, force delete, reverify | internal admin plane | -| F4 | arbitrary path, symlink, recursive delete, webroot storage | 전체 차단 | - -### 27.2 필수 방어 - -- opaque ID와 server-generated physical key -- original filename sanitization -- extension allowlist가 있더라도 Content-Type을 신뢰하지 않음 -- signature/parser·scanner verdict -- executable permission 제거 -- separate mount와 webroot 밖 저장 -- size, part count, concurrency, minimum-rate 제한 -- private download cache 제한 -- READY gate -- CSRF 방어가 필요한 cookie 기반 upload endpoint -- authorization on every access -- range bomb 제한 -- ZIP/XML expanded-size 제한을 verifier에 적용 - -### 27.3 파일명 sanitization - -제거·치환 대상: - -- `/`, `\`, NUL -- control characters -- bidi override characters -- CR/LF와 quote injection -- trailing dot·space -- Windows reserved names -- UTF-8 255 byte 초과 - -sanitized name은 Content-Disposition에만 사용하며 physical path 생성에는 사용하지 않는다. - ---- - -## 28. 다중 인스턴스와 NFS - -### 28.1 Writer lease - -```java -public record WriterLease( - UploadId uploadId, - String owner, - UUID token, - Instant expiresAt, - long version -) {} -``` - -- DB conditional update로 획득한다. -- append 중 주기적으로 갱신한다. -- lease token이 다르면 offset commit을 거부한다. -- process pause로 lease가 만료된 writer는 이후 commit하지 못한다. -- local file lock이나 NFS lock을 correctness 근거로 사용하지 않는다. - -### 28.2 NFS reconciliation - -다음 이벤트에서 metadata와 physical state를 재확인한다. - -- rename timeout -- stale file handle -- mount reconnect -- attribute mismatch -- server restart -- lease takeover - -reconciliation 결과: - -```text -CONFIRMED_SUCCESS -CONFIRMED_NOT_APPLIED -RECOVERABLE_PARTIAL -QUARANTINE_REQUIRED -UNRESOLVED -``` - -`UNRESOLVED`는 자동 retry하지 않고 운영 queue로 보낸다. - -### 28.3 PVC certification unit - -지원 단위는 `PVC`라는 이름이 아니라 다음 tuple이다. - -```text -Kubernetes version -+ CSI driver/version -+ StorageClass -+ access mode -+ filesystem/backend -+ mount options -``` - ---- - -## 29. Cleanup와 Reconciliation - -### 29.1 Cleanup 종류 - -- expired upload -- cancelled staging -- failed verification content -- deleted READY content -- orphan physical object -- stale quota reservation -- abandoned lease -- previous version after pointer publish - -### 29.2 안전 규칙 - -- cleanup은 version과 lease를 확인한다. -- 기본 admin 실행은 dry-run이다. -- active upload와 동일 physical key는 삭제하지 않는다. -- batch size와 bytes budget을 둔다. -- 실패는 exponential backoff와 최대 retry를 사용한다. -- 장기 실패는 orphan metric과 alert로 승격한다. - -### 29.3 Reconciliation - -```java -public interface FileReconciliationService { - ReconciliationResult reconcile(FileId fileId); - ReconciliationBatchResult reconcileOrphans(ReconciliationQuery query); -} -``` - -자동 reconciliation이 READY를 임의 추정해서는 안 된다. size, digest, expected content key, metadata version이 모두 맞을 때만 상태를 복원한다. - ---- - -## 30. 관측성 - -### 30.1 Metric - -| Metric | 주요 tag | -|---|---| -| upload count·duration | protocol, storageType, resultCode, sizeBucket | -| download count·duration | transferMode, rangeType, resultCode, sizeBucket | -| transfer bytes | direction, storageType | -| active transfers | direction, instance | -| interruption | direction, reason | -| resumable append | protocol, result | -| offset mismatch | protocol, clientType | -| checksum failure | algorithm, stage | -| verification queue | verifier, verdict, ageBucket | -| temp·orphan bytes | storagePool, ageBucket | -| storage usage | pool, mountProfile | -| quota | scopeType, result | -| cleanup | type, result | -| delegation ratio | sizeBucket | -| access denial | operation, policyCode | - -실제 file ID, upload ID, filename, path, user ID를 metric label로 사용하지 않는다. - -### 30.2 Trace - -```text -upload.create -upload.append -upload.finalize -verify.digest -verify.media-type -verify.malware -storage.publish -storage.stat -metadata.transition -download.authorize -download.resolve-range -download.open -download.delegate -cleanup.item -reconcile.file -``` - -### 30.3 Audit - -다음 작업은 audit 대상이다. - -- overwrite -- delete·force delete -- admin reverify -- orphan reconcile -- quarantine 승인·거절 -- delegated download 발급 -- access denial - -filename, path, signed token, content sample은 audit에 기록하지 않는다. - ---- - -## 31. Spring Boot 설정 - -```yaml -backend: - fileserver: - enabled: true - storage: - type: local - root: /var/lib/backend/files - publish-mode: atomic-move-preferred - require-same-file-store: true - fail-on-symlink: true - buffer-size: 128KiB - upload: - profile: standard - max-file-size: 100MiB - max-request-size: 116MiB - max-parts: 16 - require-content-length: false - incomplete-ttl: 24h - idle-timeout: 45s - instance-concurrency: 16 - scope-concurrency: 4 - download: - single-range-only: true - max-ranges: 8 - direct-concurrency: 64 - private-cache-control: "private, no-store" - content-digest: false - nginx: - enabled: false - delegate-threshold: 16MiB - internal-prefix: /__files/ - verification: - async: true - checksum: sha-256 - require-media-type-verdict: true - scanner-required: false - max-attempts: 5 - quota: - enabled: true - reservation-ttl: 24h - cleanup: - batch-size: 100 - max-bytes-per-run: 10GiB - fixed-delay: 5m - tus: - enabled: false - checksum: true - expiration: true - termination: true - httpbis-draft12: - enabled: false - mvc: - executor: - core-threads: 8 - max-threads: 32 - queue-capacity: 64 - webflux: - io-workers: 16 - max-in-flight-buffers: 8 -``` - -### 31.1 Startup validation - -다음 조건은 startup 실패다. - -- storage root가 webroot 또는 application config 아래임 -- staging과 content가 다른 `FileStore` -- symlink no-follow probe 실패 -- `ATOMIC_MOVE_REQUIRED`인데 probe 실패 -- metadata store 없이 multi-instance mode 활성화 -- no-op authorization policy가 production profile에서 활성화 -- scanner-required인데 verifier bean 없음 -- Nginx delegation을 켰는데 token service 또는 mapping 검증 없음 - ---- - -## 32. 관리자 API - -| Method·Path | 기능 | 통제 | -|---|---|---| -| `GET /internal/fileserver/storage-health` | capacity와 probe 결과 | admin network·role | -| `GET /internal/fileserver/capabilities` | runtime capability | path 비노출 | -| `GET /internal/fileserver/orphans` | bounded orphan 조회 | pagination·rate limit | -| `POST /internal/fileserver/orphans:reconcile` | dry-run·apply | audit | -| `POST /internal/fileserver/files/{id}:reverify` | 재검사 | audit | -| `POST /internal/fileserver/files/{id}:force-delete` | 강제 삭제 | 사유·이중 권한 | -| `GET /internal/fileserver/uploads/incomplete` | 미완료 조회 | filename 마스킹 | -| `POST /internal/fileserver/uploads:cleanup` | cleanup | lease·version 확인 | -| `GET /internal/fileserver/verification-queue` | 검사 지연 | bounded result | - -관리자 API는 public starter에서 자동 노출하지 않고 별도 `fileserver-admin` 모듈과 management port에서만 활성화한다. - ---- - -## 33. 테스트 전략 - -### 33.1 계약 테스트 - -- Content Store blocking·async contract -- Metadata optimistic transition contract -- state machine illegal transition -- upload offset and lease -- checksum and size -- GET·HEAD header parity -- `200/206/304/412/416` -- Range first, middle, suffix, end, empty -- `If-Range`, `If-Match`, `If-None-Match` -- multipart single·batch -- tus create·HEAD·PATCH·checksum·expiry·termination - -### 33.2 보안 테스트 - -- `../`, percent-encoded separator, absolute path, Windows drive path -- parent symlink replacement race -- hard link discovery -- filename CRLF·bidi·reserved name -- extension·Content-Type·signature mismatch -- scriptable content inline 차단 -- scanner timeout·malware verdict -- internal Nginx path direct access -- unauthorized download and existence hiding -- multi Range bomb - -### 33.3 장애 테스트 - -- write 전·중·후 process kill -- close 후 publish 전 kill -- physical publish 후 DB commit 전 kill -- disk full and quota exhaustion -- permission denied -- slow upload·download -- network disconnect -- WebFlux cancellation -- MVC executor saturation -- NFS disconnect·server restart·rename ambiguity -- PVC remount·Pod reschedule -- scanner unavailable - -### 33.4 성능 테스트 - -- 100 MiB와 5 GiB streaming -- concurrent upload/download -- direct vs Nginx throughput -- p50, p95, p99, max latency -- heap, direct memory, allocation, GC -- temp disk and scanner throughput -- Range overhead -- cleanup throughput - -### 33.5 인증 매트릭스 - -| 프로파일 | 빈도 | Gate | -|---|---|---| -| Linux ext4 local | PR | 필수 | -| Linux XFS local | nightly | release 필수 | -| PVC RWO 주 CSI | release | 필수 | -| PVC RWX | release | 지원 선언 시 필수 | -| NFSv4.1 | nightly | 제한 지원 필수 | -| NFS fault injection | RC | 제한 지원 필수 | -| Windows NTFS | nightly | 초기 non-blocking | -| Nginx stable | release | nginx 모듈 필수 | -| MVC Tomcat | PR | 필수 | -| MVC Jetty | release | 지원 선언 시 필수 | -| WebFlux Reactor Netty | PR | 필수 | -| Spring 6.2 latest | release | 필수 | -| Spring 7.0 latest | release | 필수 | - ---- - -## 34. CI 품질 Gate - -모든 pull request: - -```text -unit test -core contract test -local ext4 integration -MVC Tomcat HTTP contract -WebFlux Reactor Netty contract -architecture test -path traversal·symlink security suite -bounded-memory regression -``` - -Nightly: - -```text -XFS -NFSv4.1 -Windows NTFS -large-file performance -slow client -process-kill matrix -scanner failure -``` - -Release: - -```text -Spring 6.2 / 7.0 matrix -PVC certification -Nginx contract -multi-instance lease -fault injection -support-matrix diff -sensitive-log scan -``` - ---- - -## 35. 릴리스 단계 - -### Milestone A — Core Alpha - -- core model·state machine -- JPA metadata -- local staging·append·publish -- raw upload -- full download -- checksum - -### Milestone B — HTTP Beta - -- multipart -- GET·HEAD·single Range -- conditional request -- MVC·WebFlux -- security verifier -- cleanup - -### Milestone C — Distributed RC - -- multi-instance lease -- Nginx delegation -- PVC RWO certification -- admin plane -- chaos·performance gate - -### Milestone D — Extended Release - -- tus 1.0 -- NFS limited profile -- PVC RWX certification -- multi Range Beta -- HTTPbis draft-12 Experimental - ---- - -## 36. 구현자가 임의로 변경하면 안 되는 결정 - -- 공개 API에 `Path`와 physical filename을 노출하지 않는다. -- Content Store의 최소 Port를 filesystem 명령 mirror로 바꾸지 않는다. -- READY 이전 다운로드를 허용하지 않는다. -- metadata DB를 우회해 physical file 존재만으로 READY를 추정하지 않는다. -- create-only 기본값을 unconditional overwrite로 바꾸지 않는다. -- client Content-Type과 filename을 신뢰하지 않는다. -- WebFlux event loop에서 blocking I/O를 실행하지 않는다. -- MVC streaming에 unbounded executor를 사용하지 않는다. -- NFS lock을 단독 correctness mechanism으로 사용하지 않는다. -- upload timeout 후 blind retry를 수행하지 않는다. -- arbitrary path, symlink, recursive delete를 escape hatch로 열지 않는다. -- IETF draft 모듈을 Stable API와 섞지 않는다. -- metric label에 fileId·filename·path를 넣지 않는다. - ---- - -## 37. 완료 정의 - -프로젝트 완료는 다음 산출물이 코드와 CI에 연결됐을 때 선언한다. - -| 산출물 | 완료 기준 | -|---|---| -| 지원 매트릭스 | runtime·filesystem·protocol별 자동 test job 연결 | -| 상태 머신 | 모든 허용·금지 전이 contract test | -| Content Store | blocking·async contract와 local adapter 인증 | -| Metadata Store | optimistic version·lease·recovery test | -| HTTP 계약 | MVC·WebFlux·Nginx mode parity | -| 보안 | traversal·symlink·MIME·권한 공격 suite | -| 장애 | crash point·disk full·network fault 후 invariant 유지 | -| 성능 | 최대 파일에서도 bounded heap·direct memory | -| 운영 | metric, trace, audit, cleanup, reconciliation, runbook | -| 재개 업로드 | tus 1.0 contract suite | -| 제한 지원 | NFS·PVC RWX·Windows 수준이 runtime capability와 문서에 표시 | - ---- - -## 38. 구현 순서 - -```text -1. 모듈·품질 기반 -2. core ID·상태·오류 -3. Content Store와 Metadata Store 계약 -4. JPA metadata -5. local path·staging·capability probe -6. append·checksum·quota -7. publish·state transition·reconciliation -8. upload application -9. HTTP Range·conditional core -10. MVC -11. WebFlux -12. verification·authorization -13. delete·cleanup·admin -14. Nginx delegation -15. multi-instance·PVC -16. tus 1.0 -17. NFS limited certification -18. HTTPbis draft Experimental -19. chaos·performance·release matrix -``` diff --git a/fileserver-superpowers-package/fileserver-platform-implementation-plan.md b/fileserver-superpowers-package/fileserver-platform-implementation-plan.md deleted file mode 100644 index 93932a6..0000000 --- a/fileserver-superpowers-package/fileserver-platform-implementation-plan.md +++ /dev/null @@ -1,3422 +0,0 @@ -# Fileserver Platform Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Spring 기반 Backend Skeleton에 로컬 파일시스템·PVC·제한형 NFS를 대상으로 안전한 streaming upload, 상태 기반 publish, HTTP Range 다운로드, MVC·WebFlux, Nginx 위임, tus 1.0을 제공하는 운영 가능한 Fileserver 플랫폼을 구현한다. - -**Architecture:** `fileserver-core-api`는 저장소 구현과 Spring 타입이 새지 않는 ID·상태·Port를 정의하고, `fileserver-application`이 metadata와 content store를 조정한다. 로컬 저장소는 staging과 immutable content를 분리하고, 관계형 metadata DB의 version·lease·READY 상태가 공개 가능 여부를 결정한다. HTTP adapter, 검사, Nginx, 재개 업로드는 별도 모듈로 분리한다. - -**Tech Stack:** Java 21, Gradle Kotlin DSL, Spring MVC, Spring WebFlux, Spring Data JPA, Flyway, Reactor, Micrometer, OpenTelemetry, JUnit 5, AssertJ, ArchUnit, Testcontainers, Toxiproxy, Awaitility, BlockHound, Nginx. - -## Global Constraints - -- 공개 API에는 `Path`, 실제 파일명, mount 경로를 노출하지 않는다. -- 공개 식별자는 opaque `FileId`와 `UploadId`다. -- metadata store가 상태와 공개 가능 여부의 authoritative source다. -- READY가 아닌 파일은 direct와 Nginx 경로 모두에서 다운로드할 수 없다. -- 로컬 staging·content·quarantine은 동일 `FileStore`에 둔다. -- create-only가 기본이며 overwrite에는 `If-Match` 또는 metadata version이 필요하다. -- 서버 계산 SHA-256과 actual size를 저장한다. -- client filename과 `Content-Type`은 비신뢰 metadata다. -- Spring MVC streaming은 bounded 전용 executor를 사용한다. -- Spring WebFlux event loop에서 filesystem, JDBC, scanner blocking call을 실행하지 않는다. -- multi-instance upload는 DB writer lease와 optimistic version을 사용한다. -- NFS lock을 단독 정합성 근거로 사용하지 않는다. -- timeout 후 write는 blind retry하지 않고 ambiguous completion을 표현한다. -- tus 1.0은 Stable 모듈, HTTPbis draft-12는 Experimental 모듈이다. -- arbitrary path, symlink follow, hard link 생성, recursive delete는 구현하지 않는다. -- 실제 file ID, filename, path, checksum 원문을 metric label에 기록하지 않는다. -- 모든 작업은 실패 테스트 작성 → 실패 확인 → 최소 구현 → 통과 확인 → 커밋 순서로 진행한다. -- 각 작업은 독립 검토가 가능한 하나의 커밋으로 종료한다. - ---- - -## 1. 확정 파일 구조 - -```text -backend-skeleton/ -├── settings.gradle.kts -├── build.gradle.kts -├── build-logic/ -│ └── src/main/kotlin/fileserver-library-conventions.gradle.kts -├── modules/fileserver/ -│ ├── fileserver-core-api/ -│ ├── fileserver-application/ -│ ├── fileserver-metadata-jpa/ -│ ├── fileserver-storage-local/ -│ ├── fileserver-verification/ -│ ├── fileserver-mvc/ -│ ├── fileserver-webflux/ -│ ├── fileserver-nginx/ -│ ├── fileserver-admin/ -│ ├── fileserver-tus/ -│ ├── fileserver-resumable-httpbis-draft12/ -│ ├── fileserver-spring-boot-starter/ -│ └── fileserver-testkit/ -├── infra/fileserver/ -│ ├── nginx/ -│ ├── nfs/ -│ └── kubernetes/ -├── docs/fileserver/ -│ ├── support-matrix.md -│ ├── http-contract.md -│ ├── storage-certification.md -│ ├── security.md -│ ├── operations.md -│ └── upgrade-guide.md -└── docs/superpowers/specs/2026-08-07-fileserver-platform-design.md -``` - -## 2. 핵심 패키지 - -```text -io.backend.skeleton.fileserver.api -io.backend.skeleton.fileserver.api.content -io.backend.skeleton.fileserver.api.error -io.backend.skeleton.fileserver.api.metadata -io.backend.skeleton.fileserver.api.security -io.backend.skeleton.fileserver.api.transfer -io.backend.skeleton.fileserver.application -io.backend.skeleton.fileserver.jpa -io.backend.skeleton.fileserver.local -io.backend.skeleton.fileserver.verification -io.backend.skeleton.fileserver.mvc -io.backend.skeleton.fileserver.webflux -io.backend.skeleton.fileserver.nginx -io.backend.skeleton.fileserver.admin -io.backend.skeleton.fileserver.tus -io.backend.skeleton.fileserver.httpbisdraft12 -io.backend.skeleton.fileserver.autoconfigure -io.backend.skeleton.fileserver.testkit -``` - ---- - -### Task 1: Gradle 멀티모듈과 공통 품질 규칙 구성 - -**Files:** -- Modify: `settings.gradle.kts` -- Create: `build-logic/src/main/kotlin/fileserver-library-conventions.gradle.kts` -- Create: `modules/fileserver/fileserver-core-api/build.gradle.kts` -- Create: `modules/fileserver/fileserver-application/build.gradle.kts` -- Create: `modules/fileserver/fileserver-metadata-jpa/build.gradle.kts` -- Create: `modules/fileserver/fileserver-storage-local/build.gradle.kts` -- Create: `modules/fileserver/fileserver-verification/build.gradle.kts` -- Create: `modules/fileserver/fileserver-mvc/build.gradle.kts` -- Create: `modules/fileserver/fileserver-webflux/build.gradle.kts` -- Create: `modules/fileserver/fileserver-nginx/build.gradle.kts` -- Create: `modules/fileserver/fileserver-admin/build.gradle.kts` -- Create: `modules/fileserver/fileserver-tus/build.gradle.kts` -- Create: `modules/fileserver/fileserver-resumable-httpbis-draft12/build.gradle.kts` -- Create: `modules/fileserver/fileserver-spring-boot-starter/build.gradle.kts` -- Create: `modules/fileserver/fileserver-testkit/build.gradle.kts` -- Test: `modules/fileserver/fileserver-core-api/src/test/java/io/backend/skeleton/fileserver/api/ModuleSmokeTest.java` - -**Interfaces:** -- Produces all Gradle project paths used by later tasks. -- `fileserver-core-api` must have no Spring MVC, WebFlux, JPA, NIO filesystem implementation dependency. -- Java toolchain is 21. - -- [ ] **Step 1: Write the failing core module smoke test** - -```java -package io.backend.skeleton.fileserver.api; - -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; - -class ModuleSmokeTest { - @Test - void coreApiModuleLoads() { - assertThat(ModuleSmokeTest.class.getPackageName()) - .isEqualTo("io.backend.skeleton.fileserver.api"); - } -} -``` - -- [ ] **Step 2: Register module paths and verify the build fails before module build files exist** - -Add to `settings.gradle.kts`: - -```kotlin -include( - ":modules:fileserver:fileserver-core-api", - ":modules:fileserver:fileserver-application", - ":modules:fileserver:fileserver-metadata-jpa", - ":modules:fileserver:fileserver-storage-local", - ":modules:fileserver:fileserver-verification", - ":modules:fileserver:fileserver-mvc", - ":modules:fileserver:fileserver-webflux", - ":modules:fileserver:fileserver-nginx", - ":modules:fileserver:fileserver-admin", - ":modules:fileserver:fileserver-tus", - ":modules:fileserver:fileserver-resumable-httpbis-draft12", - ":modules:fileserver:fileserver-spring-boot-starter", - ":modules:fileserver:fileserver-testkit" -) -``` - -Run: - -```bash -./gradlew :modules:fileserver:fileserver-core-api:test -``` - -Expected: FAIL because the registered module build files do not exist. - -- [ ] **Step 3: Add the convention plugin and module dependency boundaries** - -Create `fileserver-library-conventions.gradle.kts`: - -```kotlin -plugins { - `java-library` - id("java-test-fixtures") -} - -java { - toolchain { - languageVersion.set(JavaLanguageVersion.of(21)) - } -} - -tasks.withType().configureEach { - useJUnitPlatform() - failFast = false -} - -dependencies { - "testImplementation"(platform("org.junit:junit-bom:5.12.2")) - "testImplementation"("org.junit.jupiter:junit-jupiter") - "testImplementation"("org.assertj:assertj-core:3.27.3") -} -``` - -Apply it to every Fileserver module. Add only these directed dependencies: - -```text -application → core-api -metadata-jpa → core-api -storage-local → core-api -verification → core-api -mvc → application, core-api -webflux → application, core-api -nginx → application, core-api -admin → application, core-api -tus → application, core-api -httpbis-draft12 → application, core-api -starter → all runtime modules -testkit → core-api, application -``` - -- [ ] **Step 4: Run module tests and dependency report** - -```bash -./gradlew :modules:fileserver:fileserver-core-api:test \ - :modules:fileserver:fileserver-core-api:dependencies -``` - -Expected: PASS; dependency report contains no Spring MVC, WebFlux, Hibernate, or `java.nio.file.Path`-specific adapter library. - -- [ ] **Step 5: Commit** - -```bash -git add settings.gradle.kts build-logic modules/fileserver -git commit -m "build: add fileserver module boundaries" -``` - ---- - -### Task 2: 식별자, 상태, 범위 값 객체 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/FileId.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/UploadId.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/ContentKey.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/StorageNamespace.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/FileState.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/ByteRange.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/FileStateMachine.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/DefaultFileStateMachine.java` -- Test: `modules/fileserver/fileserver-core-api/src/test/java/io/backend/skeleton/fileserver/api/FileStateMachineTest.java` -- Test: `modules/fileserver/fileserver-core-api/src/test/java/io/backend/skeleton/fileserver/api/ValueObjectTest.java` - -**Interfaces:** -- Produces `FileId`, `UploadId`, `ContentKey`, `StorageNamespace`, `FileState`, `ByteRange`. -- Later persistence and HTTP tasks use these exact types. - -- [ ] **Step 1: Write failing value object and transition tests** - -```java -class FileStateMachineTest { - private final FileStateMachine stateMachine = new DefaultFileStateMachine(); - - @Test - void allowsUploadedToVerifying() { - assertThat(stateMachine.canTransition(FileState.UPLOADED, FileState.VERIFYING)) - .isTrue(); - } - - @Test - void rejectsCreatedToReady() { - assertThatThrownBy(() -> - stateMachine.requireTransition(FileState.CREATED, FileState.READY)) - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("CREATED -> READY"); - } -} -``` - -```java -class ValueObjectTest { - @Test - void rejectsInvalidContentKey() { - assertThatThrownBy(() -> new ContentKey("../../etc/passwd")) - .isInstanceOf(IllegalArgumentException.class); - } - - @Test - void calculatesInclusiveRangeLength() { - assertThat(new ByteRange(10, 19).length()).isEqualTo(10); - } -} -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -```bash -./gradlew :modules:fileserver:fileserver-core-api:test \ - --tests '*FileStateMachineTest' --tests '*ValueObjectTest' -``` - -Expected: FAIL because the types do not exist. - -- [ ] **Step 3: Implement exact state transitions and validation** - -```java -public final class DefaultFileStateMachine implements FileStateMachine { - private static final Map> ALLOWED = Map.ofEntries( - Map.entry(FileState.CREATED, Set.of(FileState.UPLOADING)), - Map.entry(FileState.UPLOADING, Set.of( - FileState.UPLOADED, FileState.FAILED, FileState.EXPIRED, FileState.DELETING)), - Map.entry(FileState.UPLOADED, Set.of( - FileState.VERIFYING, FileState.FAILED, FileState.DELETING)), - Map.entry(FileState.VERIFYING, Set.of( - FileState.READY, FileState.QUARANTINED, FileState.REJECTED, FileState.FAILED)), - Map.entry(FileState.QUARANTINED, Set.of( - FileState.VERIFYING, FileState.READY, FileState.REJECTED, FileState.DELETING)), - Map.entry(FileState.READY, Set.of(FileState.DELETING)), - Map.entry(FileState.REJECTED, Set.of(FileState.DELETING)), - Map.entry(FileState.FAILED, Set.of( - FileState.UPLOADING, FileState.VERIFYING, FileState.DELETING, FileState.EXPIRED)), - Map.entry(FileState.DELETING, Set.of(FileState.DELETED, FileState.FAILED)), - Map.entry(FileState.EXPIRED, Set.of(FileState.DELETING)), - Map.entry(FileState.DELETED, Set.of()) - ); - - @Override - public boolean canTransition(FileState current, FileState target) { - return ALLOWED.getOrDefault(current, Set.of()).contains(target); - } - - @Override - public void requireTransition(FileState current, FileState target) { - if (!canTransition(current, target)) { - throw new IllegalStateException("illegal file transition: " + current + " -> " + target); - } - } -} -``` - -Implement ID records with non-null validation and `ContentKey`/namespace regex exactly as the design document. - -- [ ] **Step 4: Run the module tests** - -```bash -./gradlew :modules:fileserver:fileserver-core-api:test -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-core-api -git commit -m "feat: add fileserver core value objects and state machine" -``` - ---- - -### Task 3: 안정된 오류 모델과 failure context 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/FileserverException.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/FileserverFailureContext.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/UploadOffsetMismatchException.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/AmbiguousCompletionException.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/FileNotReadyException.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/StorageFullException.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/IntegrityMismatchException.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/FileNotFoundException.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/FileAlreadyExistsException.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/InvalidPathException.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/PathOutsideNamespaceException.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/FileAccessDeniedException.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/QuotaExceededException.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/FileTooLargeException.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/UnsupportedMediaTypeException.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/UploadExpiredException.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/AtomicPublishUnsupportedException.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/TransferTimeoutException.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/PartialWriteException.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/StorageUnavailableException.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/ConcurrentFileModificationException.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/MalwareDetectedException.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/RangeNotSatisfiableException.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/TransferAdmissionRejectedException.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/FileserverErrorCode.java` -- Test: `modules/fileserver/fileserver-core-api/src/test/java/io/backend/skeleton/fileserver/api/error/FileserverExceptionTest.java` - -**Interfaces:** -- Produces `FileserverException#context()` and stable `FileserverErrorCode` values. -- HTTP adapters map these errors without inspecting storage-driver exceptions. - -- [ ] **Step 1: Write a failing ambiguous execution test** - -```java -class FileserverExceptionTest { - @Test - void ambiguousCompletionCarriesReconciliationFlag() { - AmbiguousCompletionException exception = new AmbiguousCompletionException( - "publish result is unknown", - FileserverFailureContext.forUpload( - FileserverErrorCode.AMBIGUOUS_COMPLETION, - new UploadId(UUID.randomUUID()), - false, - true, - true - ) - ); - - assertThat(exception.context().ambiguous()).isTrue(); - assertThat(exception.context().reconciliationRequired()).isTrue(); - assertThat(exception.context().retryable()).isFalse(); - } -} -``` - -- [ ] **Step 2: Run the test to verify it fails** - -```bash -./gradlew :modules:fileserver:fileserver-core-api:test \ - --tests '*FileserverExceptionTest' -``` - -Expected: FAIL because the exception hierarchy does not exist. - -- [ ] **Step 3: Implement the hierarchy and context** - -```java -public abstract class FileserverException extends RuntimeException { - private final FileserverFailureContext context; - - protected FileserverException(String message, FileserverFailureContext context) { - super(message); - this.context = Objects.requireNonNull(context, "context"); - } - - public final FileserverFailureContext context() { - return context; - } -} -``` - -```java -public record FileserverFailureContext( - FileserverErrorCode code, - boolean retryable, - boolean ambiguous, - boolean reconciliationRequired, - Optional fileId, - Optional uploadId, - OptionalLong expectedOffset, - OptionalLong currentOffset, - Optional currentState -) {} -``` - -Add all design error codes, including `FILE_NOT_FOUND`, `FILE_NOT_READY`, `FILE_TOO_LARGE`, `QUOTA_EXCEEDED`, `STORAGE_FULL`, `UPLOAD_OFFSET_MISMATCH`, `INTEGRITY_MISMATCH`, `CONCURRENT_MODIFICATION`, `STORAGE_UNAVAILABLE`, and `AMBIGUOUS_COMPLETION`. - -- [ ] **Step 4: Run error tests** - -```bash -./gradlew :modules:fileserver:fileserver-core-api:test \ - --tests '*FileserverExceptionTest' -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error \ - modules/fileserver/fileserver-core-api/src/test/java/io/backend/skeleton/fileserver/api/error -git commit -m "feat: define fileserver failure semantics" -``` - ---- - -### Task 4: Content Store capability와 blocking·async Port 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/content/ContentStoreCapabilities.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/content/BlockingContentStore.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/content/AsyncContentStore.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/content/UploadHandle.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/content/CreateContentCommand.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/content/FinalizeContentCommand.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/content/AppendResult.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/content/StoredContent.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/content/ContentMetadata.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/content/DeletePrecondition.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/content/DeleteResult.java` -- Test: `modules/fileserver/fileserver-core-api/src/test/java/io/backend/skeleton/fileserver/api/content/ContentStoreApiArchitectureTest.java` - -**Interfaces:** -- Produces the exact storage SPI consumed by application and implemented by local storage. -- No public signature may include `Path`, `Resource`, `DataBuffer`, `Flux`, or provider SDK types. - -- [ ] **Step 1: Write a failing architecture test** - -```java -class ContentStoreApiArchitectureTest { - @Test - void publicContentApiDoesNotExposeFrameworkOrFilesystemTypes() { - Set forbidden = Set.of( - "java.nio.file.Path", - "org.springframework.core.io.Resource", - "org.springframework.core.io.buffer.DataBuffer", - "reactor.core.publisher.Flux" - ); - - for (Method method : BlockingContentStore.class.getMethods()) { - assertThat(method.getReturnType().getName()).isNotIn(forbidden); - assertThat(Arrays.stream(method.getParameterTypes()).map(Class::getName)) - .doesNotContainAnyElementsOf(forbidden); - } - } -} -``` - -- [ ] **Step 2: Run the test to verify it fails** - -```bash -./gradlew :modules:fileserver:fileserver-core-api:test \ - --tests '*ContentStoreApiArchitectureTest' -``` - -Expected: FAIL because the interfaces do not exist. - -- [ ] **Step 3: Implement the blocking and async contracts** - -Use these signatures exactly: - -```java -public interface BlockingContentStore { - UploadHandle createUpload(CreateContentCommand command); - AppendResult append(UploadHandle handle, long expectedOffset, - ReadableByteChannel source, long contentLength); - StoredContent finalizeUpload(UploadHandle handle, FinalizeContentCommand command); - ContentMetadata stat(ContentKey key); - ReadableByteChannel openRead(ContentKey key, ByteRange range); - DeleteResult delete(ContentKey key, DeletePrecondition precondition); - ContentStoreCapabilities capabilities(); -} -``` - -```java -public interface AsyncContentStore { - CompletionStage createUpload(CreateContentCommand command); - CompletionStage append( - UploadHandle handle, long expectedOffset, Flow.Publisher content); - CompletionStage finalizeUpload( - UploadHandle handle, FinalizeContentCommand command); - CompletionStage stat(ContentKey key); - Flow.Publisher openRead(ContentKey key, ByteRange range); - CompletionStage delete( - ContentKey key, DeletePrecondition precondition); - ContentStoreCapabilities capabilities(); -} -``` - -- [ ] **Step 4: Run API and architecture tests** - -```bash -./gradlew :modules:fileserver:fileserver-core-api:test -``` - -Expected: PASS; `jdeps` or ArchUnit output confirms no forbidden adapter dependency. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-core-api -git commit -m "feat: define content store ports" -``` - ---- - -### Task 5: Metadata Store, upload session, lease, quota Port 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/metadata/FileRecord.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/metadata/FileRecordDraft.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/metadata/FileRecordMutation.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/metadata/FileDescriptor.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/metadata/FileRecoveryQuery.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/metadata/FileMetadataStore.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/metadata/UploadSession.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/metadata/UploadSessionDraft.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/metadata/UploadSessionStore.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/metadata/WriterLease.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/metadata/QuotaReservation.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/metadata/FileQuotaService.java` -- Test: `modules/fileserver/fileserver-core-api/src/test/java/io/backend/skeleton/fileserver/api/metadata/MetadataPortContractTest.java` - -**Interfaces:** -- Produces optimistic transition and writer lease signatures used by Tasks 6, 12, 15, and 24. -- Offset commit always requires a lease token and expected offset. - -- [ ] **Step 1: Write failing port signature tests** - -```java -class MetadataPortContractTest { - @Test - void offsetCommitRequiresLeaseAndExpectedOffset() throws Exception { - Method method = UploadSessionStore.class.getMethod( - "commitOffset", - UploadId.class, - WriterLease.class, - long.class, - long.class - ); - - assertThat(method.getReturnType()).isEqualTo(UploadSession.class); - } - - @Test - void fileTransitionRequiresExpectedVersionAndState() throws Exception { - Method method = FileMetadataStore.class.getMethod( - "transition", - FileId.class, - long.class, - FileState.class, - FileState.class, - FileRecordMutation.class - ); - - assertThat(method).isNotNull(); - } -} -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -```bash -./gradlew :modules:fileserver:fileserver-core-api:test \ - --tests '*MetadataPortContractTest' -``` - -Expected: FAIL because the port types do not exist. - -- [ ] **Step 3: Implement metadata records and exact methods** - -```java -public interface FileMetadataStore { - FileRecord insert(FileRecordDraft draft); - Optional find(FileId fileId); - FileRecord transition( - FileId fileId, - long expectedVersion, - FileState expectedState, - FileState targetState, - FileRecordMutation mutation - ); - FileRecord markDeleting(FileId fileId, long expectedVersion); - List findRecoverable(FileRecoveryQuery query); -} -``` - -```java -public interface UploadSessionStore { - UploadSession create(UploadSessionDraft draft); - Optional find(UploadId uploadId); - WriterLease acquireLease( - UploadId uploadId, - String owner, - Instant now, - Duration leaseDuration, - long expectedVersion - ); - UploadSession commitOffset( - UploadId uploadId, - WriterLease lease, - long expectedOffset, - long committedOffset - ); - void releaseLease(UploadId uploadId, WriterLease lease); - List findExpired(Instant cutoff, int limit); -} -``` - -- [ ] **Step 4: Run the core API tests** - -```bash -./gradlew :modules:fileserver:fileserver-core-api:test -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-core-api -git commit -m "feat: define fileserver metadata and lease ports" -``` - ---- - -### Task 6: Flyway metadata schema와 JPA entity 구성 - -**Files:** -- Create: `modules/fileserver/fileserver-metadata-jpa/src/main/resources/db/migration/fileserver/V1__create_fileserver_metadata.sql` -- Create: `modules/fileserver/fileserver-metadata-jpa/src/main/java/io/backend/skeleton/fileserver/jpa/entity/FileEntity.java` -- Create: `modules/fileserver/fileserver-metadata-jpa/src/main/java/io/backend/skeleton/fileserver/jpa/entity/UploadSessionEntity.java` -- Create: `modules/fileserver/fileserver-metadata-jpa/src/main/java/io/backend/skeleton/fileserver/jpa/entity/VerificationResultEntity.java` -- Create: `modules/fileserver/fileserver-metadata-jpa/src/main/java/io/backend/skeleton/fileserver/jpa/entity/QuotaReservationEntity.java` -- Create: `modules/fileserver/fileserver-metadata-jpa/src/main/java/io/backend/skeleton/fileserver/jpa/entity/CleanupItemEntity.java` -- Create: `modules/fileserver/fileserver-metadata-jpa/src/main/java/io/backend/skeleton/fileserver/jpa/repository/JpaFileRepository.java` -- Create: `modules/fileserver/fileserver-metadata-jpa/src/main/java/io/backend/skeleton/fileserver/jpa/repository/JpaUploadSessionRepository.java` -- Test: `modules/fileserver/fileserver-metadata-jpa/src/test/java/io/backend/skeleton/fileserver/jpa/FileserverMigrationTest.java` - -**Interfaces:** -- Consumes `FileState`, IDs, and metadata records from Tasks 2 and 5. -- Produces database tables and JPA repositories used by Task 7. - -- [ ] **Step 1: Write a failing migration test** - -```java -@Testcontainers -class FileserverMigrationTest { - @Container - static final PostgreSQLContainer POSTGRES = - new PostgreSQLContainer<>("postgres:17-alpine"); - - @Test - void createsFileserverTablesAndVersionColumns() throws Exception { - Flyway.configure() - .dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()) - .locations("classpath:db/migration/fileserver") - .load() - .migrate(); - - try (Connection connection = DriverManager.getConnection( - POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())) { - assertThat(columnExists(connection, "fs_file", "version")).isTrue(); - assertThat(columnExists(connection, "fs_upload_session", "lease_until")).isTrue(); - assertThat(columnExists(connection, "fs_quota_reservation", "reserved_bytes")).isTrue(); - } - } -} -``` - -- [ ] **Step 2: Run the migration test to verify it fails** - -```bash -./gradlew :modules:fileserver:fileserver-metadata-jpa:test \ - --tests '*FileserverMigrationTest' -``` - -Expected: FAIL because the migration does not exist. - -- [ ] **Step 3: Create the schema and entity mappings** - -Use the following core DDL shape: - -```sql -create table fs_file ( - file_id uuid primary key, - namespace varchar(63) not null, - state varchar(32) not null, - content_key varchar(200), - original_name varchar(255) not null, - claimed_media_type varchar(255), - verified_media_type varchar(255), - expected_size bigint, - actual_size bigint, - sha256 char(64), - strong_etag varchar(80), - published_at timestamptz, - last_error_code varchar(64), - version bigint not null default 0, - created_at timestamptz not null, - updated_at timestamptz not null, - constraint ck_fs_file_size check (actual_size is null or actual_size >= 0) -); - -create table fs_upload_session ( - upload_id uuid primary key, - file_id uuid not null references fs_file(file_id), - protocol varchar(32) not null, - expected_length bigint, - committed_offset bigint not null default 0, - expires_at timestamptz not null, - lease_owner varchar(128), - lease_token uuid, - lease_until timestamptz, - version bigint not null default 0, - created_at timestamptz not null, - updated_at timestamptz not null, - constraint ck_fs_upload_offset check (committed_offset >= 0) -); -``` - -Add the verification, quota, and cleanup tables from the design with indexes on state, expiry, lease, and cleanup schedule. Map optimistic version with `@Version`. - -- [ ] **Step 4: Run migration and JPA schema validation** - -```bash -./gradlew :modules:fileserver:fileserver-metadata-jpa:test \ - --tests '*FileserverMigrationTest' -``` - -Expected: PASS; Hibernate schema validation reports no mismatch. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-metadata-jpa -git commit -m "feat: add fileserver metadata schema" -``` - ---- - -### Task 7: JPA Metadata Store와 optimistic transition 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-metadata-jpa/src/main/java/io/backend/skeleton/fileserver/jpa/JpaFileMetadataStore.java` -- Create: `modules/fileserver/fileserver-metadata-jpa/src/main/java/io/backend/skeleton/fileserver/jpa/JpaUploadSessionStore.java` -- Create: `modules/fileserver/fileserver-metadata-jpa/src/main/java/io/backend/skeleton/fileserver/jpa/JpaFileQuotaService.java` -- Create: `modules/fileserver/fileserver-metadata-jpa/src/main/java/io/backend/skeleton/fileserver/jpa/FileEntityMapper.java` -- Create: `modules/fileserver/fileserver-metadata-jpa/src/main/java/io/backend/skeleton/fileserver/jpa/repository/FileTransitionRepository.java` -- Create: `modules/fileserver/fileserver-metadata-jpa/src/main/java/io/backend/skeleton/fileserver/jpa/repository/UploadLeaseRepository.java` -- Test: `modules/fileserver/fileserver-metadata-jpa/src/test/java/io/backend/skeleton/fileserver/jpa/JpaFileMetadataStoreTest.java` -- Test: `modules/fileserver/fileserver-metadata-jpa/src/test/java/io/backend/skeleton/fileserver/jpa/JpaUploadSessionStoreTest.java` - -**Interfaces:** -- Consumes metadata ports from Task 5 and schema from Task 6. -- Produces transactional implementations used by the application layer. - -- [ ] **Step 1: Write failing concurrent transition and lease tests** - -```java -@Test -void onlyOneReadyTransitionWinsForTheSameVersion() { - FileRecord record = fixture.insertVerifyingFile(); - - CompletableFuture first = async(() -> store.transition( - record.fileId(), record.version(), FileState.VERIFYING, FileState.READY, - FileRecordMutation.publish(fixture.contentKey(), 10, fixture.sha256(), fixture.etag()))); - CompletableFuture second = async(() -> store.transition( - record.fileId(), record.version(), FileState.VERIFYING, FileState.READY, - FileRecordMutation.publish(fixture.contentKey(), 10, fixture.sha256(), fixture.etag()))); - - assertThat(successCount(first, second)).isEqualTo(1); - assertThat(concurrentModificationCount(first, second)).isEqualTo(1); -} -``` - -```java -@Test -void onlyOneWriterLeaseIsValid() { - UploadSession session = fixture.insertActiveUpload(); - Instant now = Instant.parse("2026-08-07T10:00:00Z"); - - WriterLease first = store.acquireLease( - session.uploadId(), "node-a", now, Duration.ofSeconds(30), session.version()); - - assertThatThrownBy(() -> store.acquireLease( - session.uploadId(), "node-b", now.plusSeconds(1), Duration.ofSeconds(30), session.version())) - .isInstanceOf(ConcurrentFileModificationException.class); - assertThat(first.owner()).isEqualTo("node-a"); -} -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -```bash -./gradlew :modules:fileserver:fileserver-metadata-jpa:test \ - --tests '*JpaFileMetadataStoreTest' --tests '*JpaUploadSessionStoreTest' -``` - -Expected: FAIL because store implementations do not exist. - -- [ ] **Step 3: Implement conditional update repositories** - -Use an update query that includes both state and version: - -```java -@Modifying -@Query(""" - update FileEntity f - set f.state = :targetState, - f.contentKey = :contentKey, - f.actualSize = :actualSize, - f.sha256 = :sha256, - f.strongEtag = :strongEtag, - f.publishedAt = :publishedAt, - f.version = f.version + 1, - f.updatedAt = :updatedAt - where f.fileId = :fileId - and f.state = :expectedState - and f.version = :expectedVersion - """) -int transition(...); -``` - -Lease acquisition must update only when `lease_until is null or lease_until < now` and the expected version matches. `commitOffset` must require matching `lease_token`, current offset, and unexpired lease. - -- [ ] **Step 4: Run all JPA tests** - -```bash -./gradlew :modules:fileserver:fileserver-metadata-jpa:test -``` - -Expected: PASS; repeated concurrency runs produce one winner only. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-metadata-jpa -git commit -m "feat: implement fileserver metadata stores" -``` - ---- - -### Task 8: 원본 파일명 sanitization과 path 정책 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/security/OriginalFilenamePolicy.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/security/SanitizedFilename.java` -- Create: `modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/LocalStorageLayout.java` -- Create: `modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/PhysicalPathResolver.java` -- Create: `modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/DefaultPhysicalPathResolver.java` -- Test: `modules/fileserver/fileserver-core-api/src/test/java/io/backend/skeleton/fileserver/api/security/OriginalFilenamePolicyTest.java` -- Test: `modules/fileserver/fileserver-storage-local/src/test/java/io/backend/skeleton/fileserver/local/PhysicalPathResolverTest.java` - -**Interfaces:** -- Produces sanitized display names and package-private physical path resolution. -- No controller may call `PhysicalPathResolver` directly. - -- [ ] **Step 1: Write failing malicious filename and root escape tests** - -```java -class OriginalFilenamePolicyTest { - private final OriginalFilenamePolicy policy = new OriginalFilenamePolicy(255); - - @Test - void removesPathAndHeaderInjectionCharacters() { - SanitizedFilename result = policy.sanitize("../report\r\nX-Test: yes.pdf"); - - assertThat(result.value()).doesNotContain("..", "/", "\\", "\r", "\n"); - assertThat(result.value()).endsWith(".pdf"); - } - - @Test - void replacesWindowsReservedName() { - assertThat(policy.sanitize("CON").value()).isEqualTo("_CON"); - } -} -``` - -```java -class PhysicalPathResolverTest { - @TempDir Path root; - - @Test - void generatedContentPathAlwaysStaysBelowContentRoot() { - DefaultPhysicalPathResolver resolver = new DefaultPhysicalPathResolver(root); - Path result = resolver.contentPath(new ContentKey("ab/cd/0123456789abcdef")); - - assertThat(result.normalize()).startsWith(root.resolve("content").normalize()); - } -} -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -```bash -./gradlew :modules:fileserver:fileserver-core-api:test \ - :modules:fileserver:fileserver-storage-local:test \ - --tests '*OriginalFilenamePolicyTest' --tests '*PhysicalPathResolverTest' -``` - -Expected: FAIL because policy and resolver do not exist. - -- [ ] **Step 3: Implement sanitization and server-generated layout** - -`OriginalFilenamePolicy` must: - -```text -strip path separators and NUL -replace control and bidi override characters -remove CR/LF and quote injection -trim trailing dot and space -prefix Windows reserved names with `_` -truncate by UTF-8 byte length, preserving the final extension when possible -return `file` when the normalized name becomes empty -``` - -`DefaultPhysicalPathResolver` must only accept validated IDs and construct: - -```text -staging///.part -content///.bin -quarantine///.bin -``` - -- [ ] **Step 4: Run filename and path tests** - -```bash -./gradlew :modules:fileserver:fileserver-core-api:test \ - :modules:fileserver:fileserver-storage-local:test -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-core-api modules/fileserver/fileserver-storage-local -git commit -m "feat: enforce fileserver filename and path policy" -``` - ---- - -### Task 9: Local staging 생성과 `CREATE_NEW` 경쟁 제어 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/LocalBlockingContentStore.java` -- Create: `modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/LocalStorageProperties.java` -- Create: `modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/SafeFileChannelFactory.java` -- Create: `modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/LocalUploadHandle.java` -- Test: `modules/fileserver/fileserver-storage-local/src/test/java/io/backend/skeleton/fileserver/local/LocalCreateUploadTest.java` -- Test: `modules/fileserver/fileserver-storage-local/src/test/java/io/backend/skeleton/fileserver/local/LocalCreateUploadConcurrencyTest.java` - -**Interfaces:** -- Implements `BlockingContentStore#createUpload` from Task 4. -- Produces `LocalUploadHandle` used by append and finalize tasks. - -- [ ] **Step 1: Write failing create-only and concurrent-create tests** - -```java -@Test -void createsStagingFileWithZeroLengthAndNoOriginalName() { - UploadHandle handle = store.createUpload(commandFor("../../secret.pdf")); - - Path staging = testSupport.pathOf(handle); - assertThat(staging).exists().isEmptyFile(); - assertThat(staging.getFileName().toString()).doesNotContain("secret.pdf"); -} -``` - -```java -@Test -void exactlyOneConcurrentCreateWinsForSameUploadId() { - CreateContentCommand command = fixture.commandWithFixedUploadId(); - - List failures = runConcurrently(2, () -> store.createUpload(command)); - - assertThat(failures).hasSize(1); - assertThat(failures.getFirst()).isInstanceOf(FileAlreadyExistsException.class); -} -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -```bash -./gradlew :modules:fileserver:fileserver-storage-local:test \ - --tests '*LocalCreateUploadTest' --tests '*LocalCreateUploadConcurrencyTest' -``` - -Expected: FAIL because local store is not implemented. - -- [ ] **Step 3: Implement safe staging creation** - -Open the staging file with: - -```java -Set options = Set.of( - StandardOpenOption.CREATE_NEW, - StandardOpenOption.WRITE, - LinkOption.NOFOLLOW_LINKS -); -``` - -Create parent directories from server-generated components only. Before and after open, verify that no parent is a symbolic link. Set owner-only permissions on POSIX providers. Convert `FileAlreadyExistsException`, `AccessDeniedException`, and `FileSystemException` into stable Fileserver errors. - -- [ ] **Step 4: Run local storage creation tests repeatedly** - -```bash -./gradlew :modules:fileserver:fileserver-storage-local:test \ - --tests '*LocalCreateUpload*' --rerun-tasks -``` - -Expected: PASS for 20 repeated runs; exactly one concurrent create succeeds. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-storage-local -git commit -m "feat: create safe local upload staging files" -``` - ---- - -### Task 10: Storage capability probe와 startup gate 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/LocalStorageCapabilityProbe.java` -- Create: `modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/LocalStorageProbeResult.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/content/PublishMode.java` -- Create: `modules/fileserver/fileserver-spring-boot-starter/src/main/java/io/backend/skeleton/fileserver/autoconfigure/FileserverStartupValidator.java` -- Test: `modules/fileserver/fileserver-storage-local/src/test/java/io/backend/skeleton/fileserver/local/LocalStorageCapabilityProbeTest.java` -- Test: `modules/fileserver/fileserver-spring-boot-starter/src/test/java/io/backend/skeleton/fileserver/autoconfigure/FileserverStartupValidatorTest.java` - -**Interfaces:** -- Produces runtime `ContentStoreCapabilities` and selected `PublishMode`. -- Later finalize logic must consume this result instead of assuming atomic move. - -- [ ] **Step 1: Write failing same-FileStore and required-atomic tests** - -```java -@Test -void reportsAtomicCreateAndSameFileStore() { - LocalStorageProbeResult result = probe.run(); - - assertThat(result.atomicCreate()).isTrue(); - assertThat(result.sameFileStore()).isTrue(); - assertThat(result.symlinkNoFollow()).isTrue(); -} -``` - -```java -@Test -void requiredAtomicModeRejectsUnsupportedStorage() { - LocalStorageProbeResult result = fixture.resultWithAtomicMove(false); - - assertThatThrownBy(() -> validator.validate( - PublishMode.ATOMIC_MOVE_REQUIRED, result)) - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("atomic move"); -} -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -```bash -./gradlew :modules:fileserver:fileserver-storage-local:test \ - :modules:fileserver:fileserver-spring-boot-starter:test \ - --tests '*LocalStorageCapabilityProbeTest' \ - --tests '*FileserverStartupValidatorTest' -``` - -Expected: FAIL because probe and validator do not exist. - -- [ ] **Step 3: Implement real filesystem probes** - -The probe must create files below `${root}/probe` and verify: - -```text -writable root -concurrent CREATE_NEW -staging/content/quarantine FileStore equality -ATOMIC_MOVE -replace semantics -NOFOLLOW_LINKS -open-delete behavior -capacity access -``` - -Delete all probe artifacts in `finally`. In `ATOMIC_MOVE_PREFERRED`, return `METADATA_POINTER` as fallback when atomic move is unavailable. In `ATOMIC_MOVE_REQUIRED`, fail startup. - -- [ ] **Step 4: Run probe tests and a local integration probe** - -```bash -./gradlew :modules:fileserver:fileserver-storage-local:test \ - :modules:fileserver:fileserver-spring-boot-starter:test -``` - -Expected: PASS; probe directory is empty after completion. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-storage-local \ - modules/fileserver/fileserver-core-api \ - modules/fileserver/fileserver-spring-boot-starter -git commit -m "feat: probe fileserver storage capabilities" -``` - ---- - -### Task 11: Streaming append, size 제한, SHA-256 계산 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/LocalAppendEngine.java` -- Create: `modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/StreamingDigest.java` -- Create: `modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/TransferBufferPool.java` -- Modify: `modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/LocalBlockingContentStore.java` -- Test: `modules/fileserver/fileserver-storage-local/src/test/java/io/backend/skeleton/fileserver/local/LocalAppendEngineTest.java` -- Test: `modules/fileserver/fileserver-storage-local/src/test/java/io/backend/skeleton/fileserver/local/LocalAppendMemoryTest.java` - -**Interfaces:** -- Implements `BlockingContentStore#append`. -- Produces `AppendResult(committedOffset, appendedBytes, sha256Snapshot)`. -- Uses 128 KiB default buffer and never allocates proportional to file size. - -- [ ] **Step 1: Write failing offset, digest, and bounded-buffer tests** - -```java -@Test -void appendsAtExpectedOffsetAndCalculatesDigest() throws Exception { - UploadHandle handle = fixture.emptyUpload(); - byte[] payload = "fileserver".getBytes(StandardCharsets.UTF_8); - - AppendResult result = store.append( - handle, 0, Channels.newChannel(new ByteArrayInputStream(payload)), payload.length); - - assertThat(result.committedOffset()).isEqualTo(payload.length); - assertThat(result.appendedBytes()).isEqualTo(payload.length); - assertThat(result.sha256()).isEqualTo(sha256Hex(payload)); -} - -@Test -void rejectsOffsetMismatchWithoutWriting() throws Exception { - UploadHandle handle = fixture.uploadContaining("abc"); - - assertThatThrownBy(() -> store.append( - handle, 2, Channels.newChannel(new ByteArrayInputStream("d".getBytes())), 1)) - .isInstanceOf(UploadOffsetMismatchException.class); - - assertThat(fixture.readBytes(handle)).isEqualTo("abc".getBytes()); -} -``` - -```java -@Test -void maxObservedBufferDoesNotGrowWithPayload() throws Exception { - fixture.appendGeneratedBytes(256L * 1024 * 1024); - assertThat(bufferPool.maxBorrowedBytes()).isLessThanOrEqualTo(128 * 1024); -} -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -```bash -./gradlew :modules:fileserver:fileserver-storage-local:test \ - --tests '*LocalAppendEngineTest' --tests '*LocalAppendMemoryTest' -``` - -Expected: FAIL because append engine and digest tracking do not exist. - -- [ ] **Step 3: Implement sequential channel append** - -```java -public AppendResult append( - Path staging, - long expectedOffset, - ReadableByteChannel source, - long contentLength, - long maximumFileSize -) { - try (FileChannel target = FileChannel.open( - staging, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) { - long actualOffset = target.size(); - if (actualOffset != expectedOffset) { - throw UploadOffsetMismatchException.of(expectedOffset, actualOffset); - } - target.position(expectedOffset); - return copyAndDigest(target, source, contentLength, maximumFileSize); - } -} -``` - -`copyAndDigest` must: - -```text -borrow one bounded buffer -update SHA-256 for every written byte -stop immediately when maximumFileSize would be exceeded -verify fixed contentLength when non-negative -return only after bytes are written to the channel -release the buffer in finally -``` - -- [ ] **Step 4: Run append tests and inspect heap allocation** - -```bash -./gradlew :modules:fileserver:fileserver-storage-local:test \ - --tests '*LocalAppend*' -``` - -Expected: PASS; 256 MiB test uses at most the configured transfer buffer plus test harness overhead. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-storage-local -git commit -m "feat: stream local file appends with sha256" -``` - ---- - -### Task 12: Quota reservation과 transfer admission control 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/quota/QuotaScope.java` -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/quota/TransferAdmissionController.java` -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/quota/DefaultTransferAdmissionController.java` -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/quota/TransferPermit.java` -- Modify: `modules/fileserver/fileserver-metadata-jpa/src/main/java/io/backend/skeleton/fileserver/jpa/JpaFileQuotaService.java` -- Test: `modules/fileserver/fileserver-application/src/test/java/io/backend/skeleton/fileserver/application/quota/TransferAdmissionControllerTest.java` -- Test: `modules/fileserver/fileserver-metadata-jpa/src/test/java/io/backend/skeleton/fileserver/jpa/JpaFileQuotaServiceTest.java` - -**Interfaces:** -- Consumes `FileQuotaService` from Task 5. -- Produces `TransferPermit` required before create or append. -- Default standard profile: 100 MiB file, 16 instance uploads, 4 scope uploads, soft 70%, hard 85%. - -- [ ] **Step 1: Write failing quota and concurrency tests** - -```java -@Test -void rejectsWhenScopeConcurrencyIsExhausted() { - TransferPermit first = controller.acquire(scope("tenant-a"), 10); - TransferPermit second = controller.acquire(scope("tenant-a"), 10); - TransferPermit third = controller.acquire(scope("tenant-a"), 10); - TransferPermit fourth = controller.acquire(scope("tenant-a"), 10); - - assertThatThrownBy(() -> controller.acquire(scope("tenant-a"), 10)) - .isInstanceOf(QuotaExceededException.class); - - Stream.of(first, second, third, fourth).forEach(TransferPermit::close); -} -``` - -```java -@Test -void reservationCommitUsesActualBytesAndReleasesRemainder() { - QuotaReservation reservation = quota.reserve(scope, 1000, Duration.ofHours(1)); - quota.commit(reservation, 600); - - assertThat(fixture.committedBytes(scope)).isEqualTo(600); - assertThat(fixture.reservedBytes(scope)).isZero(); -} -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -```bash -./gradlew :modules:fileserver:fileserver-application:test \ - :modules:fileserver:fileserver-metadata-jpa:test \ - --tests '*TransferAdmissionControllerTest' --tests '*JpaFileQuotaServiceTest' -``` - -Expected: FAIL because admission control is not implemented. - -- [ ] **Step 3: Implement reservation and bounded permits** - -Use DB conditional updates for quota bytes and JVM semaphores for per-instance transfer concurrency. A create request with unknown length reserves the configured initial chunk; append extends the reservation before writing additional bytes. On cancellation or failure, release the reservation in `finally` or cleanup recovery. - -```java -public interface TransferAdmissionController { - TransferPermit acquireUpload(QuotaScope scope, long requestedBytes); - TransferPermit acquireDirectDownload(QuotaScope scope); -} -``` - -A hard storage high-water condition maps to `StorageFullException`; scope limit maps to `QuotaExceededException`; temporary permit exhaustion maps to `TransferAdmissionRejectedException` with `retryable=true`. - -- [ ] **Step 4: Run quota and concurrency tests** - -```bash -./gradlew :modules:fileserver:fileserver-application:test \ - :modules:fileserver:fileserver-metadata-jpa:test -``` - -Expected: PASS; no permit or reservation remains after test cleanup. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-application \ - modules/fileserver/fileserver-metadata-jpa -git commit -m "feat: enforce fileserver quota and transfer admission" -``` - ---- - -### Task 13: Atomic move와 metadata pointer publish 전략 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/ContentPublisher.java` -- Create: `modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/AtomicMoveContentPublisher.java` -- Create: `modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/MetadataPointerContentPublisher.java` -- Create: `modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/PublishResult.java` -- Modify: `modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/LocalBlockingContentStore.java` -- Test: `modules/fileserver/fileserver-storage-local/src/test/java/io/backend/skeleton/fileserver/local/AtomicMoveContentPublisherTest.java` -- Test: `modules/fileserver/fileserver-storage-local/src/test/java/io/backend/skeleton/fileserver/local/MetadataPointerContentPublisherTest.java` - -**Interfaces:** -- Consumes `PublishMode` and probe results from Task 10. -- Implements `BlockingContentStore#finalizeUpload`. -- Produces immutable `StoredContent` and never exposes a partial final target. - -- [ ] **Step 1: Write failing publish strategy tests** - -```java -@Test -void atomicPublisherMovesStagingToCreateOnlyTarget() throws Exception { - LocalUploadHandle handle = fixture.uploadContaining("ready"); - - PublishResult result = publisher.publish(handle, fixture.finalizeCommand()); - - assertThat(result.contentPath()).exists(); - assertThat(handle.stagingPath()).doesNotExist(); - assertThat(Files.readString(result.contentPath())).isEqualTo("ready"); -} -``` - -```java -@Test -void pointerPublisherKeepsImmutableObjectAndReturnsNewContentKey() throws Exception { - LocalUploadHandle handle = fixture.uploadContaining("ready"); - - PublishResult result = pointerPublisher.publish(handle, fixture.finalizeCommand()); - - assertThat(result.contentKey()).isNotNull(); - assertThat(result.contentPath()).exists(); - assertThat(result.atomicMoveUsed()).isFalse(); -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -```bash -./gradlew :modules:fileserver:fileserver-storage-local:test \ - --tests '*ContentPublisherTest' -``` - -Expected: FAIL because publishers do not exist. - -- [ ] **Step 3: Implement publish strategies** - -`AtomicMoveContentPublisher` must use `ATOMIC_MOVE` and omit `REPLACE_EXISTING` for create-only. `MetadataPointerContentPublisher` must complete an immutable physical object under a fresh `ContentKey`; public visibility remains false until the application commits metadata READY. - -Both implementations must: - -```text -verify expected length -verify SHA-256 -optionally force the channel according to durability profile -stat the final object -return actual size and content key -map uncertain filesystem results to AmbiguousCompletionException -``` - -- [ ] **Step 4: Run publish tests including process-visible observer checks** - -```bash -./gradlew :modules:fileserver:fileserver-storage-local:test \ - --tests '*ContentPublisherTest' --rerun-tasks -``` - -Expected: PASS; observers see no partial final target. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-storage-local -git commit -m "feat: publish files with atomic or pointer strategy" -``` - ---- - -### Task 14: Finalize orchestration과 READY invariant 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/FileVerificationService.java` -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/FinalizeUploadService.java` -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/DefaultFinalizeUploadService.java` -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/FinalizeUploadRequest.java` -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/FileView.java` -- Test: `modules/fileserver/fileserver-application/src/test/java/io/backend/skeleton/fileserver/application/FinalizeUploadServiceTest.java` - -**Interfaces:** -- Consumes metadata stores, content store, state machine, quota service. -- Consumes the `FileVerificationService` Port created in this Task; Task 16 provides its production coordinator implementation. Tests use a deterministic ACCEPT stub. -- Produces READY or non-public VERIFYING/REJECTED results. - -- [ ] **Step 1: Write failing READY and checksum mismatch tests** - -```java -@Test -void publishesAndTransitionsToReadyOnlyAfterPhysicalVerification() { - FileView result = service.finalizeUpload( - fixture.uploadedSession(), - new FinalizeUploadRequest(Optional.of(fixture.sha256()), false), - fixture.context()); - - assertThat(result.state()).isEqualTo(FileState.READY); - assertThat(fixture.metadata(result.fileId()).contentKey()).isPresent(); - assertThat(fixture.contentExists(result.fileId())).isTrue(); -} -``` - -```java -@Test -void digestMismatchNeverTransitionsToReady() { - assertThatThrownBy(() -> service.finalizeUpload( - fixture.uploadedSession(), - new FinalizeUploadRequest(Optional.of("0".repeat(64)), false), - fixture.context())) - .isInstanceOf(IntegrityMismatchException.class); - - assertThat(fixture.fileState()).isEqualTo(FileState.REJECTED); - assertThat(fixture.publicDownloadAvailable()).isFalse(); -} -``` - -- [ ] **Step 2: Run the test to verify it fails** - -```bash -./gradlew :modules:fileserver:fileserver-application:test \ - --tests '*FinalizeUploadServiceTest' -``` - -Expected: FAIL because finalize service does not exist. - -- [ ] **Step 3: Implement the finalize sequence** - -Implement this exact order: - -```text -load upload and file -validate expected length -transition UPLOADING → UPLOADED when final append is complete -compare client digest if supplied -transition UPLOADED → VERIFYING -run verifier coordinator -on ACCEPT call contentStore.finalizeUpload -stat published object -transition VERIFYING → READY with content key, size, digest, etag, publishedAt -commit quota with actual bytes -release writer lease -``` - -On REJECT, transition to REJECTED and enqueue cleanup. On QUARANTINE, transition to QUARANTINED. Do not return READY when metadata transition fails after physical publish; enqueue reconciliation and throw `AmbiguousCompletionException`. - -- [ ] **Step 4: Run finalize tests** - -```bash -./gradlew :modules:fileserver:fileserver-application:test \ - --tests '*FinalizeUploadServiceTest' -``` - -Expected: PASS; every READY fixture has readable content and matching size/digest. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-application -git commit -m "feat: finalize uploads with ready invariants" -``` - ---- - -### Task 15: Ambiguous completion과 파일 reconciliation 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/recovery/FileReconciliationService.java` -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/recovery/DefaultFileReconciliationService.java` -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/recovery/ReconciliationResult.java` -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/recovery/ReconciliationStatus.java` -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/recovery/RecoveryQueue.java` -- Test: `modules/fileserver/fileserver-application/src/test/java/io/backend/skeleton/fileserver/application/recovery/FileReconciliationServiceTest.java` - -**Interfaces:** -- Consumes content `stat`, metadata version/state, expected size/digest. -- Produces `CONFIRMED_SUCCESS`, `CONFIRMED_NOT_APPLIED`, `RECOVERABLE_PARTIAL`, `QUARANTINE_REQUIRED`, or `UNRESOLVED`. - -- [ ] **Step 1: Write failing ambiguous publish recovery tests** - -```java -@Test -void confirmsSuccessWhenPhysicalObjectAndMetadataMatch() { - fixture.preparePhysicalObjectAndVerifyingMetadata(); - - ReconciliationResult result = service.reconcile(fixture.fileId()); - - assertThat(result.status()).isEqualTo(ReconciliationStatus.CONFIRMED_SUCCESS); - assertThat(fixture.fileState()).isEqualTo(FileState.READY); -} -``` - -```java -@Test -void neverGuessesReadyWhenDigestCannotBeVerified() { - fixture.prepareUnknownPhysicalObject(); - - ReconciliationResult result = service.reconcile(fixture.fileId()); - - assertThat(result.status()).isEqualTo(ReconciliationStatus.UNRESOLVED); - assertThat(fixture.fileState()).isNotEqualTo(FileState.READY); -} -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -```bash -./gradlew :modules:fileserver:fileserver-application:test \ - --tests '*FileReconciliationServiceTest' -``` - -Expected: FAIL because reconciliation is absent. - -- [ ] **Step 3: Implement deterministic reconciliation** - -Use the following decision rules: - -```text -metadata READY + physical size/digest match → CONFIRMED_SUCCESS -metadata pre-publish + no physical target → CONFIRMED_NOT_APPLIED -staging exists + known committed offset → RECOVERABLE_PARTIAL -physical exists + expected key/size/digest match + version unchanged → transition READY -physical exists but key/size/digest differ → QUARANTINE_REQUIRED -insufficient evidence → UNRESOLVED -``` - -Never perform blind write retry from this service. Store recovery attempts and reason codes in the cleanup/recovery queue. - -- [ ] **Step 4: Run recovery tests** - -```bash -./gradlew :modules:fileserver:fileserver-application:test \ - --tests '*FileReconciliationServiceTest' -``` - -Expected: PASS; no unresolved case changes the file to READY. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-application -git commit -m "feat: reconcile ambiguous fileserver operations" -``` - ---- - -### Task 16: Verification pipeline과 quarantine 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/security/FileVerifier.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/security/VerificationRequest.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/security/VerificationResult.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/security/VerificationVerdict.java` -- Create: `modules/fileserver/fileserver-verification/src/main/java/io/backend/skeleton/fileserver/verification/VerificationCoordinator.java` -- Create: `modules/fileserver/fileserver-verification/src/main/java/io/backend/skeleton/fileserver/verification/Sha256Verifier.java` -- Create: `modules/fileserver/fileserver-verification/src/main/java/io/backend/skeleton/fileserver/verification/MediaTypeVerifier.java` -- Create: `modules/fileserver/fileserver-verification/src/main/java/io/backend/skeleton/fileserver/verification/VerificationPolicyCombiner.java` -- Test: `modules/fileserver/fileserver-verification/src/test/java/io/backend/skeleton/fileserver/verification/VerificationCoordinatorTest.java` - -**Interfaces:** -- Produces `VerificationCoordinator#verify(VerificationRequest)` consumed by Task 14. -- Verifiers return only safe metadata and stable reason codes. - -- [ ] **Step 1: Write failing accept, quarantine, and retry tests** - -```java -@Test -void rejectDominatesAccept() { - VerificationCoordinator coordinator = coordinator( - verifier("digest", VerificationVerdict.ACCEPT), - verifier("malware", VerificationVerdict.REJECT)); - - VerificationResult result = coordinator.verify(fixture.request()).toCompletableFuture().join(); - - assertThat(result.verdict()).isEqualTo(VerificationVerdict.REJECT); - assertThat(result.code()).isEqualTo("MALWARE_REJECTED"); -} - -@Test -void scannerTimeoutDoesNotBecomeAccept() { - VerificationCoordinator coordinator = coordinator(timeoutVerifier("scanner")); - - VerificationResult result = coordinator.verify(fixture.request()).toCompletableFuture().join(); - - assertThat(result.verdict()).isEqualTo(VerificationVerdict.RETRY); -} -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -```bash -./gradlew :modules:fileserver:fileserver-verification:test \ - --tests '*VerificationCoordinatorTest' -``` - -Expected: FAIL because verification types do not exist. - -- [ ] **Step 3: Implement ordered verification and policy combination** - -Run verifiers in this order: - -```text -length -sha256 -filename policy -media-type detection -signature/parser -optional malware scanner -optional CDR -``` - -Combination precedence is `REJECT > QUARANTINE > RETRY > ACCEPT`. Apply per-verifier timeout and record started/completed timestamps through the metadata adapter. Never log content samples or scanner raw payloads. - -- [ ] **Step 4: Run verification tests** - -```bash -./gradlew :modules:fileserver:fileserver-verification:test -``` - -Expected: PASS; timeout, reject, quarantine, and accept paths are deterministic. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-core-api modules/fileserver/fileserver-verification -git commit -m "feat: add fileserver verification pipeline" -``` - ---- - -### Task 17: Authorization hook과 upload application service 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/security/FileAccessPolicy.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/security/FileOperation.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/security/FileAccessSubject.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/security/RequestContext.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/transfer/UploadProtocol.java` -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/UploadApplicationService.java` -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/DefaultUploadApplicationService.java` -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/CreateUploadRequest.java` -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/UploadSessionView.java` -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/AppendUploadResult.java` -- Test: `modules/fileserver/fileserver-application/src/test/java/io/backend/skeleton/fileserver/application/UploadApplicationServiceTest.java` - -**Interfaces:** -- Consumes metadata, content store, quota, state machine, filename policy, access policy. -- Produces create, append, status, cancel methods used by HTTP adapters. - -- [ ] **Step 1: Write failing authorization, create, append, cancel tests** - -```java -@Test -void authorizationRunsBeforeQuotaAndStorageMutation() { - accessPolicy.deny(FileOperation.CREATE); - - assertThatThrownBy(() -> service.create(fixture.createRequest(), fixture.context())) - .isInstanceOf(FileAccessDeniedException.class); - - assertThat(fixture.fileRecordCount()).isZero(); - assertThat(fixture.stagingFileCount()).isZero(); -} -``` - -```java -@Test -void createAppendAndCancelMaintainStateAndOffset() throws Exception { - UploadSessionView created = service.create(fixture.createRequest(), fixture.context()); - AppendUploadResult appended = service.append( - created.uploadId(), 0, fixture.channel("abc"), 3, fixture.context()); - service.cancel(created.uploadId(), fixture.context()); - - assertThat(appended.committedOffset()).isEqualTo(3); - assertThat(fixture.fileState(created.fileId())).isEqualTo(FileState.DELETING); - assertThat(fixture.publicDownloadAvailable(created.fileId())).isFalse(); -} -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -```bash -./gradlew :modules:fileserver:fileserver-application:test \ - --tests '*UploadApplicationServiceTest' -``` - -Expected: FAIL because upload orchestration is absent. - -- [ ] **Step 3: Implement create, append, status, cancel** - -Create sequence: - -```text -authorize CREATE -sanitize original filename -validate expected length -acquire admission permit -reserve quota -insert CREATED file -insert upload session -create staging -transition CREATED → UPLOADING -return offset 0 and expiry -``` - -Append sequence: - -```text -authorize APPEND -load non-expired session -acquire writer lease -validate metadata offset and physical length -extend quota reservation if needed -stream append -commit offset with lease token -release lease and transfer permit -``` - -Cancel sequence transitions to DELETING first, then queues cleanup. It does not synchronously remove large content from the request thread. - -- [ ] **Step 4: Run upload application tests** - -```bash -./gradlew :modules:fileserver:fileserver-application:test \ - --tests '*UploadApplicationServiceTest' -``` - -Expected: PASS; authorization denial creates no side effect and offset commits are monotonic. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-core-api modules/fileserver/fileserver-application -git commit -m "feat: implement fileserver upload application flow" -``` - ---- - -### Task 18: HTTP Range, validator, header contract core 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/transfer/HttpRangeResolver.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/transfer/DefaultHttpRangeResolver.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/transfer/RangeBudget.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/transfer/ResolvedRanges.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/transfer/ConditionalRequestEvaluator.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/transfer/DownloadDecision.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/transfer/ContentDispositionFactory.java` -- Test: `modules/fileserver/fileserver-core-api/src/test/java/io/backend/skeleton/fileserver/api/transfer/HttpRangeResolverTest.java` -- Test: `modules/fileserver/fileserver-core-api/src/test/java/io/backend/skeleton/fileserver/api/transfer/ConditionalRequestEvaluatorTest.java` - -**Interfaces:** -- Produces a framework-neutral `DownloadDecision` used by MVC, WebFlux, and Nginx. -- Default public budget is one range; optional multi-range budget is eight merged ranges. - -- [ ] **Step 1: Write failing Range and conditional tests** - -```java -@ParameterizedTest -@CsvSource({ - "bytes=0-9,0,9", - "bytes=90-,90,99", - "bytes=-10,90,99" -}) -void resolvesSingleRanges(String header, long start, long end) { - ResolvedRanges result = resolver.resolve(header, 100, RangeBudget.single()); - assertThat(result.ranges()).containsExactly(new ByteRange(start, end)); -} - -@Test -void unsatisfiableRangeCarriesRepresentationLength() { - assertThatThrownBy(() -> resolver.resolve("bytes=100-200", 100, RangeBudget.single())) - .isInstanceOf(RangeNotSatisfiableException.class) - .extracting("representationLength") - .isEqualTo(100L); -} -``` - -```java -@Test -void mismatchedIfRangeFallsBackToFullResponse() { - DownloadDecision result = evaluator.evaluate(fixture.requestWithIfRange("\"old\""), - fixture.representation("\"new\"", 100)); - - assertThat(result.status()).isEqualTo(200); - assertThat(result.ranges()).isEmpty(); -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -```bash -./gradlew :modules:fileserver:fileserver-core-api:test \ - --tests '*HttpRangeResolverTest' --tests '*ConditionalRequestEvaluatorTest' -``` - -Expected: FAIL because HTTP contract utilities do not exist. - -- [ ] **Step 3: Implement parsing and decision order** - -Implement: - -```text -If-Match / If-Unmodified-Since -If-None-Match / If-Modified-Since -Range syntax and budget -If-Range -200 / 206 / 304 / 412 / 416 -``` - -Merge overlapping ranges only when multi-range is enabled. Reject more than eight ranges or a total requested byte count above the configured budget. `ContentDispositionFactory` must emit sanitized ASCII `filename` and UTF-8 `filename*` without CR/LF. - -- [ ] **Step 4: Run all transfer contract tests** - -```bash -./gradlew :modules:fileserver:fileserver-core-api:test \ - --tests '*transfer*' -``` - -Expected: PASS for first, middle, suffix, open-ended, empty, invalid, conditional, and If-Range cases. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-core-api -git commit -m "feat: implement fileserver HTTP range contract" -``` - ---- - -### Task 19: Spring MVC raw·multipart upload adapter 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-mvc/src/main/java/io/backend/skeleton/fileserver/mvc/FileUploadController.java` -- Create: `modules/fileserver/fileserver-mvc/src/main/java/io/backend/skeleton/fileserver/mvc/RawUploadRequestMapper.java` -- Create: `modules/fileserver/fileserver-mvc/src/main/java/io/backend/skeleton/fileserver/mvc/MultipartUploadRequestMapper.java` -- Create: `modules/fileserver/fileserver-mvc/src/main/java/io/backend/skeleton/fileserver/mvc/MvcTransferExecutorConfiguration.java` -- Create: `modules/fileserver/fileserver-mvc/src/main/java/io/backend/skeleton/fileserver/mvc/BatchUploadResponse.java` -- Test: `modules/fileserver/fileserver-mvc/src/test/java/io/backend/skeleton/fileserver/mvc/FileUploadControllerTest.java` -- Test: `modules/fileserver/fileserver-mvc/src/test/java/io/backend/skeleton/fileserver/mvc/MvcUploadExecutorSaturationTest.java` - -**Interfaces:** -- Consumes `UploadApplicationService` and `FinalizeUploadService`. -- Implements `POST /v1/files`, `POST /v1/files:raw`, `POST /v1/files:batch`. - -- [ ] **Step 1: Write failing MVC endpoint tests** - -```java -@Test -void rawUploadStreamsWithoutCallingReadAllBytes() throws Exception { - mockMvc.perform(post("/v1/files:raw") - .contentType(MediaType.APPLICATION_OCTET_STREAM) - .header("X-Filename", "report.bin") - .content("abc")) - .andExpect(status().isCreated()) - .andExpect(header().exists("Location")) - .andExpect(jsonPath("$.state").value("READY")); - - verify(uploadService).append(any(), eq(0L), any(ReadableByteChannel.class), eq(3L), any()); -} -``` - -```java -@Test -void batchReturnsPerPartResultsAndIsExplicitlyNonAtomic() throws Exception { - mockMvc.perform(multipart("/v1/files:batch") - .file(new MockMultipartFile("files", "a.txt", "text/plain", "a".getBytes())) - .file(new MockMultipartFile("files", "b.txt", "text/plain", "b".getBytes()))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.results.length()").value(2)); -} -``` - -- [ ] **Step 2: Run MVC tests to verify they fail** - -```bash -./gradlew :modules:fileserver:fileserver-mvc:test \ - --tests '*FileUploadControllerTest' --tests '*MvcUploadExecutorSaturationTest' -``` - -Expected: FAIL because the controller and executor are absent. - -- [ ] **Step 3: Implement controllers with bounded streaming executor** - -Use `ServletInputStream` through `Channels.newChannel`. Do not call `getBytes()` on `MultipartFile`. Submit blocking transfer work to a `ThreadPoolTaskExecutor` configured with core 8, max 32, queue 64. Convert rejection to retryable `429` or `503` with `Retry-After`. - -Batch behavior: - -```text -maximum 16 parts -one independent upload per part -successes are retained when another part fails -return 200 with ordered result array -never expose container temp path -``` - -- [ ] **Step 4: Run MVC upload and saturation tests** - -```bash -./gradlew :modules:fileserver:fileserver-mvc:test -``` - -Expected: PASS; saturation does not create unbounded threads or queues. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-mvc -git commit -m "feat: add MVC streaming upload endpoints" -``` - ---- - -### Task 20: Spring MVC GET·HEAD·Range download adapter 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/DownloadApplicationService.java` -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/DefaultDownloadApplicationService.java` -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/DownloadDescriptor.java` -- Create: `modules/fileserver/fileserver-mvc/src/main/java/io/backend/skeleton/fileserver/mvc/FileDownloadController.java` -- Create: `modules/fileserver/fileserver-mvc/src/main/java/io/backend/skeleton/fileserver/mvc/MvcDownloadResponseWriter.java` -- Test: `modules/fileserver/fileserver-mvc/src/test/java/io/backend/skeleton/fileserver/mvc/FileDownloadControllerContractTest.java` - -**Interfaces:** -- Consumes authorization, metadata, `HttpRangeResolver`, conditional evaluator, content store. -- Produces identical headers for GET and HEAD and exact `200/206/304/412/416` behavior. - -- [ ] **Step 1: Write failing GET, HEAD, Range, and READY-gate tests** - -```java -@Test -void headMatchesGetHeadersWithoutBody() throws Exception { - MvcResult get = mockMvc.perform(get(contentUrl()).header("Authorization", token())) - .andExpect(status().isOk()) - .andReturn(); - - MvcResult head = mockMvc.perform(head(contentUrl()).header("Authorization", token())) - .andExpect(status().isOk()) - .andExpect(content().bytes(new byte[0])) - .andReturn(); - - assertThat(head.getResponse().getHeader("ETag")) - .isEqualTo(get.getResponse().getHeader("ETag")); - assertThat(head.getResponse().getHeader("Content-Length")) - .isEqualTo(get.getResponse().getHeader("Content-Length")); -} -``` - -```java -@Test -void returnsPartialContentForSingleRange() throws Exception { - mockMvc.perform(get(contentUrl()) - .header("Authorization", token()) - .header("Range", "bytes=2-4")) - .andExpect(status().isPartialContent()) - .andExpect(header().string("Content-Range", "bytes 2-4/10")) - .andExpect(content().bytes(new byte[]{2, 3, 4})); -} -``` - -```java -@Test -void nonReadyFileIsNeverOpened() throws Exception { - fixture.fileInState(FileState.VERIFYING); - - mockMvc.perform(get(contentUrl()).header("Authorization", token())) - .andExpect(status().isConflict()); - - verify(contentStore, never()).openRead(any(), any()); -} -``` - -- [ ] **Step 2: Run MVC download tests to verify they fail** - -```bash -./gradlew :modules:fileserver:fileserver-mvc:test \ - --tests '*FileDownloadControllerContractTest' -``` - -Expected: FAIL because download service and controller do not exist. - -- [ ] **Step 3: Implement application decision and MVC writer** - -`DefaultDownloadApplicationService` must authorize before opening content, require READY, evaluate validators and Range, then return a descriptor with status, headers, content key, and normalized ranges. `MvcDownloadResponseWriter` uses a `StreamingResponseBody` or repeatable file resource; it must not use `InputStreamResource` for Range. - -Add headers: - -```text -ETag -Last-Modified -Accept-Ranges -Content-Type -Content-Disposition -Cache-Control -Content-Length or Content-Range -``` - -For `416`, include `Content-Range: bytes */`. - -- [ ] **Step 4: Run full MVC HTTP contract tests** - -```bash -./gradlew :modules:fileserver:fileserver-mvc:test -``` - -Expected: PASS for full, HEAD, first, middle, suffix, unsatisfiable, ETag, If-Range, and non-READY cases. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-application modules/fileserver/fileserver-mvc -git commit -m "feat: add MVC fileserver download contract" -``` - ---- - -### Task 21: Spring WebFlux raw·multipart upload adapter 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-webflux/src/main/java/io/backend/skeleton/fileserver/webflux/ReactiveUploadApplicationService.java` -- Create: `modules/fileserver/fileserver-webflux/src/main/java/io/backend/skeleton/fileserver/webflux/FileUploadHandler.java` -- Create: `modules/fileserver/fileserver-webflux/src/main/java/io/backend/skeleton/fileserver/webflux/PartEventUploadReader.java` -- Create: `modules/fileserver/fileserver-webflux/src/main/java/io/backend/skeleton/fileserver/webflux/DataBufferByteBufferPublisher.java` -- Create: `modules/fileserver/fileserver-webflux/src/main/java/io/backend/skeleton/fileserver/webflux/FileserverIoScheduler.java` -- Test: `modules/fileserver/fileserver-webflux/src/test/java/io/backend/skeleton/fileserver/webflux/FileUploadHandlerTest.java` -- Test: `modules/fileserver/fileserver-webflux/src/test/java/io/backend/skeleton/fileserver/webflux/DataBufferReleaseTest.java` -- Test: `modules/fileserver/fileserver-webflux/src/test/java/io/backend/skeleton/fileserver/webflux/WebFluxBlockingCallTest.java` - -**Interfaces:** -- Consumes `AsyncContentStore` when available or adapts the blocking application service on a dedicated bounded scheduler. -- Every received pooled `DataBuffer` is forwarded or released exactly once. - -- [ ] **Step 1: Write failing upload, cancellation, and buffer-release tests** - -```java -@Test -void rawUploadConsumesFluxWithoutJoiningWholeBody() { - webTestClient.post() - .uri("/v1/files:raw") - .contentType(MediaType.APPLICATION_OCTET_STREAM) - .header("X-Filename", "large.bin") - .body(Flux.just(buffer("abc"), buffer("def")), DataBuffer.class) - .exchange() - .expectStatus().isCreated() - .expectBody() - .jsonPath("$.state").isEqualTo("READY"); - - assertThat(testBufferFactory.joinInvocationCount()).isZero(); -} -``` - -```java -@Test -void cancellationReleasesAllObservedBuffers() { - StepVerifier.create(handler.consume(fixture.cancellableBuffers())) - .thenCancel() - .verify(); - - assertThat(fixture.allocatedBufferCount()).isEqualTo(fixture.releasedBufferCount()); -} -``` - -- [ ] **Step 2: Run WebFlux tests to verify they fail** - -```bash -./gradlew :modules:fileserver:fileserver-webflux:test \ - --tests '*FileUploadHandlerTest' --tests '*DataBufferReleaseTest' \ - --tests '*WebFluxBlockingCallTest' -``` - -Expected: FAIL because handlers and buffer adapters do not exist. - -- [ ] **Step 3: Implement streaming adapters and dedicated scheduler** - -`PartEventUploadReader` must process windowed multipart events sequentially and enforce part count and byte limits. Use `DataBufferUtils.release(buffer)` in every discard, error, and cancellation path. For a blocking local store, schedule filesystem work on a fixed bounded scheduler named `fileserver-io`; never use the Reactor Netty event loop. - -```java -public final class FileserverIoScheduler implements AutoCloseable { - private final Scheduler scheduler; - - public FileserverIoScheduler(int workers, int queueCapacity) { - this.scheduler = Schedulers.newBoundedElastic( - workers, queueCapacity, "fileserver-io", 60, false); - } - - public Scheduler scheduler() { - return scheduler; - } -} -``` - -- [ ] **Step 4: Run WebFlux tests with leak detection and BlockHound** - -```bash -./gradlew :modules:fileserver:fileserver-webflux:test -``` - -Expected: PASS; no unreleased buffers and no blocking call on event-loop threads. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-webflux -git commit -m "feat: add WebFlux streaming upload adapter" -``` - ---- - -### Task 22: Spring WebFlux download와 zero-copy capability 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-webflux/src/main/java/io/backend/skeleton/fileserver/webflux/FileDownloadHandler.java` -- Create: `modules/fileserver/fileserver-webflux/src/main/java/io/backend/skeleton/fileserver/webflux/ReactiveDownloadResponseWriter.java` -- Create: `modules/fileserver/fileserver-webflux/src/main/java/io/backend/skeleton/fileserver/webflux/ZeroCopyEligibility.java` -- Test: `modules/fileserver/fileserver-webflux/src/test/java/io/backend/skeleton/fileserver/webflux/FileDownloadHandlerContractTest.java` -- Test: `modules/fileserver/fileserver-webflux/src/test/java/io/backend/skeleton/fileserver/webflux/SlowClientBackpressureTest.java` - -**Interfaces:** -- Reuses the exact `DownloadDecision` from Task 18. -- Produces HTTP parity with Task 20. - -- [ ] **Step 1: Write failing parity and backpressure tests** - -```java -@Test -void rangeHeadersMatchMvcContract() { - webTestClient.get() - .uri(contentUrl()) - .header("Authorization", token()) - .header("Range", "bytes=2-4") - .exchange() - .expectStatus().isEqualTo(206) - .expectHeader().valueEquals("Content-Range", "bytes 2-4/10") - .expectBody().isEqualTo(new byte[]{2, 3, 4}); -} -``` - -```java -@Test -void slowSubscriberDoesNotExceedInFlightBufferLimit() { - StepVerifier.withVirtualTime(() -> fixture.slowDownload()) - .thenAwait(Duration.ofSeconds(10)) - .thenCancel() - .verify(); - - assertThat(fixture.maxInFlightBuffers()).isLessThanOrEqualTo(8); -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -```bash -./gradlew :modules:fileserver:fileserver-webflux:test \ - --tests '*FileDownloadHandlerContractTest' --tests '*SlowClientBackpressureTest' -``` - -Expected: FAIL because download handler is absent. - -- [ ] **Step 3: Implement reactive write and optional zero-copy** - -For async stores, map `Flow.Publisher` to `Flux` with bounded demand. For local files, use zero-copy only when the response implementation supports it, no body transformation is required, and TLS/runtime constraints allow it. Zero-copy remains an optimization and does not alter the public contract. - -- [ ] **Step 4: Run WebFlux download contract tests** - -```bash -./gradlew :modules:fileserver:fileserver-webflux:test -``` - -Expected: PASS; MVC and WebFlux golden HTTP snapshots are equal for shared scenarios. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-webflux -git commit -m "feat: add WebFlux fileserver downloads" -``` - ---- - -### Task 23: Nginx `X-Accel-Redirect` 위임 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-nginx/src/main/java/io/backend/skeleton/fileserver/nginx/NginxInternalUriMapper.java` -- Create: `modules/fileserver/fileserver-nginx/src/main/java/io/backend/skeleton/fileserver/nginx/DefaultNginxInternalUriMapper.java` -- Create: `modules/fileserver/fileserver-nginx/src/main/java/io/backend/skeleton/fileserver/nginx/NginxDownloadStrategy.java` -- Create: `modules/fileserver/fileserver-nginx/src/main/java/io/backend/skeleton/fileserver/nginx/NginxDelegationProperties.java` -- Create: `infra/fileserver/nginx/nginx.conf` -- Test: `modules/fileserver/fileserver-nginx/src/test/java/io/backend/skeleton/fileserver/nginx/NginxInternalUriMapperTest.java` -- Test: `modules/fileserver/fileserver-nginx/src/test/java/io/backend/skeleton/fileserver/nginx/NginxDownloadIntegrationTest.java` - -**Interfaces:** -- Consumes an authorized READY `DownloadDescriptor`. -- Produces a validated relative internal URI, never an absolute physical path. -- Default threshold is 16 MiB. - -- [ ] **Step 1: Write failing URI mapping and internal-path tests** - -```java -@Test -void mapsValidatedContentKeyWithoutExposingAbsolutePath() { - String internalUri = mapper.map(new ContentKey("ab/cd/0123456789abcdef")); - - assertThat(internalUri).isEqualTo("/__files/ab/cd/0123456789abcdef.bin"); - assertThat(internalUri).doesNotContain("/var/lib", "..", "\"); -} - -@Test -void rejectsMalformedContentKeyEvenWhenCalledInternally() { - assertThatThrownBy(() -> mapper.mapUnchecked("../../etc/passwd")) - .isInstanceOf(InvalidPathException.class); -} -``` - -```java -@Test -void directAccessToInternalLocationIsRejected() { - nginxClient.get("/__files/ab/cd/0123456789abcdef.bin") - .expectStatus(404); -} -``` - -- [ ] **Step 2: Run unit and integration tests to verify they fail** - -```bash -./gradlew :modules:fileserver:fileserver-nginx:test \ - --tests '*NginxInternalUriMapperTest' --tests '*NginxDownloadIntegrationTest' -``` - -Expected: FAIL because URI mapper and Nginx configuration do not exist. - -- [ ] **Step 3: Implement safe relative mapping and Nginx internal location** - -`DefaultNginxInternalUriMapper` accepts only a validated `ContentKey`, rebuilds the shard components, and returns a URI below `/__files/`. Configure Nginx: - -```nginx -location /__files/ { - internal; - alias /srv/files/content/; - sendfile on; - sendfile_max_chunk 2m; - add_header X-Content-Type-Options nosniff always; -} -``` - -The application response includes `X-Accel-Redirect` only after authorization and READY gate. Ensure the header is consumed by Nginx and not copied to the client. The resulting URI path after `/__files/` must map exactly to the local content layout. - -- [ ] **Step 4: Run direct-vs-Nginx HTTP parity tests** - -```bash -./gradlew :modules:fileserver:fileserver-nginx:test -``` - -Expected: PASS for full GET, HEAD, Range, ETag, Content-Disposition, private cache headers, and external internal-location rejection. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-nginx infra/fileserver/nginx -git commit -m "feat: delegate large downloads to nginx" -``` - ---- - -### Task 24: Delete, copy, move, cleanup lifecycle 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/FileLifecycleService.java` -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/DefaultFileLifecycleService.java` -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/cleanup/CleanupService.java` -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/cleanup/DefaultCleanupService.java` -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/cleanup/CleanupItem.java` -- Modify: `modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/LocalBlockingContentStore.java` -- Test: `modules/fileserver/fileserver-application/src/test/java/io/backend/skeleton/fileserver/application/FileLifecycleServiceTest.java` -- Test: `modules/fileserver/fileserver-application/src/test/java/io/backend/skeleton/fileserver/application/cleanup/CleanupServiceTest.java` - -**Interfaces:** -- Implements logical delete first, bounded asynchronous physical cleanup. -- Public move changes logical namespace metadata only. -- Copy defaults to create-only target. - -- [ ] **Step 1: Write failing delete and cleanup-race tests** - -```java -@Test -void logicalDeleteBlocksDownloadBeforePhysicalDeleteCompletes() { - fixture.readyFileWithSlowPhysicalDelete(); - - service.delete(fixture.fileId(), fixture.version(), fixture.context()); - - assertThat(fixture.fileState()).isEqualTo(FileState.DELETING); - assertThat(fixture.publicDownloadAvailable()).isFalse(); - assertThat(fixture.physicalObjectExists()).isTrue(); -} -``` - -```java -@Test -void cleanupDoesNotDeleteContentOwnedByAnActiveLease() { - fixture.cleanupItemForActiveUpload(); - - CleanupBatchResult result = cleanup.runBatch(100, 1L << 30); - - assertThat(result.skippedActiveLease()).isEqualTo(1); - assertThat(fixture.physicalObjectExists()).isTrue(); -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -```bash -./gradlew :modules:fileserver:fileserver-application:test \ - --tests '*FileLifecycleServiceTest' --tests '*CleanupServiceTest' -``` - -Expected: FAIL because lifecycle services do not exist. - -- [ ] **Step 3: Implement lifecycle operations** - -Delete: - -```text -authorize DELETE -validate If-Match/version -transition to DELETING -enqueue cleanup -return 202 or 204 -worker deletes physical content -release quota -transition to DELETED -``` - -Copy creates a new FileRecord and physical target; partial target is queued for cleanup on failure. Move changes logical namespace metadata without moving immutable physical content. Cleanup verifies state, version, lease, and content key before deleting. - -- [ ] **Step 4: Run lifecycle tests** - -```bash -./gradlew :modules:fileserver:fileserver-application:test \ - --tests '*FileLifecycleServiceTest' --tests '*CleanupServiceTest' -``` - -Expected: PASS; active content is never deleted and logical delete blocks reads immediately. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-application modules/fileserver/fileserver-storage-local -git commit -m "feat: implement fileserver lifecycle and cleanup" -``` - ---- - -### Task 25: 별도 Admin Plane 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-admin/src/main/java/io/backend/skeleton/fileserver/admin/FileserverAdminController.java` -- Create: `modules/fileserver/fileserver-admin/src/main/java/io/backend/skeleton/fileserver/admin/StorageHealthView.java` -- Create: `modules/fileserver/fileserver-admin/src/main/java/io/backend/skeleton/fileserver/admin/OrphanAdminService.java` -- Create: `modules/fileserver/fileserver-admin/src/main/java/io/backend/skeleton/fileserver/admin/AdminAuditService.java` -- Test: `modules/fileserver/fileserver-admin/src/test/java/io/backend/skeleton/fileserver/admin/FileserverAdminControllerTest.java` -- Test: `modules/fileserver/fileserver-admin/src/test/java/io/backend/skeleton/fileserver/admin/OrphanAdminServiceTest.java` - -**Interfaces:** -- Exposes management-only health, capabilities, orphan dry-run/apply, reverify, force-delete, incomplete upload cleanup. -- Never returns physical root, filename, raw scanner data, or signed tokens. - -- [ ] **Step 1: Write failing management-isolation and dry-run tests** - -```java -@Test -void publicApplicationPortDoesNotExposeAdminEndpoints() { - publicWebClient.get().uri("/internal/fileserver/capabilities") - .exchange() - .expectStatus().isNotFound(); -} - -@Test -void orphanReconcileDefaultsToDryRun() { - managementWebClient.post().uri("/internal/fileserver/orphans:reconcile") - .bodyValue(Map.of("limit", 100)) - .exchange() - .expectStatus().isOk() - .expectBody() - .jsonPath("$.dryRun").isEqualTo(true); - - assertThat(fixture.deletedObjectCount()).isZero(); -} -``` - -- [ ] **Step 2: Run admin tests to verify they fail** - -```bash -./gradlew :modules:fileserver:fileserver-admin:test \ - --tests '*FileserverAdminControllerTest' --tests '*OrphanAdminServiceTest' -``` - -Expected: FAIL because the admin module is not implemented. - -- [ ] **Step 3: Implement management-only endpoints and audit** - -Implement endpoints from the design. `force-delete` requires an explicit reason and a second authorization predicate. Orphan apply requests require `dryRun=false`, expected object fingerprint, and bounded byte budget. Audit records operation, reason code, actor fingerprint, result, and trace ID without path or filename. - -- [ ] **Step 4: Run admin isolation and behavior tests** - -```bash -./gradlew :modules:fileserver:fileserver-admin:test -``` - -Expected: PASS; admin routes exist only on the management context and all mutating actions emit audit records. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-admin -git commit -m "feat: add isolated fileserver admin plane" -``` - ---- - -### Task 26: 다중 인스턴스 writer lease와 NFS ambiguity 처리 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/concurrency/WriterLeaseCoordinator.java` -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/concurrency/DefaultWriterLeaseCoordinator.java` -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/concurrency/LeaseHeartbeat.java` -- Create: `modules/fileserver/fileserver-storage-local/src/main/java/io/backend/skeleton/fileserver/local/AmbiguousFilesystemOperationDetector.java` -- Test: `modules/fileserver/fileserver-application/src/test/java/io/backend/skeleton/fileserver/application/concurrency/MultiInstanceWriterLeaseTest.java` -- Test: `modules/fileserver/fileserver-storage-local/src/test/java/io/backend/skeleton/fileserver/local/AmbiguousFilesystemOperationDetectorTest.java` - -**Interfaces:** -- Builds on DB lease methods from Task 7. -- A writer whose lease token expired or changed may not commit offset or READY state. -- Filesystem timeout with possible server-side completion becomes `AmbiguousCompletionException`. - -- [ ] **Step 1: Write failing two-node and expired-writer tests** - -```java -@Test -void onlyOneNodeCanAppendTheSameUpload() { - UploadId uploadId = fixture.activeUpload(); - - CompletableFuture nodeA = node("a").append(uploadId, 0, "abc"); - CompletableFuture nodeB = node("b").append(uploadId, 0, "xyz"); - - assertThat(successCount(nodeA, nodeB)).isEqualTo(1); - assertThat(conflictCount(nodeA, nodeB)).isEqualTo(1); - assertThat(fixture.committedOffset(uploadId)).isEqualTo(3); -} -``` - -```java -@Test -void pausedWriterCannotCommitAfterLeaseTakeover() { - WriterLease stale = coordinator.acquire(fixture.uploadId(), "node-a"); - clock.advance(Duration.ofMinutes(1)); - WriterLease current = coordinator.acquire(fixture.uploadId(), "node-b"); - - assertThatThrownBy(() -> coordinator.commitOffset(stale, 0, 3)) - .isInstanceOf(ConcurrentFileModificationException.class); - assertThat(current.owner()).isEqualTo("node-b"); -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -```bash -./gradlew :modules:fileserver:fileserver-application:test \ - :modules:fileserver:fileserver-storage-local:test \ - --tests '*MultiInstanceWriterLeaseTest' \ - --tests '*AmbiguousFilesystemOperationDetectorTest' -``` - -Expected: FAIL because coordinator and ambiguity classification are absent. - -- [ ] **Step 3: Implement lease heartbeat and ambiguity classification** - -Heartbeat renews at one third of the lease duration. Every commit validates upload ID, owner, token, expiry, expected offset, and metadata version. Do not use `FileLock` as a correctness dependency. - -Classify NFS-style outcomes: - -```text -request definitely not sent → retryable failure -server explicitly rejected → definite failure -response lost after possible rename/write → ambiguous completion -stale handle with physical evidence available → reconciliation required -``` - -- [ ] **Step 4: Run multi-instance tests with repeated scheduling jitter** - -```bash -./gradlew :modules:fileserver:fileserver-application:test \ - :modules:fileserver:fileserver-storage-local:test \ - --tests '*MultiInstanceWriterLeaseTest' \ - --tests '*AmbiguousFilesystemOperationDetectorTest' --rerun-tasks -``` - -Expected: PASS; no run commits bytes from a stale lease. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-application modules/fileserver/fileserver-storage-local -git commit -m "feat: enforce multi-instance fileserver leases" -``` - ---- - -### Task 27: tus 1.0 Stable 모듈 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-tus/src/main/java/io/backend/skeleton/fileserver/tus/TusController.java` -- Create: `modules/fileserver/fileserver-tus/src/main/java/io/backend/skeleton/fileserver/tus/TusRequestParser.java` -- Create: `modules/fileserver/fileserver-tus/src/main/java/io/backend/skeleton/fileserver/tus/TusResponseHeaders.java` -- Create: `modules/fileserver/fileserver-tus/src/main/java/io/backend/skeleton/fileserver/tus/TusProperties.java` -- Create: `modules/fileserver/fileserver-tus/src/main/java/io/backend/skeleton/fileserver/tus/TusChecksumVerifier.java` -- Test: `modules/fileserver/fileserver-tus/src/test/java/io/backend/skeleton/fileserver/tus/TusProtocolContractTest.java` -- Test: `modules/fileserver/fileserver-tus/src/test/java/io/backend/skeleton/fileserver/tus/TusOffsetConcurrencyTest.java` - -**Interfaces:** -- Consumes `UploadApplicationService` create/status/append/cancel. -- Supports creation, HEAD, PATCH, checksum, expiration, termination. -- Concatenation is Beta and feature-flagged. - -- [ ] **Step 1: Write failing tus creation, HEAD, PATCH, mismatch tests** - -```java -@Test -void createsAndAppendsTusUpload() { - String location = client.post("/v1/uploads") - .header("Tus-Resumable", "1.0.0") - .header("Upload-Length", "6") - .expectStatus(201) - .returnHeader("Location"); - - client.patch(location) - .header("Tus-Resumable", "1.0.0") - .header("Upload-Offset", "0") - .contentType("application/offset+octet-stream") - .body("abc") - .expectStatus(204) - .expectHeader("Upload-Offset", "3"); - - client.head(location) - .header("Tus-Resumable", "1.0.0") - .expectStatus(204) - .expectHeader("Upload-Offset", "3"); -} -``` - -```java -@Test -void mismatchedOffsetReturns409WithoutMutation() { - fixture.uploadAtOffset(3); - - client.patch(fixture.location()) - .header("Tus-Resumable", "1.0.0") - .header("Upload-Offset", "1") - .contentType("application/offset+octet-stream") - .body("x") - .expectStatus(409); - - assertThat(fixture.offset()).isEqualTo(3); -} -``` - -- [ ] **Step 2: Run tus tests to verify they fail** - -```bash -./gradlew :modules:fileserver:fileserver-tus:test \ - --tests '*TusProtocolContractTest' --tests '*TusOffsetConcurrencyTest' -``` - -Expected: FAIL because tus endpoints do not exist. - -- [ ] **Step 3: Implement tus 1.0 protocol mapping** - -Implement: - -```text -POST creation with Location -HEAD with Upload-Offset and Upload-Length -PATCH application/offset+octet-stream -409 on offset mismatch without body mutation -Upload-Checksum validation -Upload-Expires -DELETE termination -Tus-Resumable validation on every protocol request -``` - -Use one writer lease per upload. Return `410` after expiration and release quota on termination. Concatenation uses independent part resources and verifies each part before final combine. - -- [ ] **Step 4: Run tus protocol suite** - -```bash -./gradlew :modules:fileserver:fileserver-tus:test -``` - -Expected: PASS for create, append, resume after restart, checksum, expiry, termination, and concurrent offset conflict. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-tus -git commit -m "feat: add tus 1.0 resumable uploads" -``` - ---- - -### Task 28: HTTPbis resumable upload draft-12 Experimental 모듈 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-resumable-httpbis-draft12/src/main/java/io/backend/skeleton/fileserver/httpbisdraft12/Draft12UploadController.java` -- Create: `modules/fileserver/fileserver-resumable-httpbis-draft12/src/main/java/io/backend/skeleton/fileserver/httpbisdraft12/Draft12Headers.java` -- Create: `modules/fileserver/fileserver-resumable-httpbis-draft12/src/main/java/io/backend/skeleton/fileserver/httpbisdraft12/Draft12ProblemDetails.java` -- Create: `modules/fileserver/fileserver-resumable-httpbis-draft12/src/main/java/io/backend/skeleton/fileserver/httpbisdraft12/Draft12Properties.java` -- Test: `modules/fileserver/fileserver-resumable-httpbis-draft12/src/test/java/io/backend/skeleton/fileserver/httpbisdraft12/Draft12ProtocolTest.java` -- Test: `modules/fileserver/fileserver-resumable-httpbis-draft12/src/test/java/io/backend/skeleton/fileserver/httpbisdraft12/DraftIsolationTest.java` - -**Interfaces:** -- Reuses application upload services but has a distinct endpoint namespace and media types. -- Module is disabled by default and its package, properties, and docs include `draft12`. - -- [ ] **Step 1: Write failing draft protocol and isolation tests** - -```java -@Test -void disabledDraftDoesNotRegisterEndpoints() { - contextRunner.withPropertyValues("backend.fileserver.httpbis-draft12.enabled=false") - .run(context -> assertThat(context).doesNotHaveBean(Draft12UploadController.class)); -} -``` - -```java -@Test -void offsetMismatchReturnsDraftProblemDetail() { - fixture.uploadAtOffset(10); - - client.patch(fixture.draftLocation()) - .header("Upload-Offset", "5") - .contentType("application/partial-upload") - .body("abc") - .expectStatus(409) - .expectJsonPath("$.expectedOffset", 10) - .expectJsonPath("$.providedOffset", 5); -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -```bash -./gradlew :modules:fileserver:fileserver-resumable-httpbis-draft12:test -``` - -Expected: FAIL because the Experimental module is absent. - -- [ ] **Step 3: Implement draft-12 behind an explicit feature flag** - -Implement only the researched draft-12 contract: `Upload-Offset`, `Upload-Complete`, `application/partial-upload`, offset mismatch problem detail, and runtime capability for 104 interim response. Do not share controller paths or DTOs with tus. Add an `ExperimentalApi` marker annotation and runtime warning on enablement. - -- [ ] **Step 4: Run isolation and protocol tests** - -```bash -./gradlew :modules:fileserver:fileserver-resumable-httpbis-draft12:test -``` - -Expected: PASS; disabled mode registers no endpoints and Stable modules have no dependency on draft types. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-resumable-httpbis-draft12 -git commit -m "feat: add experimental HTTP resumable draft12" -``` - ---- - -### Task 29: HTTP Problem Detail과 보안 hardening 통합 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-mvc/src/main/java/io/backend/skeleton/fileserver/mvc/FileserverMvcExceptionHandler.java` -- Create: `modules/fileserver/fileserver-webflux/src/main/java/io/backend/skeleton/fileserver/webflux/FileserverWebFluxExceptionHandler.java` -- Create: `modules/fileserver/fileserver-core-api/src/main/java/io/backend/skeleton/fileserver/api/error/FileserverProblem.java` -- Create: `modules/fileserver/fileserver-verification/src/main/java/io/backend/skeleton/fileserver/verification/ScriptableContentPolicy.java` -- Test: `modules/fileserver/fileserver-testkit/src/test/java/io/backend/skeleton/fileserver/testkit/security/PathTraversalSecurityTest.java` -- Test: `modules/fileserver/fileserver-testkit/src/test/java/io/backend/skeleton/fileserver/testkit/security/SymlinkRaceSecurityTest.java` -- Test: `modules/fileserver/fileserver-testkit/src/test/java/io/backend/skeleton/fileserver/testkit/security/FilenameInjectionSecurityTest.java` -- Test: `modules/fileserver/fileserver-testkit/src/test/java/io/backend/skeleton/fileserver/testkit/security/RangeBombSecurityTest.java` -- Test: `modules/fileserver/fileserver-testkit/src/test/java/io/backend/skeleton/fileserver/testkit/security/ScriptableContentSecurityTest.java` - -**Interfaces:** -- Maps the same core failure context to MVC and WebFlux `application/problem+json`. -- Security tests run against both adapters. - -- [ ] **Step 1: Write failing problem-detail and attack tests** - -```java -@Test -void offsetMismatchProblemDoesNotExposePath() { - ProblemResponse response = client.patchOffsetMismatch(); - - assertThat(response.status()).isEqualTo(409); - assertThat(response.json("code")).isEqualTo("UPLOAD_OFFSET_MISMATCH"); - assertThat(response.body()).doesNotContain("/var/lib", "staging", "java.nio.file"); -} -``` - -```java -@ParameterizedTest -@ValueSource(strings = {"../x", "%2e%2e%2fx", "/etc/passwd", "C:\\Windows\\system.ini"}) -void rejectsPathShapedInputs(String input) { - client.uploadWithFilename(input).expectNoStorageEscape(); -} -``` - -```java -@Test -void excessiveRangesAreRejectedBeforeContentOpen() { - client.getWithRange("bytes=0-0,2-2,4-4,6-6,8-8,10-10,12-12,14-14,16-16") - .expectClientError(); - assertThat(fixture.contentOpenCount()).isZero(); -} -``` - -- [ ] **Step 2: Run security tests to verify they fail** - -```bash -./gradlew :modules:fileserver:fileserver-testkit:test \ - --tests '*security*' -``` - -Expected: FAIL because unified error mapping and all guards are not connected. - -- [ ] **Step 3: Implement error mapping and hardening** - -Map every `FileserverErrorCode` to the design status code and emit: - -```json -{ - "type": "urn:fileserver:problem:", - "title": "stable title", - "status": 409, - "code": "UPLOAD_OFFSET_MISMATCH", - "retryable": true, - "traceId": "..." -} -``` - -Add `X-Content-Type-Options: nosniff`; default scriptable content to attachment; enforce range budget before content open; ensure symlink checks occur at open time, not only at path construction. - -- [ ] **Step 4: Run MVC, WebFlux, and security suites** - -```bash -./gradlew :modules:fileserver:fileserver-mvc:test \ - :modules:fileserver:fileserver-webflux:test \ - :modules:fileserver:fileserver-testkit:test \ - --tests '*security*' --tests '*ExceptionHandler*' -``` - -Expected: PASS; MVC and WebFlux problem JSON is equivalent and contains no sensitive path data. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-core-api \ - modules/fileserver/fileserver-mvc \ - modules/fileserver/fileserver-webflux \ - modules/fileserver/fileserver-verification \ - modules/fileserver/fileserver-testkit -git commit -m "feat: harden fileserver HTTP and error handling" -``` - ---- - -### Task 30: Metric, trace, audit와 민감정보 차단 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/observability/FileserverMetrics.java` -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/observability/FileserverTracing.java` -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/observability/SafeFileFingerprint.java` -- Create: `modules/fileserver/fileserver-application/src/main/java/io/backend/skeleton/fileserver/application/observability/FileserverAuditEvent.java` -- Test: `modules/fileserver/fileserver-application/src/test/java/io/backend/skeleton/fileserver/application/observability/FileserverObservabilityTest.java` -- Test: `modules/fileserver/fileserver-testkit/src/test/java/io/backend/skeleton/fileserver/testkit/security/SensitiveTelemetryLeakTest.java` - -**Interfaces:** -- Produces metric names and spans defined in the design. -- High-cardinality IDs and raw metadata are prohibited. - -- [ ] **Step 1: Write failing metric and leak tests** - -```java -@Test -void uploadMetricUsesBoundedTags() { - metrics.recordUpload( - UploadProtocol.RAW, - "LOCAL", - "READY", - SizeBucket.MEDIUM, - Duration.ofMillis(10), - 1024); - - Meter meter = registry.find("fileserver.upload.duration").meter(); - assertThat(meter.getId().getTags()) - .extracting(Tag::getKey) - .containsExactlyInAnyOrder("protocol", "storage", "result", "size_bucket"); -} -``` - -```java -@Test -void telemetryNeverContainsFilenamePathOrRawIds() { - fixture.runUpload("private-name.pdf", "/var/lib/backend/files", fixture.fileId()); - - assertThat(fixture.allTelemetryText()) - .doesNotContain("private-name.pdf", "/var/lib/backend/files", fixture.fileId().toString()); -} -``` - -- [ ] **Step 2: Run observability tests to verify they fail** - -```bash -./gradlew :modules:fileserver:fileserver-application:test \ - :modules:fileserver:fileserver-testkit:test \ - --tests '*FileserverObservabilityTest' --tests '*SensitiveTelemetryLeakTest' -``` - -Expected: FAIL because instrumentation is absent. - -- [ ] **Step 3: Implement bounded metrics, spans, and audit** - -Add timers/counters for upload, download, active transfer, interruption, offset mismatch, checksum, verification queue, temp/orphan, quota, cleanup, delegation, and access denial. Add spans named exactly as the design. When correlation is required, use a keyed HMAC fingerprint; never emit the raw file ID or checksum. - -- [ ] **Step 4: Run observability and sensitive-log tests** - -```bash -./gradlew :modules:fileserver:fileserver-application:test \ - :modules:fileserver:fileserver-testkit:test \ - --tests '*Observability*' --tests '*SensitiveTelemetryLeakTest' -``` - -Expected: PASS; all tags belong to the approved bounded vocabulary. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-application modules/fileserver/fileserver-testkit -git commit -m "feat: add safe fileserver observability" -``` - ---- - -### Task 31: Spring Boot properties와 auto-configuration 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-spring-boot-starter/src/main/java/io/backend/skeleton/fileserver/autoconfigure/FileserverProperties.java` -- Create: `modules/fileserver/fileserver-spring-boot-starter/src/main/java/io/backend/skeleton/fileserver/autoconfigure/FileserverAutoConfiguration.java` -- Create: `modules/fileserver/fileserver-spring-boot-starter/src/main/java/io/backend/skeleton/fileserver/autoconfigure/FileserverMvcAutoConfiguration.java` -- Create: `modules/fileserver/fileserver-spring-boot-starter/src/main/java/io/backend/skeleton/fileserver/autoconfigure/FileserverWebFluxAutoConfiguration.java` -- Create: `modules/fileserver/fileserver-spring-boot-starter/src/main/java/io/backend/skeleton/fileserver/autoconfigure/FileserverNginxAutoConfiguration.java` -- Create: `modules/fileserver/fileserver-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` -- Test: `modules/fileserver/fileserver-spring-boot-starter/src/test/java/io/backend/skeleton/fileserver/autoconfigure/FileserverAutoConfigurationTest.java` -- Test: `modules/fileserver/fileserver-spring-boot-starter/src/test/java/io/backend/skeleton/fileserver/autoconfigure/FileserverPropertiesValidationTest.java` - -**Interfaces:** -- Binds the exact `backend.fileserver.*` property tree from the design. -- Creates MVC or WebFlux adapters only when their runtime is present. -- Production startup must fail without a real `FileAccessPolicy`. - -- [ ] **Step 1: Write failing default-binding and invalid-startup tests** - -```java -@Test -void bindsStandardProfileDefaults() { - contextRunner.withPropertyValues( - "backend.fileserver.enabled=true", - "backend.fileserver.storage.root=" + tempDir) - .withUserConfiguration(TestAccessPolicyConfiguration.class) - .run(context -> { - FileserverProperties properties = context.getBean(FileserverProperties.class); - assertThat(properties.upload().maxFileSize()).isEqualTo(DataSize.ofMegabytes(100)); - assertThat(properties.storage().bufferSize()).isEqualTo(DataSize.ofKilobytes(128)); - assertThat(properties.upload().maxParts()).isEqualTo(16); - }); -} -``` - -```java -@Test -void productionRejectsNoOpAuthorizationPolicy() { - contextRunner.withPropertyValues( - "spring.profiles.active=prod", - "backend.fileserver.enabled=true", - "backend.fileserver.storage.root=" + tempDir) - .run(context -> assertThat(context).hasFailed()); -} -``` - -- [ ] **Step 2: Run starter tests to verify they fail** - -```bash -./gradlew :modules:fileserver:fileserver-spring-boot-starter:test \ - --tests '*FileserverAutoConfigurationTest' \ - --tests '*FileserverPropertiesValidationTest' -``` - -Expected: FAIL because properties and auto-configurations do not exist. - -- [ ] **Step 3: Implement typed properties and conditional beans** - -Bind these groups exactly: - -```text -storage -upload -download -nginx -verification -quota -cleanup -tus -httpbis-draft12 -mvc.executor -webflux -``` - -Validate: - -```text -root is absolute and outside configured webroot/config roots -maxRequestSize >= maxFileSize -soft limit < hard limit -maxRanges between 1 and 8 -ATOMIC_MOVE_REQUIRED matches probe -scanner-required has a verifier bean -nginx enabled has token service and internal prefix -tus and draft endpoints do not collide -``` - -Use `@ConditionalOnWebApplication` and `@ConditionalOnClass` so MVC and WebFlux adapters do not appear together accidentally unless an explicit dual-adapter test application requests both. - -- [ ] **Step 4: Run starter context tests** - -```bash -./gradlew :modules:fileserver:fileserver-spring-boot-starter:test -``` - -Expected: PASS; invalid property combinations fail during context startup with stable validation messages. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-spring-boot-starter -git commit -m "feat: add fileserver Spring Boot starter" -``` - ---- - -### Task 32: Filesystem, HTTP, fault, performance Testkit 구현 - -**Files:** -- Create: `modules/fileserver/fileserver-testkit/src/main/java/io/backend/skeleton/fileserver/testkit/ContentStoreContract.java` -- Create: `modules/fileserver/fileserver-testkit/src/main/java/io/backend/skeleton/fileserver/testkit/HttpDownloadContract.java` -- Create: `modules/fileserver/fileserver-testkit/src/main/java/io/backend/skeleton/fileserver/testkit/CrashPoint.java` -- Create: `modules/fileserver/fileserver-testkit/src/main/java/io/backend/skeleton/fileserver/testkit/ProcessCrashHarness.java` -- Create: `modules/fileserver/fileserver-testkit/src/main/java/io/backend/skeleton/fileserver/testkit/NfsTestEnvironment.java` -- Create: `modules/fileserver/fileserver-testkit/src/main/java/io/backend/skeleton/fileserver/testkit/PvcCertificationDescriptor.java` -- Create: `modules/fileserver/fileserver-testkit/src/test/java/io/backend/skeleton/fileserver/testkit/LocalContentStoreContractTest.java` -- Create: `modules/fileserver/fileserver-testkit/src/test/java/io/backend/skeleton/fileserver/testkit/CrashRecoveryMatrixTest.java` -- Create: `modules/fileserver/fileserver-testkit/src/test/java/io/backend/skeleton/fileserver/testkit/LargeFileBoundedMemoryTest.java` -- Create: `modules/fileserver/fileserver-testkit/src/test/java/io/backend/skeleton/fileserver/testkit/NfsAmbiguityIntegrationTest.java` -- Create: `infra/fileserver/nfs/compose.yml` -- Create: `infra/fileserver/kubernetes/pvc-certification-job.yaml` - -**Interfaces:** -- Produces reusable contracts for future Object Storage adapters. -- Provides crash points before/after append, publish, and metadata commit. -- Certification descriptors identify Kubernetes, CSI, StorageClass, access mode, backend, and mount options. - -- [ ] **Step 1: Write failing contract and crash-matrix tests** - -```java -abstract class ContentStoreContract { - protected abstract BlockingContentStore store(); - - @Test - void createAppendFinalizeStatReadDeleteRoundTrip() throws Exception { - UploadHandle handle = store().createUpload(fixture.createCommand()); - store().append(handle, 0, fixture.channel("abcdef"), 6); - StoredContent content = store().finalizeUpload(handle, fixture.finalizeCommand()); - - assertThat(store().stat(content.contentKey()).size()).isEqualTo(6); - assertThat(fixture.read(store().openRead(content.contentKey(), new ByteRange(1, 3)))) - .isEqualTo("bcd"); - assertThat(store().delete(content.contentKey(), DeletePrecondition.none()).deleted()) - .isTrue(); - } -} -``` - -```java -@ParameterizedTest -@EnumSource(CrashPoint.class) -void readyInvariantSurvivesEveryCrashPoint(CrashPoint crashPoint) { - harness.runUploadAndKillAt(crashPoint); - harness.restartAndReconcile(); - - assertThat(harness.readyFiles()) - .allSatisfy(file -> { - assertThat(file.physicalContentExists()).isTrue(); - assertThat(file.digestMatches()).isTrue(); - }); -} -``` - -- [ ] **Step 2: Run testkit tests to verify they fail** - -```bash -./gradlew :modules:fileserver:fileserver-testkit:test \ - --tests '*ContentStoreContract*' --tests '*CrashRecoveryMatrixTest' -``` - -Expected: FAIL because the testkit contracts and harness do not exist. - -- [ ] **Step 3: Implement reusable certification harnesses** - -Implement contract scenarios for: - -```text -create-only race -append offset -range read -checksum -finalize -logical and physical delete -symlink no-follow -disk full -permission denied -process kill at every crash point -slow client -network interruption -NFS rename ambiguity -large-file bounded heap and direct memory -``` - -The NFS environment must support server restart and a network cut. The PVC job writes a machine-readable result containing the full certification tuple and probe results. - -- [ ] **Step 4: Run local, NFS, and large-file suites** - -```bash -./gradlew :modules:fileserver:fileserver-testkit:test -``` - -Expected: PASS for local tests; NFS tests are tagged and run when `FILESERVER_NFS_TESTS=true`. Large-file test confirms heap does not scale with file size. - -- [ ] **Step 5: Commit** - -```bash -git add modules/fileserver/fileserver-testkit infra/fileserver/nfs infra/fileserver/kubernetes -git commit -m "test: add fileserver certification harness" -``` - ---- - -### Task 33: CI matrix, 지원 문서, 운영 Runbook, release gate 연결 - -**Files:** -- Create: `.github/workflows/fileserver-pr.yml` -- Create: `.github/workflows/fileserver-nightly.yml` -- Create: `.github/workflows/fileserver-release.yml` -- Create: `docs/fileserver/support-matrix.md` -- Create: `docs/fileserver/http-contract.md` -- Create: `docs/fileserver/storage-certification.md` -- Create: `docs/fileserver/security.md` -- Create: `docs/fileserver/operations.md` -- Create: `docs/fileserver/upgrade-guide.md` -- Create: `modules/fileserver/fileserver-testkit/src/test/java/io/backend/skeleton/fileserver/testkit/DocumentationCoverageTest.java` - -**Interfaces:** -- Connects every support claim to a CI job or certification artifact. -- Documents Stable, Beta, Limited, Compatibility, and Experimental levels. - -- [ ] **Step 1: Write a failing documentation coverage test** - -```java -class DocumentationCoverageTest { - @Test - void everyRuntimeProfileHasAReferencedCiJob() throws Exception { - SupportMatrix matrix = SupportMatrix.load(Path.of("docs/fileserver/support-matrix.md")); - WorkflowIndex workflows = WorkflowIndex.load(Path.of(".github/workflows")); - - assertThat(matrix.requiredProfiles()) - .allMatch(profile -> workflows.containsJob(profile.ciJob())); - } - - @Test - void everyPublicEndpointAppearsInHttpContract() throws Exception { - Set endpoints = EndpointScanner.scanPublicFileserverEndpoints(); - String contract = Files.readString(Path.of("docs/fileserver/http-contract.md")); - - assertThat(endpoints).allMatch(contract::contains); - } -} -``` - -- [ ] **Step 2: Run the coverage test to verify it fails** - -```bash -./gradlew :modules:fileserver:fileserver-testkit:test \ - --tests '*DocumentationCoverageTest' -``` - -Expected: FAIL because workflows and docs do not exist. - -- [ ] **Step 3: Add workflows and complete operational documentation** - -PR workflow runs: - -```text -unit and architecture tests -local ext4 contract -MVC Tomcat contract -WebFlux Reactor Netty contract -security suite -bounded-memory regression -``` - -Nightly runs: - -```text -XFS -NFSv4.1 and server restart -Windows NTFS compatibility -large-file performance -slow client -process-kill matrix -``` - -Release runs: - -```text -Spring Framework 6.2 and 7.0 compatible lines -Nginx stable -PVC RWO certification -optional PVC RWX certification -multi-instance lease -fault injection -sensitive telemetry scan -support matrix diff -``` - -`operations.md` must include storage-full, orphan growth, verification backlog, NFS ambiguity, PVC remount, Nginx delegation failure, and cleanup backlog runbooks with exact metric names and recovery commands. - -- [ ] **Step 4: Run documentation coverage and full release verification** - -```bash -./gradlew clean test -./gradlew :modules:fileserver:fileserver-testkit:test \ - --tests '*DocumentationCoverageTest' -``` - -Expected: PASS; every support claim maps to a concrete workflow job and every public endpoint is documented. - -- [ ] **Step 5: Commit** - -```bash -git add .github/workflows docs/fileserver modules/fileserver/fileserver-testkit -git commit -m "docs: connect fileserver support claims to CI" -``` - ---- - -## 3. 작업 간 의존 순서 - -```text -Task 1 -├─ Task 2 -│ ├─ Task 3 -│ ├─ Task 4 -│ └─ Task 5 -│ └─ Task 6 -│ └─ Task 7 -├─ Task 8 -│ └─ Task 9 -│ ├─ Task 10 -│ └─ Task 11 -├─ Task 12 -├─ Task 13 -│ └─ Task 14 -│ └─ Task 15 -├─ Task 16 -│ └─ Task 14 integration -├─ Task 17 -├─ Task 18 -│ ├─ Task 20 -│ ├─ Task 22 -│ └─ Task 23 -├─ Task 19 -├─ Task 21 -├─ Task 24 -│ └─ Task 25 -├─ Task 26 -│ ├─ Task 27 -│ └─ Task 28 -├─ Task 29 -├─ Task 30 -├─ Task 31 -├─ Task 32 -└─ Task 33 -``` - -권장 직렬 실행 순서는 Task 1부터 Task 33까지다. 병렬 실행은 다음 묶음에서만 허용한다. - -```text -Task 16 verification ↔ Task 18 HTTP contract -Task 19 MVC upload ↔ Task 21 WebFlux upload -Task 20 MVC download ↔ Task 22 WebFlux download -Task 27 tus ↔ Task 28 draft12, 단 Task 26 완료 후 -Task 29 security ↔ Task 30 observability, 공통 API가 안정된 후 -``` - ---- - -## 4. 단계별 Release 기준 - -### Milestone A — Core Alpha - -완료 작업: - -```text -Task 1~15 -``` - -Gate: - -- core module dependency boundary 통과 -- metadata migration·optimistic locking 통과 -- local create·append·digest·publish contract 통과 -- READY invariant와 ambiguous reconciliation 통과 -- 100 MiB upload에서 bounded memory 확인 - -### Milestone B — HTTP Beta - -완료 작업: - -```text -Task 16~22, Task 29 -``` - -Gate: - -- raw·multipart upload -- GET·HEAD·single Range -- conditional request -- MVC·WebFlux parity -- DataBuffer leak 0 -- path·symlink·filename·range security suite 통과 - -### Milestone C — Distributed RC - -완료 작업: - -```text -Task 23~26, Task 30~32 -``` - -Gate: - -- Nginx parity -- logical delete와 cleanup -- admin isolation -- two-node writer lease -- PVC RWO certification -- process-kill matrix -- sensitive telemetry scan - -### Milestone D — Extended Release - -완료 작업: - -```text -Task 27~28, Task 33 -``` - -Gate: - -- tus 1.0 protocol suite -- draft12 isolation -- NFS limited profile fault tests -- support matrix와 CI mapping -- operations runbook review - ---- - -## 5. 구현자가 임의로 변경하면 안 되는 결정 - -- `ContentStore`에 `Path` 또는 provider SDK 타입을 추가하지 않는다. -- public endpoint에 path query parameter를 추가하지 않는다. -- state 변경을 JPA entity setter로 우회하지 않는다. -- READY gate를 controller마다 복제하지 않고 application service에서 강제한다. -- create-only 기본을 overwrite 기본으로 바꾸지 않는다. -- atomic move 지원을 설정값만으로 가정하지 않는다. -- `Files.exists` 후 create하는 TOCTOU 패턴을 사용하지 않는다. -- WebFlux body를 `DataBufferUtils.join`으로 전체 적재하지 않는다. -- MVC에서 `MultipartFile#getBytes()`를 사용하지 않는다. -- filename 또는 client MIME을 physical key·보안 verdict로 사용하지 않는다. -- scanner timeout을 ACCEPT로 변환하지 않는다. -- multi-instance 정확성을 `FileLock` 또는 NFS lock에 맡기지 않는다. -- Nginx internal URI에 physical path를 넣지 않는다. -- tus와 HTTPbis draft DTO·endpoint를 공유하지 않는다. -- cleanup이 version·lease 확인 없이 삭제하지 않는다. -- `AmbiguousCompletionException`을 일반 retryable exception으로 낮추지 않는다. - ---- - -## 6. 계획 자체 검증 체크리스트 - -- [ ] 설계서의 포함 범위가 최소 하나의 Task에 매핑된다. -- [ ] 설계서의 비지원 범위를 구현하는 Task가 없다. -- [ ] Task 1~33 번호가 연속적이다. -- [ ] 모든 Task에 Files, Interfaces, 실패 테스트, 실패 확인, 구현, 통과 확인, commit이 있다. -- [ ] later Task가 사용하는 공개 타입은 earlier Task에서 정의된다. -- [ ] MVC·WebFlux·Nginx가 동일한 `DownloadDecision`을 사용한다. -- [ ] READY transition은 physical stat·digest 검증 뒤에만 실행된다. -- [ ] multi-instance append는 lease token과 expected offset을 요구한다. -- [ ] tus Stable과 draft Experimental이 분리돼 있다. -- [ ] security suite가 traversal, symlink, filename, Range, scriptable content를 포함한다. -- [ ] CI와 support matrix가 자동 coverage test로 연결된다. -- [ ] 문서에 미확정 표식, 빈 구현 지시, 무정의 type이 없다. - ---- - -## 7. 실행 인계 - -계획 실행 시 권장 방식은 `superpowers:subagent-driven-development`다. 각 Task마다 새 작업자를 사용하고 다음 두 단계 review를 적용한다. - -```text -1. 요구사항·설계 일치 review -2. 코드 품질·테스트 evidence review -``` - -동일 세션에서 실행할 경우 `superpowers:executing-plans`를 사용하고 Milestone A, B, C, D마다 전체 test·diff·문서 gate를 확인한다. diff --git a/fileserver-superpowers-package/validate_fileserver_docs.py b/fileserver-superpowers-package/validate_fileserver_docs.py deleted file mode 100644 index f52b18a..0000000 --- a/fileserver-superpowers-package/validate_fileserver_docs.py +++ /dev/null @@ -1,174 +0,0 @@ -from __future__ import annotations - -from collections import Counter -from pathlib import Path -import hashlib -import json -import re -import sys - -ROOT = Path('/mnt/data') -DESIGN = ROOT / 'fileserver-platform-design.md' -PLAN = ROOT / 'fileserver-platform-implementation-plan.md' - -errors: list[str] = [] -checks: list[tuple[str, bool, str]] = [] - - -def add(name: str, ok: bool, detail: str) -> None: - checks.append((name, ok, detail)) - if not ok: - errors.append(f'{name}: {detail}') - - -def sha256(path: Path) -> str: - return hashlib.sha256(path.read_bytes()).hexdigest() - -for path in (DESIGN, PLAN): - add(f'{path.name} exists', path.exists(), str(path)) - -if errors: - print('\n'.join(errors), file=sys.stderr) - raise SystemExit(1) - -design = DESIGN.read_text(encoding='utf-8') -plan = PLAN.read_text(encoding='utf-8') - -add('design title', design.startswith('# Fileserver Platform 설계서'), True.__str__()) -add('plan header', plan.startswith('# Fileserver Platform Implementation Plan\n\n> **For agentic workers:**'), 'required Superpowers header') -add('design code fences', design.count('```') % 2 == 0, f"count={design.count('```')}") -add('plan code fences', plan.count('```') % 2 == 0, f"count={plan.count('```')}") - -for label, text in [('design', design), ('plan', plan)]: - forbidden = [r'\bTBD\b', r'\bTODO\b', r'implement later', r'fill in details', r'Similar to Task'] - hits = [p for p in forbidden if re.search(p, text, re.I)] - add(f'{label} placeholder scan', not hits, f'hits={hits}') - -required_design_sections = [ - '## 5. 지원 매트릭스', - '## 6. 전체 아키텍처', - '## 9. 상태 머신과 invariant', - '## 10. Metadata Store 설계', - '## 11. Content Store Port', - '## 12. Local Filesystem Adapter', - '## 14. Publish와 완료 처리', - '## 15. Upload Application 설계', - '## 19. HTTP API', - '## 20. Range와 Conditional Request', - '## 21. Spring MVC Adapter', - '## 22. Spring WebFlux Adapter', - '## 23. Nginx 전송 위임', - '## 24. 재개 가능한 업로드', - '## 27. 보안 정책', - '## 28. 다중 인스턴스와 NFS', - '## 30. 관측성', - '## 33. 테스트 전략', - '## 37. 완료 정의', -] -missing_sections = [s for s in required_design_sections if s not in design] -add('design section coverage', not missing_sections, f'missing={missing_sections}') - -source_topics = { - 'MVC': ['Spring MVC Adapter', 'MvcTransferExecutorProperties'], - 'WebFlux': ['Spring WebFlux Adapter', 'DataBuffer'], - 'local/PVC/NFS': ['Kubernetes PVC', 'NFSv4.1', 'Local Filesystem Adapter'], - 'content/metadata separation': ['Content Store Port', 'Metadata Store 설계'], - 'upload': ['Upload Application 설계', 'multipart', 'application/octet-stream'], - 'download': ['Range와 Conditional Request', 'ETag', 'If-Range'], - 'publish': ['ATOMIC_MOVE_REQUIRED', 'METADATA_POINTER', 'AmbiguousCompletionException'], - 'security': ['traversal', 'symlink', 'READY gate'], - 'resumable': ['tus 1.0 Stable', 'draft-12 Experimental'], - 'observability': ['Metric', 'Trace', 'Audit'], -} -for topic, needles in source_topics.items(): - missing = [n for n in needles if n not in design] - add(f'design topic: {topic}', not missing, f'missing={missing}') - -# Core Port snippet must not expose adapter types. -port_match = re.search(r'### 11\.2 Blocking SPI\n(.*?)### 11\.3 Async SPI', design, re.S) -port_text = port_match.group(1) if port_match else '' -forbidden_port_types = ['java.nio.file.Path', 'org.springframework.core.io.Resource', 'DataBuffer', 'Flux<'] -port_hits = [x for x in forbidden_port_types if x in port_text] -add('blocking core port leakage', bool(port_match) and not port_hits, f'hits={port_hits}') - -# Task structure. -task_matches = list(re.finditer(r'^### Task (\d+):', plan, re.M)) -task_numbers = [int(m.group(1)) for m in task_matches] -add('task count', len(task_numbers) == 33, f'count={len(task_numbers)}') -add('task numbering', task_numbers == list(range(1, 34)), f'numbers={task_numbers}') - -missing_task_blocks: dict[int, list[str]] = {} -for idx, match in enumerate(task_matches): - end = task_matches[idx + 1].start() if idx + 1 < len(task_matches) else plan.find('\n## 3.', match.start()) - segment = plan[match.start():end] - required = [ - '**Files:**', '**Interfaces:**', '**Step 1:', '**Step 2:', - '**Step 3:', '**Step 4:', '**Step 5:', 'Expected:', 'git commit' - ] - missing = [item for item in required if item not in segment] - if missing: - missing_task_blocks[int(match.group(1))] = missing -add('task block completeness', not missing_task_blocks, json.dumps(missing_task_blocks, ensure_ascii=False)) - -create_paths = re.findall(r'^- Create: `([^`]+)`', plan, re.M) -duplicates = {path: count for path, count in Counter(create_paths).items() if count > 1} -add('unique create paths', not duplicates, json.dumps(duplicates, ensure_ascii=False)) - -required_plan_topics = [ - 'Task 10: Storage capability probe', - 'Task 11: Streaming append', - 'Task 13: Atomic move와 metadata pointer publish', - 'Task 18: HTTP Range', - 'Task 21: Spring WebFlux raw·multipart upload', - 'Task 23: Nginx `X-Accel-Redirect`', - 'Task 26: 다중 인스턴스 writer lease', - 'Task 27: tus 1.0 Stable', - 'Task 28: HTTPbis resumable upload draft-12 Experimental', - 'Task 29: HTTP Problem Detail과 보안 hardening', - 'Task 32: Filesystem, HTTP, fault, performance Testkit', - 'Task 33: CI matrix', -] -missing_plan_topics = [x for x in required_plan_topics if x not in plan] -add('plan scope coverage', not missing_plan_topics, f'missing={missing_plan_topics}') - -add('no Redis carryover', 'redis' not in design.lower() and 'redis' not in plan.lower(), 'search term=redis') -add('no deprecated nginx token design', 'DelegatedPathToken' not in design + plan and 'opaque-token' not in design + plan, 'token mapper removed') - -status = 'PASS' if not errors else 'FAIL' -report = ROOT / 'fileserver-superpowers-validation.md' -lines = [ - '# Fileserver Superpowers 문서 검증', - '', - f'**결과:** {status}', - '', - '## 파일', - '', - f'- `{DESIGN.name}` — {len(design.splitlines())} lines, {len(design.encode())} bytes, SHA-256 `{sha256(DESIGN)}`', - f'- `{PLAN.name}` — {len(plan.splitlines())} lines, {len(plan.encode())} bytes, SHA-256 `{sha256(PLAN)}`', - '', - '## 검증 항목', - '', -] -for name, ok, detail in checks: - lines.append(f"- [{'x' if ok else ' '}] **{name}** — {detail}") - -lines += [ - '', - '## 검증 범위의 한계', - '', - '- 현재 Backend Skeleton 저장소가 입력되지 않아 Gradle compilation, integration test, Nginx execution, PVC·NFS certification은 실행하지 않았다.', - '- 본 검증은 설계·계획 문서의 구조, 내부 일관성, 범위 추적성, 미확정 표식과 중복 경로를 확인한 정적 검증이다.', -] -report.write_text('\n'.join(lines) + '\n', encoding='utf-8') - -print(json.dumps({ - 'status': status, - 'errors': errors, - 'checks': len(checks), - 'design_lines': len(design.splitlines()), - 'plan_lines': len(plan.splitlines()), - 'task_count': len(task_numbers), - 'report': str(report), -}, ensure_ascii=False, indent=2)) - -raise SystemExit(0 if not errors else 1) diff --git a/httpclient-superpowers-package/README.md b/httpclient-superpowers-package/README.md deleted file mode 100644 index 447d14d..0000000 --- a/httpclient-superpowers-package/README.md +++ /dev/null @@ -1,23 +0,0 @@ -# HTTP Client Superpowers 설계 패키지 - -이 패키지는 `Java/Spring 외부 HTTP Client 플랫폼 설계 심층 리서치`를 기반으로 작성한 설계서와 구현 계획서다. - -## 파일 - -- `docs/superpowers/specs/2026-08-08-httpclient-platform-design.md` -- `docs/superpowers/plans/2026-08-08-httpclient-platform-implementation-plan.md` -- `VALIDATION.md` -- `validate_httpclient_docs.py` - -## 구현 기준 - -- Java 21 -- Gradle Kotlin DSL -- 공통 API는 Spring Framework 6.2 기준 -- Spring Framework 7.0 호환성 검증 -- Apache HttpClient 5 + RestClient -- JDK HttpClient + RestClient -- Reactor Netty + WebClient -- Jetty HTTP/3 Experimental - -실제 Backend Skeleton 저장소가 제공되지 않았으므로 package 경로와 Gradle 구조는 설계서의 명시적 구현 가정이다. 구현 전 저장소의 기존 convention과 root package에 맞춰 경로만 조정하고 공개 계약과 정책 의미론은 유지한다. diff --git a/httpclient-superpowers-package/VALIDATION.md b/httpclient-superpowers-package/VALIDATION.md deleted file mode 100644 index 4f53497..0000000 --- a/httpclient-superpowers-package/VALIDATION.md +++ /dev/null @@ -1,31 +0,0 @@ -# HTTP Client Superpowers 문서 검증 - -**검증 결과:** PASS - -## 검증 항목 - -- 설계서 존재 및 최소 구조: PASS -- 구현 계획서 존재 및 최소 구조: PASS -- Task 번호 연속성: PASS -- Task별 Files·Interfaces·Step 1~5·Expected·Commit: PASS -- Markdown code fence 균형: PASS -- Placeholder scan: PASS -- 중복 Create 경로: PASS -- 핵심 설계 범위: PASS -- 핵심 구현 범위: PASS - -## 통계 - -- explicitly forbidden signature documented: ApacheHttpClient nativeApacheClient() -- explicitly forbidden signature documented: HttpClient nativeJdkClient() -- explicitly forbidden signature documented: WebClient.Builder mutableBuilder() -- explicitly forbidden signature documented: RestClient.Builder mutableBuilder() -- design lines=1956, bytes=64493 -- plan lines=3635, bytes=158401 -- tasks=38, create_paths=306 - -## 결론 - -- 설계 결정과 구현 작업의 정적 추적성이 확인됐다. -- 실제 저장소가 제공되지 않았으므로 Gradle compile, integration, fault, security, performance test는 아직 실행되지 않았다. -- 계획의 Java 21, Gradle Kotlin DSL, root package는 명시된 구현 가정이다. diff --git a/httpclient-superpowers-package/docs/superpowers/plans/2026-08-08-httpclient-platform-implementation-plan.md b/httpclient-superpowers-package/docs/superpowers/plans/2026-08-08-httpclient-platform-implementation-plan.md deleted file mode 100644 index d00adfa..0000000 --- a/httpclient-superpowers-package/docs/superpowers/plans/2026-08-08-httpclient-platform-implementation-plan.md +++ /dev/null @@ -1,3635 +0,0 @@ -# HTTP Client Platform Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Spring 기반 Backend Skeleton에 Typed Service Client, Named Client Profile, 증거 기반 Retry, Blocking·Reactive 전송, OAuth2·TLS, Dynamic URL SSRF 방어, Streaming·SSE, 관측성을 제공하는 운영 가능한 외부 HTTP Client 플랫폼을 구현한다. - -**Architecture:** 일반 서비스 코드는 `@HttpExchange` 기반 H1 Typed Client를 사용하고, H2 Generic Gateway와 H3 Dynamic Target Gateway는 별도 권한 경계로 제공한다. 모든 호출은 immutable Named Client Profile에서 transport, pool, timeout, auth, resilience, security, observability 설정을 가져오며, Retry Coordinator가 `OperationIdempotency`, `BodyReplayability`, `ExecutionEvidence`, deadline, retry budget을 근거로 물리 시도를 통제한다. Blocking 경로는 RestClient와 Apache/JDK, Reactive 경로는 WebClient와 Reactor Netty를 사용한다. - -**Tech Stack:** Java 21, Gradle Kotlin DSL, Spring Framework 6.2 common baseline with Spring 7.0 compatibility tests, Spring RestClient, Spring WebClient, Spring HTTP Service Client, Apache HttpClient 5, JDK HttpClient, Reactor Netty, Resilience4j, Spring Security OAuth2 Client, Micrometer, OpenTelemetry, JUnit 5, AssertJ, ArchUnit, MockWebServer, WireMock, Testcontainers, Toxiproxy, BlockHound. - -## Global Constraints - -- 일반 업무 모듈의 기본 진입점은 H1 Typed Service Client다. -- H2 Generic Gateway는 등록된 profile의 scheme, host, port, TLS, credential, hard limit을 변경하지 못한다. -- H3 Dynamic Target Gateway는 Trusted profile의 credential, Cookie, default header를 상속하지 않는다. -- H4 Native engine API는 application-facing public API로 노출하지 않는다. -- 모든 upstream은 고유한 Named Client Profile을 가진다. -- Blocking 기본 전송은 RestClient + Apache HttpClient 5이며 JDK HttpClient는 경량 대안이다. -- Reactive·Streaming 기본 전송은 WebClient + Reactor Netty다. -- HTTP/1.1과 HTTP/2는 Stable, HTTP/3는 Experimental이다. -- RestTemplate은 migration module에서만 사용하고 신규 기능을 추가하지 않는다. -- production에서 Simple request factory를 허용하지 않는다. -- total deadline은 pool acquire, DNS, connect, TLS, request write, response read, retry backoff 전체를 감싼다. -- Retry는 method만으로 결정하지 않고 idempotency, idempotency key, body replayability, execution evidence, deadline, retry budget을 함께 판정한다. -- `NOT_SENT`는 전송되지 않았음을 증명할 수 있을 때만 사용한다. -- 비멱등 `SENT_NO_RESPONSE`는 자동 Retry하지 않고 `HttpAmbiguousExecutionException`으로 반환한다. -- first response byte가 application에 전달된 뒤 transparent Retry를 금지한다. -- Retry backoff 동안 connection과 attempt bulkhead permit을 보유하지 않는다. -- 물리 시도는 Circuit Breaker → Rate Limiter → Bulkhead → HTTP Call 순서를 사용한다. -- OAuth2 token refresh는 동일 cache key에 대해 single-flight다. -- 401 자동 재호출은 최대 한 번이며 replayable하고 안전한 operation에만 적용한다. -- TLS 1.2·1.3과 hostname verification을 강제하고 trust-all과 평문 fallback을 금지한다. -- Dynamic Target는 URI canonicalization, 모든 DNS 결과의 IP 검증, 실제 connection pinning, redirect 재검증을 수행한다. -- metric label에는 전체 URL, query value, path variable, user ID, tenant ID 원문, token, Cookie, idempotency key를 기록하지 않는다. -- Reactive event-loop에서 blocking DNS, file I/O, token load, JSON 변환을 실행하지 않는다. -- 모든 response lifecycle은 성공, 실패, decode error, size 초과, cancel에서 connection·buffer를 정리한다. -- 모든 작업은 실패 테스트 작성 → 실패 확인 → 최소 구현 → 통과 확인 → 커밋 순서로 수행한다. -- 각 Task는 독립 검토 가능한 하나의 커밋으로 종료한다. - ---- - -## 1. 확정 파일 구조 - -```text -backend-skeleton/ -├── settings.gradle.kts -├── build.gradle.kts -├── build-logic/ -│ └── src/main/kotlin/httpclient-library-conventions.gradle.kts -├── modules/httpclient/ -│ ├── httpclient-core-api/ -│ ├── httpclient-profile/ -│ ├── httpclient-transport-spi/ -│ ├── httpclient-transport-apache/ -│ ├── httpclient-transport-jdk/ -│ ├── httpclient-restclient/ -│ ├── httpclient-resilience/ -│ ├── httpclient-auth/ -│ ├── httpclient-security/ -│ ├── httpclient-observability/ -│ ├── httpclient-transport-reactor-netty/ -│ ├── httpclient-webclient/ -│ ├── httpclient-service-client/ -│ ├── httpclient-dynamic-target/ -│ ├── httpclient-resttemplate-migration/ -│ ├── httpclient-spring7-service-groups/ -│ ├── httpclient-jetty-http3-experimental/ -│ ├── httpclient-spring-boot-starter/ -│ └── httpclient-testkit/ -├── infra/httpclient/ -│ ├── proxy/ -│ ├── tls/ -│ ├── oauth2/ -│ └── toxiproxy/ -├── docs/httpclient/ -│ ├── support-matrix.md -│ ├── configuration-reference.md -│ ├── retry-and-ambiguity.md -│ ├── security.md -│ ├── streaming.md -│ ├── operations.md -│ └── migration-guide.md -└── docs/superpowers/specs/2026-08-08-httpclient-platform-design.md -``` - -## 2. 핵심 패키지 - -```text -io.backend.skeleton.httpclient.api -io.backend.skeleton.httpclient.api.body -io.backend.skeleton.httpclient.api.error -io.backend.skeleton.httpclient.api.operation -io.backend.skeleton.httpclient.api.result -io.backend.skeleton.httpclient.profile -io.backend.skeleton.httpclient.transport -io.backend.skeleton.httpclient.apache -io.backend.skeleton.httpclient.jdk -io.backend.skeleton.httpclient.restclient -io.backend.skeleton.httpclient.resilience -io.backend.skeleton.httpclient.auth -io.backend.skeleton.httpclient.security -io.backend.skeleton.httpclient.observation -io.backend.skeleton.httpclient.reactor -io.backend.skeleton.httpclient.webclient -io.backend.skeleton.httpclient.service -io.backend.skeleton.httpclient.dynamic -io.backend.skeleton.httpclient.migration -io.backend.skeleton.httpclient.spring7 -io.backend.skeleton.httpclient.http3 -io.backend.skeleton.httpclient.autoconfigure -io.backend.skeleton.httpclient.testkit -``` - ---- - -### Task 1: Gradle 멀티모듈과 공통 품질 규칙 구성 - -**Files:** -- Modify: `settings.gradle.kts` -- Create: `build-logic/src/main/kotlin/httpclient-library-conventions.gradle.kts` -- Create: `modules/httpclient/httpclient-core-api/build.gradle.kts` -- Create: `modules/httpclient/httpclient-profile/build.gradle.kts` -- Create: `modules/httpclient/httpclient-transport-spi/build.gradle.kts` -- Create: `modules/httpclient/httpclient-transport-apache/build.gradle.kts` -- Create: `modules/httpclient/httpclient-transport-jdk/build.gradle.kts` -- Create: `modules/httpclient/httpclient-restclient/build.gradle.kts` -- Create: `modules/httpclient/httpclient-resilience/build.gradle.kts` -- Create: `modules/httpclient/httpclient-auth/build.gradle.kts` -- Create: `modules/httpclient/httpclient-security/build.gradle.kts` -- Create: `modules/httpclient/httpclient-observability/build.gradle.kts` -- Create: `modules/httpclient/httpclient-transport-reactor-netty/build.gradle.kts` -- Create: `modules/httpclient/httpclient-webclient/build.gradle.kts` -- Create: `modules/httpclient/httpclient-service-client/build.gradle.kts` -- Create: `modules/httpclient/httpclient-dynamic-target/build.gradle.kts` -- Create: `modules/httpclient/httpclient-resttemplate-migration/build.gradle.kts` -- Create: `modules/httpclient/httpclient-spring7-service-groups/build.gradle.kts` -- Create: `modules/httpclient/httpclient-jetty-http3-experimental/build.gradle.kts` -- Create: `modules/httpclient/httpclient-spring-boot-starter/build.gradle.kts` -- Create: `modules/httpclient/httpclient-testkit/build.gradle.kts` -- Test: `modules/httpclient/httpclient-core-api/src/test/java/io/backend/skeleton/httpclient/api/ModuleSmokeTest.java` - -**Interfaces:** -- Produces every Gradle project path used by later tasks. -- `httpclient-core-api` has no Spring, Apache, Netty, Resilience4j dependency. -- Java toolchain is 21. - -- [ ] **Step 1: Write the failing core module smoke test** - -```java -package io.backend.skeleton.httpclient.api; - -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; - -class ModuleSmokeTest { - @Test - void coreApiModuleLoads() { - assertThat(ModuleSmokeTest.class.getPackageName()) - .isEqualTo("io.backend.skeleton.httpclient.api"); - } -} -``` - -- [ ] **Step 2: Register all module paths and verify the build fails before module build files exist** - -Add to `settings.gradle.kts`: - -```kotlin -include( - ":modules:httpclient:httpclient-core-api", - ":modules:httpclient:httpclient-profile", - ":modules:httpclient:httpclient-transport-spi", - ":modules:httpclient:httpclient-transport-apache", - ":modules:httpclient:httpclient-transport-jdk", - ":modules:httpclient:httpclient-restclient", - ":modules:httpclient:httpclient-resilience", - ":modules:httpclient:httpclient-auth", - ":modules:httpclient:httpclient-security", - ":modules:httpclient:httpclient-observability", - ":modules:httpclient:httpclient-transport-reactor-netty", - ":modules:httpclient:httpclient-webclient", - ":modules:httpclient:httpclient-service-client", - ":modules:httpclient:httpclient-dynamic-target", - ":modules:httpclient:httpclient-resttemplate-migration", - ":modules:httpclient:httpclient-spring7-service-groups", - ":modules:httpclient:httpclient-jetty-http3-experimental", - ":modules:httpclient:httpclient-spring-boot-starter", - ":modules:httpclient:httpclient-testkit" -) -``` - -Run: - -```bash -./gradlew :modules:httpclient:httpclient-core-api:test -``` - -Expected: FAIL because the registered module build files are absent. - -- [ ] **Step 3: Add the convention plugin and directed module dependencies** - -Create `httpclient-library-conventions.gradle.kts`: - -```kotlin -plugins { - `java-library` - id("java-test-fixtures") -} - -java { - toolchain { - languageVersion.set(JavaLanguageVersion.of(21)) - } -} - -tasks.withType().configureEach { - useJUnitPlatform() - failFast = false -} - -dependencies { - "testImplementation"(platform("org.junit:junit-bom:5.12.2")) - "testImplementation"("org.junit.jupiter:junit-jupiter") - "testImplementation"("org.assertj:assertj-core:3.27.3") -} -``` - -Apply the convention plugin to every module. Add only the dependencies listed in the design module table; in particular, `core-api` depends on no runtime framework and `testkit` is never an `implementation` dependency of production modules. - -- [ ] **Step 4: Run the core test and dependency report** - -```bash -./gradlew :modules:httpclient:httpclient-core-api:test \ - :modules:httpclient:httpclient-core-api:dependencies -``` - -Expected: PASS; the dependency report contains no Spring Web, Apache HC5, Netty, Reactor, Resilience4j, or Spring Security artifact. - -- [ ] **Step 5: Commit** - -```bash -git add settings.gradle.kts build-logic modules/httpclient -git commit -m "build: add http client module boundaries" -``` - ---- - -### Task 2: 핵심 식별자와 HTTP 의미론 타입 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/ClientProfileName.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/OperationName.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/IdempotencyKey.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/HttpMethod.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/HttpStatus.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/operation/OperationIdempotency.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/operation/ExecutionEvidence.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/operation/BodyReplayability.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/operation/AttemptStage.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/operation/FailureCategory.java` -- Test: `modules/httpclient/httpclient-core-api/src/test/java/io/backend/skeleton/httpclient/api/CoreValueTypeTest.java` - -**Interfaces:** -- Produces exact enum and record names consumed by every later module. -- `HttpMethod` excludes TRACE and provides `safe()` and `standardIdempotent()`. - -- [ ] **Step 1: Write failing validation and method semantic tests** - -```java -class CoreValueTypeTest { - @Test - void validatesStableNames() { - assertThat(new ClientProfileName("payment-api").value()) - .isEqualTo("payment-api"); - assertThatThrownBy(() -> new OperationName("Create Payment")) - .isInstanceOf(IllegalArgumentException.class); - } - - @Test - void exposesHttpMethodSemanticsWithoutTrace() { - assertThat(HttpMethod.GET.safe()).isTrue(); - assertThat(HttpMethod.PUT.standardIdempotent()).isTrue(); - assertThat(HttpMethod.POST.standardIdempotent()).isFalse(); - assertThat(Arrays.stream(HttpMethod.values()).map(Enum::name)) - .doesNotContain("TRACE"); - } -} -``` - -- [ ] **Step 2: Run the test to verify missing types fail compilation** - -```bash -./gradlew :modules:httpclient:httpclient-core-api:test \ - --tests '*CoreValueTypeTest' -``` - -Expected: FAIL with unresolved `ClientProfileName`, `OperationName`, and `HttpMethod` symbols. - -- [ ] **Step 3: Implement the records and enums** - -```java -public record ClientProfileName(String value) { - public ClientProfileName { - if (value == null || !value.matches("[a-z][a-z0-9-]{1,62}")) { - throw new IllegalArgumentException("invalid client profile name"); - } - } -} - -public enum HttpMethod { - GET(true, true), HEAD(true, true), POST(false, false), - PUT(false, true), PATCH(false, false), DELETE(false, true), - OPTIONS(true, true); - - private final boolean safe; - private final boolean standardIdempotent; - - HttpMethod(boolean safe, boolean standardIdempotent) { - this.safe = safe; - this.standardIdempotent = standardIdempotent; - } - - public boolean safe() { return safe; } - public boolean standardIdempotent() { return standardIdempotent; } -} -``` - -Implement the remaining records with non-null validation and the exact enum constants from the design. - -- [ ] **Step 4: Run the core test** - -```bash -./gradlew :modules:httpclient:httpclient-core-api:test \ - --tests '*CoreValueTypeTest' -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-core-api -git commit -m "feat: define http client core semantics" -``` - ---- - -### Task 3: Request Body와 Response 타입 계약 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/body/BodySource.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/body/EmptyBody.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/body/ObjectBody.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/body/ByteArrayBody.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/body/ReopenableStreamBody.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/body/OneShotStreamBody.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/body/IOSupplier.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/result/ResponseType.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/result/ClassResponseType.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/result/GenericResponseType.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/result/EmptyResponseType.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/result/BlockingStreamingResponse.java` -- Test: `modules/httpclient/httpclient-core-api/src/test/java/io/backend/skeleton/httpclient/api/body/BodyReplayabilityTest.java` - -**Interfaces:** -- Produces `BodySource.replayability()` and `knownLength()`. -- Retry tasks consume these exact methods. -- Blocking streaming response is `AutoCloseable`. - -- [ ] **Step 1: Write failing replayability and lifecycle tests** - -```java -class BodyReplayabilityTest { - @Test - void classifiesBodySources() { - assertThat(new ByteArrayBody(new byte[] {1, 2}, "application/octet-stream") - .replayability()).isEqualTo(BodyReplayability.REPLAYABLE); - - ReopenableStreamBody body = new ReopenableStreamBody( - () -> new ByteArrayInputStream(new byte[] {1}), - OptionalLong.of(1), - "application/octet-stream"); - assertThat(body.replayability()).isEqualTo(BodyReplayability.REOPENABLE); - } - - @Test - void oneShotBodyRejectsNullStream() { - assertThatThrownBy(() -> new OneShotStreamBody( - null, OptionalLong.empty(), "application/octet-stream")) - .isInstanceOf(NullPointerException.class); - } -} -``` - -- [ ] **Step 2: Run the failing test** - -```bash -./gradlew :modules:httpclient:httpclient-core-api:test \ - --tests '*BodyReplayabilityTest' -``` - -Expected: FAIL because body and response contracts do not exist. - -- [ ] **Step 3: Implement the sealed body and response contracts** - -```java -public sealed interface BodySource permits EmptyBody, ObjectBody, - ByteArrayBody, ReopenableStreamBody, OneShotStreamBody { - BodyReplayability replayability(); - OptionalLong knownLength(); - String mediaType(); -} - -public record ReopenableStreamBody( - IOSupplier opener, - OptionalLong knownLength, - String mediaType) implements BodySource { - public ReopenableStreamBody { - Objects.requireNonNull(opener); - Objects.requireNonNull(knownLength); - Objects.requireNonNull(mediaType); - } - @Override public BodyReplayability replayability() { - return BodyReplayability.REOPENABLE; - } -} -``` - -Implement `ByteArrayBody` with a defensive copy and `BlockingStreamingResponse` with `status()`, `headers()`, `body()`, and `close()`. - -- [ ] **Step 4: Run the core body tests** - -```bash -./gradlew :modules:httpclient:httpclient-core-api:test \ - --tests '*BodyReplayabilityTest' -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-core-api -git commit -m "feat: add replayable body and response contracts" -``` - ---- - -### Task 4: HttpOperation과 HttpCallResult 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/operation/HttpOperation.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/result/HttpCallResult.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/result/RemoteProblem.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/result/IdempotencyKeyRequirement.java` -- Test: `modules/httpclient/httpclient-core-api/src/test/java/io/backend/skeleton/httpclient/api/operation/HttpOperationTest.java` - -**Interfaces:** -- Produces the immutable operation model consumed by H2/H3 and retry. -- `IDEMPOTENCY_KEY_REQUIRED` cannot be built without a key. - -- [ ] **Step 1: Write failing operation invariant tests** - -```java -class HttpOperationTest { - @Test - void requiresIdempotencyKeyWhenPolicyRequiresIt() { - assertThatThrownBy(() -> new HttpOperation( - new OperationName("create-payment"), - HttpMethod.POST, - "/payments", - Map.of(), - Map.of(), - new EmptyBody(), - OperationIdempotency.IDEMPOTENCY_KEY_REQUIRED, - Optional.empty(), - Optional.empty())) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("idempotency key"); - } - - @Test - void storesUriTemplateRatherThanExpandedUrl() { - HttpOperation operation = HttpOperation.get( - new OperationName("get-user"), "/users/{id}", Map.of("id", "42")); - assertThat(operation.uriTemplate()).isEqualTo("/users/{id}"); - } -} -``` - -- [ ] **Step 2: Run the test and confirm failure** - -```bash -./gradlew :modules:httpclient:httpclient-core-api:test \ - --tests '*HttpOperationTest' -``` - -Expected: FAIL because `HttpOperation` and `HttpCallResult` are missing. - -- [ ] **Step 3: Implement immutable invariants** - -```java -public record HttpOperation( - OperationName operationName, - HttpMethod method, - String uriTemplate, - Map uriVariables, - Map> headers, - BodySource body, - OperationIdempotency idempotency, - Optional idempotencyKey, - Optional deadline) { - - public HttpOperation { - Objects.requireNonNull(operationName); - Objects.requireNonNull(method); - Objects.requireNonNull(uriTemplate); - Objects.requireNonNull(body); - if (idempotency == OperationIdempotency.IDEMPOTENCY_KEY_REQUIRED - && idempotencyKey.isEmpty()) { - throw new IllegalArgumentException("idempotency key is required"); - } - uriVariables = Map.copyOf(uriVariables); - headers = headers.entrySet().stream().collect(Collectors.toUnmodifiableMap( - Map.Entry::getKey, entry -> List.copyOf(entry.getValue()))); - } -} -``` - -Implement `HttpCallResult` with immutable headers and `attempts >= 1` validation. - -- [ ] **Step 4: Run core operation tests** - -```bash -./gradlew :modules:httpclient:httpclient-core-api:test \ - --tests '*HttpOperationTest' -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-core-api -git commit -m "feat: add immutable http operation result model" -``` - ---- - -### Task 5: 안정 예외 계층과 실패 Metadata 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpFailureMetadata.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpClientException.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpConfigurationException.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpTargetRejectedException.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpDnsException.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpPoolAcquireTimeoutException.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpConnectException.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpProxyException.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpTlsException.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpRequestWriteException.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpResponseTimeoutException.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpResponseTruncatedException.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpRemoteErrorException.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpProblemDetailException.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpRedirectRejectedException.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpAuthenticationException.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpSerializationException.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpResponseTooLargeException.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpDeadlineExceededException.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpCircuitOpenException.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpBulkheadRejectedException.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpRateLimitRejectedException.java` -- Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpAmbiguousExecutionException.java` -- Test: `modules/httpclient/httpclient-core-api/src/test/java/io/backend/skeleton/httpclient/api/error/StableExceptionTest.java` - -**Interfaces:** -- Every public failure extends `HttpClientException` and exposes `metadata()`. -- No exception message contains full URL, body, token, or idempotency key. - -- [ ] **Step 1: Write failing stable metadata and redaction tests** - -```java -class StableExceptionTest { - @Test - void ambiguousFailurePreservesEvidenceWithoutSecrets() { - HttpFailureMetadata metadata = Fixtures.ambiguousMetadata(); - HttpAmbiguousExecutionException exception = - new HttpAmbiguousExecutionException("remote outcome is unknown", metadata); - - assertThat(exception.metadata().evidence()) - .isEqualTo(ExecutionEvidence.SENT_NO_RESPONSE); - assertThat(exception.getMessage()) - .doesNotContain("Authorization", "secret", "https://payment.example.com/42"); - } -} -``` - -- [ ] **Step 2: Run the failing test** - -```bash -./gradlew :modules:httpclient:httpclient-core-api:test \ - --tests '*StableExceptionTest' -``` - -Expected: FAIL because the stable exception hierarchy does not exist. - -- [ ] **Step 3: Implement the root and typed subclasses** - -```java -public abstract class HttpClientException extends RuntimeException { - private final HttpFailureMetadata metadata; - - protected HttpClientException(String safeMessage, HttpFailureMetadata metadata, - Throwable cause) { - super(safeMessage, cause); - this.metadata = Objects.requireNonNull(metadata); - } - - public final HttpFailureMetadata metadata() { - return metadata; - } -} -``` - -Each concrete subclass has constructors `(String safeMessage, HttpFailureMetadata metadata)` and `(String safeMessage, HttpFailureMetadata metadata, Throwable cause)`. Do not include raw URI or body in any constructor formatting. - -- [ ] **Step 4: Run exception tests** - -```bash -./gradlew :modules:httpclient:httpclient-core-api:test \ - --tests '*StableExceptionTest' -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-core-api -git commit -m "feat: add stable http client failures" -``` - ---- - -### Task 6: Named Client Profile 모델과 startup validation 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/ClientMode.java` -- Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/TransportType.java` -- Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/HttpProtocol.java` -- Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/ClientApiType.java` -- Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/PoolSettings.java` -- Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/TimeoutSettings.java` -- Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/RedirectSettings.java` -- Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/RequestLimits.java` -- Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/ResponseLimits.java` -- Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/AuthenticationSettings.java` -- Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/RetrySettings.java` -- Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/ClientObservabilitySettings.java` -- Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/ClientProfile.java` -- Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/ClientProfileValidator.java` -- Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/ClientProfileViolation.java` -- Test: `modules/httpclient/httpclient-profile/src/test/java/io/backend/skeleton/httpclient/profile/ClientProfileValidatorTest.java` - -**Interfaces:** -- Produces immutable `ClientProfile` and `ClientProfileValidator.validate(profile, environment)`. -- Later auto-configuration and transport tasks consume this exact profile model. - -- [ ] **Step 1: Write failing unsafe configuration tests** - -```java -class ClientProfileValidatorTest { - private final ClientProfileValidator validator = new ClientProfileValidator(); - - @Test - void rejectsPlainHttpInProduction() { - ClientProfile profile = ClientProfiles.trusted("payment", URI.create("http://payment.test")); - assertThat(validator.validate(profile, RuntimeEnvironment.PRODUCTION)) - .extracting(ClientProfileViolation::code) - .contains("PLAINTEXT_PRODUCTION_TARGET"); - } - - @Test - void rejectsDynamicCredentialInheritance() { - ClientProfile profile = ClientProfiles.dynamicWithOAuth("webhook-checker"); - assertThat(validator.validate(profile, RuntimeEnvironment.PRODUCTION)) - .extracting(ClientProfileViolation::code) - .contains("DYNAMIC_DEFAULT_CREDENTIAL_FORBIDDEN"); - } - - @Test - void rejectsTotalTimeoutShorterThanConnectBudget() { - ClientProfile profile = ClientProfiles.withTimeouts( - Duration.ofSeconds(2), Duration.ofMillis(500)); - assertThat(validator.validate(profile, RuntimeEnvironment.PRODUCTION)) - .extracting(ClientProfileViolation::code) - .contains("INVALID_TIMEOUT_BUDGET"); - } -} -``` - -- [ ] **Step 2: Run the tests and confirm failure** - -```bash -./gradlew :modules:httpclient:httpclient-profile:test \ - --tests '*ClientProfileValidatorTest' -``` - -Expected: FAIL because the profile records and validator are missing. - -- [ ] **Step 3: Implement immutable settings and deterministic validation** - -```java -public record ClientProfile( - ClientProfileName name, - ClientMode mode, - URI baseUrl, - Set allowedHosts, - Set allowedPorts, - ClientApiType api, - TransportType transport, - Set protocols, - PoolSettings pool, - TimeoutSettings timeout, - RedirectSettings redirect, - RequestLimits request, - ResponseLimits response, - AuthenticationSettings authentication, - RetrySettings retry, - ClientObservabilitySettings observability) { -} -``` - -`ClientProfileValidator` must emit stable violation codes for every startup guard in the design: base URL, userinfo, allowed host/port, production plaintext, Dynamic credential, HTTP/3 Stable, Simple factory, timeout relationships, hard size maximum, redirect policy, and unsafe POST retry. - -- [ ] **Step 4: Run the profile tests** - -```bash -./gradlew :modules:httpclient:httpclient-profile:test -``` - -Expected: PASS; violation order is deterministic and sorted by code. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-profile -git commit -m "feat: add named http client profiles" -``` - ---- - -### Task 7: Immutable ClientRuntime Registry와 generation 교체 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/ClientRuntime.java` -- Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/ClientRuntimeState.java` -- Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/ClientRuntimeFactory.java` -- Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/ClientRuntimeRegistry.java` -- Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/ClientRuntimeLease.java` -- Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/RuntimeGeneration.java` -- Test: `modules/httpclient/httpclient-profile/src/test/java/io/backend/skeleton/httpclient/profile/ClientRuntimeRegistryTest.java` - -**Interfaces:** -- Produces `ClientRuntimeRegistry.acquire(ClientProfileName)` returning `ClientRuntimeLease`. -- Produces `swap(profileName, newRuntime, drainTimeout)` for secret, certificate, pool, or endpoint rotation. - -- [ ] **Step 1: Write failing atomic swap and drain tests** - -```java -class ClientRuntimeRegistryTest { - @Test - void newCallsUseNewGenerationWhileOldCallDrains() { - ClientRuntime first = FakeRuntime.running(1); - ClientRuntime second = FakeRuntime.running(2); - ClientRuntimeRegistry registry = new ClientRuntimeRegistry(Map.of(first.name(), first)); - - ClientRuntimeLease oldLease = registry.acquire(first.name()); - registry.swap(first.name(), second, Duration.ofSeconds(1)); - - try (ClientRuntimeLease newLease = registry.acquire(first.name())) { - assertThat(newLease.runtime().generation().value()).isEqualTo(2); - } - assertThat(first.state()).isEqualTo(ClientRuntimeState.DRAINING); - oldLease.close(); - assertThat(first.state()).isEqualTo(ClientRuntimeState.CLOSED); - } -} -``` - -- [ ] **Step 2: Run the test and verify failure** - -```bash -./gradlew :modules:httpclient:httpclient-profile:test \ - --tests '*ClientRuntimeRegistryTest' -``` - -Expected: FAIL because runtime lifecycle types are absent. - -- [ ] **Step 3: Implement reference-counted runtime generations** - -```java -public final class ClientRuntimeRegistry { - private final ConcurrentMap> runtimes; - - public ClientRuntimeLease acquire(ClientProfileName name) { - ClientRuntime runtime = requireRuntime(name); - if (!runtime.tryAcquire()) { - return acquire(name); - } - return new ClientRuntimeLease(runtime, runtime::release); - } - - public void swap(ClientProfileName name, ClientRuntime replacement, - Duration drainTimeout) { - ClientRuntime previous = runtimes.get(name).getAndSet(replacement); - previous.beginDrain(drainTimeout); - } -} -``` - -`ClientRuntime` closes immediately after the last lease when draining, and forcibly closes at drain timeout. It rejects new retry attempts after state becomes `DRAINING`. - -- [ ] **Step 4: Run runtime lifecycle tests** - -```bash -./gradlew :modules:httpclient:httpclient-profile:test \ - --tests '*ClientRuntimeRegistryTest' -``` - -Expected: PASS with no leaked scheduled executor thread. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-profile -git commit -m "feat: add immutable client runtime generations" -``` - ---- - -### Task 8: Blocking·Reactive Transport SPI와 capability validation 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-transport-spi/src/main/java/io/backend/skeleton/httpclient/transport/TransportId.java` -- Create: `modules/httpclient/httpclient-transport-spi/src/main/java/io/backend/skeleton/httpclient/transport/BlockingTransportProvider.java` -- Create: `modules/httpclient/httpclient-transport-spi/src/main/java/io/backend/skeleton/httpclient/transport/ReactiveTransportProvider.java` -- Create: `modules/httpclient/httpclient-transport-spi/src/main/java/io/backend/skeleton/httpclient/transport/BlockingTransportCapabilities.java` -- Create: `modules/httpclient/httpclient-transport-spi/src/main/java/io/backend/skeleton/httpclient/transport/ReactiveTransportCapabilities.java` -- Create: `modules/httpclient/httpclient-transport-spi/src/main/java/io/backend/skeleton/httpclient/transport/TransportFailureClassifier.java` -- Create: `modules/httpclient/httpclient-transport-spi/src/main/java/io/backend/skeleton/httpclient/transport/TransportLifecycleListener.java` -- Create: `modules/httpclient/httpclient-transport-spi/src/main/java/io/backend/skeleton/httpclient/transport/TransportCapabilityValidator.java` -- Create: `modules/httpclient/httpclient-transport-spi/src/main/java/io/backend/skeleton/httpclient/transport/TransportFailure.java` -- Test: `modules/httpclient/httpclient-transport-spi/src/test/java/io/backend/skeleton/httpclient/transport/TransportCapabilityValidatorTest.java` - -**Interfaces:** -- Blocking provider produces Spring `ClientHttpRequestFactory`. -- Reactive provider produces Spring `ClientHttpConnector`. -- Public application modules never receive native engine clients. - -- [ ] **Step 1: Write failing capability mismatch tests** - -```java -class TransportCapabilityValidatorTest { - @Test - void rejectsHttp3OnNonHttp3Provider() { - ClientProfile profile = ClientProfiles.http3Experimental("edge"); - BlockingTransportCapabilities capabilities = - BlockingTransportCapabilities.http11AndHttp2(); - - assertThatThrownBy(() -> new TransportCapabilityValidator() - .validate(profile, capabilities)) - .isInstanceOf(HttpConfigurationException.class) - .hasMessageContaining("HTTP_3"); - } -} -``` - -- [ ] **Step 2: Run the SPI tests and verify failure** - -```bash -./gradlew :modules:httpclient:httpclient-transport-spi:test \ - --tests '*TransportCapabilityValidatorTest' -``` - -Expected: FAIL because provider and capability contracts are missing. - -- [ ] **Step 3: Implement the provider contracts** - -```java -public interface BlockingTransportProvider { - TransportId id(); - BlockingTransportCapabilities capabilities(); - ClientHttpRequestFactory create( - ClientProfile profile, - TransportLifecycleListener listener); - TransportFailureClassifier failureClassifier(); -} - -public interface TransportFailureClassifier { - TransportFailure classify(Throwable failure, AttemptStage lastObservedStage); -} -``` - -`TransportCapabilityValidator` checks protocol, proxy, mTLS, route pool, pending queue, DNS pinning, and dynamic target capability. Error messages use profile and capability names only. - -- [ ] **Step 4: Run the SPI tests** - -```bash -./gradlew :modules:httpclient:httpclient-transport-spi:test -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-transport-spi -git commit -m "feat: define http transport provider spi" -``` - ---- - -### Task 9: HTTP Client Testkit 기반 구성 - -**Files:** -- Create: `modules/httpclient/httpclient-testkit/src/main/java/io/backend/skeleton/httpclient/testkit/MockHttpServer.java` -- Create: `modules/httpclient/httpclient-testkit/src/main/java/io/backend/skeleton/httpclient/testkit/RecordedHttpRequest.java` -- Create: `modules/httpclient/httpclient-testkit/src/main/java/io/backend/skeleton/httpclient/testkit/HttpClientContract.java` -- Create: `modules/httpclient/httpclient-testkit/src/main/java/io/backend/skeleton/httpclient/testkit/TlsFixture.java` -- Create: `modules/httpclient/httpclient-testkit/src/main/java/io/backend/skeleton/httpclient/testkit/ProxyFixture.java` -- Create: `modules/httpclient/httpclient-testkit/src/main/java/io/backend/skeleton/httpclient/testkit/OAuth2Fixture.java` -- Create: `modules/httpclient/httpclient-testkit/src/main/java/io/backend/skeleton/httpclient/testkit/ToxiproxyFixture.java` -- Create: `modules/httpclient/httpclient-testkit/src/test/java/io/backend/skeleton/httpclient/testkit/MockHttpServerTest.java` -- Create: `infra/httpclient/toxiproxy/compose.yaml` - -**Interfaces:** -- Produces deterministic HTTP/1.1 fixtures used from Task 13 onward. -- Later tasks extend the testkit with HTTP/2, TLS, OAuth2, proxy, and network failure behavior. - -- [ ] **Step 1: Write a failing server recording test** - -```java -class MockHttpServerTest { - @Test - void recordsMethodPathHeadersAndBody() throws Exception { - try (MockHttpServer server = MockHttpServer.start()) { - server.enqueueJson(200, "{\"ok\":true}"); - HttpURLConnection connection = (HttpURLConnection) - server.uri("/items/42").toURL().openConnection(); - connection.setRequestMethod("POST"); - connection.setDoOutput(true); - connection.setRequestProperty("X-Test", "value"); - connection.getOutputStream().write("body".getBytes(UTF_8)); - assertThat(connection.getResponseCode()).isEqualTo(200); - - RecordedHttpRequest request = server.takeRequest(Duration.ofSeconds(1)); - assertThat(request.method()).isEqualTo("POST"); - assertThat(request.path()).isEqualTo("/items/42"); - assertThat(request.firstHeader("X-Test")).contains("value"); - assertThat(request.bodyUtf8()).isEqualTo("body"); - } - } -} -``` - -- [ ] **Step 2: Run the test and confirm failure** - -```bash -./gradlew :modules:httpclient:httpclient-testkit:test \ - --tests '*MockHttpServerTest' -``` - -Expected: FAIL because the fixture classes are missing. - -- [ ] **Step 3: Implement MockWebServer-backed fixtures** - -```java -public final class MockHttpServer implements AutoCloseable { - private final MockWebServer server; - - public static MockHttpServer start() throws IOException { - MockWebServer delegate = new MockWebServer(); - delegate.start(); - return new MockHttpServer(delegate); - } - - public void enqueueJson(int status, String body) { - server.enqueue(new MockResponse() - .setResponseCode(status) - .setHeader("Content-Type", "application/json") - .setBody(body)); - } -} -``` - -Implement `takeRequest` with a finite timeout and immutable header/body copies. Add Testcontainers and Toxiproxy dependencies only to `httpclient-testkit`. - -- [ ] **Step 4: Run the testkit suite** - -```bash -./gradlew :modules:httpclient:httpclient-testkit:test -``` - -Expected: PASS and no listening socket remains after the test. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-testkit infra/httpclient/toxiproxy -git commit -m "test: add http client contract fixtures" -``` - ---- - -### Task 10: Effective Deadline과 단계별 시간 예산 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/Deadline.java` -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/DeadlineCalculator.java` -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/AttemptBudget.java` -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/AttemptBudgetCalculator.java` -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/DeadlineGuard.java` -- Test: `modules/httpclient/httpclient-resilience/src/test/java/io/backend/skeleton/httpclient/resilience/DeadlineCalculatorTest.java` - -**Interfaces:** -- Produces `DeadlineCalculator.effective(parent, totalCall, clock)`. -- Produces `AttemptBudgetCalculator.nextAttempt(deadline, backoff, minimumAttempt, cleanupReserve)`. - -- [ ] **Step 1: Write failing parent deadline and backoff tests** - -```java -class DeadlineCalculatorTest { - private final Clock clock = Clock.fixed(Instant.parse("2026-08-08T00:00:00Z"), UTC); - - @Test - void usesShorterParentDeadline() { - Deadline deadline = new DeadlineCalculator().effective( - Optional.of(Instant.parse("2026-08-08T00:00:02Z")), - Duration.ofSeconds(5), clock); - assertThat(deadline.at()).isEqualTo(Instant.parse("2026-08-08T00:00:02Z")); - } - - @Test - void refusesAttemptWhenBackoffConsumesRemainingBudget() { - Deadline deadline = new Deadline(Instant.parse("2026-08-08T00:00:01Z")); - Optional result = new AttemptBudgetCalculator(clock) - .nextAttempt(deadline, Duration.ofMillis(700), - Duration.ofMillis(250), Duration.ofMillis(100)); - assertThat(result).isEmpty(); - } -} -``` - -- [ ] **Step 2: Run the test and confirm failure** - -```bash -./gradlew :modules:httpclient:httpclient-resilience:test \ - --tests '*DeadlineCalculatorTest' -``` - -Expected: FAIL because deadline types are absent. - -- [ ] **Step 3: Implement monotonic budget calculations** - -```java -public final class DeadlineCalculator { - public Deadline effective(Optional parent, Duration totalCall, Clock clock) { - Instant local = clock.instant().plus(totalCall); - return new Deadline(parent.map(p -> p.isBefore(local) ? p : local).orElse(local)); - } -} -``` - -`AttemptBudgetCalculator` subtracts backoff, minimum attempt duration, and cleanup reserve. It never returns a negative duration and `DeadlineGuard` throws `HttpDeadlineExceededException` before a new attempt starts. - -- [ ] **Step 4: Run deadline tests** - -```bash -./gradlew :modules:httpclient:httpclient-resilience:test \ - --tests '*DeadlineCalculatorTest' -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-resilience -git commit -m "feat: enforce end to end http deadlines" -``` - ---- - -### Task 11: Trusted URI, Header ownership, Body limit 정책 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-security/src/main/java/io/backend/skeleton/httpclient/security/TrustedTargetPolicy.java` -- Create: `modules/httpclient/httpclient-security/src/main/java/io/backend/skeleton/httpclient/security/UriTemplateExpander.java` -- Create: `modules/httpclient/httpclient-security/src/main/java/io/backend/skeleton/httpclient/security/HeaderPolicy.java` -- Create: `modules/httpclient/httpclient-security/src/main/java/io/backend/skeleton/httpclient/security/BodyLimitPolicy.java` -- Create: `modules/httpclient/httpclient-security/src/main/java/io/backend/skeleton/httpclient/security/RedirectPolicy.java` -- Create: `modules/httpclient/httpclient-security/src/main/java/io/backend/skeleton/httpclient/security/PreparedTarget.java` -- Create: `modules/httpclient/httpclient-security/src/main/java/io/backend/skeleton/httpclient/security/PreparedOperation.java` -- Test: `modules/httpclient/httpclient-security/src/test/java/io/backend/skeleton/httpclient/security/TrustedRequestPolicyTest.java` - -**Interfaces:** -- Produces a `PreparedOperation` with canonical target, sanitized headers, and hard size budgets. -- H2 cannot supply an absolute URI. - -- [ ] **Step 1: Write failing absolute URI, CRLF, and body size tests** - -```java -class TrustedRequestPolicyTest { - @Test - void rejectsAbsoluteUriInTrustedGenericGateway() { - TrustedTargetPolicy policy = Policies.payment(); - assertThatThrownBy(() -> policy.prepare(OperationFixtures.absoluteTarget())) - .isInstanceOf(HttpTargetRejectedException.class); - } - - @Test - void rejectsHeaderInjection() { - HeaderPolicy policy = HeaderPolicy.defaultPolicy(); - assertThatThrownBy(() -> policy.validate(Map.of("X-Test", List.of("ok\r\nBad: x")))) - .isInstanceOf(HttpTargetRejectedException.class); - } - - @Test - void rejectsKnownBodyLargerThanProfileLimit() { - assertThatThrownBy(() -> BodyLimitPolicy.maxRequestBytes(4) - .validate(new ByteArrayBody(new byte[5], "application/octet-stream"))) - .isInstanceOf(HttpConfigurationException.class); - } -} -``` - -- [ ] **Step 2: Run the failing security tests** - -```bash -./gradlew :modules:httpclient:httpclient-security:test \ - --tests '*TrustedRequestPolicyTest' -``` - -Expected: FAIL because the request policy pipeline is missing. - -- [ ] **Step 3: Implement strict preparation rules** - -```java -public final class HeaderPolicy { - private static final Set PLATFORM_OWNED = Set.of( - "authorization", "proxy-authorization", "host", "content-length", - "transfer-encoding", "traceparent", "tracestate", "baggage", "cookie"); - - public Map> validate(Map> input) { - input.forEach((name, values) -> { - if (name.indexOf('\r') >= 0 || name.indexOf('\n') >= 0) reject(name); - values.forEach(value -> { - if (value.indexOf('\r') >= 0 || value.indexOf('\n') >= 0) reject(name); - }); - if (PLATFORM_OWNED.contains(name.toLowerCase(Locale.ROOT))) reject(name); - }); - return immutableCopy(input); - } -} -``` - -`UriTemplateExpander` uses Spring URI components in this integration module, encodes path and query components separately, and records the original template for observability. - -- [ ] **Step 4: Run security policy tests** - -```bash -./gradlew :modules:httpclient:httpclient-security:test -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-security -git commit -m "feat: enforce trusted http request policy" -``` - ---- - -### Task 12: Low-cardinality 관측성과 Redaction primitive 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-observability/src/main/java/io/backend/skeleton/httpclient/observation/HttpClientObservationNames.java` -- Create: `modules/httpclient/httpclient-observability/src/main/java/io/backend/skeleton/httpclient/observation/LogicalCallObservation.java` -- Create: `modules/httpclient/httpclient-observability/src/main/java/io/backend/skeleton/httpclient/observation/AttemptObservation.java` -- Create: `modules/httpclient/httpclient-observability/src/main/java/io/backend/skeleton/httpclient/observation/HttpClientTagPolicy.java` -- Create: `modules/httpclient/httpclient-observability/src/main/java/io/backend/skeleton/httpclient/observation/SensitiveValueRedactor.java` -- Create: `modules/httpclient/httpclient-observability/src/main/java/io/backend/skeleton/httpclient/observation/SafeHttpLogEvent.java` -- Test: `modules/httpclient/httpclient-observability/src/test/java/io/backend/skeleton/httpclient/observation/HttpClientTagPolicyTest.java` - -**Interfaces:** -- Produces standard low-cardinality tags consumed by RestClient, WebClient, Retry, Auth, and Dynamic modules. -- Rejects full URL and arbitrary labels rather than silently accepting them. - -- [ ] **Step 1: Write failing forbidden tag and redaction tests** - -```java -class HttpClientTagPolicyTest { - @Test - void rejectsFullUrlAsLowCardinalityTag() { - HttpClientTagPolicy policy = HttpClientTagPolicy.standard(); - assertThatThrownBy(() -> policy.tag("url", "https://api.test/users/42?q=secret")) - .isInstanceOf(IllegalArgumentException.class); - } - - @Test - void redactsCredentialsAndQueryValues() { - SensitiveValueRedactor redactor = SensitiveValueRedactor.standard(); - assertThat(redactor.header("Authorization", "Bearer abc")).isEqualTo("[REDACTED]"); - assertThat(redactor.uri(URI.create("https://api.test/a?q=secret")).toString()) - .isEqualTo("https://api.test/a"); - } -} -``` - -- [ ] **Step 2: Run observability tests and confirm failure** - -```bash -./gradlew :modules:httpclient:httpclient-observability:test \ - --tests '*HttpClientTagPolicyTest' -``` - -Expected: FAIL because tag policy and redactor are missing. - -- [ ] **Step 3: Implement bounded vocabularies and safe events** - -```java -public final class HttpClientTagPolicy { - private static final Set ALLOWED = Set.of( - "clientName", "operationName", "method", "uriTemplate", "status", - "outcome", "transport", "protocol", "timeoutType", "retryReason", - "evidence", "circuitState"); - - public KeyValue tag(String name, String value) { - if (!ALLOWED.contains(name)) { - throw new IllegalArgumentException("forbidden low-cardinality tag: " + name); - } - return KeyValue.of(name, value); - } -} -``` - -`SafeHttpLogEvent` stores profile, operation, template, status, evidence, stage, attempt, elapsed, and trace ID only. It has no fields for body, authorization, Cookie, query, or expanded URL. - -- [ ] **Step 4: Run observability tests** - -```bash -./gradlew :modules:httpclient:httpclient-observability:test -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-observability -git commit -m "feat: add safe http client observability" -``` - ---- - -### Task 13: Apache HttpClient 5 Blocking Transport 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-transport-apache/src/main/java/io/backend/skeleton/httpclient/apache/ApacheBlockingTransportProvider.java` -- Create: `modules/httpclient/httpclient-transport-apache/src/main/java/io/backend/skeleton/httpclient/apache/ApacheClientFactory.java` -- Create: `modules/httpclient/httpclient-transport-apache/src/main/java/io/backend/skeleton/httpclient/apache/ApacheFailureClassifier.java` -- Create: `modules/httpclient/httpclient-transport-apache/src/main/java/io/backend/skeleton/httpclient/apache/ApachePoolMetricsBinder.java` -- Create: `modules/httpclient/httpclient-transport-apache/src/main/java/io/backend/skeleton/httpclient/apache/ApacheDnsResolverFactory.java` -- Create: `modules/httpclient/httpclient-transport-apache/src/test/java/io/backend/skeleton/httpclient/apache/ApacheBlockingTransportProviderTest.java` -- Create: `modules/httpclient/httpclient-transport-apache/src/test/java/io/backend/skeleton/httpclient/apache/ApachePoolSaturationTest.java` - -**Interfaces:** -- Implements `BlockingTransportProvider` with ID `apache`. -- Supports route pool, pending acquire, proxy, custom TLS, HTTP/1.1·2, validated DNS resolver. - -- [ ] **Step 1: Write failing pool and request contract tests** - -```java -class ApacheBlockingTransportProviderTest { - @Test - void sendsRequestThroughConfiguredFactory() throws Exception { - try (MockHttpServer server = MockHttpServer.start()) { - server.enqueueJson(200, "{\"value\":1}"); - ClientProfile profile = ClientProfiles.apache(server.uri("/")); - ApacheBlockingTransportProvider provider = new ApacheBlockingTransportProvider(); - - ClientHttpRequestFactory factory = provider.create(profile, NoopLifecycleListener.INSTANCE); - RestClient client = RestClient.builder().requestFactory(factory).build(); - String body = client.get().uri(server.uri("/value")).retrieve().body(String.class); - - assertThat(body).contains("value"); - } - } -} - -class ApachePoolSaturationTest { - @Test - void poolAcquireTimeoutIsClassifiedAsNotSent() { - // server holds the first response; second request must exhaust a one-connection pool - TransportFailure failure = ApacheFixtures.saturateAndCaptureFailure(); - assertThat(failure.stage()).isEqualTo(AttemptStage.POOL_ACQUIRE); - assertThat(failure.evidence()).isEqualTo(ExecutionEvidence.NOT_SENT); - } -} -``` - -- [ ] **Step 2: Run Apache transport tests and confirm failure** - -```bash -./gradlew :modules:httpclient:httpclient-transport-apache:test \ - --tests '*ApacheBlockingTransportProviderTest' \ - --tests '*ApachePoolSaturationTest' -``` - -Expected: FAIL because the provider does not exist. - -- [ ] **Step 3: Implement Apache pool, lifecycle, and failure classification** - -```java -public final class ApacheBlockingTransportProvider implements BlockingTransportProvider { - @Override public TransportId id() { return new TransportId("apache"); } - - @Override - public ClientHttpRequestFactory create(ClientProfile profile, - TransportLifecycleListener listener) { - CloseableHttpClient client = new ApacheClientFactory().create(profile, listener); - HttpComponentsClientHttpRequestFactory factory = - new HttpComponentsClientHttpRequestFactory(client); - factory.setConnectionRequestTimeout(profile.pool().pendingAcquireTimeout()); - factory.setConnectTimeout(profile.timeout().connect()); - return factory; - } -} -``` - -`ApacheClientFactory` creates a `PoolingHttpClientConnectionManager` with total·route limits, connection lifetime, validation after inactivity, idle eviction, proxy, TLS strategy, and profile-scoped DNS resolver. `ApacheFailureClassifier` maps pool timeout to `NOT_SENT`, connect and pre-request TLS failures to `NOT_SENT`, and request write or response timeout to conservative `SENT_NO_RESPONSE`. - -- [ ] **Step 4: Run Apache transport and pool tests** - -```bash -./gradlew :modules:httpclient:httpclient-transport-apache:test -``` - -Expected: PASS; after every test the connection manager reports zero leased connections. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-transport-apache -git commit -m "feat: add apache blocking http transport" -``` - ---- - -### Task 14: JDK HttpClient Blocking Transport 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-transport-jdk/src/main/java/io/backend/skeleton/httpclient/jdk/JdkBlockingTransportProvider.java` -- Create: `modules/httpclient/httpclient-transport-jdk/src/main/java/io/backend/skeleton/httpclient/jdk/JdkClientFactory.java` -- Create: `modules/httpclient/httpclient-transport-jdk/src/main/java/io/backend/skeleton/httpclient/jdk/JdkFailureClassifier.java` -- Create: `modules/httpclient/httpclient-transport-jdk/src/main/java/io/backend/skeleton/httpclient/jdk/JdkTransportCapabilityPolicy.java` -- Test: `modules/httpclient/httpclient-transport-jdk/src/test/java/io/backend/skeleton/httpclient/jdk/JdkBlockingTransportProviderTest.java` -- Test: `modules/httpclient/httpclient-transport-jdk/src/test/java/io/backend/skeleton/httpclient/jdk/JdkTransportCapabilityPolicyTest.java` - -**Interfaces:** -- Implements `BlockingTransportProvider` with ID `jdk`. -- Rejects profiles that require route-level pool, bounded pending queue, or Dynamic Target DNS pinning. - -- [ ] **Step 1: Write failing request and capability tests** - -```java -class JdkTransportCapabilityPolicyTest { - @Test - void rejectsFineGrainedRoutePoolRequirement() { - ClientProfile profile = ClientProfiles.requiresRoutePool("inventory"); - assertThatThrownBy(() -> new JdkTransportCapabilityPolicy().validate(profile)) - .isInstanceOf(HttpConfigurationException.class) - .hasMessageContaining("route pool"); - } -} - -class JdkBlockingTransportProviderTest { - @Test - void performsHttp2CapableBlockingRequest() throws Exception { - try (MockHttpServer server = MockHttpServer.start()) { - server.enqueueJson(200, "{\"ok\":true}"); - ClientProfile profile = ClientProfiles.jdk(server.uri("/")); - ClientHttpRequestFactory factory = new JdkBlockingTransportProvider() - .create(profile, NoopLifecycleListener.INSTANCE); - String body = RestClient.builder().requestFactory(factory).build() - .get().uri(server.uri("/ok")).retrieve().body(String.class); - assertThat(body).contains("ok"); - } - } -} -``` - -- [ ] **Step 2: Run tests and verify failure** - -```bash -./gradlew :modules:httpclient:httpclient-transport-jdk:test -``` - -Expected: FAIL because JDK transport classes are absent. - -- [ ] **Step 3: Implement JDK transport with conservative capabilities** - -```java -public final class JdkClientFactory { - public java.net.http.HttpClient create(ClientProfile profile) { - return java.net.http.HttpClient.newBuilder() - .connectTimeout(profile.timeout().connect()) - .followRedirects(HttpClient.Redirect.NEVER) - .version(profile.protocols().contains(HttpProtocol.HTTP_2) - ? HttpClient.Version.HTTP_2 : HttpClient.Version.HTTP_1_1) - .sslContext(JdkTlsSupport.sslContext(profile)) - .build(); - } -} -``` - -Wrap it with Spring `JdkClientHttpRequestFactory`, set response read timeout, and classify `HttpConnectTimeoutException` as `NOT_SENT`. Other generic I/O failures after request creation remain conservative. - -- [ ] **Step 4: Run JDK transport tests** - -```bash -./gradlew :modules:httpclient:httpclient-transport-jdk:test -``` - -Expected: PASS; unsupported capability profiles fail before a network call. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-transport-jdk -git commit -m "feat: add jdk blocking http transport" -``` - ---- - -### Task 15: RestClient Runtime과 H2 Generic Blocking Gateway 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-restclient/src/main/java/io/backend/skeleton/httpclient/restclient/GenericHttpGateway.java` -- Create: `modules/httpclient/httpclient-restclient/src/main/java/io/backend/skeleton/httpclient/restclient/DefaultGenericHttpGateway.java` -- Create: `modules/httpclient/httpclient-restclient/src/main/java/io/backend/skeleton/httpclient/restclient/RestClientRuntimeFactory.java` -- Create: `modules/httpclient/httpclient-restclient/src/main/java/io/backend/skeleton/httpclient/restclient/BlockingAttemptExecutor.java` -- Create: `modules/httpclient/httpclient-restclient/src/main/java/io/backend/skeleton/httpclient/restclient/RestClientBodyWriter.java` -- Create: `modules/httpclient/httpclient-restclient/src/main/java/io/backend/skeleton/httpclient/restclient/RestClientResponseReader.java` -- Create: `modules/httpclient/httpclient-restclient/src/main/java/io/backend/skeleton/httpclient/restclient/BlockingOperationContext.java` -- Test: `modules/httpclient/httpclient-restclient/src/test/java/io/backend/skeleton/httpclient/restclient/DefaultGenericHttpGatewayTest.java` - -**Interfaces:** -- Produces ` HttpCallResult exchange(ClientProfileName, HttpOperation, ResponseType)`. -- Uses only registered profile-relative URI templates. - -- [ ] **Step 1: Write a failing end-to-end Generic Gateway test** - -```java -class DefaultGenericHttpGatewayTest { - @Test - void expandsRelativeTemplateAndReturnsTypedResult() throws Exception { - try (MockHttpServer server = MockHttpServer.start()) { - server.enqueueJson(200, "{\"id\":42}"); - GenericHttpGateway gateway = TestGateways.apache(server.uri("/")); - HttpOperation operation = HttpOperation.get( - new OperationName("get-user"), "/users/{id}", Map.of("id", 42)); - - HttpCallResult result = gateway.exchange( - new ClientProfileName("users"), operation, - ResponseType.of(UserResponse.class)); - - assertThat(result.status().value()).isEqualTo(200); - assertThat(result.body().id()).isEqualTo(42); - assertThat(server.takeRequest(Duration.ofSeconds(1)).path()) - .isEqualTo("/users/42"); - } - } -} -``` - -- [ ] **Step 2: Run the gateway test and confirm failure** - -```bash -./gradlew :modules:httpclient:httpclient-restclient:test \ - --tests '*DefaultGenericHttpGatewayTest' -``` - -Expected: FAIL because the gateway and runtime factory are missing. - -- [ ] **Step 3: Implement the blocking gateway pipeline skeleton** - -```java -public final class DefaultGenericHttpGateway implements GenericHttpGateway { - private final ClientRuntimeRegistry runtimes; - private final TrustedTargetPolicy targetPolicy; - private final BlockingAttemptExecutor executor; - - @Override - public HttpCallResult exchange(ClientProfileName profileName, - HttpOperation operation, - ResponseType responseType) { - try (ClientRuntimeLease lease = runtimes.acquire(profileName)) { - PreparedOperation prepared = targetPolicy.prepare( - lease.runtime().profile(), operation); - return executor.execute(lease.runtime(), prepared, responseType); - } - } -} -``` - -`RestClientRuntimeFactory` selects Apache or JDK provider, constructs an immutable RestClient, registers platform-owned interceptors, and stores the provider failure classifier in `ClientRuntime`. - -- [ ] **Step 4: Run gateway tests with both blocking transports** - -```bash -./gradlew :modules:httpclient:httpclient-restclient:test \ - -Phttpclient.contract.transports=apache,jdk -``` - -Expected: PASS for Apache and JDK contract variants. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-restclient -git commit -m "feat: add generic blocking http gateway" -``` - ---- - -### Task 16: H1 Blocking Typed Service Client Registry 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-service-client/src/main/java/io/backend/skeleton/httpclient/service/HttpServiceRegistry.java` -- Create: `modules/httpclient/httpclient-service-client/src/main/java/io/backend/skeleton/httpclient/service/DefaultHttpServiceRegistry.java` -- Create: `modules/httpclient/httpclient-service-client/src/main/java/io/backend/skeleton/httpclient/service/HttpClientProfile.java` -- Create: `modules/httpclient/httpclient-service-client/src/main/java/io/backend/skeleton/httpclient/service/HttpOperationPolicy.java` -- Create: `modules/httpclient/httpclient-service-client/src/main/java/io/backend/skeleton/httpclient/service/ServiceOperationDescriptor.java` -- Create: `modules/httpclient/httpclient-service-client/src/main/java/io/backend/skeleton/httpclient/service/ServiceOperationDescriptorScanner.java` -- Create: `modules/httpclient/httpclient-service-client/src/main/java/io/backend/skeleton/httpclient/service/BlockingServiceInvocationHandler.java` -- Create: `modules/httpclient/httpclient-service-client/src/main/java/io/backend/skeleton/httpclient/service/OperationContextHolder.java` -- Test: `modules/httpclient/httpclient-service-client/src/test/java/io/backend/skeleton/httpclient/service/BlockingHttpServiceRegistryTest.java` -- Test: `modules/httpclient/httpclient-service-client/src/test/java/io/backend/skeleton/httpclient/service/ServiceSignatureValidationTest.java` - -**Interfaces:** -- Produces ` T client(ClientProfileName, Class)`. -- Operation descriptors use exact `operationName`, idempotency, retry policy, timeout policy, and streaming flag. - -- [ ] **Step 1: Write failing proxy and signature validation tests** - -```java -@HttpClientProfile("users") -@HttpExchange("/users") -interface UsersClient { - @GetExchange("/{id}") - @HttpOperationPolicy(name = "get-user", - idempotency = OperationIdempotency.STANDARD_IDEMPOTENT) - UserResponse get(@PathVariable long id); -} - -class BlockingHttpServiceRegistryTest { - @Test - void createsTypedProxyBoundToNamedProfile() throws Exception { - try (MockHttpServer server = MockHttpServer.start()) { - server.enqueueJson(200, "{\"id\":7}"); - HttpServiceRegistry registry = TestServiceRegistries.apache(server.uri("/")); - assertThat(registry.client(new ClientProfileName("users"), UsersClient.class) - .get(7).id()).isEqualTo(7); - } - } -} - -class ServiceSignatureValidationTest { - @Test - void rejectsPostWithoutOperationPolicy() { - assertThatThrownBy(() -> new ServiceOperationDescriptorScanner() - .scan(InvalidPostClient.class)) - .isInstanceOf(HttpConfigurationException.class); - } -} -``` - -- [ ] **Step 2: Run service client tests and confirm failure** - -```bash -./gradlew :modules:httpclient:httpclient-service-client:test \ - --tests '*BlockingHttpServiceRegistryTest' \ - --tests '*ServiceSignatureValidationTest' -``` - -Expected: FAIL because annotations, scanner, and registry are missing. - -- [ ] **Step 3: Implement descriptor scanning and wrapper proxy** - -```java -public final class DefaultHttpServiceRegistry implements HttpServiceRegistry { - @Override - public T client(ClientProfileName profileName, Class serviceType) { - List descriptors = scanner.scan(serviceType); - Object springProxy = proxyFactory.create(profileName, serviceType); - InvocationHandler handler = new BlockingServiceInvocationHandler( - springProxy, descriptors, OperationContextHolder.instance()); - return serviceType.cast(Proxy.newProxyInstance( - serviceType.getClassLoader(), new Class[] {serviceType}, handler)); - } -} -``` - -The invocation handler sets the descriptor in a ThreadLocal only for the synchronous call and removes it in `finally`. Principal and user token are never loaded implicitly from this context. - -- [ ] **Step 4: Run blocking typed client tests** - -```bash -./gradlew :modules:httpclient:httpclient-service-client:test \ - -Phttpclient.contract.transports=apache,jdk -``` - -Expected: PASS; operation context is empty after successful and failed invocations. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-service-client -git commit -m "feat: add typed blocking http service clients" -``` - ---- - -### Task 17: Attempt progress와 Execution Evidence 분류 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/AttemptProgress.java` -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/AttemptProgressTracker.java` -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/ExecutionEvidenceClassifier.java` -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/DefaultExecutionEvidenceClassifier.java` -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/ProtocolEvidence.java` -- Test: `modules/httpclient/httpclient-resilience/src/test/java/io/backend/skeleton/httpclient/resilience/ExecutionEvidenceClassifierTest.java` - -**Interfaces:** -- Produces evidence from last observed stage, byte progress, response header, and optional protocol evidence. -- Never guesses `NOT_SENT` after request write begins. - -- [ ] **Step 1: Write failing conservative classification tests** - -```java -class ExecutionEvidenceClassifierTest { - private final ExecutionEvidenceClassifier classifier = - new DefaultExecutionEvidenceClassifier(); - - @Test - void poolTimeoutIsNotSent() { - AttemptProgress progress = AttemptProgress.failedAt(AttemptStage.POOL_ACQUIRE); - assertThat(classifier.classify(progress, ProtocolEvidence.none())) - .isEqualTo(ExecutionEvidence.NOT_SENT); - } - - @Test - void responseHeaderTimeoutAfterBodyWriteIsAmbiguous() { - AttemptProgress progress = new AttemptProgress( - AttemptStage.RESPONSE_HEADERS, true, 128, false, 0, false); - assertThat(classifier.classify(progress, ProtocolEvidence.none())) - .isEqualTo(ExecutionEvidence.SENT_NO_RESPONSE); - } - - @Test - void emittedBodyByteIsPartialResponse() { - AttemptProgress progress = new AttemptProgress( - AttemptStage.RESPONSE_BODY, true, 0, true, 64, true); - assertThat(classifier.classify(progress, ProtocolEvidence.none())) - .isEqualTo(ExecutionEvidence.PARTIAL_RESPONSE); - } -} -``` - -- [ ] **Step 2: Run tests and verify failure** - -```bash -./gradlew :modules:httpclient:httpclient-resilience:test \ - --tests '*ExecutionEvidenceClassifierTest' -``` - -Expected: FAIL because progress and classifier types are missing. - -- [ ] **Step 3: Implement stage monotonicity and conservative evidence rules** - -```java -public final class DefaultExecutionEvidenceClassifier - implements ExecutionEvidenceClassifier { - @Override - public ExecutionEvidence classify(AttemptProgress p, ProtocolEvidence protocol) { - if (protocol.peerDidNotProcess()) return ExecutionEvidence.NOT_SENT; - if (p.responseBytesDelivered() > 0 || p.firstByteDelivered()) - return ExecutionEvidence.PARTIAL_RESPONSE; - if (p.responseHeadersReceived()) return ExecutionEvidence.RESPONSE_RECEIVED; - if (p.requestWriteStarted()) return ExecutionEvidence.SENT_NO_RESPONSE; - return switch (p.stage()) { - case VALIDATION, AUTHENTICATION, POOL_ACQUIRE, DNS, CONNECT, - TLS_HANDSHAKE, PROXY_CONNECT -> ExecutionEvidence.NOT_SENT; - default -> ExecutionEvidence.SENT_NO_RESPONSE; - }; - } -} -``` - -`AttemptProgressTracker` forbids stage regression and records first-byte delivery exactly once. - -- [ ] **Step 4: Run evidence tests** - -```bash -./gradlew :modules:httpclient:httpclient-resilience:test \ - --tests '*ExecutionEvidenceClassifierTest' -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-resilience -git commit -m "feat: classify http execution evidence" -``` - ---- - -### Task 18: HTTP-specific Retry Eligibility Engine 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/RetryContext.java` -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/RetryDecision.java` -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/RetryAllowed.java` -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/RetryDenied.java` -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/AmbiguousFailure.java` -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/RetryEligibilityEngine.java` -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/DefaultRetryEligibilityEngine.java` -- Test: `modules/httpclient/httpclient-resilience/src/test/java/io/backend/skeleton/httpclient/resilience/RetryEligibilityEngineTest.java` - -**Interfaces:** -- Produces a pure deterministic decision without sleeping or issuing requests. -- Consumes idempotency, key presence, replayability, evidence, status, failure, deadline, attempt, and budget. - -- [ ] **Step 1: Write failing safety matrix tests** - -```java -class RetryEligibilityEngineTest { - private final RetryEligibilityEngine engine = new DefaultRetryEligibilityEngine(); - - @Test - void allowsGetAfterConnectFailure() { - assertThat(engine.decide(RetryContexts.getConnectFailure())) - .isInstanceOf(RetryAllowed.class); - } - - @Test - void marksPostWithoutKeyAmbiguousAfterSend() { - assertThat(engine.decide(RetryContexts.postSentNoResponseWithoutKey())) - .isInstanceOf(AmbiguousFailure.class); - } - - @Test - void deniesOneShotBodyEvenForPut() { - assertThat(engine.decide(RetryContexts.putOneShotNotSent())) - .isInstanceOf(RetryDenied.class); - } - - @Test - void honorsRetryAfterOnlyInsideDeadline() { - assertThat(engine.decide(RetryContexts.rateLimitedBeyondDeadline())) - .isInstanceOf(RetryDenied.class); - } -} -``` - -- [ ] **Step 2: Run tests and verify failure** - -```bash -./gradlew :modules:httpclient:httpclient-resilience:test \ - --tests '*RetryEligibilityEngineTest' -``` - -Expected: FAIL because retry decision types are absent. - -- [ ] **Step 3: Implement the complete ordered decision table** - -```java -public final class DefaultRetryEligibilityEngine implements RetryEligibilityEngine { - @Override - public RetryDecision decide(RetryContext c) { - if (c.attempt() >= c.maxAttempts()) return RetryDenied.maxAttempts(); - if (!c.budget().available()) return RetryDenied.budgetExhausted(); - if (!c.replayability().canReplay()) return RetryDenied.bodyNotReplayable(); - if (c.firstByteDelivered()) return RetryDenied.responseAlreadyDelivered(); - if (c.remainingDeadline().compareTo(c.minimumAttemptBudget()) <= 0) - return RetryDenied.deadline(); - if (c.evidence() == ExecutionEvidence.SENT_NO_RESPONSE - && !isSafelyIdempotent(c)) { - return AmbiguousFailure.remoteOutcomeUnknown(); - } - return statusOrFailureDecision(c); - } -} -``` - -Implement explicit branches for 408, 425, 429, 500, 502, 503, 504, 401-refresh-once, TLS permanent errors, pool/DNS/connect errors, response truncation, and `Retry-After`. - -- [ ] **Step 4: Run retry eligibility tests** - -```bash -./gradlew :modules:httpclient:httpclient-resilience:test \ - --tests '*RetryEligibilityEngineTest' -``` - -Expected: PASS; test parameterization covers all documented status and evidence combinations. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-resilience -git commit -m "feat: decide safe http retries" -``` - ---- - -### Task 19: Retry Coordinator, Backoff, Jitter, Retry Budget 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/RetryCoordinator.java` -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/BlockingRetryCoordinator.java` -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/BackoffStrategy.java` -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/ExponentialFullJitterBackoff.java` -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/RetryBudget.java` -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/TokenBucketRetryBudget.java` -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/Sleeper.java` -- Test: `modules/httpclient/httpclient-resilience/src/test/java/io/backend/skeleton/httpclient/resilience/BlockingRetryCoordinatorTest.java` -- Test: `modules/httpclient/httpclient-resilience/src/test/java/io/backend/skeleton/httpclient/resilience/RetryBudgetTest.java` - -**Interfaces:** -- Produces a blocking coordinator used by RestClient. -- Later reactive task implements the same semantic without blocking sleep. - -- [ ] **Step 1: Write failing attempt-count, backoff, and budget tests** - -```java -class BlockingRetryCoordinatorTest { - @Test - void retriesOnceThenReturnsSuccessWithoutHoldingAttemptResourcesDuringBackoff() { - FakeAttemptExecutor executor = FakeAttemptExecutor.failThenSucceed(); - RecordingSleeper sleeper = new RecordingSleeper(); - BlockingRetryCoordinator coordinator = Coordinators.blocking(executor, sleeper); - - HttpCallResult result = coordinator.execute(RetryFixtures.safeGet()); - - assertThat(result.attempts()).isEqualTo(2); - assertThat(sleeper.durations()).hasSize(1); - assertThat(executor.activeResourcesDuringSleep()).isZero(); - } -} - -class RetryBudgetTest { - @Test - void rejectsRetryWhenTokensAreExhausted() { - RetryBudget budget = new TokenBucketRetryBudget(1, Duration.ofMinutes(1), Clock.systemUTC()); - assertThat(budget.tryConsume()).isTrue(); - assertThat(budget.tryConsume()).isFalse(); - } -} -``` - -- [ ] **Step 2: Run tests and confirm failure** - -```bash -./gradlew :modules:httpclient:httpclient-resilience:test \ - --tests '*BlockingRetryCoordinatorTest' \ - --tests '*RetryBudgetTest' -``` - -Expected: FAIL because coordinator and budget are missing. - -- [ ] **Step 3: Implement coordinator around physical attempts** - -```java -public final class BlockingRetryCoordinator implements RetryCoordinator { - public HttpCallResult execute(BlockingLogicalCall call) { - for (int attempt = 1; ; attempt++) { - AttemptOutcome outcome = call.attempt(attempt); - RetryDecision decision = eligibility.decide(call.context(outcome, attempt)); - if (decision instanceof RetryAllowed allowed) { - if (!budget.tryConsume()) throw call.retryExhausted(attempt); - sleeper.sleep(backoff.delay(attempt, allowed.retryAfter(), call.deadline())); - continue; - } - if (decision instanceof AmbiguousFailure) throw call.ambiguous(outcome, attempt); - return call.finish(outcome, attempt); - } - } -} -``` - -Use an injectable `Sleeper` and `RandomGenerator` for deterministic tests. Never sleep past the effective deadline. - -- [ ] **Step 4: Run coordinator and budget tests** - -```bash -./gradlew :modules:httpclient:httpclient-resilience:test \ - --tests '*BlockingRetryCoordinatorTest' \ - --tests '*RetryBudgetTest' -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-resilience -git commit -m "feat: coordinate bounded http retries" -``` - ---- - -### Task 20: Circuit Breaker·Rate Limiter·Bulkhead 물리 시도 Pipeline 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/AttemptResiliencePipeline.java` -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/ResilienceRegistry.java` -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/LogicalAdmissionLimiter.java` -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/BlockingAttemptBulkhead.java` -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/AttemptRateLimiter.java` -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/AttemptCircuitBreaker.java` -- Test: `modules/httpclient/httpclient-resilience/src/test/java/io/backend/skeleton/httpclient/resilience/AttemptResiliencePipelineTest.java` - -**Interfaces:** -- Retry Coordinator invokes `AttemptResiliencePipeline.execute(attemptSupplier)` for every physical attempt. -- Pipeline order is Circuit → Rate Limiter → Bulkhead → HTTP call. - -- [ ] **Step 1: Write a failing decorator-order test** - -```java -class AttemptResiliencePipelineTest { - @Test - void appliesCircuitThenRateLimiterThenBulkheadPerAttempt() { - RecordingResilienceComponents components = new RecordingResilienceComponents(); - AttemptResiliencePipeline pipeline = components.pipeline(); - - assertThat(pipeline.execute(() -> "ok")).isEqualTo("ok"); - assertThat(components.events()).containsExactly( - "circuit-enter", "rate-enter", "bulkhead-enter", - "call", "bulkhead-exit", "rate-exit", "circuit-exit"); - } - - @Test - void openCircuitDoesNotConsumeRateOrBulkheadPermit() { - RecordingResilienceComponents components = RecordingResilienceComponents.openCircuit(); - assertThatThrownBy(() -> components.pipeline().execute(() -> "never")) - .isInstanceOf(HttpCircuitOpenException.class); - assertThat(components.events()).containsExactly("circuit-reject"); - } -} -``` - -- [ ] **Step 2: Run tests and confirm failure** - -```bash -./gradlew :modules:httpclient:httpclient-resilience:test \ - --tests '*AttemptResiliencePipelineTest' -``` - -Expected: FAIL because the physical attempt pipeline is missing. - -- [ ] **Step 3: Implement fixed decorator order using Resilience4j primitives** - -```java -public final class AttemptResiliencePipeline { - public T execute(CheckedSupplier call) { - if (!circuit.tryAcquirePermission()) throw circuitOpen(); - long started = System.nanoTime(); - try { - rateLimiter.acquirePermission(); - T result = bulkhead.execute(call); - circuit.onSuccess(System.nanoTime() - started, NANOSECONDS); - return result; - } catch (Throwable failure) { - circuit.onError(System.nanoTime() - started, NANOSECONDS, failure); - throw translate(failure); - } - } -} -``` - -Use adapter classes around Resilience4j rather than leaking its exception types. `LogicalAdmissionLimiter` runs once before creating the Retry Coordinator; attempt rate and bulkhead run for every physical attempt. - -- [ ] **Step 4: Run resilience pipeline tests** - -```bash -./gradlew :modules:httpclient:httpclient-resilience:test \ - --tests '*AttemptResiliencePipelineTest' -``` - -Expected: PASS; no rate or bulkhead permit is consumed when the circuit is open. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-resilience -git commit -m "feat: enforce http attempt resilience order" -``` - ---- - -### Task 21: Response 크기 제한, RFC 9457, 안정 오류 변환 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-restclient/src/main/java/io/backend/skeleton/httpclient/restclient/ResponseSizeLimiter.java` -- Create: `modules/httpclient/httpclient-restclient/src/main/java/io/backend/skeleton/httpclient/restclient/BlockingResponseMapper.java` -- Create: `modules/httpclient/httpclient-restclient/src/main/java/io/backend/skeleton/httpclient/restclient/RemoteProblemDecoder.java` -- Create: `modules/httpclient/httpclient-restclient/src/main/java/io/backend/skeleton/httpclient/restclient/StableBlockingExceptionMapper.java` -- Create: `modules/httpclient/httpclient-restclient/src/main/java/io/backend/skeleton/httpclient/restclient/BoundedErrorBody.java` -- Modify: `modules/httpclient/httpclient-restclient/src/main/java/io/backend/skeleton/httpclient/restclient/BlockingAttemptExecutor.java` -- Test: `modules/httpclient/httpclient-restclient/src/test/java/io/backend/skeleton/httpclient/restclient/BlockingResponseMapperTest.java` -- Test: `modules/httpclient/httpclient-restclient/src/test/java/io/backend/skeleton/httpclient/restclient/ResponseSizeLimiterTest.java` - -**Interfaces:** -- Maps all non-success responses and transport failures to `HttpClientException` subclasses. -- Preserves RFC 9457 fields under a byte and extension allowlist. - -- [ ] **Step 1: Write failing problem and oversized response tests** - -```java -class BlockingResponseMapperTest { - @Test - void mapsProblemJsonWithoutTrustingBodyStatus() { - RemoteProblem problem = new RemoteProblemDecoder(4096, Set.of("code")) - .decode(503, "application/problem+json", - """{"type":"urn:test","title":"busy","status":400,"detail":"later","code":"UPSTREAM_BUSY"}""" - .getBytes(UTF_8)); - assertThat(problem.httpStatus().value()).isEqualTo(503); - assertThat(problem.extensions()).containsEntry("code", "UPSTREAM_BUSY"); - } -} - -class ResponseSizeLimiterTest { - @Test - void abortsWhenDecodedBytesExceedLimit() { - ResponseSizeLimiter limiter = new ResponseSizeLimiter(10, 20); - assertThatThrownBy(() -> limiter.recordDecodedBytes(21)) - .isInstanceOf(HttpResponseTooLargeException.class); - } -} -``` - -- [ ] **Step 2: Run response mapping tests and verify failure** - -```bash -./gradlew :modules:httpclient:httpclient-restclient:test \ - --tests '*BlockingResponseMapperTest' \ - --tests '*ResponseSizeLimiterTest' -``` - -Expected: FAIL because response mapping components are absent. - -- [ ] **Step 3: Implement bounded response and stable exception mapping** - -```java -public final class RemoteProblemDecoder { - public RemoteProblem decode(int actualStatus, String contentType, byte[] body) { - if (!"application/problem+json".equalsIgnoreCase(contentType)) { - return RemoteProblem.empty(new HttpStatus(actualStatus)); - } - byte[] bounded = body.length <= maxBytes ? body : Arrays.copyOf(body, maxBytes); - ProblemPayload payload = objectMapper.readValue(bounded, ProblemPayload.class); - return new RemoteProblem( - optionalUri(payload.type()), payload.title(), new HttpStatus(actualStatus), - payload.detail(), payload.instance(), allowedExtensions(payload.extensions())); - } -} -``` - -`BlockingResponseMapper` counts wire and decoded bytes, closes the body on every branch, and creates `HttpRemoteErrorException` or `HttpProblemDetailException` with sanitized metadata. It never stores the raw error body in the exception. - -- [ ] **Step 4: Run response mapping tests** - -```bash -./gradlew :modules:httpclient:httpclient-restclient:test \ - --tests '*BlockingResponseMapperTest' \ - --tests '*ResponseSizeLimiterTest' -``` - -Expected: PASS; pool contract tests show zero leased connections after decode failure and size rejection. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-restclient -git commit -m "feat: map bounded remote http failures" -``` - ---- - -### Task 22: Static Credential과 OAuth2 Client 통합 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-auth/src/main/java/io/backend/skeleton/httpclient/auth/CredentialType.java` -- Create: `modules/httpclient/httpclient-auth/src/main/java/io/backend/skeleton/httpclient/auth/RequestCredentials.java` -- Create: `modules/httpclient/httpclient-auth/src/main/java/io/backend/skeleton/httpclient/auth/CredentialRequest.java` -- Create: `modules/httpclient/httpclient-auth/src/main/java/io/backend/skeleton/httpclient/auth/RequestCredentialProvider.java` -- Create: `modules/httpclient/httpclient-auth/src/main/java/io/backend/skeleton/httpclient/auth/NoAuthCredentialProvider.java` -- Create: `modules/httpclient/httpclient-auth/src/main/java/io/backend/skeleton/httpclient/auth/BasicCredentialProvider.java` -- Create: `modules/httpclient/httpclient-auth/src/main/java/io/backend/skeleton/httpclient/auth/ApiKeyHeaderCredentialProvider.java` -- Create: `modules/httpclient/httpclient-auth/src/main/java/io/backend/skeleton/httpclient/auth/StaticBearerCredentialProvider.java` -- Create: `modules/httpclient/httpclient-auth/src/main/java/io/backend/skeleton/httpclient/auth/OAuth2CredentialProvider.java` -- Create: `modules/httpclient/httpclient-auth/src/main/java/io/backend/skeleton/httpclient/auth/OAuth2TokenCacheKey.java` -- Create: `modules/httpclient/httpclient-auth/src/main/java/io/backend/skeleton/httpclient/auth/SingleFlightTokenLoader.java` -- Create: `modules/httpclient/httpclient-auth/src/main/java/io/backend/skeleton/httpclient/auth/UnauthorizedRetryPolicy.java` -- Test: `modules/httpclient/httpclient-auth/src/test/java/io/backend/skeleton/httpclient/auth/SingleFlightTokenLoaderTest.java` -- Test: `modules/httpclient/httpclient-auth/src/test/java/io/backend/skeleton/httpclient/auth/UnauthorizedRetryPolicyTest.java` - -**Interfaces:** -- Produces blocking credential materialization for RestClient. -- Reactive credential provider is added with the WebClient task. -- Token cache key includes registration, principal class, scopes, audience, tenant boundary, and mTLS identity. - -- [ ] **Step 1: Write failing concurrent refresh and 401 safety tests** - -```java -class SingleFlightTokenLoaderTest { - @Test - void concurrentRequestsShareOneTokenRefresh() throws Exception { - AtomicInteger loads = new AtomicInteger(); - SingleFlightTokenLoader loader = new SingleFlightTokenLoader(key -> { - loads.incrementAndGet(); - return AccessTokens.validFor(Duration.ofMinutes(5)); - }); - - ExecutorService pool = Executors.newFixedThreadPool(20); - List> futures = IntStream.range(0, 20) - .mapToObj(i -> pool.submit(() -> loader.load(TokenKeys.payment()))) - .toList(); - for (Future future : futures) future.get(); - - assertThat(loads).hasValue(1); - pool.shutdownNow(); - } -} - -class UnauthorizedRetryPolicyTest { - @Test - void denies401ReplayForOneShotPost() { - assertThat(new UnauthorizedRetryPolicy().mayRetry( - AuthRetryFixtures.oneShotPost401())).isFalse(); - } -} -``` - -- [ ] **Step 2: Run auth tests and confirm failure** - -```bash -./gradlew :modules:httpclient:httpclient-auth:test \ - --tests '*SingleFlightTokenLoaderTest' \ - --tests '*UnauthorizedRetryPolicyTest' -``` - -Expected: FAIL because credential providers and token loader are missing. - -- [ ] **Step 3: Implement provider registry and Spring Security OAuth2 delegation** - -```java -public final class SingleFlightTokenLoader { - private final ConcurrentMap> inFlight = - new ConcurrentHashMap<>(); - - public AccessToken load(OAuth2TokenCacheKey key) { - CompletableFuture future = inFlight.computeIfAbsent(key, - ignored -> CompletableFuture.supplyAsync(() -> delegate.load(key))); - try { - return future.join(); - } finally { - if (future.isDone()) inFlight.remove(key, future); - } - } -} -``` - -`OAuth2CredentialProvider` calls `OAuth2AuthorizedClientManager`, applies expiry skew, and returns only an immutable Authorization header. Token endpoint calls use a separate Named Client Profile. `UnauthorizedRetryPolicy` allows at most one refresh-and-replay for a replayable safe or explicitly contract-idempotent operation. - -- [ ] **Step 4: Run authentication tests** - -```bash -./gradlew :modules:httpclient:httpclient-auth:test -``` - -Expected: PASS; test logs contain no access token, client secret, or authorization code. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-auth -git commit -m "feat: add bounded http client authentication" -``` - ---- - -### Task 23: TLS·mTLS Policy와 Certificate Runtime Rotation 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-security/src/main/java/io/backend/skeleton/httpclient/security/TlsProfileId.java` -- Create: `modules/httpclient/httpclient-security/src/main/java/io/backend/skeleton/httpclient/security/TlsProfile.java` -- Create: `modules/httpclient/httpclient-security/src/main/java/io/backend/skeleton/httpclient/security/TlsPolicyValidator.java` -- Create: `modules/httpclient/httpclient-security/src/main/java/io/backend/skeleton/httpclient/security/TlsMaterialProvider.java` -- Create: `modules/httpclient/httpclient-security/src/main/java/io/backend/skeleton/httpclient/security/ClientCertificateIdentity.java` -- Create: `modules/httpclient/httpclient-security/src/main/java/io/backend/skeleton/httpclient/security/TlsRuntimeRotationCoordinator.java` -- Create: `modules/httpclient/httpclient-security/src/main/java/io/backend/skeleton/httpclient/security/SslContextMaterial.java` -- Modify: `modules/httpclient/httpclient-transport-apache/src/main/java/io/backend/skeleton/httpclient/apache/ApacheClientFactory.java` -- Modify: `modules/httpclient/httpclient-transport-jdk/src/main/java/io/backend/skeleton/httpclient/jdk/JdkClientFactory.java` -- Test: `modules/httpclient/httpclient-security/src/test/java/io/backend/skeleton/httpclient/security/TlsPolicyValidatorTest.java` -- Test: `modules/httpclient/httpclient-security/src/test/java/io/backend/skeleton/httpclient/security/TlsRuntimeRotationCoordinatorTest.java` - -**Interfaces:** -- Produces verified SSL material for Apache, JDK, Reactor, and Jetty providers. -- Rotation builds a new `ClientRuntime` generation and drains the old generation. - -- [ ] **Step 1: Write failing unsafe TLS and rotation tests** - -```java -class TlsPolicyValidatorTest { - @Test - void rejectsTrustAllAndHostnameVerificationDisablement() { - TlsProfile unsafe = TlsProfiles.trustAllWithoutHostnameVerification(); - assertThat(new TlsPolicyValidator().validate(unsafe)) - .extracting(TlsViolation::code) - .contains("TRUST_ALL_FORBIDDEN", "HOSTNAME_VERIFICATION_REQUIRED"); - } -} - -class TlsRuntimeRotationCoordinatorTest { - @Test - void swapsRuntimeWhenCertificateIdentityChanges() { - ClientRuntimeRegistry registry = RuntimeFixtures.registryWithCertificate("cert-v1"); - TlsRuntimeRotationCoordinator coordinator = RotationFixtures.coordinator(registry); - coordinator.rotate(new ClientCertificateIdentity("cert-v2")); - try (ClientRuntimeLease lease = registry.acquire(new ClientProfileName("partner"))) { - assertThat(lease.runtime().generation().value()).isEqualTo(2); - } - } -} -``` - -- [ ] **Step 2: Run TLS tests and confirm failure** - -```bash -./gradlew :modules:httpclient:httpclient-security:test \ - --tests '*TlsPolicyValidatorTest' \ - --tests '*TlsRuntimeRotationCoordinatorTest' -``` - -Expected: FAIL because TLS profile and rotation components are missing. - -- [ ] **Step 3: Implement strict TLS profiles and generation swap** - -```java -public record TlsProfile( - TlsProfileId id, - Set protocols, - boolean hostnameVerification, - TrustMaterialRef trustMaterial, - Optional clientKeyMaterial, - boolean allowPlainHttp) { -} -``` - -`TlsPolicyValidator` permits only TLS 1.2 and 1.3 in production, requires hostname verification, and has no representation for trust-all. `TlsRuntimeRotationCoordinator` loads new material, builds and validates a replacement runtime, swaps it atomically, then drains the old pool. - -- [ ] **Step 4: Run TLS security and transport integration tests** - -```bash -./gradlew :modules:httpclient:httpclient-security:test \ - :modules:httpclient:httpclient-transport-apache:test \ - :modules:httpclient:httpclient-transport-jdk:test -``` - -Expected: PASS; a hostname mismatch fails without a second network attempt. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-security \ - modules/httpclient/httpclient-transport-apache \ - modules/httpclient/httpclient-transport-jdk -git commit -m "feat: enforce tls and mtls runtime policy" -``` - ---- - -### Task 24: Redirect 실행과 Credential stripping 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-security/src/main/java/io/backend/skeleton/httpclient/security/RedirectDecision.java` -- Create: `modules/httpclient/httpclient-security/src/main/java/io/backend/skeleton/httpclient/security/RedirectEvaluator.java` -- Create: `modules/httpclient/httpclient-security/src/main/java/io/backend/skeleton/httpclient/security/SensitiveHeaderStripper.java` -- Create: `modules/httpclient/httpclient-restclient/src/main/java/io/backend/skeleton/httpclient/restclient/BlockingRedirectCoordinator.java` -- Modify: `modules/httpclient/httpclient-restclient/src/main/java/io/backend/skeleton/httpclient/restclient/BlockingAttemptExecutor.java` -- Test: `modules/httpclient/httpclient-security/src/test/java/io/backend/skeleton/httpclient/security/RedirectEvaluatorTest.java` -- Test: `modules/httpclient/httpclient-restclient/src/test/java/io/backend/skeleton/httpclient/restclient/BlockingRedirectCoordinatorTest.java` - -**Interfaces:** -- Engine automatic redirect remains disabled. -- Platform coordinator evaluates every hop and rebuilds request headers explicitly. - -- [ ] **Step 1: Write failing method-preservation and header-leak tests** - -```java -class RedirectEvaluatorTest { - @Test - void rejects307WhenBodyIsOneShot() { - RedirectContext context = RedirectFixtures.oneShotPost307(); - assertThat(new RedirectEvaluator().evaluate(context)) - .isInstanceOf(RedirectDecision.Reject.class); - } - - @Test - void stripsCredentialsOnCrossOriginRedirect() { - Map> result = SensitiveHeaderStripper.standard() - .stripForCrossOrigin(Map.of( - "Authorization", List.of("Bearer secret"), - "Cookie", List.of("sid=x"), - "Accept", List.of("application/json"))); - assertThat(result).containsOnlyKeys("Accept"); - } -} -``` - -- [ ] **Step 2: Run redirect tests and confirm failure** - -```bash -./gradlew :modules:httpclient:httpclient-security:test \ - --tests '*RedirectEvaluatorTest' \ - :modules:httpclient:httpclient-restclient:test \ - --tests '*BlockingRedirectCoordinatorTest' -``` - -Expected: FAIL because redirect components are absent. - -- [ ] **Step 3: Implement bounded hop evaluation** - -```java -public final class RedirectEvaluator { - public RedirectDecision evaluate(RedirectContext c) { - if (!c.policy().enabled()) return RedirectDecision.reject("REDIRECT_DISABLED"); - if (c.hop() >= c.policy().maxHops()) return RedirectDecision.reject("MAX_HOPS"); - if ((c.status() == 307 || c.status() == 308) && !c.body().replayability().canReplay()) - return RedirectDecision.reject("BODY_NOT_REPLAYABLE"); - if (c.crossOrigin() && !c.policy().allowCrossOrigin()) - return RedirectDecision.reject("CROSS_ORIGIN_FORBIDDEN"); - return RedirectDecision.follow(c.target(), c.crossOrigin()); - } -} -``` - -`BlockingRedirectCoordinator` counts every redirect request as a physical attempt for rate and bulkhead purposes but not as a Retry caused by failure. It re-applies target security before each hop. - -- [ ] **Step 4: Run redirect contract tests** - -```bash -./gradlew :modules:httpclient:httpclient-security:test \ - :modules:httpclient:httpclient-restclient:test \ - --tests '*Redirect*Test' -``` - -Expected: PASS; cross-origin recorded requests contain no Authorization, Cookie, or API key header. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-security \ - modules/httpclient/httpclient-restclient -git commit -m "feat: control outbound http redirects" -``` - ---- - -### Task 25: H3 Dynamic Target SSRF 방어와 DNS/IP Pinning 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-dynamic-target/src/main/java/io/backend/skeleton/httpclient/dynamic/DynamicTargetGateway.java` -- Create: `modules/httpclient/httpclient-dynamic-target/src/main/java/io/backend/skeleton/httpclient/dynamic/DynamicTargetPolicyName.java` -- Create: `modules/httpclient/httpclient-dynamic-target/src/main/java/io/backend/skeleton/httpclient/dynamic/DynamicTargetPolicy.java` -- Create: `modules/httpclient/httpclient-dynamic-target/src/main/java/io/backend/skeleton/httpclient/dynamic/CanonicalTarget.java` -- Create: `modules/httpclient/httpclient-dynamic-target/src/main/java/io/backend/skeleton/httpclient/dynamic/TargetCanonicalizer.java` -- Create: `modules/httpclient/httpclient-dynamic-target/src/main/java/io/backend/skeleton/httpclient/dynamic/IpAddressClassifier.java` -- Create: `modules/httpclient/httpclient-dynamic-target/src/main/java/io/backend/skeleton/httpclient/dynamic/ValidatedDnsResolver.java` -- Create: `modules/httpclient/httpclient-dynamic-target/src/main/java/io/backend/skeleton/httpclient/dynamic/PinnedTarget.java` -- Create: `modules/httpclient/httpclient-dynamic-target/src/main/java/io/backend/skeleton/httpclient/dynamic/DefaultDynamicTargetGateway.java` -- Create: `modules/httpclient/httpclient-dynamic-target/src/main/java/io/backend/skeleton/httpclient/dynamic/DynamicCredentialBinding.java` -- Test: `modules/httpclient/httpclient-dynamic-target/src/test/java/io/backend/skeleton/httpclient/dynamic/TargetCanonicalizerTest.java` -- Test: `modules/httpclient/httpclient-dynamic-target/src/test/java/io/backend/skeleton/httpclient/dynamic/DynamicTargetSecurityTest.java` - -**Interfaces:** -- Supports Apache first; Reactor integration is added after its transport task. -- JDK and Jetty are rejected for H3 Stable until validated pinning capability exists. - -- [ ] **Step 1: Write failing SSRF matrix tests** - -```java -class DynamicTargetSecurityTest { - @ParameterizedTest - @ValueSource(strings = { - "http://127.0.0.1/a", - "https://[::1]/a", - "https://169.254.169.254/latest/meta-data", - "file:///etc/passwd", - "https://user:pass@example.com/a" - }) - void rejectsForbiddenTargets(String raw) { - DynamicTargetPolicy policy = DynamicPolicies.publicHttpsOnly(); - assertThatThrownBy(() -> DynamicTargets.prepare(policy, URI.create(raw))) - .isInstanceOf(HttpTargetRejectedException.class); - } - - @Test - void rejectsDnsAnswerWhenAnyAddressIsPrivate() { - ValidatedDnsResolver resolver = DnsFixtures.resolvesTo( - "mixed.test", "203.0.113.10", "10.0.0.4"); - assertThatThrownBy(() -> resolver.resolve("mixed.test")) - .isInstanceOf(HttpTargetRejectedException.class); - } -} -``` - -- [ ] **Step 2: Run Dynamic Target tests and confirm failure** - -```bash -./gradlew :modules:httpclient:httpclient-dynamic-target:test \ - --tests '*TargetCanonicalizerTest' \ - --tests '*DynamicTargetSecurityTest' -``` - -Expected: FAIL because canonicalization and IP policy are missing. - -- [ ] **Step 3: Implement canonicalization, all-answer validation, and pinning** - -```java -public final class TargetCanonicalizer { - public CanonicalTarget canonicalize(DynamicTargetPolicy policy, URI input) { - if (input.getUserInfo() != null) reject("USERINFO_FORBIDDEN"); - String scheme = input.getScheme().toLowerCase(Locale.ROOT); - if (!policy.allowedSchemes().contains(scheme)) reject("SCHEME_FORBIDDEN"); - String host = IDN.toASCII(stripTrailingDot(input.getHost()), IDN.USE_STD3_ASCII_RULES) - .toLowerCase(Locale.ROOT); - int port = effectivePort(input); - if (!policy.allowedPorts().contains(port)) reject("PORT_FORBIDDEN"); - return new CanonicalTarget(scheme, host, port, normalizedPath(input), input.getRawQuery()); - } -} -``` - -`ValidatedDnsResolver` validates every A and AAAA answer, normalizes IPv4-mapped IPv6, and returns a `PinnedTarget` containing the canonical host and exact approved addresses. Apache uses this resolver for the actual connection. Redirects restart the full validation flow. - -- [ ] **Step 4: Run the Dynamic Target security suite** - -```bash -./gradlew :modules:httpclient:httpclient-dynamic-target:test -``` - -Expected: PASS for loopback, link-local, private, ULA, metadata, IDNA, mapped IPv6, mixed DNS answer, and redirect fixtures. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-dynamic-target -git commit -m "feat: secure dynamic outbound http targets" -``` - ---- - -### Task 26: Reactor Netty Reactive Transport 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-transport-reactor-netty/src/main/java/io/backend/skeleton/httpclient/reactor/ReactorNettyTransportProvider.java` -- Create: `modules/httpclient/httpclient-transport-reactor-netty/src/main/java/io/backend/skeleton/httpclient/reactor/ReactorConnectionProviderFactory.java` -- Create: `modules/httpclient/httpclient-transport-reactor-netty/src/main/java/io/backend/skeleton/httpclient/reactor/ReactorHttpClientFactory.java` -- Create: `modules/httpclient/httpclient-transport-reactor-netty/src/main/java/io/backend/skeleton/httpclient/reactor/ReactorFailureClassifier.java` -- Create: `modules/httpclient/httpclient-transport-reactor-netty/src/main/java/io/backend/skeleton/httpclient/reactor/ReactorPoolMetricsBinder.java` -- Create: `modules/httpclient/httpclient-transport-reactor-netty/src/main/java/io/backend/skeleton/httpclient/reactor/ValidatedAddressResolverGroup.java` -- Test: `modules/httpclient/httpclient-transport-reactor-netty/src/test/java/io/backend/skeleton/httpclient/reactor/ReactorNettyTransportProviderTest.java` -- Test: `modules/httpclient/httpclient-transport-reactor-netty/src/test/java/io/backend/skeleton/httpclient/reactor/ReactorCancellationTest.java` - -**Interfaces:** -- Implements `ReactiveTransportProvider` with ID `reactor-netty`. -- Supports profile-scoped pool, pending acquire, DNS pinning, proxy, TLS, HTTP/1.1·2, cancellation. - -- [ ] **Step 1: Write failing reactive request and cancellation tests** - -```java -class ReactorCancellationTest { - @Test - void cancellationReleasesConnection() { - ReactorTransportFixture fixture = ReactorTransportFixture.slowBody(); - StepVerifier.create(fixture.webClient().get().uri(fixture.uri()).retrieve() - .bodyToFlux(DataBuffer.class).take(1)) - .expectNextCount(1) - .verifyComplete(); - await().atMost(Duration.ofSeconds(2)) - .untilAsserted(() -> assertThat(fixture.leasedConnections()).isZero()); - } -} -``` - -- [ ] **Step 2: Run Reactor transport tests and confirm failure** - -```bash -./gradlew :modules:httpclient:httpclient-transport-reactor-netty:test -``` - -Expected: FAIL because the provider and pool factory are missing. - -- [ ] **Step 3: Implement profile-scoped Reactor Netty runtime** - -```java -public final class ReactorConnectionProviderFactory { - public ConnectionProvider create(ClientProfile profile) { - return ConnectionProvider.builder(profile.name().value()) - .maxConnections(profile.pool().maxTotalConnections()) - .pendingAcquireMaxCount(profile.pool().maxPendingAcquires()) - .pendingAcquireTimeout(profile.pool().pendingAcquireTimeout()) - .maxIdleTime(profile.pool().maxIdleTime()) - .maxLifeTime(profile.pool().maxLifeTime()) - .evictInBackground(profile.pool().evictionInterval()) - .metrics(true) - .build(); - } -} -``` - -Configure connect, response, TLS handshake, proxy, DNS resolver, protocol, and wire/decoded byte hooks. `doOnDiscard(DataBuffer.class, DataBufferUtils::release)` is registered in the WebClient integration rather than the transport provider. - -- [ ] **Step 4: Run Reactor transport and cancellation tests** - -```bash -./gradlew :modules:httpclient:httpclient-transport-reactor-netty:test -``` - -Expected: PASS; cancellation, timeout, and decode error return the pool to zero leased connections. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-transport-reactor-netty -git commit -m "feat: add reactor netty http transport" -``` - ---- - -### Task 27: WebClient Reactive Gateway와 Non-blocking Retry Coordinator 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-webclient/src/main/java/io/backend/skeleton/httpclient/webclient/ReactiveHttpGateway.java` -- Create: `modules/httpclient/httpclient-webclient/src/main/java/io/backend/skeleton/httpclient/webclient/DefaultReactiveHttpGateway.java` -- Create: `modules/httpclient/httpclient-webclient/src/main/java/io/backend/skeleton/httpclient/webclient/WebClientRuntimeFactory.java` -- Create: `modules/httpclient/httpclient-webclient/src/main/java/io/backend/skeleton/httpclient/webclient/ReactiveAttemptExecutor.java` -- Create: `modules/httpclient/httpclient-webclient/src/main/java/io/backend/skeleton/httpclient/webclient/WebClientBodyWriter.java` -- Create: `modules/httpclient/httpclient-webclient/src/main/java/io/backend/skeleton/httpclient/webclient/WebClientResponseMapper.java` -- Create: `modules/httpclient/httpclient-webclient/src/main/java/io/backend/skeleton/httpclient/webclient/ReactiveBodySource.java` -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/ReactiveRetryCoordinator.java` -- Create: `modules/httpclient/httpclient-auth/src/main/java/io/backend/skeleton/httpclient/auth/ReactiveRequestCredentialProvider.java` -- Test: `modules/httpclient/httpclient-webclient/src/test/java/io/backend/skeleton/httpclient/webclient/DefaultReactiveHttpGatewayTest.java` -- Test: `modules/httpclient/httpclient-resilience/src/test/java/io/backend/skeleton/httpclient/resilience/ReactiveRetryCoordinatorTest.java` - -**Interfaces:** -- Produces `Mono> exchange(...)`. -- Uses Reactor delay for backoff and never calls `Thread.sleep()` or `.block()`. - -- [ ] **Step 1: Write failing reactive retry and context tests** - -```java -class DefaultReactiveHttpGatewayTest { - @Test - void returnsTypedResultWithoutBlocking() { - try (MockHttpServer server = MockHttpServer.start()) { - server.enqueueJson(200, "{\"id\":9}"); - ReactiveHttpGateway gateway = TestGateways.reactor(server.uri("/")); - Mono> result = gateway.exchange( - new ClientProfileName("users"), - HttpOperation.get(new OperationName("get-user"), "/users/9", Map.of()), - ResponseType.of(UserResponse.class)); - - StepVerifier.create(result) - .assertNext(value -> assertThat(value.body().id()).isEqualTo(9)) - .verifyComplete(); - } - } -} - -class ReactiveRetryCoordinatorTest { - @Test - void backoffDoesNotBlockCallingThread() { - VirtualTimeScheduler.getOrSet(); - Mono> call = ReactiveRetryFixtures.failThenSucceed(); - StepVerifier.withVirtualTime(() -> call) - .thenAwait(Duration.ofMillis(100)) - .assertNext(result -> assertThat(result.attempts()).isEqualTo(2)) - .verifyComplete(); - } -} -``` - -- [ ] **Step 2: Run reactive gateway tests and confirm failure** - -```bash -./gradlew :modules:httpclient:httpclient-webclient:test \ - :modules:httpclient:httpclient-resilience:test \ - --tests '*ReactiveRetryCoordinatorTest' -``` - -Expected: FAIL because reactive gateway and coordinator are missing. - -- [ ] **Step 3: Implement Reactor-context-aware non-blocking pipeline** - -```java -public final class ReactiveRetryCoordinator { - public Mono> execute(ReactiveLogicalCall call) { - return attempt(call, 1); - } - - private Mono> attempt(ReactiveLogicalCall call, int number) { - return call.attempt(number).flatMap(outcome -> { - RetryDecision decision = eligibility.decide(call.context(outcome, number)); - if (decision instanceof RetryAllowed allowed) { - if (!budget.tryConsume()) return Mono.error(call.retryExhausted(number)); - return Mono.delay(backoff.delay(number, allowed.retryAfter(), call.deadline())) - .then(attempt(call, number + 1)); - } - if (decision instanceof AmbiguousFailure) return Mono.error(call.ambiguous(outcome, number)); - return call.finish(outcome, number); - }); - } -} -``` - -`DefaultReactiveHttpGateway` acquires and releases runtime leases with `Mono.usingWhen`, applies Reactor Context operation metadata, and registers buffer discard hooks. - -- [ ] **Step 4: Run reactive tests with BlockHound enabled** - -```bash -./gradlew :modules:httpclient:httpclient-webclient:test \ - :modules:httpclient:httpclient-resilience:test \ - -Pblockhound.enabled=true -``` - -Expected: PASS with no blocking call detected on Reactor event-loop threads. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-webclient \ - modules/httpclient/httpclient-resilience \ - modules/httpclient/httpclient-auth -git commit -m "feat: add reactive http gateway and retries" -``` - ---- - -### Task 28: H1 Reactive Typed Service Client Registry 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-service-client/src/main/java/io/backend/skeleton/httpclient/service/ReactiveHttpServiceRegistry.java` -- Create: `modules/httpclient/httpclient-service-client/src/main/java/io/backend/skeleton/httpclient/service/DefaultReactiveHttpServiceRegistry.java` -- Create: `modules/httpclient/httpclient-service-client/src/main/java/io/backend/skeleton/httpclient/service/ReactiveServiceInvocationHandler.java` -- Create: `modules/httpclient/httpclient-service-client/src/main/java/io/backend/skeleton/httpclient/service/ReactiveOperationContext.java` -- Modify: `modules/httpclient/httpclient-service-client/src/main/java/io/backend/skeleton/httpclient/service/ServiceOperationDescriptorScanner.java` -- Test: `modules/httpclient/httpclient-service-client/src/test/java/io/backend/skeleton/httpclient/service/ReactiveHttpServiceRegistryTest.java` -- Test: `modules/httpclient/httpclient-service-client/src/test/java/io/backend/skeleton/httpclient/service/BlockingReactiveSignatureSeparationTest.java` - -**Interfaces:** -- Produces typed proxies returning `Mono`, `Flux`, and SSE types. -- A service interface is classified as blocking or reactive at startup; mixed ambiguous signatures are rejected. - -- [ ] **Step 1: Write failing reactive proxy and mixed-signature tests** - -```java -@HttpClientProfile("events") -@HttpExchange("/events") -interface ReactiveEventsClient { - @GetExchange("/{id}") - @HttpOperationPolicy(name = "get-event", - idempotency = OperationIdempotency.STANDARD_IDEMPOTENT) - Mono get(@PathVariable String id); -} - -class ReactiveHttpServiceRegistryTest { - @Test - void propagatesOperationDescriptorThroughReactorContext() { - ReactiveHttpServiceRegistry registry = ReactiveServiceFixtures.registry(); - StepVerifier.create(registry.client( - new ClientProfileName("events"), ReactiveEventsClient.class).get("e1")) - .expectNextMatches(event -> event.id().equals("e1")) - .verifyComplete(); - assertThat(ReactiveServiceFixtures.lastOperationName()).isEqualTo("get-event"); - } -} -``` - -- [ ] **Step 2: Run reactive service client tests and verify failure** - -```bash -./gradlew :modules:httpclient:httpclient-service-client:test \ - --tests '*ReactiveHttpServiceRegistryTest' \ - --tests '*BlockingReactiveSignatureSeparationTest' -``` - -Expected: FAIL because reactive registry and handler are missing. - -- [ ] **Step 3: Implement Reactor Context wrapper proxy** - -```java -public final class ReactiveServiceInvocationHandler implements InvocationHandler { - @Override - public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { - ServiceOperationDescriptor descriptor = descriptors.require(method); - Object result = method.invoke(delegate, args); - if (result instanceof Mono mono) { - return mono.contextWrite(ctx -> ctx.put(ReactiveOperationContext.KEY, descriptor)); - } - if (result instanceof Flux flux) { - return flux.contextWrite(ctx -> ctx.put(ReactiveOperationContext.KEY, descriptor)); - } - throw new HttpConfigurationException("reactive service method must return Mono or Flux", metadata); - } -} -``` - -Reject a single interface that combines synchronous values with `Mono`/`Flux`, and reject `.block()` adapters in the generated registry. - -- [ ] **Step 4: Run service client tests with context-loss tracking** - -```bash -./gradlew :modules:httpclient:httpclient-service-client:test \ - -Dreactor.trace.operatorStacktrace=true -``` - -Expected: PASS; operation descriptor is visible at subscription time and absent from unrelated subscriptions. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-service-client -git commit -m "feat: add typed reactive http service clients" -``` - ---- - -### Task 29: Streaming Upload·Download Lifecycle과 First-byte Boundary 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-restclient/src/main/java/io/backend/skeleton/httpclient/restclient/BlockingStreamingGateway.java` -- Create: `modules/httpclient/httpclient-restclient/src/main/java/io/backend/skeleton/httpclient/restclient/DefaultBlockingStreamingResponse.java` -- Create: `modules/httpclient/httpclient-restclient/src/main/java/io/backend/skeleton/httpclient/restclient/CountingBoundedInputStream.java` -- Create: `modules/httpclient/httpclient-webclient/src/main/java/io/backend/skeleton/httpclient/webclient/ReactiveStreamingGateway.java` -- Create: `modules/httpclient/httpclient-webclient/src/main/java/io/backend/skeleton/httpclient/webclient/FirstByteDeliveryGuard.java` -- Create: `modules/httpclient/httpclient-webclient/src/main/java/io/backend/skeleton/httpclient/webclient/BoundedDataBufferFlux.java` -- Create: `modules/httpclient/httpclient-webclient/src/main/java/io/backend/skeleton/httpclient/webclient/MultipartReplayability.java` -- Test: `modules/httpclient/httpclient-restclient/src/test/java/io/backend/skeleton/httpclient/restclient/BlockingStreamingLifecycleTest.java` -- Test: `modules/httpclient/httpclient-webclient/src/test/java/io/backend/skeleton/httpclient/webclient/ReactiveStreamingLifecycleTest.java` -- Test: `modules/httpclient/httpclient-webclient/src/test/java/io/backend/skeleton/httpclient/webclient/FirstByteRetryBoundaryTest.java` - -**Interfaces:** -- Blocking response implements `AutoCloseable` and owns the response body lifecycle. -- Reactive response emits bounded `DataBuffer` values and disables Retry after first `onNext`. - -- [ ] **Step 1: Write failing close, cancel, and first-byte tests** - -```java -class BlockingStreamingLifecycleTest { - @Test - void closeReturnsConnectionAfterPartialRead() throws Exception { - StreamingFixture fixture = StreamingFixture.apacheLargeBody(); - try (BlockingStreamingResponse response = fixture.gateway().download(fixture.operation())) { - assertThat(response.body().readNBytes(16)).hasSize(16); - } - await().atMost(Duration.ofSeconds(2)) - .untilAsserted(() -> assertThat(fixture.leasedConnections()).isZero()); - } -} - -class FirstByteRetryBoundaryTest { - @Test - void doesNotRetryAfterFirstBufferWasDelivered() { - ReactiveStreamingFixture fixture = ReactiveStreamingFixture.emitThenReset(); - StepVerifier.create(fixture.gateway().download(fixture.operation())) - .expectNextCount(1) - .expectError(HttpResponseTruncatedException.class) - .verify(); - assertThat(fixture.physicalRequestCount()).isEqualTo(1); - } -} -``` - -- [ ] **Step 2: Run streaming lifecycle tests and confirm failure** - -```bash -./gradlew :modules:httpclient:httpclient-restclient:test \ - --tests '*BlockingStreamingLifecycleTest' \ - :modules:httpclient:httpclient-webclient:test \ - --tests '*ReactiveStreamingLifecycleTest' \ - --tests '*FirstByteRetryBoundaryTest' -``` - -Expected: FAIL because streaming gateways and guards are missing. - -- [ ] **Step 3: Implement bounded lifecycle wrappers** - -```java -public final class DefaultBlockingStreamingResponse - implements BlockingStreamingResponse { - private final InputStream body; - private final Runnable closeAction; - private final AtomicBoolean closed = new AtomicBoolean(); - - @Override - public void close() { - if (closed.compareAndSet(false, true)) { - try { body.close(); } catch (IOException ignored) { } - closeAction.run(); - } - } -} -``` - -`CountingBoundedInputStream` throws `HttpResponseTooLargeException` when actual bytes exceed the profile limit and closes the underlying response. `FirstByteDeliveryGuard` atomically marks `firstByteDelivered` before forwarding the first buffer. `BoundedDataBufferFlux` releases the current and discarded buffers on error or cancellation. - -- [ ] **Step 4: Run streaming tests with leak detection** - -```bash -./gradlew :modules:httpclient:httpclient-restclient:test \ - :modules:httpclient:httpclient-webclient:test \ - -Dio.netty.leakDetection.level=paranoid -``` - -Expected: PASS with zero leaked connection and zero Netty leak report. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-restclient \ - modules/httpclient/httpclient-webclient -git commit -m "feat: enforce http streaming lifecycle" -``` - ---- - -### Task 30: SSE 연결·Idle Timeout·재연결 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-webclient/src/main/java/io/backend/skeleton/httpclient/webclient/ReactiveSseGateway.java` -- Create: `modules/httpclient/httpclient-webclient/src/main/java/io/backend/skeleton/httpclient/webclient/DefaultReactiveSseGateway.java` -- Create: `modules/httpclient/httpclient-webclient/src/main/java/io/backend/skeleton/httpclient/webclient/SseOperation.java` -- Create: `modules/httpclient/httpclient-webclient/src/main/java/io/backend/skeleton/httpclient/webclient/SseReconnectPolicy.java` -- Create: `modules/httpclient/httpclient-webclient/src/main/java/io/backend/skeleton/httpclient/webclient/SseIdleTimeoutException.java` -- Test: `modules/httpclient/httpclient-webclient/src/test/java/io/backend/skeleton/httpclient/webclient/ReactiveSseGatewayTest.java` - -**Interfaces:** -- Produces `Flux> connect(...)`. -- Setup deadline, streaming idle timeout, max stream duration, and `Last-Event-ID` policy are separate. - -- [ ] **Step 1: Write failing event decode, idle, and reconnect tests** - -```java -class ReactiveSseGatewayTest { - @Test - void reconnectsWithLastEventIdWhenPolicyAllowsIt() { - SseFixture fixture = SseFixture.disconnectAfterEvent("event-1"); - StepVerifier.create(fixture.gateway().connect( - fixture.profile(), fixture.operationWithReconnect(), - ResponseType.of(EventPayload.class)).take(2)) - .expectNextMatches(event -> event.id().equals("event-1")) - .expectNextMatches(event -> event.id().equals("event-2")) - .verifyComplete(); - assertThat(fixture.secondRequestHeader("Last-Event-ID")) - .contains("event-1"); - } - - @Test - void closesSilentStreamAtStreamingIdleTimeout() { - SseFixture fixture = SseFixture.neverEmits(); - StepVerifier.withVirtualTime(() -> fixture.gateway().connect( - fixture.profile(), fixture.shortIdleOperation(), - ResponseType.of(EventPayload.class))) - .thenAwait(Duration.ofSeconds(5)) - .expectError(SseIdleTimeoutException.class) - .verify(); - } -} -``` - -- [ ] **Step 2: Run SSE tests and confirm failure** - -```bash -./gradlew :modules:httpclient:httpclient-webclient:test \ - --tests '*ReactiveSseGatewayTest' -``` - -Expected: FAIL because SSE contracts are missing. - -- [ ] **Step 3: Implement setup and stream-phase policies** - -```java -public final class DefaultReactiveSseGateway implements ReactiveSseGateway { - @Override - public Flux> connect(ClientProfileName profile, - SseOperation operation, - ResponseType eventType) { - return open(profile, operation, eventType, Optional.empty()) - .timeout(operation.streamingIdleTimeout(), - Flux.error(new SseIdleTimeoutException(operation.operationName()))) - .retryWhen(reconnectSpec(operation)); - } -} -``` - -`reconnectSpec` uses Retry Budget and only sets `Last-Event-ID` when the operation explicitly opts in. Application cancellation stops reconnect and closes the active connection. - -- [ ] **Step 4: Run SSE and cancellation tests** - -```bash -./gradlew :modules:httpclient:httpclient-webclient:test \ - --tests '*ReactiveSseGatewayTest' \ - -Dio.netty.leakDetection.level=paranoid -``` - -Expected: PASS; a cancelled subscription produces no later reconnect request. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-webclient -git commit -m "feat: add bounded reactive sse clients" -``` - ---- - -### Task 31: Proxy 지원과 HTTP/2 Protocol Evidence 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/ProxySettings.java` -- Create: `modules/httpclient/httpclient-auth/src/main/java/io/backend/skeleton/httpclient/auth/ProxyCredentialProvider.java` -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/Http2ProtocolEvidence.java` -- Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/Http2EvidenceMapper.java` -- Modify: `modules/httpclient/httpclient-transport-apache/src/main/java/io/backend/skeleton/httpclient/apache/ApacheClientFactory.java` -- Modify: `modules/httpclient/httpclient-transport-reactor-netty/src/main/java/io/backend/skeleton/httpclient/reactor/ReactorHttpClientFactory.java` -- Create: `modules/httpclient/httpclient-testkit/src/main/java/io/backend/skeleton/httpclient/testkit/Http2FailureFixture.java` -- Test: `modules/httpclient/httpclient-testkit/src/test/java/io/backend/skeleton/httpclient/testkit/ForwardProxyContractTest.java` -- Test: `modules/httpclient/httpclient-resilience/src/test/java/io/backend/skeleton/httpclient/resilience/Http2EvidenceMapperTest.java` - -**Interfaces:** -- Proxy connect failure remains distinct from target connect and TLS failure. -- `REFUSED_STREAM` and GOAWAY stream IDs can prove peer non-processing. - -- [ ] **Step 1: Write failing proxy isolation and H2 evidence tests** - -```java -class Http2EvidenceMapperTest { - @Test - void refusedStreamIsPeerNotProcessedEvidence() { - Http2ProtocolEvidence evidence = Http2ProtocolEvidence.refusedStream(7); - assertThat(new Http2EvidenceMapper().map(evidence)) - .isEqualTo(ProtocolEvidence.peerDidNotProcess("REFUSED_STREAM")); - } - - @Test - void streamAfterGoAwayLastIdIsPeerNotProcessed() { - Http2ProtocolEvidence evidence = Http2ProtocolEvidence.goAway(11, 15); - assertThat(new Http2EvidenceMapper().map(evidence).peerDidNotProcess()).isTrue(); - } -} -``` - -- [ ] **Step 2: Run proxy and HTTP/2 tests and verify failure** - -```bash -./gradlew :modules:httpclient:httpclient-testkit:test \ - --tests '*ForwardProxyContractTest' \ - :modules:httpclient:httpclient-resilience:test \ - --tests '*Http2EvidenceMapperTest' -``` - -Expected: FAIL because proxy settings and H2 evidence mapping are missing. - -- [ ] **Step 3: Implement explicit proxy and H2 mappings** - -```java -public record ProxySettings( - boolean enabled, - String host, - int port, - ProxyType type, - Optional credentialProvider, - Duration connectTimeout) { -} -``` - -Configure target and proxy credentials separately. Ignore ambient `NO_PROXY` in production unless explicitly imported into the validated profile. Map GOAWAY and REFUSED_STREAM only when the transport exposes reliable stream IDs; otherwise retain conservative evidence. - -- [ ] **Step 4: Run proxy, HTTP/2, Apache, and Reactor tests** - -```bash -./gradlew :modules:httpclient:httpclient-testkit:test \ - :modules:httpclient:httpclient-resilience:test \ - :modules:httpclient:httpclient-transport-apache:test \ - :modules:httpclient:httpclient-transport-reactor-netty:test -``` - -Expected: PASS; proxy authentication never appears in target requests or logs. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-profile \ - modules/httpclient/httpclient-auth \ - modules/httpclient/httpclient-resilience \ - modules/httpclient/httpclient-transport-apache \ - modules/httpclient/httpclient-transport-reactor-netty \ - modules/httpclient/httpclient-testkit -git commit -m "feat: add proxy and http2 failure semantics" -``` - ---- - -### Task 32: Spring Boot Starter·Properties·Actuator 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-spring-boot-starter/src/main/java/io/backend/skeleton/httpclient/autoconfigure/HttpClientsProperties.java` -- Create: `modules/httpclient/httpclient-spring-boot-starter/src/main/java/io/backend/skeleton/httpclient/autoconfigure/HttpClientProfileAutoConfiguration.java` -- Create: `modules/httpclient/httpclient-spring-boot-starter/src/main/java/io/backend/skeleton/httpclient/autoconfigure/HttpClientTransportAutoConfiguration.java` -- Create: `modules/httpclient/httpclient-spring-boot-starter/src/main/java/io/backend/skeleton/httpclient/autoconfigure/HttpClientResilienceAutoConfiguration.java` -- Create: `modules/httpclient/httpclient-spring-boot-starter/src/main/java/io/backend/skeleton/httpclient/autoconfigure/HttpClientAuthenticationAutoConfiguration.java` -- Create: `modules/httpclient/httpclient-spring-boot-starter/src/main/java/io/backend/skeleton/httpclient/autoconfigure/HttpClientSecurityAutoConfiguration.java` -- Create: `modules/httpclient/httpclient-spring-boot-starter/src/main/java/io/backend/skeleton/httpclient/autoconfigure/HttpClientObservationAutoConfiguration.java` -- Create: `modules/httpclient/httpclient-spring-boot-starter/src/main/java/io/backend/skeleton/httpclient/autoconfigure/HttpServiceClientAutoConfiguration.java` -- Create: `modules/httpclient/httpclient-spring-boot-starter/src/main/java/io/backend/skeleton/httpclient/autoconfigure/DynamicTargetAutoConfiguration.java` -- Create: `modules/httpclient/httpclient-spring-boot-starter/src/main/java/io/backend/skeleton/httpclient/autoconfigure/HttpClientStartupValidator.java` -- Create: `modules/httpclient/httpclient-spring-boot-starter/src/main/java/io/backend/skeleton/httpclient/autoconfigure/HttpClientActuatorEndpoint.java` -- Create: `modules/httpclient/httpclient-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` -- Test: `modules/httpclient/httpclient-spring-boot-starter/src/test/java/io/backend/skeleton/httpclient/autoconfigure/HttpClientAutoConfigurationTest.java` -- Test: `modules/httpclient/httpclient-spring-boot-starter/src/test/java/io/backend/skeleton/httpclient/autoconfigure/UnsafeStartupConfigurationTest.java` - -**Interfaces:** -- Binds `http-clients.*` properties into immutable profiles. -- Startup fails on all unsafe conditions listed in the design. - -- [ ] **Step 1: Write failing safe binding and unsafe startup tests** - -```java -class UnsafeStartupConfigurationTest { - private final ApplicationContextRunner runner = new ApplicationContextRunner() - .withConfiguration(AutoConfigurations.of(HttpClientProfileAutoConfiguration.class)); - - @Test - void productionTrustAllConfigurationFailsStartup() { - runner.withPropertyValues( - "spring.profiles.active=prod", - "http-clients.payment.base-url=https://payment.test", - "http-clients.payment.transport=APACHE", - "http-clients.payment.tls.trust-all=true") - .run(context -> assertThat(context).hasFailed()); - } - - @Test - void bindsNamedProfileAndCreatesTypedRegistry() { - runner.withPropertyValues(ProfileProperties.validPayment()) - .run(context -> { - assertThat(context).hasSingleBean(ClientRuntimeRegistry.class); - assertThat(context).hasSingleBean(HttpServiceRegistry.class); - }); - } -} -``` - -- [ ] **Step 2: Run starter tests and confirm failure** - -```bash -./gradlew :modules:httpclient:httpclient-spring-boot-starter:test -``` - -Expected: FAIL because property binding and auto-configuration are missing. - -- [ ] **Step 3: Implement typed properties and fail-fast startup** - -```java -@ConfigurationProperties("http-clients") -public record HttpClientsProperties(Map clients) { - public HttpClientsProperties { - clients = Map.copyOf(clients); - } -} -``` - -`HttpClientStartupValidator` aggregates profile, TLS, transport capability, duplicate operation, Dynamic credential, production Simple factory, Retry owner, and HTTP/3 Stable violations and throws one `HttpConfigurationException` with stable violation codes. Actuator exposes only name, generation, transport, protocol, pool state, circuit state, credential type, TLS profile ID, and reload outcome. - -- [ ] **Step 4: Run starter and complete module tests** - -```bash -./gradlew :modules:httpclient:httpclient-spring-boot-starter:test \ - :modules:httpclient:httpclient-service-client:test -``` - -Expected: PASS; `/actuator/httpclients` output contains no base URL, credential, trust path, resolved IP, or secret. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-spring-boot-starter -git commit -m "feat: add http client spring boot starter" -``` - ---- - -### Task 33: RestTemplate Migration 호환 계층 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-resttemplate-migration/src/main/java/io/backend/skeleton/httpclient/migration/RestTemplateInventory.java` -- Create: `modules/httpclient/httpclient-resttemplate-migration/src/main/java/io/backend/skeleton/httpclient/migration/RestTemplateInventoryScanner.java` -- Create: `modules/httpclient/httpclient-resttemplate-migration/src/main/java/io/backend/skeleton/httpclient/migration/RestTemplateToRestClientAdapter.java` -- Create: `modules/httpclient/httpclient-resttemplate-migration/src/main/java/io/backend/skeleton/httpclient/migration/MigrationFinding.java` -- Create: `modules/httpclient/httpclient-resttemplate-migration/src/main/java/io/backend/skeleton/httpclient/migration/DeprecatedRestTemplateUsageArchRule.java` -- Test: `modules/httpclient/httpclient-resttemplate-migration/src/test/java/io/backend/skeleton/httpclient/migration/RestTemplateToRestClientAdapterTest.java` -- Test: `modules/httpclient/httpclient-resttemplate-migration/src/test/java/io/backend/skeleton/httpclient/migration/RestTemplateBoundaryTest.java` - -**Interfaces:** -- Converts existing converter, interceptor, request factory settings into a migration report and RestClient builder. -- Does not expose Dynamic Target, HTTP/3, or new resilience features through RestTemplate. - -- [ ] **Step 1: Write failing behavior parity and boundary tests** - -```java -class RestTemplateToRestClientAdapterTest { - @Test - void preservesExistingMessageConvertersAndInterceptors() { - RestTemplate template = RestTemplateFixtures.withJsonAndCorrelationInterceptor(); - RestClient client = new RestTemplateToRestClientAdapter().adapt(template); - assertThat(RestTemplateFixtures.exchangeWith(client)).isEqualTo("ok"); - assertThat(RestTemplateFixtures.recordedCorrelationHeader()).isPresent(); - } -} - -class RestTemplateBoundaryTest { - @Test - void productionModulesCannotDependOnMigrationModule() { - JavaClasses classes = new ClassFileImporter().importPackages("io.backend.skeleton"); - DeprecatedRestTemplateUsageArchRule.rule().check(classes); - } -} -``` - -- [ ] **Step 2: Run migration tests and confirm failure** - -```bash -./gradlew :modules:httpclient:httpclient-resttemplate-migration:test -``` - -Expected: FAIL because migration adapter and ArchUnit rule are missing. - -- [ ] **Step 3: Implement audit-first migration** - -```java -public final class RestTemplateToRestClientAdapter { - public RestClient adapt(RestTemplate template) { - return RestClient.builder(template) - .build(); - } -} -``` - -`RestTemplateInventoryScanner` reports request factory type, converters, interceptors, error handler, URI handler, and timeout gaps. The ArchUnit rule permits RestTemplate only inside the migration module and named legacy packages. - -- [ ] **Step 4: Run migration and architecture tests** - -```bash -./gradlew :modules:httpclient:httpclient-resttemplate-migration:test -``` - -Expected: PASS; no new production module references `RestTemplate`. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-resttemplate-migration -git commit -m "feat: add resttemplate migration path" -``` - ---- - -### Task 34: Spring 7 HTTP Service Group 선택 통합 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-spring7-service-groups/src/main/java/io/backend/skeleton/httpclient/spring7/NamedHttpServiceGroupRegistrar.java` -- Create: `modules/httpclient/httpclient-spring7-service-groups/src/main/java/io/backend/skeleton/httpclient/spring7/HttpServiceGroupProfileResolver.java` -- Create: `modules/httpclient/httpclient-spring7-service-groups/src/main/java/io/backend/skeleton/httpclient/spring7/Spring7GroupCompatibility.java` -- Test: `modules/httpclient/httpclient-spring7-service-groups/src/test/java/io/backend/skeleton/httpclient/spring7/NamedHttpServiceGroupRegistrarTest.java` -- Create: `modules/httpclient/httpclient-spring7-service-groups/src/test/resources/application-groups.yml` - -**Interfaces:** -- Compiles only in the Spring 7 compatibility test suite. -- Reuses Named Client Profile and operation validation rather than creating a parallel configuration model. - -- [ ] **Step 1: Write a failing group-to-profile registration test** - -```java -class NamedHttpServiceGroupRegistrarTest { - @Test - void registersMultipleInterfacesAgainstOneNamedProfile() { - ApplicationContext context = Spring7GroupFixtures.start( - "catalog", CatalogClient.class, PriceClient.class); - assertThat(context.getBean(CatalogClient.class)).isNotNull(); - assertThat(context.getBean(PriceClient.class)).isNotNull(); - assertThat(Spring7GroupFixtures.profileFor(CatalogClient.class)).isEqualTo("catalog"); - assertThat(Spring7GroupFixtures.profileFor(PriceClient.class)).isEqualTo("catalog"); - } -} -``` - -- [ ] **Step 2: Run the Spring 7-only test and confirm failure** - -```bash -./gradlew :modules:httpclient:httpclient-spring7-service-groups:test \ - -PspringFrameworkLine=7.0 -``` - -Expected: FAIL because the group registrar is missing. - -- [ ] **Step 3: Implement the optional group adapter** - -```java -public final class HttpServiceGroupProfileResolver { - public ClientProfileName resolve(String groupName) { - return new ClientProfileName(groupName); - } -} -``` - -The registrar delegates interface validation to `ServiceOperationDescriptorScanner`, obtains the existing profile runtime, and configures the Spring 7 service group with the same RestClient/WebClient instance. It does not compile into the Spring 6.2 distribution. - -- [ ] **Step 4: Run Spring 6.2 common and Spring 7 group matrices** - -```bash -./gradlew spring62CompatibilityTest spring70CompatibilityTest \ - :modules:httpclient:httpclient-spring7-service-groups:test \ - -PspringFrameworkLine=7.0 -``` - -Expected: PASS; common artifacts remain free of Spring 7-only class references. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-spring7-service-groups -git commit -m "feat: integrate spring7 http service groups" -``` - ---- - -### Task 35: Jetty HTTP/3 Experimental Transport 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-jetty-http3-experimental/src/main/java/io/backend/skeleton/httpclient/http3/JettyHttp3TransportProvider.java` -- Create: `modules/httpclient/httpclient-jetty-http3-experimental/src/main/java/io/backend/skeleton/httpclient/http3/Http3ExperimentalAcknowledgement.java` -- Create: `modules/httpclient/httpclient-jetty-http3-experimental/src/main/java/io/backend/skeleton/httpclient/http3/JettyHttp3FailureClassifier.java` -- Create: `modules/httpclient/httpclient-jetty-http3-experimental/src/main/java/io/backend/skeleton/httpclient/http3/Http3CapabilityReport.java` -- Test: `modules/httpclient/httpclient-jetty-http3-experimental/src/test/java/io/backend/skeleton/httpclient/http3/JettyHttp3TransportProviderTest.java` -- Test: `modules/httpclient/httpclient-jetty-http3-experimental/src/test/java/io/backend/skeleton/httpclient/http3/Http3OptInTest.java` - -**Interfaces:** -- Requires `experimental=true` and explicit acknowledgement string. -- Never auto-configured by the Stable starter. - -- [ ] **Step 1: Write failing opt-in and QUIC capability tests** - -```java -class Http3OptInTest { - @Test - void rejectsHttp3WithoutExplicitAcknowledgement() { - ClientProfile profile = ClientProfiles.http3WithoutAcknowledgement(); - assertThatThrownBy(() -> new JettyHttp3TransportProvider().create( - profile, NoopLifecycleListener.INSTANCE)) - .isInstanceOf(HttpConfigurationException.class) - .hasMessageContaining("experimental acknowledgement"); - } -} -``` - -- [ ] **Step 2: Run HTTP/3 tests and confirm failure** - -```bash -./gradlew :modules:httpclient:httpclient-jetty-http3-experimental:test -``` - -Expected: FAIL because the Experimental provider is missing. - -- [ ] **Step 3: Implement isolated Jetty HTTP/3 transport** - -```java -public record Http3ExperimentalAcknowledgement(String value) { - public static final String REQUIRED = "I_ACCEPT_HTTP3_EXPERIMENTAL_SEMANTICS"; - public Http3ExperimentalAcknowledgement { - if (!REQUIRED.equals(value)) { - throw new IllegalArgumentException("invalid HTTP/3 experimental acknowledgement"); - } - } -} -``` - -Create a Jetty QUIC transport with TLS 1.3, separate capability report, and failure classifier. Reuse stable result, error, deadline, retry, observation, and body lifecycle contracts. Keep Dynamic Target disabled in this module. - -- [ ] **Step 4: Run HTTP/3 tests in the dedicated environment** - -```bash -./gradlew :modules:httpclient:httpclient-jetty-http3-experimental:test \ - -Phttp3.tests.enabled=true -``` - -Expected: PASS when QUIC native support is present; otherwise the task fails with a clear missing-capability message rather than silently skipping release verification. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-jetty-http3-experimental -git commit -m "feat: add experimental jetty http3 transport" -``` - ---- - -### Task 36: 통합 장애·보안·관측 Contract Suite 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-testkit/src/testFixtures/java/io/backend/skeleton/httpclient/testkit/BlockingTransportContract.java` -- Create: `modules/httpclient/httpclient-testkit/src/testFixtures/java/io/backend/skeleton/httpclient/testkit/ReactiveTransportContract.java` -- Create: `modules/httpclient/httpclient-testkit/src/testFixtures/java/io/backend/skeleton/httpclient/testkit/RetrySafetyContract.java` -- Create: `modules/httpclient/httpclient-testkit/src/testFixtures/java/io/backend/skeleton/httpclient/testkit/DynamicTargetSecurityContract.java` -- Create: `modules/httpclient/httpclient-testkit/src/testFixtures/java/io/backend/skeleton/httpclient/testkit/ObservabilityContract.java` -- Create: `modules/httpclient/httpclient-testkit/src/testFixtures/java/io/backend/skeleton/httpclient/testkit/ResourceLifecycleContract.java` -- Create: `modules/httpclient/httpclient-testkit/src/test/java/io/backend/skeleton/httpclient/testkit/AllStableTransportsContractTest.java` -- Create: `modules/httpclient/httpclient-testkit/src/test/java/io/backend/skeleton/httpclient/testkit/FailureInjectionContractTest.java` -- Create: `modules/httpclient/httpclient-testkit/src/test/java/io/backend/skeleton/httpclient/testkit/SecurityContractTest.java` - -**Interfaces:** -- Executes the same semantic contract against Apache, JDK, and Reactor. -- Jetty HTTP/3 uses the subset declared by `Http3CapabilityReport`. - -- [ ] **Step 1: Write a failing cross-transport contract runner** - -```java -class AllStableTransportsContractTest { - @ParameterizedTest - @MethodSource("stableTransports") - void notSentConnectFailureHasSameStableMetadata(HttpClientHarness harness) { - HttpClientException failure = catchThrowableOfType( - () -> harness.callBlackholedTarget(), HttpClientException.class); - assertThat(failure.metadata().stage()).isEqualTo(AttemptStage.CONNECT); - assertThat(failure.metadata().evidence()).isEqualTo(ExecutionEvidence.NOT_SENT); - assertThat(failure.getClass()).isEqualTo(HttpConnectException.class); - } -} -``` - -- [ ] **Step 2: Run the contract runner and inspect current differences** - -```bash -./gradlew :modules:httpclient:httpclient-testkit:test \ - --tests '*AllStableTransportsContractTest' \ - --tests '*FailureInjectionContractTest' \ - --tests '*SecurityContractTest' -``` - -Expected: FAIL until every transport produces the same stable metadata and security behavior. - -- [ ] **Step 3: Implement the complete matrix and fix each adapter to satisfy it** - -The contract suite must contain executable cases for: - -```text -all supported methods and URI encoding -pool, DNS, connect, TLS, proxy, header, body idle, total deadline -GET, PUT, POST with and without idempotency key -408, 425, 429, 500, 502, 503, 504, Retry-After -partial request write and partial response -body not consumed, close, decode error, cancellation -OAuth token cache, concurrent refresh, 401 replay, secret rotation -loopback, private, link-local, ULA, metadata, IDNA, DNS rebinding -public-to-private redirect and credential leakage -full URL metric cardinality and secret redaction -shutdown drain and retry suppression -``` - -Use Toxiproxy for TCP faults, WireMock for protocol status, TLS fixtures for certificate failures, and the HTTP/2 fixture for GOAWAY and REFUSED_STREAM. - -- [ ] **Step 4: Run the complete stable contract suite** - -```bash -./gradlew httpClientStableContractTest \ - -Dio.netty.leakDetection.level=paranoid \ - -Pblockhound.enabled=true -``` - -Expected: PASS for Apache, JDK, and Reactor with no leaked connection, buffer, thread, secret, or forbidden metric label. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-testkit \ - modules/httpclient/httpclient-transport-apache \ - modules/httpclient/httpclient-transport-jdk \ - modules/httpclient/httpclient-transport-reactor-netty \ - modules/httpclient/httpclient-restclient \ - modules/httpclient/httpclient-webclient -git commit -m "test: certify http client failure semantics" -``` - ---- - -### Task 37: 부하·Resource·Rotation 성능 인증 구현 - -**Files:** -- Create: `modules/httpclient/httpclient-testkit/src/jmh/java/io/backend/skeleton/httpclient/testkit/BlockingClientBenchmark.java` -- Create: `modules/httpclient/httpclient-testkit/src/jmh/java/io/backend/skeleton/httpclient/testkit/ReactiveClientBenchmark.java` -- Create: `modules/httpclient/httpclient-testkit/src/performanceTest/java/io/backend/skeleton/httpclient/testkit/PoolSaturationPerformanceTest.java` -- Create: `modules/httpclient/httpclient-testkit/src/performanceTest/java/io/backend/skeleton/httpclient/testkit/Http2StreamSaturationTest.java` -- Create: `modules/httpclient/httpclient-testkit/src/performanceTest/java/io/backend/skeleton/httpclient/testkit/LargeBodyResourceTest.java` -- Create: `modules/httpclient/httpclient-testkit/src/performanceTest/java/io/backend/skeleton/httpclient/testkit/RetryStormBudgetTest.java` -- Create: `modules/httpclient/httpclient-testkit/src/performanceTest/java/io/backend/skeleton/httpclient/testkit/OAuthRefreshContentionTest.java` -- Create: `modules/httpclient/httpclient-testkit/src/performanceTest/java/io/backend/skeleton/httpclient/testkit/RuntimeRotationDrainTest.java` -- Create: `docs/httpclient/performance-baseline.md` - -**Interfaces:** -- Produces reproducible performance evidence, not runtime adaptive defaults. -- Baseline records configuration, hardware, JVM, transport, protocol, payload, and concurrency. - -- [ ] **Step 1: Write failing hard resource-bound assertions** - -```java -class RetryStormBudgetTest { - @Test - void failedUpstreamCannotMultiplyPhysicalTrafficBeyondBudget() { - LoadResult result = LoadHarness.failedUpstream() - .logicalCalls(10_000) - .retryBudgetRatio(0.10) - .run(); - assertThat(result.physicalAttempts()).isLessThanOrEqualTo(11_000); - } -} - -class LargeBodyResourceTest { - @Test - void streamingDownloadDoesNotBufferWholePayloadOnHeap() { - ResourceSample sample = LoadHarness.download(512 * MEBIBYTE).streaming().run(); - assertThat(sample.peakHeapIncrease()).isLessThan(64 * MEBIBYTE); - } -} -``` - -- [ ] **Step 2: Run performance tests and capture the failing baseline** - -```bash -./gradlew httpClientPerformanceTest \ - -Pperformance.assertions.enabled=true -``` - -Expected: FAIL until pool, streaming, retry, and rotation resource bounds are enforced. - -- [ ] **Step 3: Tune only explicit profile settings and record the baseline** - -Set and record: - -```text -max connections -max pending acquires -attempt bulkhead -HTTP/2 stream concurrency -request and response size limits -total and stage timeouts -retry budget and max attempts -runtime drain timeout -``` - -Do not introduce hidden adaptive defaults. Update `performance-baseline.md` with command, commit, hardware, JVM flags, profile YAML, p50/p95/p99/max, heap, direct memory, threads, connections, attempts, and error count. - -- [ ] **Step 4: Run the performance certification suite** - -```bash -./gradlew httpClientPerformanceTest jmh \ - -Pperformance.assertions.enabled=true -``` - -Expected: PASS within the documented heap, direct memory, thread, connection, retry, and latency bounds. - -- [ ] **Step 5: Commit** - -```bash -git add modules/httpclient/httpclient-testkit docs/httpclient/performance-baseline.md -git commit -m "perf: certify http client resource bounds" -``` - ---- - -### Task 38: CI Matrix, Support Matrix, Runbook, Release Gate 완성 - -**Files:** -- Create: `.github/workflows/httpclient-contract.yml` -- Create: `.github/workflows/httpclient-nightly.yml` -- Create: `.github/workflows/httpclient-release.yml` -- Create: `docs/httpclient/support-matrix.md` -- Create: `docs/httpclient/configuration-reference.md` -- Create: `docs/httpclient/retry-and-ambiguity.md` -- Create: `docs/httpclient/security.md` -- Create: `docs/httpclient/streaming.md` -- Create: `docs/httpclient/operations.md` -- Create: `docs/httpclient/migration-guide.md` -- Create: `docs/httpclient/release-checklist.md` -- Create: `scripts/verify-httpclient-docs.py` -- Test: `modules/httpclient/httpclient-testkit/src/test/java/io/backend/skeleton/httpclient/testkit/PublicApiArchitectureTest.java` - -**Interfaces:** -- CI gates Spring 6.2·7.0, Apache, JDK, Reactor, HTTP/1.1·2, OAuth2, TLS, Dynamic Target, and fault injection. -- HTTP/3 is a separate Experimental nightly job. - -- [ ] **Step 1: Write failing public API and documentation verification tests** - -```java -class PublicApiArchitectureTest { - @Test - void publicApiDoesNotExposeNativeEnginesOrUnsafeBuilders() { - JavaClasses classes = new ClassFileImporter() - .importPackages("io.backend.skeleton.httpclient"); - noClasses().that().resideInAPackage("..api..") - .should().dependOnClassesThat() - .resideInAnyPackage( - "org.apache.hc..", "reactor.netty..", "org.eclipse.jetty..", - "java.net.http..", "io.github.resilience4j..") - .check(classes); - } -} -``` - -`verify-httpclient-docs.py` must fail when a Stable profile, exception, configuration property, metric, or support matrix row exists in code but not in documentation. - -- [ ] **Step 2: Run final verification before CI files are complete** - -```bash -./gradlew :modules:httpclient:httpclient-testkit:test \ - --tests '*PublicApiArchitectureTest' -python scripts/verify-httpclient-docs.py -``` - -Expected: FAIL because CI workflows and complete documentation are missing. - -- [ ] **Step 3: Add CI jobs and exact release commands** - -`httpclient-contract.yml` runs on every PR: - -```yaml -jobs: - stable-contract: - strategy: - matrix: - spring-line: ["6.2", "7.0"] - transport: [apache, jdk, reactor] - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: "21" - - run: ./gradlew httpClientStableContractTest -PspringFrameworkLine=${{ matrix.spring-line }} -Phttpclient.contract.transport=${{ matrix.transport }} -``` - -Nightly runs Toxiproxy, mTLS rotation, HTTP/2 failure, performance smoke, and HTTP/3 Experimental. Release runs all tests, documentation verifier, support matrix verifier, and dependency report. - -- [ ] **Step 4: Run the complete release gate** - -```bash -./gradlew clean \ - test \ - spring62CompatibilityTest \ - spring70CompatibilityTest \ - httpClientStableContractTest \ - httpClientSecurityTest \ - httpClientFailureInjectionTest \ - httpClientPerformanceTest -python scripts/verify-httpclient-docs.py -``` - -Expected: PASS with zero failed test, zero documentation drift, zero forbidden dependency, and zero secret/cardinality violation. - -- [ ] **Step 5: Commit** - -```bash -git add .github/workflows docs/httpclient scripts/verify-httpclient-docs.py \ - modules/httpclient/httpclient-testkit -git commit -m "docs: finalize http client release gates" -``` - ---- - -## 3. Plan Self-Review Checklist - -Before execution begins, verify the plan against the design using the following checklist. - -- [ ] Every design decision D-01 through D-18 maps to at least one Task. -- [ ] H1, H2, H3, and H4 exposure rules are enforced by code or ArchUnit. -- [ ] Apache, JDK, Reactor, and Experimental Jetty modules have explicit capability matrices. -- [ ] `ExecutionEvidence`, `BodyReplayability`, and `OperationIdempotency` signatures are consistent across Tasks. -- [ ] Retry Eligibility is a pure decision and Retry Coordinator performs timing and attempts. -- [ ] Circuit → Rate Limiter → Bulkhead order is tested. -- [ ] total deadline includes Retry backoff and shutdown suppresses new retries. -- [ ] response body lifecycle is tested for success, partial read, error, size rejection, and cancel. -- [ ] OAuth2 single-flight and 401 maximum-one-replay rules are tested. -- [ ] TLS trust-all and hostname verification bypass are impossible to configure. -- [ ] Dynamic Target validates every resolved address and pins the actual connection. -- [ ] cross-origin redirect strips credentials. -- [ ] first-byte delivery disables transparent Retry. -- [ ] full URL and secret values cannot become low-cardinality tags. -- [ ] Spring 6.2 common and Spring 7 optional paths are separate. -- [ ] RestTemplate is limited to the migration module. -- [ ] HTTP/3 requires explicit Experimental acknowledgement. -- [ ] final CI executes contract, security, failure, compatibility, performance, and documentation gates. - -## 4. Execution Handoff - -Implementation must begin with Task 1 and proceed in order. The recommended execution mode is `superpowers:subagent-driven-development`: one fresh implementation agent per Task, followed by a requirements review and a code-quality review before the next Task begins. An inline execution session may instead use `superpowers:executing-plans`, but it must retain the same red-green-commit boundaries and release gates. diff --git a/httpclient-superpowers-package/docs/superpowers/specs/2026-08-08-httpclient-platform-design.md b/httpclient-superpowers-package/docs/superpowers/specs/2026-08-08-httpclient-platform-design.md deleted file mode 100644 index bd14ef4..0000000 --- a/httpclient-superpowers-package/docs/superpowers/specs/2026-08-08-httpclient-platform-design.md +++ /dev/null @@ -1,1956 +0,0 @@ -# HTTP Client Platform 설계서 - -**문서 상태:** 구현 기준선 확정 -**작성 기준일:** 2026-08-08 -**입력 근거:** `Java/Spring 외부 HTTP Client 플랫폼 설계 심층 리서치` -**대상 저장소:** Spring 기반 Backend Skeleton -**문서 목적:** 구현 중 추가 설계 판단이나 반복 질문 없이 공개 API, 전송 엔진, Named Client Profile, 시간 예산, 재시도 안전성, 복원력, 인증, 보안, 관측성, 테스트 및 릴리스 조건을 확정한다. - ---- - -## 1. 요약 - -이 설계는 `httpclient`를 `RestClient`나 `WebClient`를 한 번 감싼 편의 Wrapper가 아니라, 외부 HTTP 호출의 **대상, 연결 자원, 시간 예산, 실행 증거, 재시도 안전성, 인증, 보안, 관측성**을 하나의 계약으로 통제하는 공통 플랫폼으로 정의한다. - -일반 애플리케이션은 다음 네 단계 중 필요한 최소 단계만 사용한다. - -1. **H1 Typed Service Client** — `@HttpExchange` 기반 interface를 기본 진입점으로 사용한다. -2. **H2 Generic Exchange Gateway** — 등록된 Named Client Profile 안에서만 동적 method, path, header, body를 허용한다. -3. **H3 Dynamic Target Gateway** — 사용자 입력 URL이 필요한 기능을 별도 보안 경계와 SSRF 정책 아래에서 제공한다. -4. **H4 Native Engine SPI** — Apache, Reactor Netty, JDK, Jetty 고유 API는 플랫폼 내부 또는 Lab에만 공개한다. - -플랫폼의 핵심 판정은 단순한 `성공/예외`가 아니다. - -```text -요청이 실제 서버에 전달됐는가? -서버가 업무 처리를 완료했을 가능성이 있는가? -요청 Body를 동일한 의미로 다시 생성할 수 있는가? -호출이 표준 또는 계약상 멱등한가? -남은 deadline과 retry budget으로 다음 시도를 완료할 수 있는가? -``` - -이를 위해 모든 물리 시도는 다음 세 축을 보존한다. - -```text -ExecutionEvidence -├─ NOT_SENT -├─ SENT_NO_RESPONSE -├─ RESPONSE_RECEIVED -└─ PARTIAL_RESPONSE - -BodyReplayability -├─ REPLAYABLE -├─ REOPENABLE -├─ ONE_SHOT -└─ UNKNOWN - -OperationIdempotency -├─ STANDARD_IDEMPOTENT -├─ CONTRACT_IDEMPOTENT -├─ IDEMPOTENCY_KEY_REQUIRED -└─ NON_IDEMPOTENT -``` - -최종 구조는 다음과 같다. - -```text -Application - → Typed Service Client 또는 제한형 Gateway - → Named Client Profile - → Operation Policy Validation - → Effective Deadline - → Authentication Materialization - → Retry Coordinator - → Circuit Breaker - → Attempt Rate Limiter - → Attempt Bulkhead - → RestClient 또는 WebClient - → Apache / JDK / Reactor Netty / Jetty - → Execution Evidence Classification - → Stable Result 또는 Stable Exception -``` - ---- - -## 2. 목표와 성공 기준 - -### 2.1 목표 - -- 다양한 내부 서비스와 외부 SaaS API를 동일한 운영 기준으로 호출할 수 있게 한다. -- 일반 서비스 코드는 H1 Typed Client만으로 대부분의 호출을 구현하게 한다. -- upstream별 connection pool, timeout, 인증, retry, circuit, bulkhead를 서로 격리한다. -- 비멱등 요청의 중복 실행과 장애 시 retry 폭풍을 구조적으로 차단한다. -- Blocking과 Reactive 호출을 모두 지원하되 resource lifecycle과 cancellation 의미론을 구분한다. -- Dynamic URL 호출은 Trusted Client와 완전히 다른 보안 경계로 제공한다. -- Apache, Reactor Netty, JDK 전송 엔진이 동일한 오류·관측 semantic을 제공하게 한다. -- 구현자가 timeout, retry, redirect, 인증, TLS, SSRF, streaming 정책을 다시 판단하지 않게 한다. - -### 2.2 성공 기준 - -| 영역 | 완료 기준 | -|---|---| -| 공개 API | 일반 업무 모듈이 Native client를 직접 참조하지 않고 H1 Typed Client를 사용한다. | -| 설정 | 모든 호출 대상이 `clientName`으로 등록된 Named Client Profile을 가진다. | -| 시간 예산 | pool acquire부터 retry backoff까지 전체 호출이 effective deadline을 초과하지 않는다. | -| 실행 증거 | 실패 시 `NOT_SENT`, `SENT_NO_RESPONSE`, `RESPONSE_RECEIVED`, `PARTIAL_RESPONSE` 중 하나를 설명할 수 있다. | -| Retry | idempotency, body replayability, evidence, status, deadline, retry budget을 모두 통과한 시도만 재실행된다. | -| Resource | 성공, timeout, decode 실패, size 초과, cancellation에서 connection과 buffer가 회수된다. | -| 보안 | trust-all, hostname verification 해제, unrestricted Dynamic URL, credential redirect leakage가 차단된다. | -| 관측성 | 논리 호출과 물리 시도 수가 분리되고 전체 URL·사용자 ID·token이 metric label에 들어가지 않는다. | -| 호환성 | Spring Framework 6.2와 7.0 지원 범위가 CI 매트릭스로 검증된다. | -| 전송 엔진 | Apache·JDK blocking과 Reactor Netty reactive가 공통 계약 테스트를 통과한다. | -| Streaming | 첫 byte가 호출자에게 전달된 이후 투명 retry가 발생하지 않는다. | -| Dynamic Target | canonicalization, DNS/IP 검증, redirect 재검증, egress 정책이 함께 적용된다. | - ---- - -## 3. 입력 자료의 제약과 구현 가정 - -첨부 리서치는 설계 방향, 지원 범위, API 초안, 장애 의미론, 테스트 및 구현 순서를 충분히 제공하지만 실제 Backend Skeleton 저장소의 다음 정보는 포함하지 않는다. - -- root package -- Java toolchain -- Gradle 구조 -- Spring Boot BOM -- 기존 observability·security·resilience 공통 모듈 -- 배포 환경의 proxy, service mesh, egress 정책 - -따라서 이 문서는 실행 가능한 계획을 만들기 위해 다음 구현 기준을 사용한다. - -| 항목 | 구현 기준 | -|---|---| -| Java | Java 21 | -| 빌드 | Gradle Kotlin DSL 멀티모듈 | -| root package | `io.backend.skeleton.httpclient` | -| Spring 기준 | 공통 코드는 Spring Framework 6.2 API 기준으로 컴파일하고 7.0 호환 테스트를 수행한다. | -| Spring 7 전용 기능 | HTTP Service Group은 독립 선택 모듈로 분리한다. | -| Spring Boot | host 저장소의 dependency management를 사용하고 라이브러리가 Boot patch version을 직접 고정하지 않는다. | -| Reactive type | Reactor `Mono`, `Flux`는 reactive integration module에서만 공개한다. | -| Resilience | Resilience4j를 실행 primitive로 사용하되 HTTP retry 가능성 판정은 플랫폼이 소유한다. | -| 테스트 | JUnit 5, AssertJ, ArchUnit, MockWebServer, WireMock, Testcontainers, Toxiproxy, BlockHound | - -실제 저장소가 다른 package 또는 더 높은 Java 기준을 사용하면 경로와 toolchain만 조정한다. 본 문서의 공개 계약, 정책 순서, 오류 의미론은 변경하지 않는다. - ---- - -## 4. 범위 - -### 4.1 포함 범위 - -- Spring `RestClient` -- Spring `WebClient` -- `@HttpExchange` 기반 HTTP Service Client -- `RestTemplate` 마이그레이션 호환 계층 -- Apache HttpClient 5 blocking transport -- JDK HttpClient blocking transport -- Reactor Netty reactive transport -- Jetty HTTP/3 Experimental transport -- HTTP/1.1과 HTTP/2 Stable -- 동기 DTO·header·empty response -- Reactive `Mono`·`Flux` -- JSON, XML, text, bytes, form, multipart, octet-stream -- streaming upload·download -- SSE -- redirect, compression, conditional request, Range client semantics -- proxy와 HTTPS CONNECT -- connection pool과 lifecycle -- 단계별 timeout과 전체 deadline -- retry, retry budget, backoff, jitter, `Retry-After` -- Circuit Breaker, Bulkhead, Rate Limiter -- API key, Basic, Bearer, OAuth2 Client, mTLS, request signing SPI -- TLS 1.2·1.3, custom CA, certificate rotation -- Dynamic URL SSRF 방어 -- RFC 9457 problem response 변환 -- metric, trace, logging, audit -- 계약·장애·보안·성능 테스트 - -### 4.2 제외 또는 별도 모듈 - -- WebSocket -- gRPC -- GraphQL query·error·subscription 의미론 -- Fileserver의 저장·publish·Range 응답 생성 -- 브라우저 JavaScript HTTP Client -- API Gateway와 inbound routing -- 서비스 디스커버리와 client-side load balancing 구현 -- unrestricted Dynamic URL -- application-facing Native engine access -- 자동 공유 Cookie Jar -- TRACE -- 무제한 redirect -- one-shot request body의 자동 retry -- partial response가 호출자에게 전달된 뒤의 투명 retry -- HTTP/3 공통 Stable 보장 -- request hedging Stable 지원 -- transparent shared response cache -- trust-all, hostname verification 해제, 평문 fallback -- Simple request factory의 운영 사용 -- RestTemplate 신규 기능 - ---- - -## 5. 설계 결정 - -| ID | 결정 | 결과 | -|---|---|---| -| D-01 | 기본 진입점은 H1 Typed Service Client다. | 일반 업무 코드가 URL, timeout, auth, retry를 매번 조립하지 않는다. | -| D-02 | H2 Generic Gateway는 등록 profile의 base URL과 정책을 변경할 수 없다. | 범용 호출 기능은 제공하되 정책 우회를 막는다. | -| D-03 | H3 Dynamic Target Gateway는 별도 모듈·권한·설정으로 제공한다. | Trusted credential, Cookie, default header를 상속하지 않는다. | -| D-04 | H4 Native API는 플랫폼 내부 SPI다. | 애플리케이션이 engine 설정과 관측성을 우회하지 못한다. | -| D-05 | 설정 단위는 upstream별 Named Client Profile이다. | pool, timeout, auth, resilience, observability가 upstream마다 격리된다. | -| D-06 | Blocking 기본은 `RestClient + Apache HC5`, 경량 대안은 JDK HttpClient다. | 세밀한 운영 profile과 의존성 최소화 profile을 모두 제공한다. | -| D-07 | Reactive·Streaming 기본은 `WebClient + Reactor Netty`다. | backpressure, cancellation, SSE를 안정적으로 제공한다. | -| D-08 | Jetty와 HTTP/3는 Experimental로 격리한다. | Stable portability와 장애 의미론을 훼손하지 않는다. | -| D-09 | Retry 가능성은 HTTP method 하나로 결정하지 않는다. | idempotency, idempotency key, body replayability, evidence, deadline, budget을 함께 판정한다. | -| D-10 | 전체 deadline이 모든 timeout과 retry의 상위 예산이다. | 개별 attempt가 성공해도 전체 사용자 요청 시간을 초과하지 않는다. | -| D-11 | Retry Coordinator 바깥에서 logical admission을 적용하고, 각 물리 시도는 Circuit → Rate Limiter → Bulkhead를 통과한다. | backoff 중 permit을 점유하지 않고 실제 upstream 요청 수를 제한한다. | -| D-12 | 첫 response byte를 application에 전달한 뒤에는 transparent retry를 금지한다. | streaming 중복·순서 오류를 차단한다. | -| D-13 | OAuth2 token 획득은 Spring Security에 위임하되 cache key, refresh single-flight, 401 재호출 규칙은 플랫폼이 고정한다. | 인증 구현을 재작성하지 않으면서 동시 갱신과 중복 호출을 통제한다. | -| D-14 | TLS 오류 중 trust·hostname·expiry 오류는 영구 오류로 분류한다. | 인증서 오류를 retry하거나 평문으로 fallback하지 않는다. | -| D-15 | Dynamic Target Stable은 검증한 DNS 결과로 실제 연결을 pin할 수 있는 transport에서만 제공한다. | DNS rebinding과 검사-연결 간 TOCTOU를 줄인다. | -| D-16 | Spring 표준 `http.client.requests`는 물리 시도 metric으로 유지하고 logical call metric을 추가한다. | retry가 사용자 호출 성공률과 upstream 부하를 왜곡하지 않는다. | -| D-17 | Spring 6.2 공통 API를 기준으로 하고 Spring 7 전용 Service Group은 선택 모듈로 둔다. | 두 안정 계열을 지원하면서 공통 모듈의 분기를 줄인다. | -| D-18 | RestTemplate은 migration module에서만 허용한다. | 신규 코드가 deprecated API에 고착되지 않는다. | - ---- - -## 6. 지원 매트릭스 - -### 6.1 Spring API - -| API | 등급 | 역할 | 제약 | -|---|---:|---|---| -| `RestClient` | Stable | Blocking 요청 실행 | bounded concurrency와 deadline 필수 | -| `WebClient` | Stable | Reactive·Streaming·SSE | event-loop blocking 금지 | -| HTTP Service Client | 기본 | 선언형 Typed Client | operation metadata 등록 필수 | -| `RestTemplate` | Migration only | 기존 호출 이전 | 신규 profile·기능 금지 | -| Generic Exchange | 제한 | 동적 method·path·body | base URL과 정책 변경 금지 | -| Dynamic Target | 제한 | 사용자 URL | 별도 SSRF 정책과 credential 미상속 | -| Native Engine | Internal/Lab | 엔진 고유 기능 | application public API 금지 | - -### 6.2 전송 엔진 - -| 엔진 | Blocking | Reactive | HTTP/2 | HTTP/3 | Stable 역할 | -|---|---:|---:|---:|---:|---| -| Apache HttpClient 5 | 예 | 내부 async 가능 | 예 | 아니오 | Blocking 기본 | -| JDK HttpClient | 예 | `sendAsync` 가능 | 예 | 아니오 | 경량 Blocking 대안 | -| Reactor Netty | 제한 | 예 | 예 | Experimental | Reactive 기본 | -| Jetty HttpClient | sync facade | 예 | 예 | 예 | HTTP/3 Experimental | -| Simple factory | 예 | 아니오 | 제한 | 아니오 | local test only | - -### 6.3 HTTP 기능 - -| 기능 | Stable | 제약 | -|---|---:|---| -| GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS | 예 | operation idempotency 등록 | -| TRACE | 아니오 | startup과 runtime에서 차단 | -| custom method | 제한 | 사전 등록 descriptor 필요 | -| path·query template | 예 | 문자열 연결 금지, component encoding | -| absolute URI | H3만 | SSRF 정책 필수 | -| JSON, XML, text, bytes | 예 | codec와 크기 상한 | -| form, multipart | 예 | part 수·크기·replayability 계산 | -| InputStream request | 제한 | one-shot, 자동 retry 금지 | -| reopenable file request | 예 | 매 시도 새 stream 생성 | -| DTO response | 예 | decoded size 상한 | -| InputStream response | 제한 | `AutoCloseable` lifecycle | -| Reactive body | 예 | cancellation·buffer release | -| SSE | 예 | setup deadline과 idle timeout 분리 | -| redirect | 제한 | 기본 off, hop·origin 정책 | -| compression | 예 | wire·decoded size 모두 제한 | -| conditional request | 예 | validator 전달 | -| Range request | 예 | client 의미론만 제공 | -| trailer | Stable 제외 | engine-specific advanced API | -| `100-continue` | 선택 | 대용량 replayable body만 | -| HTTP/1.1 | 예 | fallback | -| HTTP/2 | 예 | stream concurrency 별도 제한 | -| HTTP/3 | Experimental | Jetty/Reactor 전용 | - ---- - -## 7. 전체 아키텍처 - -```mermaid -flowchart TB - APP[Application] - - subgraph PublicAPI[Public API] - H1[H1 Typed Service Client] - H2[H2 Generic Exchange] - H3[H3 Dynamic Target Gateway] - end - - subgraph Runtime[Runtime and Policy] - REG[Client Profile Registry] - META[Operation Descriptor Registry] - TARGET[Target Policy] - DEADLINE[Deadline Calculator] - AUTH[Authentication Provider] - RETRY[Retry Coordinator] - RES[Attempt Resilience] - ERROR[Error Mapper] - OBS[Observation] - end - - subgraph SpringClients[Spring Client Layer] - REST[RestClient] - WEB[WebClient] - end - - subgraph Transport[Transport Providers] - APACHE[Apache HC5] - JDK[JDK HttpClient] - REACTOR[Reactor Netty] - JETTY[Jetty Experimental] - end - - APP --> H1 - APP --> H2 - APP --> H3 - H1 --> REG - H2 --> REG - H3 --> REG - REG --> META - META --> TARGET - TARGET --> DEADLINE - DEADLINE --> AUTH - AUTH --> RETRY - RETRY --> RES - RES --> REST - RES --> WEB - REST --> APACHE - REST --> JDK - WEB --> REACTOR - WEB --> JETTY - REST --> ERROR - WEB --> ERROR - ERROR --> OBS -``` - -### 7.1 논리 호출 흐름 - -```text -1. profileName과 operationName을 해석한다. -2. profile과 operation descriptor를 immutable snapshot으로 가져온다. -3. method, URI template, body type, idempotency metadata를 검증한다. -4. Trusted 또는 Dynamic target policy를 적용한다. -5. parent deadline과 profile timeout에서 effective deadline을 계산한다. -6. credential을 materialize한다. -7. logical admission limit를 통과한다. -8. Retry Coordinator가 attempt 1을 생성한다. -9. attempt가 Circuit Breaker → Rate Limiter → Bulkhead를 통과한다. -10. RestClient 또는 WebClient가 물리 요청을 실행한다. -11. transport classifier가 stage와 execution evidence를 판정한다. -12. Retry Eligibility Engine이 다음 시도 여부를 결정한다. -13. 최종 결과를 `HttpCallResult` 또는 안정 예외로 반환한다. -14. 성공·실패·cancel 모두에서 response body와 connection을 정리한다. -``` - -### 7.2 Runtime 세대 교체 - -Named Client Profile은 mutable client를 직접 수정하지 않는다. - -```text -ClientRuntimeRegistry - payment → generation 17 - search → generation 4 -``` - -인증서, secret, base URL 또는 pool 설정이 변경되면 다음 순서로 교체한다. - -1. 새 immutable `ClientRuntime`을 생성한다. -2. startup validation과 선택적 connectivity probe를 수행한다. -3. registry pointer를 새 generation으로 atomic swap한다. -4. 신규 호출은 새 runtime을 사용한다. -5. 기존 runtime은 drain timeout 동안 진행 호출을 완료한다. -6. timeout 후 pool과 connection을 강제 종료한다. - -이 구조는 mTLS certificate와 OAuth client secret rotation을 connection pool lifecycle과 일치시킨다. - ---- - -## 8. 모듈 구조 - -```text -backend-skeleton/ -├── modules/httpclient/ -│ ├── httpclient-core-api/ -│ ├── httpclient-profile/ -│ ├── httpclient-transport-spi/ -│ ├── httpclient-transport-apache/ -│ ├── httpclient-transport-jdk/ -│ ├── httpclient-restclient/ -│ ├── httpclient-resilience/ -│ ├── httpclient-auth/ -│ ├── httpclient-security/ -│ ├── httpclient-observability/ -│ ├── httpclient-transport-reactor-netty/ -│ ├── httpclient-webclient/ -│ ├── httpclient-service-client/ -│ ├── httpclient-dynamic-target/ -│ ├── httpclient-resttemplate-migration/ -│ ├── httpclient-spring7-service-groups/ -│ ├── httpclient-jetty-http3-experimental/ -│ ├── httpclient-spring-boot-starter/ -│ └── httpclient-testkit/ -├── infra/httpclient/ -│ ├── proxy/ -│ ├── tls/ -│ ├── oauth2/ -│ └── toxiproxy/ -└── docs/httpclient/ -``` - -| 모듈 | 책임 | 의존 규칙 | -|---|---|---| -| `httpclient-core-api` | 안정 타입, result, evidence, body, 오류 | Spring·Apache·Netty·Resilience4j에 의존하지 않는다. | -| `httpclient-profile` | Named Client Profile, validation, runtime registry | core-api에만 공개적으로 의존한다. | -| `httpclient-transport-spi` | blocking·reactive transport provider와 classifier | Spring Web integration type은 이 SPI부터 허용한다. | -| `httpclient-transport-apache` | Apache HC5 request factory, pool, proxy, TLS hooks | native client를 외부에 반환하지 않는다. | -| `httpclient-transport-jdk` | JDK request factory와 제한 capability | fine-grained pool이 필요한 profile을 거부한다. | -| `httpclient-restclient` | Blocking Generic Gateway와 RestClient 실행 pipeline | Apache/JDK provider를 선택한다. | -| `httpclient-resilience` | deadline, retry, budget, circuit, rate, bulkhead | HTTP-specific retry 판정을 소유한다. | -| `httpclient-auth` | API key, Basic, Bearer, OAuth2, mTLS identity, signing SPI | token과 secret을 result·log에 노출하지 않는다. | -| `httpclient-security` | target, URI, redirect, header, body size, TLS 정책 | H1·H2·H3 모두 우회하지 못한다. | -| `httpclient-observability` | logical·attempt metric, trace, redaction | low-cardinality vocabulary를 소유한다. | -| `httpclient-transport-reactor-netty` | Reactor connector, pool, timeout, cancel | event-loop blocking을 허용하지 않는다. | -| `httpclient-webclient` | Reactive Generic Gateway, streaming, SSE | Reactor Context로 operation metadata를 전달한다. | -| `httpclient-service-client` | `@HttpExchange` proxy, profile·operation annotation | blocking·reactive proxy를 생성한다. | -| `httpclient-dynamic-target` | canonicalization, DNS/IP pinning, redirect revalidation | trusted credential을 의존하거나 상속하지 않는다. | -| `httpclient-resttemplate-migration` | 기존 RestTemplate 설정을 RestClient로 이전 | 신규 feature annotation을 제공하지 않는다. | -| `httpclient-spring7-service-groups` | Spring 7 HTTP Service Group 통합 | Spring 6.2 core에서 완전히 분리한다. | -| `httpclient-jetty-http3-experimental` | Jetty HTTP/3 connector와 capability matrix | Stable starter가 자동 활성화하지 않는다. | -| `httpclient-spring-boot-starter` | properties, auto-configuration, validation | production unsafe 설정에서 startup을 실패시킨다. | -| `httpclient-testkit` | mock·fault·TLS·H2·proxy·OAuth contract fixture | production module에서 의존하지 않는다. | - ---- - -## 9. 공개 API - -### 9.1 핵심 식별자 - -```java -public record ClientProfileName(String value) { - public ClientProfileName { - if (value == null || !value.matches("[a-z][a-z0-9-]{1,62}")) { - throw new IllegalArgumentException("invalid client profile name"); - } - } -} - -public record OperationName(String value) { - public OperationName { - if (value == null || !value.matches("[a-z][a-z0-9.-]{1,127}")) { - throw new IllegalArgumentException("invalid operation name"); - } - } -} -``` - -### 9.2 H1 Typed Service Client - -```java -public interface HttpServiceRegistry { - T client(ClientProfileName profileName, Class serviceType); -} - -@Target(ElementType.TYPE) -@Retention(RetentionPolicy.RUNTIME) -public @interface HttpClientProfile { - String value(); -} - -@Target(ElementType.METHOD) -@Retention(RetentionPolicy.RUNTIME) -public @interface HttpOperationPolicy { - String name(); - OperationIdempotency idempotency(); - String retryPolicy() default "none"; - String timeoutPolicy() default "default"; - boolean streaming() default false; -} -``` - -```java -@HttpClientProfile("payment") -@HttpExchange("/payments") -public interface PaymentClient { - - @PostExchange - @HttpOperationPolicy( - name = "create-payment", - idempotency = OperationIdempotency.IDEMPOTENCY_KEY_REQUIRED, - retryPolicy = "payment-write") - PaymentResponse create( - @RequestHeader("Idempotency-Key") String idempotencyKey, - @RequestBody PaymentRequest request); -} -``` - -Typed interface는 다음 조건을 만족해야 startup에 성공한다. - -- interface에 `@HttpClientProfile`이 존재한다. -- 모든 method에 안정적인 `operationName`이 존재한다. -- POST·PATCH는 idempotency를 명시한다. -- `IDEMPOTENCY_KEY_REQUIRED` method에는 등록된 key parameter가 존재한다. -- streaming method는 one-shot 여부가 드러나는 wrapper type을 사용한다. -- 반환형이 blocking인지 reactive인지 하나의 interface에서 모호하지 않다. - -### 9.3 H2 Generic Exchange - -```java -public interface GenericHttpGateway { - HttpCallResult exchange( - ClientProfileName profileName, - HttpOperation operation, - ResponseType responseType); -} - -public interface ReactiveHttpGateway { - Mono> exchange( - ClientProfileName profileName, - HttpOperation operation, - ResponseType responseType); -} -``` - -H2가 변경할 수 있는 것은 method, profile 내부 상대 path, query, 승인된 header와 body다. 다음은 변경할 수 없다. - -- scheme -- host -- port -- proxy -- TLS trust -- credential provider -- hard body limit -- redirect cross-origin 허용 -- metric naming - -### 9.4 H3 Dynamic Target - -```java -public interface DynamicTargetGateway { - HttpCallResult exchange( - DynamicTargetPolicyName policyName, - URI target, - HttpOperation operation, - ResponseType responseType); -} - -public interface ReactiveDynamicTargetGateway { - Mono> exchange( - DynamicTargetPolicyName policyName, - URI target, - HttpOperation operation, - ResponseType responseType); -} -``` - -H3는 profile의 API key, OAuth token, Cookie, custom default header를 상속하지 않는다. host별 credential이 필요하면 보안 관리자가 `DynamicCredentialBinding`을 별도로 등록한다. - -### 9.5 H4 Native SPI - -다음 형태의 application-facing API는 제공하지 않는다. - -```java -ApacheHttpClient nativeApacheClient(); -HttpClient nativeJdkClient(); -reactor.netty.http.client.HttpClient nativeReactorClient(); -WebClient.Builder mutableBuilder(); -RestClient.Builder mutableBuilder(); -``` - -Native 구성은 `TransportProvider` 구현 내부와 Experimental 모듈에서만 접근한다. - ---- - -## 10. Core 계약 - -### 10.1 Operation - -```java -public record HttpOperation( - OperationName operationName, - HttpMethod method, - String uriTemplate, - Map uriVariables, - Map> headers, - BodySource body, - OperationIdempotency idempotency, - Optional idempotencyKey, - Optional deadline) { -} -``` - -`HttpMethod`는 플랫폼 enum을 사용한다. TRACE는 enum에 포함하지 않고 custom method descriptor도 사전 등록해야 한다. - -### 10.2 BodySource - -```java -public sealed interface BodySource permits - EmptyBody, - ObjectBody, - ByteArrayBody, - ReopenableStreamBody, - OneShotStreamBody { - - BodyReplayability replayability(); - OptionalLong knownLength(); -} - -public record ReopenableStreamBody( - IOSupplier opener, - OptionalLong knownLength, - String mediaType) implements BodySource { - @Override public BodyReplayability replayability() { - return BodyReplayability.REOPENABLE; - } -} - -public record OneShotStreamBody( - InputStream stream, - OptionalLong knownLength, - String mediaType) implements BodySource { - @Override public BodyReplayability replayability() { - return BodyReplayability.ONE_SHOT; - } -} -``` - -Reactive body는 `httpclient-webclient`의 별도 타입을 사용한다. - -```java -public record ReactiveBodySource( - Supplier> publisherFactory, - BodyReplayability replayability, - OptionalLong knownLength, - MediaType mediaType) { -} -``` - -`Publisher` instance 자체를 받는 API는 one-shot으로 간주한다. retry 가능한 body는 매 시도 새 publisher를 생성하는 factory를 요구한다. - -### 10.3 ResponseType과 lifecycle - -```java -public sealed interface ResponseType permits - ClassResponseType, - GenericResponseType, - ByteArrayResponseType, - EmptyResponseType { -} - -public interface BlockingStreamingResponse extends AutoCloseable { - HttpStatus status(); - Map> headers(); - InputStream body(); - @Override void close(); -} -``` - -Streaming response는 반드시 `AutoCloseable`로 반환한다. `InputStream`만 단독 반환하지 않는다. - -### 10.4 Result - -```java -public record HttpCallResult( - HttpStatus status, - Map> headers, - T body, - int attempts, - Duration elapsed, - ExecutionEvidence evidence, - Optional remoteProblem) { -} -``` - -2xx 이외 status를 result로 반환할지 예외로 변환할지는 operation policy가 결정한다. 기본 Typed Client는 4xx·5xx를 안정 예외로 변환하고 Generic Gateway는 `StatusHandlingPolicy`를 명시할 수 있다. - ---- - -## 11. Named Client Profile - -### 11.1 구성 모델 - -```yaml -http-clients: - payment: - mode: TRUSTED - base-url: https://payment.example.com - allowed-hosts: [payment.example.com] - allowed-ports: [443] - api: REST_CLIENT - transport: APACHE - protocols: [HTTP_2, HTTP_1_1] - - pool: - max-total-connections: 100 - max-connections-per-route: 50 - max-pending-acquires: 200 - pending-acquire-timeout: 200ms - max-idle-time: 30s - max-life-time: 5m - validate-after-inactivity: 5s - eviction-interval: 15s - - timeout: - dns: 300ms - connect: 500ms - tls-handshake: 1s - proxy-connect: 500ms - request-write-idle: 1s - response-header: 2s - read-idle: 3s - total-call: 4s - streaming-idle: 30s - - redirect: - enabled: false - max-hops: 0 - allow-cross-origin: false - - request: - max-body-bytes: 1048576 - compression: false - - response: - max-wire-bytes: 5242880 - max-decoded-bytes: 10485760 - allowed-content-types: - - application/json - - application/problem+json - - authentication: - type: OAUTH2_CLIENT_CREDENTIALS - registration-id: payment - scopes: [payments.write] - audience: payment-api - - retry: - policy: payment-write - max-attempts: 2 - base-backoff: 50ms - max-backoff: 200ms - jitter: FULL - retry-after: HONOR - budget: payment - - circuit-breaker: payment - bulkhead: payment - rate-limiter: payment-attempts - - observability: - operation-name-required: true - full-url-recording: false - body-logging: false -``` - -위 숫자는 플랫폼 default가 아니라 `payment` profile의 명시적 예시다. production profile은 upstream SLO와 부하 계산 없이 숨은 기본값으로 생성되지 않는다. - -### 11.2 startup validation - -다음 조건은 startup 실패다. - -- Trusted profile에 base URL이 없다. -- `http` scheme이 production profile에서 사용된다. -- base URL에 userinfo 또는 query가 포함된다. -- allowed host와 base URL host가 다르다. -- redirect가 활성화됐는데 max hops가 0이거나 cross-origin credential 정책이 없다. -- total call timeout이 connect 또는 response header timeout보다 짧다. -- max decoded bytes가 global hard maximum을 초과한다. -- JDK transport에 세밀한 pending queue 또는 route pool 보장을 요구한다. -- HTTP/3를 Stable profile에서 요청한다. -- Dynamic mode에 default OAuth, API key, Cookie가 설정된다. -- trust-all, hostname verification off, plaintext fallback이 설정된다. -- production에서 Simple request factory가 선택된다. -- POST retry policy가 idempotency 조건 없이 활성화된다. - -### 11.3 Operation override - -operation은 profile 값을 더 위험한 방향으로 넓힐 수 없다. - -```text -허용: -- 더 짧은 total timeout -- 더 작은 response size -- retry 비활성화 -- stricter content type -- streaming idle timeout 지정 - -금지: -- 더 긴 timeout -- 더 큰 body limit -- 다른 host -- 다른 credential -- cross-origin redirect 활성화 -- non-idempotent retry 강제 -``` - ---- - -## 12. Target·URI·Header 정책 - -### 12.1 Trusted target - -Trusted profile은 startup에 다음을 검증한다. - -- URI strict parsing -- scheme, host, port -- IDNA canonical host -- userinfo 없음 -- path base normalization -- allowed host·port 일치 -- production TLS policy - -H2 호출자는 상대 URI template만 전달한다. `URI` absolute 값이 들어오면 거부한다. - -### 12.2 URI encoding - -- path와 query를 문자열로 연결하지 않는다. -- template variable은 component별로 encode한다. -- 이미 인코딩된 값과 raw 값을 동일 API에서 혼용하지 않는다. -- query value의 민감정보는 log와 trace에서 제거한다. -- 국제화 host는 Punycode canonical form으로 allowlist와 비교한다. -- IPv4-mapped IPv6를 원래 IPv4로 정규화한다. - -### 12.3 Header - -다음 header는 플랫폼이 소유한다. - -```text -Authorization -Proxy-Authorization -Host -Content-Length -Transfer-Encoding -Traceparent -Tracestate -Baggage -Cookie (profile opt-in일 때) -``` - -호출자가 임의로 덮어쓰지 못한다. `Idempotency-Key`는 operation descriptor가 요구할 때만 허용한다. header name·value에 CR 또는 LF가 있으면 요청 전 거부한다. - -### 12.4 Redirect - -기본값은 비활성이다. - -| 상태 | 기본 정책 | -|---|---| -| 301, 302, 303 | 자동 method 변환을 신뢰하지 않고 operation별로 명시한다. | -| 307, 308 | method와 body를 보존하므로 body replayable일 때만 허용한다. | -| same-origin | max hop과 method 정책 안에서 선택 허용한다. | -| cross-origin | 기본 거부한다. 허용 시 Authorization, Cookie, API key를 제거한다. | -| Dynamic Target | 각 hop을 새로운 target으로 canonicalize·resolve·IP 검증한다. | - ---- - -## 13. Transport SPI - -### 13.1 Blocking provider - -```java -public interface BlockingTransportProvider { - TransportId id(); - BlockingTransportCapabilities capabilities(); - ClientHttpRequestFactory create( - ClientProfile profile, - TransportLifecycleListener listener); - TransportFailureClassifier failureClassifier(); -} -``` - -### 13.2 Reactive provider - -```java -public interface ReactiveTransportProvider { - TransportId id(); - ReactiveTransportCapabilities capabilities(); - ClientHttpConnector create( - ClientProfile profile, - TransportLifecycleListener listener); - TransportFailureClassifier failureClassifier(); -} -``` - -### 13.3 공통 원칙 - -- provider는 native client를 반환하지 않는다. -- capability가 profile 요구사항보다 약하면 startup에 실패한다. -- transport exception은 public API에 직접 노출하지 않는다. -- transport가 `NOT_SENT`를 증명할 수 없으면 `SENT_NO_RESPONSE` 또는 보수적 unknown reason으로 분류한다. -- response body를 소비·close하지 않은 경우 connection 재사용 여부를 명시한다. -- client runtime 종료 시 신규 retry를 금지하고 진행 호출을 drain한다. - -### 13.4 Apache profile - -- 전체·route별 connection 제한 -- pending acquire timeout -- max idle, max lifetime -- validate after inactivity -- background eviction -- proxy와 CONNECT -- custom TLS strategy -- HTTP/1.1·2 -- blocking response lifecycle - -### 13.5 JDK profile - -- 의존성 최소화 profile -- HTTP/1.1·2 -- sync send 기반 -- 세밀한 pool queue·route limit을 요구하지 않는 경우만 사용 -- Dynamic Target Stable에서 제외 -- streaming body close·cancel contract 검증 - -### 13.6 Reactor Netty profile - -- provider를 upstream별로 분리한다. -- max connections, pending acquire, idle, lifetime, eviction을 설정한다. -- DNS, connect, TLS, proxy, response timeout을 stage별로 계측한다. -- event-loop에서 blocking codec·file I/O를 금지한다. -- cancellation에서 inbound buffer를 release하고 connection을 반환 또는 폐기한다. - -### 13.7 Jetty HTTP/3 - -- feature flag와 별도 module이 필요하다. -- Stable starter가 자동 구성하지 않는다. -- QUIC native dependency와 TLS 1.3을 요구한다. -- HTTP/3 failure를 공통 evidence로 변환하는 contract suite를 통과해야 Beta로 승격한다. - ---- - -## 14. Connection Pool과 동시성 - -### 14.1 pool과 bulkhead 분리 - -HTTP/1.1은 connection과 in-flight 요청 수가 가까울 수 있지만 HTTP/2는 하나의 connection에 여러 stream을 multiplex한다. 따라서 다음을 독립 설정으로 둔다. - -```text -connection pool limit -pending acquire queue limit -HTTP/2 stream capacity -logical admission limit -attempt bulkhead concurrency -``` - -### 14.2 pool 설정 - -| 설정 | 의미 | -|---|---| -| `maxTotalConnections` | runtime 전체 socket 상한 | -| `maxConnectionsPerRoute` | 한 upstream route 상한 | -| `maxPendingAcquires` | 대기 요청 메모리 상한 | -| `pendingAcquireTimeout` | pool·stream 대기 상한 | -| `maxIdleTime` | 유휴 연결 제거 | -| `maxLifeTime` | DNS·LB 변경과 인증서 rotation 반영 | -| `validateAfterInactivity` | stale·half-open 연결 검사 | -| `evictionInterval` | background cleanup | -| `shutdownTimeout` | drain 후 강제 종료 시각 | - -### 14.3 DNS와 기존 연결 - -DNS TTL만으로 pooled connection이 새 IP로 전환된다고 가정하지 않는다. `maxLifeTime`과 eviction을 함께 사용하고, DNS 변경 contract test에서 일정 시간 내 새 endpoint로 전환되는지 확인한다. - ---- - -## 15. Timeout과 Deadline - -### 15.1 단계별 timeout - -| 타입 | 시작과 종료 | -|---|---| -| DNS | hostname resolve 시작부터 결과 | -| Pool Acquire | queue 진입부터 connection 또는 stream 확보 | -| Connect | socket connect 시작부터 성공 | -| TLS Handshake | TCP 이후 TLS·ALPN 완료 | -| Proxy Connect | proxy socket 또는 CONNECT 완료 | -| Request Write Idle | request chunk 진행이 없는 시간 | -| Response Header | request 전송 후 final header 수신까지 | -| Read Idle | response chunk 사이 무진행 시간 | -| Total Call | 최초 논리 호출부터 모든 retry·backoff 종료까지 | -| Streaming Idle | 장기 stream event 사이 무진행 시간 | -| Shutdown | runtime drain 시작부터 강제 종료까지 | - -### 15.2 effective deadline - -```text -effectiveDeadline = min(parentDeadline, now + profile.totalCallTimeout) -remaining = effectiveDeadline - now - safetyMargin -attemptBudget = remaining - plannedBackoff - cleanupReserve -``` - -다음이면 새 attempt를 시작하지 않는다. - -- `remaining <= minimumAttemptBudget` -- 다음 backoff 이후 attempt budget이 없다. -- body가 replayable하지 않다. -- ambiguous execution이고 operation이 안전하지 않다. -- retry budget이 고갈됐다. -- circuit이 open이다. -- runtime이 draining 상태다. - -### 15.3 Streaming - -Streaming은 연결 설정 단계와 연결 유지 단계를 분리한다. - -```text -setupDeadline -→ response headers 수신 -→ streamingIdleTimeout -→ optional maxStreamDuration -``` - -일반 total timeout을 SSE 전체 수명에 적용하지 않는다. - ---- - -## 16. 실행 증거 - -### 16.1 public evidence - -| Evidence | 의미 | 예 | -|---|---|---| -| `NOT_SENT` | 서버에 요청이 전달되지 않았음을 증명 | profile 거부, pool timeout, DNS 실패, connect 실패, request 전 TLS 실패 | -| `SENT_NO_RESPONSE` | 일부 또는 전체 요청을 보냈으나 final header를 받지 못함 | partial write, response header timeout, reset | -| `RESPONSE_RECEIVED` | final HTTP header를 받음 | 2xx, 4xx, 5xx, redirect | -| `PARTIAL_RESPONSE` | header와 body 일부를 받음 | decode 중 reset, streaming 중단 | - -### 16.2 stage - -```java -public enum AttemptStage { - VALIDATION, - AUTHENTICATION, - POOL_ACQUIRE, - DNS, - CONNECT, - TLS_HANDSHAKE, - PROXY_CONNECT, - REQUEST_HEADERS, - REQUEST_BODY, - RESPONSE_HEADERS, - RESPONSE_BODY, - COMPLETE -} -``` - -### 16.3 보수적 분류 - -- `NOT_SENT`는 증명 가능한 stage 실패에서만 사용한다. -- engine generic I/O exception은 false `NOT_SENT`로 만들지 않는다. -- request body write가 시작됐으면 기본 `SENT_NO_RESPONSE`다. -- response header를 받았으면 status와 무관하게 `RESPONSE_RECEIVED`다. -- body 일부가 application에 전달됐으면 `PARTIAL_RESPONSE`다. -- HTTP/2 `REFUSED_STREAM`과 GOAWAY last-stream-id는 내부 protocol evidence로 보존하고 안전한 경우 `NOT_SENT`에 준해 retry한다. - ---- - -## 17. Retry - -### 17.1 판정 입력 - -```java -public record RetryContext( - OperationIdempotency idempotency, - Optional idempotencyKey, - BodyReplayability replayability, - ExecutionEvidence evidence, - FailureCategory failureCategory, - Optional responseStatus, - Optional retryAfter, - int attempt, - Duration remainingDeadline, - RetryBudgetSnapshot budget) { -} -``` - -### 17.2 판정 결과 - -```java -public sealed interface RetryDecision permits - RetryAllowed, - RetryDenied, - AmbiguousFailure { -} -``` - -### 17.3 기본 규칙 - -| 상황 | 기본 판정 | -|---|---| -| validation·auth configuration failure | retry 금지 | -| pool·DNS·connect failure | body 재생 가능하고 deadline·budget이 있으면 허용 | -| certificate·hostname failure | retry 금지 | -| request body 일부 송신 | standard 또는 contract idempotent가 아니면 ambiguous | -| response header timeout | read-only 또는 idempotency contract가 있을 때만 허용 | -| 408 | replayability·deadline 조건으로 제한 | -| 425 | early data 없이 한 번만 제한 retry | -| 429 | `Retry-After`, deadline, budget 내에서 허용 | -| 500 | 기본 금지, upstream policy가 transient로 등록한 경우만 | -| 502·503·504 | 안전한 operation에 제한 허용 | -| 401 | credential invalidation 후 최대 1회, 안전한 body와 operation만 | -| partial response | application 전달 전 read-only buffering에서만 제한 | -| one-shot body | retry 금지 | -| first byte delivered | retry 금지 | - -### 17.4 Retry budget - -upstream별 token bucket을 사용한다. - -```text -원 요청 성공·실패 수에 비례한 retry token 공급 -물리 retry마다 token 소비 -budget 고갈 시 즉시 최종 실패 -``` - -metric은 logical call 수와 physical attempt 수를 분리한다. - -### 17.5 Backoff - -- exponential backoff -- full 또는 decorrelated jitter -- max backoff -- `Retry-After` 상한 -- deadline보다 긴 대기 금지 -- backoff 중 bulkhead permit과 connection을 보유하지 않음 - ---- - -## 18. Resilience 실행 순서 - -```mermaid -flowchart LR - A[Operation Validation] --> B[Effective Deadline] - B --> C[Authentication] - C --> D[Logical Admission] - D --> E[Retry Coordinator] - E --> F{Circuit Open?} - F -- Yes --> X[Fail Fast] - F -- No --> G[Attempt Rate Limiter] - G --> H[Attempt Bulkhead] - H --> I[HTTP Attempt] - I --> J[Evidence Classification] - J --> K{Retry Safe?} - K -- Yes --> L[Backoff + Jitter] - L --> E - K -- No --> M[Result or Stable Error] -``` - -### 18.1 역할 - -| 기능 | 보호 대상 | -|---|---| -| Logical admission | retry coordinator와 대기 객체의 과도한 생성 | -| Circuit Breaker | 실패하거나 느린 upstream 호출 | -| Attempt Rate Limiter | 외부 API의 물리 요청 quota | -| Attempt Bulkhead | in-flight 물리 요청과 thread·stream capacity | -| Retry Budget | 장애 중 추가 요청 총량 | -| Total Deadline | 사용자 요청의 전체 시간 예산 | - -### 18.2 Blocking과 Reactive - -- Blocking Apache/JDK는 semaphore 또는 bounded executor bulkhead를 사용한다. -- Reactive는 event-loop를 thread-pool bulkhead로 감싸지 않고 semaphore concurrency를 사용한다. -- blocking token acquisition이나 secret load는 event-loop에서 실행하지 않는다. - ---- - -## 19. 오류 모델 - -```text -HttpClientException - ├─ HttpConfigurationException - ├─ HttpTargetRejectedException - ├─ HttpDnsException - ├─ HttpPoolAcquireTimeoutException - ├─ HttpConnectException - ├─ HttpProxyException - ├─ HttpTlsException - ├─ HttpRequestWriteException - ├─ HttpResponseTimeoutException - ├─ HttpResponseTruncatedException - ├─ HttpRemoteErrorException - ├─ HttpProblemDetailException - ├─ HttpRedirectRejectedException - ├─ HttpAuthenticationException - ├─ HttpSerializationException - ├─ HttpResponseTooLargeException - ├─ HttpDeadlineExceededException - ├─ HttpCircuitOpenException - ├─ HttpBulkheadRejectedException - ├─ HttpRateLimitRejectedException - └─ HttpAmbiguousExecutionException -``` - -### 19.1 공통 metadata - -```java -public record HttpFailureMetadata( - ClientProfileName clientName, - OperationName operationName, - HttpMethod method, - String uriTemplate, - ExecutionEvidence evidence, - BodyReplayability replayability, - AttemptStage stage, - boolean retryable, - int attempt, - Duration elapsed, - Duration remainingDeadline, - Optional status, - Optional traceId) { -} -``` - -다음은 예외 message나 public metadata에 포함하지 않는다. - -- 전체 URL -- query value -- 실제 path variable -- request·response body -- Authorization, Cookie, API key -- idempotency key 원문 -- client secret -- resolved IP의 metric label - -### 19.2 RFC 9457 - -`application/problem+json`은 다음 필드를 제한 크기로 보존한다. - -```text -type -title -status -detail -instance -등록된 extension allowlist -``` - -HTTP response status가 authoritative다. body의 `status`로 실제 status를 덮어쓰지 않는다. `detail`, `instance`, extension은 log에 기본 기록하지 않는다. - ---- - -## 20. 인증 - -### 20.1 지원 방식 - -| 방식 | 등급 | 정책 | -|---|---:|---| -| None | Stable | 명시 profile | -| Basic | 제한 | TLS 필수, secret provider | -| API Key Header | Stable | header name allowlist | -| API Key Query | 승인 필요 | provider 요구 시만 | -| Static Bearer | 제한 | 짧은 TTL과 rotation | -| OAuth2 Client Credentials | Stable | M2M 기본 | -| Authorization Code authorized client | 지원 | principal을 명시 전달 | -| token relay | 제한 | audience·scope 확인 | -| Token Exchange | 선택 | audience 축소·delegation | -| mTLS | Stable | TLS identity profile | -| Request Signing | SPI | provider별 module | -| Proxy Authentication | Stable | target auth와 분리 | - -### 20.2 credential provider - -```java -public interface RequestCredentialProvider { - CredentialType type(); - RequestCredentials resolve(CredentialRequest request); -} - -public interface ReactiveRequestCredentialProvider { - CredentialType type(); - Mono resolve(CredentialRequest request); -} -``` - -### 20.3 OAuth2 token cache - -cache key는 다음을 포함한다. - -```text -registrationId -principalClass -scopeSet -audience -tenantBoundary -mTLSCertificateIdentity -``` - -동일 key의 refresh는 single-flight로 수행한다. token endpoint는 target upstream과 별도 Named Client Profile을 사용한다. - -### 20.4 401 재호출 - -- token을 한 번 invalidate한다. -- refresh 후 최대 한 번만 재호출한다. -- body가 replayable해야 한다. -- operation이 read-only이거나 인증 실패가 side effect 전 반환된다는 계약이 있어야 한다. -- one-shot upload와 ambiguous write에는 적용하지 않는다. - ---- - -## 21. TLS와 인증서 rotation - -### 21.1 허용 - -- TLS 1.2·1.3 -- hostname verification -- JVM trust store -- profile별 custom CA -- profile별 client certificate -- mTLS -- SNI와 ALPN -- 새 runtime generation으로 certificate rotation - -### 21.2 금지 - -- trust-all TrustManager -- hostname verification 비활성화 -- 인증서 오류 무시 -- production self-signed 자동 신뢰 -- HTTPS 실패 후 HTTP fallback -- key material의 config file·log 기록 - -### 21.3 오류 분류 - -| 오류 | retry | -|---|---:| -| unknown CA | 금지 | -| hostname mismatch | 금지 | -| expired certificate | 금지 | -| revoked certificate | 금지 | -| protocol mismatch | profile 오류로 금지 | -| transient handshake timeout | deadline과 policy 안에서 제한 | -| client certificate 없음 | 금지 | - ---- - -## 22. Dynamic Target와 SSRF - -### 22.1 처리 순서 - -```text -1. URI strict parse -2. scheme allowlist -3. userinfo·invalid port 거부 -4. host IDNA canonicalization -5. host allowlist 또는 suffix policy -6. 모든 A·AAAA resolve -7. 각 주소를 canonical IP로 정규화 -8. loopback, link-local, private, ULA, metadata 대역 검사 -9. 검증한 주소로 실제 connection pinning -10. response size·content policy 적용 -11. redirect마다 1~10을 반복 -``` - -### 22.2 기본 금지 주소 - -- IPv4·IPv6 loopback -- link-local -- RFC1918 private address -- IPv6 ULA -- unspecified·multicast -- IPv4-mapped IPv6의 차단 대상 -- cloud metadata endpoint -- 조직이 정의한 internal CIDR - -### 22.3 transport 제한 - -Dynamic Target Stable은 validated resolver 또는 validated address pinning을 제공하는 Apache와 Reactor Netty에서 먼저 지원한다. JDK와 Jetty는 동일 보장을 contract test로 증명하기 전까지 H3에서 사용할 수 없다. - -### 22.4 redirect credential - -origin이 변경되면 다음을 제거한다. - -```text -Authorization -Proxy-Authorization -Cookie -API key header -custom sensitive header -``` - -Dynamic profile에는 Cookie Jar를 기본 생성하지 않는다. - -### 22.5 네트워크 계층 - -애플리케이션 검증만으로 충분하다고 간주하지 않는다. Kubernetes NetworkPolicy, service mesh egress, firewall, proxy ACL 중 하나 이상의 네트워크 제어를 운영 완료 조건으로 요구한다. - ---- - -## 23. Streaming과 대용량 Body - -### 23.1 request replayability - -| Body | Replayability | -|---|---| -| immutable `byte[]` | REPLAYABLE | -| DTO + deterministic codec | REPLAYABLE | -| reopenable file/resource supplier | REOPENABLE | -| one `InputStream` instance | ONE_SHOT | -| publisher factory | 선언값에 따름 | -| publisher instance | ONE_SHOT | -| multipart | 가장 약한 part와 동일 | - -### 23.2 response lifecycle - -- blocking stream은 `AutoCloseable` response wrapper로 반환한다. -- reactive body는 consume, cancel, error에서 buffer를 release한다. -- content length를 신뢰하지 않고 실제 wire bytes와 decoded bytes를 측정한다. -- gzip·deflate 응답은 압축 전후 상한을 각각 적용한다. -- decode error와 size 초과에서도 connection을 회수하거나 명시적으로 폐기한다. - -### 23.3 first-byte boundary - -```text -response header 수신 -→ 내부 buffer에 아직 byte 미전달 - → read-only operation은 제한 retry 가능 -→ application InputStream read 또는 Flux onNext 발생 - → transparent retry 영구 금지 -``` - -### 23.4 SSE - -```java -public interface ReactiveSseGateway { - Flux> connect( - ClientProfileName profileName, - SseOperation operation, - ResponseType eventType); -} -``` - -- setup deadline -- streaming idle timeout -- `Last-Event-ID` 재연결은 operation opt-in -- reconnect에도 retry budget 적용 -- application cancel 시 connection close - ---- - -## 24. HTTP protocol 세부 정책 - -### 24.1 HTTP/2 - -- connection 수와 stream concurrency를 분리한다. -- max concurrent streams를 metric으로 노출한다. -- `REFUSED_STREAM`은 peer 미처리 증거로 제한 retry할 수 있다. -- GOAWAY의 last stream ID 이후 요청만 peer 미처리로 분류한다. -- stream reset 원인을 stable failure category로 변환한다. -- connection coalescing은 host·certificate·security policy를 검증한 profile에서만 허용한다. - -### 24.2 HTTP/3 - -- TLS 1.3 필수 -- UDP·QUIC 네트워크 경로 테스트 -- proxy·egress 지원 별도 매트릭스 -- Stable H1/H2 API의 result·error semantic을 재사용 -- 별도 `experimental=true`와 startup acknowledgment 요구 - -### 24.3 Proxy - -- target auth와 proxy auth를 분리한다. -- proxy connect timeout을 별도 metric으로 기록한다. -- HTTPS CONNECT 실패를 target TLS 실패로 오분류하지 않는다. -- `NO_PROXY` 환경변수가 production allowlist를 우회하지 못하게 한다. -- service mesh retry가 활성화되면 application retry owner 검사를 수행한다. - ---- - -## 25. 관측성 - -### 25.1 metric - -| 이름 | 의미 | -|---|---| -| `http.client.requests` | 물리 attempt timer | -| `http.client.logical.calls` | 사용자 논리 호출 timer | -| `http.client.attempts` | attempt counter | -| `http.client.retry.count` | retry 이유별 수 | -| `http.client.retry.exhausted` | retry 소진 | -| `http.client.ambiguous` | 결과 모호성 | -| `http.client.timeout` | timeout stage | -| `http.client.request.bytes` | request wire bytes | -| `http.client.response.bytes` | response wire·decoded bytes | -| `http.client.active` | 진행 중 attempt | -| `http.client.pool.connections` | active·idle connection | -| `http.client.pool.pending` | pool 대기 | -| `http.client.pool.acquire.duration` | pool 대기 시간 | -| `http.client.dns.duration` | DNS 시간 | -| `http.client.connect.duration` | connect 시간 | -| `http.client.tls.duration` | TLS 시간 | -| `http.client.circuit.state` | circuit 상태 | -| `http.client.bulkhead.rejected` | bulkhead 거절 | -| `http.client.rate_limit.rejected` | local rate 거절 | -| `http.client.oauth.refresh` | token refresh 결과 | -| `http.client.ssrf.rejected` | dynamic target 거절 | - -### 25.2 low-cardinality tag - -허용: - -```text -clientName -operationName -method -uriTemplate -status -outcome -transport -protocol -timeoutType -retryReason -evidence -circuitState -``` - -금지: - -```text -full URL -query parameter -path variable value -user ID -tenant ID 원문 -resolved IP -API key -token -Cookie -idempotency key -request·response body -exception message -``` - -### 25.3 trace - -```text -http.client.operation logical internal span -└─ http.client.request attempt 1 CLIENT span -└─ http.client.request attempt 2 CLIENT span -``` - -- W3C Trace Context -- Baggage allowlist -- Dynamic target는 기본 trace propagation off -- retry reason과 evidence를 span event로 기록 -- credential과 remote error body는 attribute에 기록하지 않음 - -### 25.4 logging - -- 시도마다 WARN을 남기지 않는다. -- 최종 실패 한 번을 구조화 로그로 남긴다. -- retry attempt는 DEBUG 또는 trace event다. -- URL은 template과 profile name만 남긴다. -- body logging은 production에서 off다. -- header는 이름 allowlist, 값은 redaction policy를 적용한다. - ---- - -## 26. Spring 통합 - -### 26.1 RestClient - -- profile마다 immutable RestClient를 생성한다. -- Apache 또는 JDK request factory를 선택한다. -- default header는 credential과 trace보다 먼저 고정하지 않는다. -- request interceptor는 operation context와 attempt context를 읽는다. -- response extractor는 body lifecycle과 size를 통제한다. - -### 26.2 WebClient - -- profile마다 immutable WebClient를 생성한다. -- Reactor Netty provider를 upstream별로 분리한다. -- filter chain은 context에서 operation metadata를 가져온다. -- body cancel·discard hook을 등록한다. -- `.block()`을 public API 내부에서 호출하지 않는다. - -### 26.3 HTTP Service Client - -`HttpServiceRegistry`는 다음 작업을 수행한다. - -1. interface annotation scan -2. method descriptor 생성 -3. signature validation -4. RestClient 또는 WebClient proxy 생성 -5. operation context wrapper proxy 생성 -6. blocking은 try/finally로 context 제거 -7. reactive는 Reactor Context에 descriptor 주입 - -### 26.4 Spring 7 Service Group - -Spring 7 전용 모듈은 여러 service interface가 같은 profile을 공유하도록 group integration을 제공한다. 공통 계약과 profile validation은 그대로 재사용한다. - -### 26.5 RestTemplate migration - -Migration module은 다음만 제공한다. - -- 기존 request factory와 message converter를 조사하는 audit 도구 -- RestTemplate에서 RestClient builder로 이전하는 adapter -- deprecated usage report -- 동일 동작 contract test - -신규 retry, Dynamic URL, HTTP/3 기능은 RestTemplate 경로에 추가하지 않는다. - ---- - -## 27. Spring Boot Starter - -### 27.1 auto-configuration - -```text -HttpClientProfileAutoConfiguration -HttpClientTransportAutoConfiguration -HttpClientResilienceAutoConfiguration -HttpClientAuthenticationAutoConfiguration -HttpClientSecurityAutoConfiguration -HttpClientObservationAutoConfiguration -HttpServiceClientAutoConfiguration -DynamicTargetAutoConfiguration -``` - -### 27.2 startup guard - -- production unsafe TLS 설정 탐지 -- Simple factory 차단 -- profile capability mismatch -- duplicate client name -- operation name duplicate -- H1 interface annotation 누락 -- POST/PATCH idempotency 누락 -- Dynamic credential 상속 -- unsupported HTTP/3 Stable 설정 -- response size hard max 위반 -- retry owner 중복 선언 - -### 27.3 actuator - -관리 endpoint는 값 원문을 숨기고 다음만 제공한다. - -```text -profile name -runtime generation -transport -protocol -pool state -circuit state -credential type -TLS profile ID -last reload outcome -capability warnings -``` - -base URL 전체, credential, trust store path, resolved IP는 공개하지 않는다. - ---- - -## 28. 테스트 전략 - -### 28.1 test topology - -| 도구 | 용도 | -|---|---| -| MockWebServer | deterministic request·response contract | -| WireMock | stateful status, redirect, OAuth fixture | -| Toxiproxy | latency, reset, bandwidth, half-open | -| TLS test server | CA, hostname, expiry, mTLS | -| HTTP/2 server | GOAWAY, REFUSED_STREAM, reset | -| Forward proxy | CONNECT, auth, target failure | -| OAuth2 server | token expiry, refresh race, rotation | -| Testcontainers | isolated proxy·server runtime | -| BlockHound | event-loop blocking 검출 | -| ArchUnit | module·native type 경계 | - -### 28.2 계약 테스트 - -- method와 URI encoding -- header ownership과 CRLF 차단 -- JSON·XML·form·multipart -- empty, generic, streaming response -- redirect 301·302·303·307·308 -- compression과 decoded size -- conditional request와 Range -- HTTP/1.1·2 -- Apache·JDK·Reactor 공통 result·error semantic - -### 28.3 timeout·failure - -- DNS timeout -- pool saturation -- connect refused·blackhole -- TLS timeout·trust·hostname -- slow request receiver -- response header delay -- body idle -- total deadline -- streaming idle -- shutdown 중 신규 retry 금지 - -### 28.4 retry·resilience - -- GET connect failure -- PUT body replay -- POST idempotency key 있음·없음 -- partial write -- 408, 425, 429, 500, 502, 503, 504 -- Retry-After -- budget exhaustion -- circuit half-open -- rate limiter와 retry attempt 수 -- bulkhead permit 반환 -- service mesh 중복 retry configuration guard - -### 28.5 security - -- loopback, private, link-local, ULA, metadata -- IPv4-mapped IPv6 -- IDNA host -- DNS rebinding -- public→private redirect -- Authorization·Cookie leakage -- trust-all bean startup failure -- hostname mismatch -- mTLS certificate 없음·rotation -- CRLF header -- compressed bomb -- JSON nesting·XML entity - -### 28.6 streaming lifecycle - -- response 미소비 -- partial read 후 close -- decode failure -- size limit -- reactive cancel -- DataBuffer release -- slow subscriber backpressure -- first byte 이후 retry 없음 -- SSE idle와 Last-Event-ID reconnect -- event-loop blocking 없음 - -### 28.7 observability - -- logical call 1, attempt N -- retry reason -- evidence -- URI template cardinality -- 전체 URL label 없음 -- token·API key 마스킹 -- dynamic target trace propagation off - -### 28.8 성능 - -- pool·bulkhead saturation -- HTTP/2 stream saturation -- 대용량 upload·download -- gzip decoded size -- concurrent OAuth refresh -- runtime generation rotation -- shutdown drain -- heap·direct memory·thread 상한 - ---- - -## 29. 호환성 인증 매트릭스 - -| 프로파일 | CI 빈도 | 릴리스 Gate | -|---|---|---| -| Spring Framework 6.2 latest patch | 모든 PR·release | 필수 | -| Spring Framework 7.0 latest patch | release | 필수 | -| Apache HC5 + RestClient | 모든 PR | 필수 | -| JDK HttpClient + RestClient | 모든 PR | 필수 | -| Reactor Netty + WebClient | 모든 PR | 필수 | -| Jetty HTTP/3 | nightly | Experimental 비차단 | -| HTTP/1.1 | 모든 PR | 필수 | -| HTTP/2 | release | 필수 | -| Forward proxy | release | 지원 선언 시 필수 | -| OAuth2 Client Credentials | 모든 PR | 필수 | -| mTLS | release | 지원 선언 시 필수 | -| Dynamic Target Apache | security suite | 필수 | -| Dynamic Target Reactor | security suite | 필수 | -| Toxiproxy failure suite | nightly·release | 필수 | - ---- - -## 30. 운영 설정과 기본 정책 - -### 30.1 숨은 운영 기본값 금지 - -production Named Client Profile은 다음을 명시해야 한다. - -```text -base URL -transport -total timeout -response header timeout -pool 또는 concurrency limit -request·response body hard limit -authentication type -retry policy 또는 none -redirect policy -TLS profile -``` - -미설정 시 매우 큰 framework default로 조용히 동작하지 않고 startup에 실패한다. - -### 30.2 retry owner - -application HTTP Client, 외부 SDK, service mesh 중 하나만 retry owner가 된다. starter는 known mesh annotation 또는 설정을 읽어 중복 retry를 경고하거나 strict mode에서 실패시킨다. - -### 30.3 shutdown - -```text -runtime state RUNNING → DRAINING -신규 logical call 거부 또는 새 generation으로 routing -진행 attempt 완료 -신규 retry 금지 -shutdown timeout -남은 call cancel -pool close -``` - ---- - -## 31. 비지원 범위의 runtime 강제 - -문서만으로 금지하지 않고 다음 guard를 코드로 둔다. - -| 비지원 | 강제 방식 | -|---|---| -| TRACE | method registry에서 부재·runtime reject | -| unrestricted absolute URL | H2 parser에서 reject | -| trust-all | bean·SSLContext validator startup fail | -| hostname verification off | transport capability validator fail | -| production Simple factory | environment guard fail | -| one-shot retry | Retry Eligibility Engine deny | -| partial stream retry | first-byte marker deny | -| full URL metric | observation convention test | -| Dynamic credential inheritance | configuration validator fail | -| RestTemplate 신규 기능 | module dependency·ArchUnit rule | -| native client exposure | public API signature ArchUnit rule | -| HTTP/3 Stable | profile validator fail | - ---- - -## 32. 릴리스 단계 - -### 32.1 Core Alpha - -- core types -- Named Client Profile -- stable exceptions -- transport SPI -- testkit -- deadline model -- target·header·body guard - -완료 조건: core module의 public API와 configuration validation contract가 통과한다. - -### 32.2 Blocking Beta - -- Apache HC5 -- JDK HttpClient -- RestClient Generic Gateway -- H1 blocking Typed Client -- connection pool·timeout -- error mapping - -완료 조건: Apache와 JDK가 공통 Blocking contract suite를 통과한다. - -### 32.3 Resilience RC - -- execution evidence -- retry eligibility -- retry budget -- circuit·rate·bulkhead -- RFC 9457 -- OAuth2·TLS - -완료 조건: duplicate POST, partial write, 429, pool saturation, token refresh race가 통과한다. - -### 32.4 Security Release - -- Trusted target validation -- Dynamic Target Apache -- DNS/IP pinning -- redirect revalidation -- SSRF security suite - -완료 조건: loopback·private·metadata·rebind·redirect 공격이 모두 차단된다. - -### 32.5 Reactive Release - -- Reactor Netty -- WebClient Gateway -- Reactive Typed Client -- streaming upload·download -- SSE -- cancellation·backpressure - -완료 조건: buffer leak, event-loop blocking, first-byte retry, stream idle suite가 통과한다. - -### 32.6 Extended Release - -- Spring Boot starter -- Spring 7 Service Groups -- RestTemplate migration -- proxy·HTTP/2 advanced evidence -- support matrix와 runbook - -### 32.7 Experimental - -- Jetty HTTP/3 -- Reactor HTTP/3 profile -- Native engine Lab -- request hedging Lab - ---- - -## 33. 완료 정의 - -플랫폼은 다음이 코드와 CI로 증명될 때 완료된다. - -| 영역 | 증명 조건 | -|---|---| -| API | 주요 호출이 Typed Client로 구현되고 H2·H3 사용이 별도 권한으로 제한된다. | -| 경계 | H1~H4가 timeout, host, TLS, auth, size, observation을 우회하지 못한다. | -| Engine | Apache·JDK·Reactor가 동일 result·exception metadata를 제공한다. | -| Deadline | pool·DNS·connect·TLS·retry backoff를 포함한 전체 시간이 effective deadline 이내다. | -| Retry | 모든 추가 attempt가 idempotency·replayability·evidence·deadline·budget으로 설명된다. | -| Ambiguity | 비멱등 `SENT_NO_RESPONSE`가 `HttpAmbiguousExecutionException`으로 구분된다. | -| Resource | body 미소비, decode 오류, cancel, size 초과 후에도 pool과 buffer가 회수된다. | -| Auth | token refresh single-flight, 401 최대 1회, secret rotation이 검증된다. | -| TLS | trust-all과 hostname 검증 해제가 startup에서 차단된다. | -| SSRF | canonicalization, DNS/IP, redirect, egress 테스트가 통과한다. | -| Streaming | first byte 이후 transparent retry가 0회다. | -| Observability | logical call과 attempt가 분리되고 forbidden label이 없다. | -| Failure | DNS, pool, TLS, reset, partial response, HTTP/2 GOAWAY를 재현한다. | -| Performance | 설정된 thread, heap, direct memory, pool, retry budget 상한을 넘지 않는다. | -| Compatibility | Spring 6.2·7.0과 지원 transport matrix가 release CI에 연결된다. | -| Documentation | support matrix, configuration reference, security guide, runbook, migration guide가 코드와 일치한다. | - ---- - -## 34. 최종 구현 기준 - -이 설계의 최종 원칙은 다음과 같다. - -> HTTP 기능을 최대한 많이 열어두되, 호출자가 URL·timeout·retry·credential·TLS·resource lifecycle을 임의로 조립하게 하지 않는다. 일반 호출은 Typed Client와 Named Client Profile을 사용하고, 플랫폼은 요청이 실제로 실행됐을 가능성과 다시 실행해도 되는지를 증거 기반으로 판정한다. - -구현 우선순위는 다음으로 고정한다. - -```text -Core 계약 -→ Named Client Profile -→ Transport SPI와 Testkit -→ Deadline·Target·Observability -→ Apache·JDK Blocking -→ RestClient와 Typed Client -→ Execution Evidence와 Retry -→ Resilience -→ Error·Auth·TLS -→ Dynamic Target SSRF -→ Reactor Netty·WebClient -→ Streaming·SSE -→ Starter·Migration·Compatibility -→ HTTP/3 Experimental -``` diff --git a/httpclient-superpowers-package/validate_httpclient_docs.py b/httpclient-superpowers-package/validate_httpclient_docs.py deleted file mode 100644 index 3a6dfce..0000000 --- a/httpclient-superpowers-package/validate_httpclient_docs.py +++ /dev/null @@ -1,148 +0,0 @@ -from pathlib import Path -import re -import sys -import zipfile - -base = Path('/mnt/data') -design_path = base / 'httpclient-platform-design.md' -plan_path = base / 'httpclient-platform-implementation-plan.md' -errors = [] -notes = [] - -def read(p): - if not p.exists(): - errors.append(f'missing file: {p}') - return '' - return p.read_text(encoding='utf-8') - -design = read(design_path) -plan = read(plan_path) - -# Basic size and structure -if len(design.splitlines()) < 1200: - errors.append(f'design unexpectedly short: {len(design.splitlines())} lines') -if len(plan.splitlines()) < 2500: - errors.append(f'plan unexpectedly short: {len(plan.splitlines())} lines') - -# Task continuity and task internals -matches = list(re.finditer(r'^### Task (\d+): (.+)$', plan, flags=re.M)) -nums = [int(m.group(1)) for m in matches] -expected = list(range(1, (max(nums) if nums else 0) + 1)) -if nums != expected: - errors.append(f'task numbers not continuous: {nums[:5]}...{nums[-5:] if nums else []}') - -for i, m in enumerate(matches): - start = m.start() - end = matches[i+1].start() if i+1 < len(matches) else plan.find('\n## 3. Plan Self-Review Checklist', start) - if end == -1: - end = len(plan) - block = plan[start:end] - n = m.group(1) - for token in ['**Files:**', '**Interfaces:**', '**Step 1:', '**Step 2:', '**Step 3:', '**Step 4:', '**Step 5:']: - if token not in block: - errors.append(f'Task {n} missing {token}') - if 'git commit -m ' not in block: - errors.append(f'Task {n} missing commit command') - if 'Expected:' not in block: - errors.append(f'Task {n} missing expected result') - -# Markdown fence balance -for name, text in [('design', design), ('plan', plan)]: - count = len(re.findall(r'^```', text, flags=re.M)) - if count % 2: - errors.append(f'{name} has unbalanced code fences: {count}') - -# Placeholder scan -patterns = { - 'TBD': r'\bTBD\b', - 'TODO': r'\bTODO\b', - 'implement later': r'implement later', - 'fill in': r'fill in', - 'similar to task': r'similar to Task', - 'placeholder': r'placeholder', -} -for name, text in [('design', design), ('plan', plan)]: - for label, pat in patterns.items(): - if re.search(pat, text, flags=re.I): - errors.append(f'{name} contains placeholder pattern: {label}') - -# Duplicate create path scan -create_paths = re.findall(r'^- Create: `([^`]+)`', plan, flags=re.M) -dupes = sorted({p for p in create_paths if create_paths.count(p) > 1}) -if dupes: - errors.append(f'duplicate Create paths: {dupes}') - -# Required design coverage -required_design_terms = [ - 'H1 Typed Service Client', 'H2 Generic Exchange', 'H3 Dynamic Target', - 'ExecutionEvidence', 'BodyReplayability', 'OperationIdempotency', - 'Named Client Profile', 'Apache HttpClient 5', 'Reactor Netty', - 'Retry Coordinator', 'Circuit Breaker', 'Rate Limiter', 'Bulkhead', - 'OAuth2', 'TLS', 'SSRF', 'Streaming', 'SSE', 'HTTP/3', - 'Spring Framework 6.2', 'Spring 7', 'RestTemplate' -] -for term in required_design_terms: - if term not in design: - errors.append(f'design missing term: {term}') - -required_plan_terms = [ - 'httpclient-core-api', 'httpclient-transport-apache', 'httpclient-transport-jdk', - 'httpclient-transport-reactor-netty', 'httpclient-dynamic-target', - 'httpclient-spring-boot-starter', 'HttpAmbiguousExecutionException', - 'first response byte', 'DNS/IP Pinning', 'SingleFlightTokenLoader', - 'httpClientStableContractTest', 'spring62CompatibilityTest', - 'spring70CompatibilityTest' -] -for term in required_plan_terms: - if term not in plan: - errors.append(f'plan missing term: {term}') - -# Core API should not deliberately expose native clients in design signatures. -for forbidden_signature in [ - 'ApacheHttpClient nativeApacheClient()', - 'HttpClient nativeJdkClient()', - 'WebClient.Builder mutableBuilder()', - 'RestClient.Builder mutableBuilder()' -]: - # These appear in an explicit "do not provide" code block. Note rather than fail. - if forbidden_signature in design: - notes.append(f'explicitly forbidden signature documented: {forbidden_signature}') - -# Record task count and file counts -notes.append(f'design lines={len(design.splitlines())}, bytes={len(design.encode())}') -notes.append(f'plan lines={len(plan.splitlines())}, bytes={len(plan.encode())}') -notes.append(f'tasks={len(nums)}, create_paths={len(create_paths)}') - -report = base / 'httpclient-superpowers-validation.md' -status = 'PASS' if not errors else 'FAIL' -report_text = [ - '# HTTP Client Superpowers 문서 검증', '', - f'**검증 결과:** {status}', '', - '## 검증 항목', '', - f'- 설계서 존재 및 최소 구조: {"PASS" if design else "FAIL"}', - f'- 구현 계획서 존재 및 최소 구조: {"PASS" if plan else "FAIL"}', - f'- Task 번호 연속성: {"PASS" if nums == expected else "FAIL"}', - f'- Task별 Files·Interfaces·Step 1~5·Expected·Commit: {"PASS" if not any("Task " in e for e in errors) else "FAIL"}', - f'- Markdown code fence 균형: {"PASS" if not any("code fences" in e for e in errors) else "FAIL"}', - f'- Placeholder scan: {"PASS" if not any("placeholder" in e for e in errors) else "FAIL"}', - f'- 중복 Create 경로: {"PASS" if not dupes else "FAIL"}', - f'- 핵심 설계 범위: {"PASS" if not any("design missing" in e for e in errors) else "FAIL"}', - f'- 핵심 구현 범위: {"PASS" if not any("plan missing" in e for e in errors) else "FAIL"}', - '', '## 통계', '' -] -report_text += [f'- {note}' for note in notes] -if errors: - report_text += ['', '## 오류', ''] + [f'- {e}' for e in errors] -else: - report_text += ['', '## 결론', '', - '- 설계 결정과 구현 작업의 정적 추적성이 확인됐다.', - '- 실제 저장소가 제공되지 않았으므로 Gradle compile, integration, fault, security, performance test는 아직 실행되지 않았다.', - '- 계획의 Java 21, Gradle Kotlin DSL, root package는 명시된 구현 가정이다.'] -report.write_text('\n'.join(report_text) + '\n', encoding='utf-8') - -print(status) -for note in notes: - print(note) -for e in errors: - print('ERROR:', e) -sys.exit(0 if not errors else 1) diff --git a/redis-superpowers-package/README.md b/redis-superpowers-package/README.md deleted file mode 100644 index 9eb0482..0000000 --- a/redis-superpowers-package/README.md +++ /dev/null @@ -1,43 +0,0 @@ -# Redis Wrapper 및 Typed API 설계 패키지 - -이 패키지는 Spring 기반 Backend Skeleton에서 Redis 자료구조와 명령을 폭넓게 제공하기 위한 설계서와 구현 계획서다. - -## 문서 - -- `docs/superpowers/specs/2026-08-07-redis-wrapper-typed-api-design.md` - - 범위와 비지원 범위 - - Redis 버전·배포 모드 - - 모듈과 의존 규칙 - - 자료구조별 동기·Reactive Typed API - - R1~R4 명령 노출 정책 - - permit·budget·Raw Gateway·Admin Plane - - namespace·직렬화·TTL·timeout·retry·오류·관측성·ACL - - Standalone·Sentinel·Cluster - - 테스트·CI·완료 정의 - -- `docs/superpowers/plans/2026-08-07-redis-wrapper-typed-api-implementation-plan.md` - - 27개 구현 작업 - - 작업별 생성·수정 파일 - - 작업 간 입력·출력 인터페이스 - - 실패 테스트, 실행 명령, 최소 구현, 통과 검증, 커밋 - - Redis 7.2·7.4·8.2·8.10 및 Sentinel·Cluster 테스트 작업 - -- `VALIDATION.md` - - 문서 구조와 계획 완전성에 대한 정적 검증 결과 - -## 핵심 결정 - -1. classic Redis 자료구조는 최대한 Typed API로 제공한다. -2. 고비용·Blocking·다중 키 명령은 permit와 `OperationBudget`을 요구한다. -3. Typed API에 아직 없는 R1·R2 명령은 승인형 Raw Gateway로 제공한다. -4. 운영 명령은 별도 Admin Plane, 파괴적 명령은 SDK 차단으로 분리한다. -5. command catalog는 Redis 공식 metadata에서 생성하고 조직 정책을 오버레이한다. -6. 동기와 Reactive API를 정식 지원하고 같은 내부 async primitive를 공유한다. -7. 일반·Blocking·Transaction·Pub/Sub·Admin 연결을 격리한다. -8. timeout 후 write는 자동 재시도하지 않고 실행 결과 불명을 표현한다. - -## 적용 전제 - -현재 Backend Skeleton 저장소가 첨부되지 않아 경로와 Gradle 구조는 목표 구조로 확정했다. 실제 저장소에 적용할 때 기존 package naming, convention plugin, dependency management가 더 강한 기준을 이미 갖고 있다면 구조적 계약은 유지하면서 해당 규칙에 맞춘다. - -입력 Markdown이 참조한 309행 Excel 워크북은 현재 작업 공간에 존재하지 않았다. 따라서 정확한 command matrix는 구현 과정에서 `COMMAND DOCS`, `COMMAND INFO`, `COMMAND GETKEYSANDFLAGS`를 읽어 재생성하고 정책 오버레이를 적용하도록 설계했다. diff --git a/redis-superpowers-package/VALIDATION.md b/redis-superpowers-package/VALIDATION.md deleted file mode 100644 index a11631b..0000000 --- a/redis-superpowers-package/VALIDATION.md +++ /dev/null @@ -1,38 +0,0 @@ -# 정적 검증 결과 - -- **결과:** PASS -- **검사 수:** 29 -- **설계서 SHA-256:** `e742ea78f4f40c2f5ed65093a71d2c85c27da52b0761374030a5ca64143aea63` -- **계획서 SHA-256:** `6592a37373a79bc2ccf9434fc3516d8273d9f9369e392d5e4f3360a46d6398c0` - -| 검사 | 결과 | 세부 | -|---|---|---| -| 설계서 파일 존재 | PASS | docs/superpowers/specs/2026-08-07-redis-wrapper-typed-api-design.md | -| 설계서 코드 펜스 균형 | PASS | fences=70 | -| 설계서 미확정 표식 없음 | PASS | none | -| 계획서 파일 존재 | PASS | docs/superpowers/plans/2026-08-07-redis-wrapper-typed-api-implementation-plan.md | -| 계획서 코드 펜스 균형 | PASS | fences=276 | -| 계획서 미확정 표식 없음 | PASS | none | -| 계획 작업 수 | PASS | 27 tasks | -| 모든 작업 Step 1 보유 | PASS | 27/27 | -| 모든 작업 Step 2 보유 | PASS | 27/27 | -| 모든 작업 Step 3 보유 | PASS | 27/27 | -| 모든 작업 Step 4 보유 | PASS | 27/27 | -| 모든 작업 Step 5 보유 | PASS | 27/27 | -| 모든 작업 **Files:** 보유 | PASS | 27/27 | -| 모든 작업 **Interfaces:** 보유 | PASS | 27/27 | -| 모든 작업 git commit -m 보유 | PASS | 27/27 | -| Create 경로 중복 없음 | PASS | none | -| Superpowers 계획 헤더 | PASS | required header present | -| 설계 입력 제약 명시 | PASS | missing workbook handled explicitly | -| Typed/Advanced/Raw/Admin 4단계 | PASS | four exposure tiers | -| classic 자료구조 범위 | PASS | all classic groups present | -| 동기·Reactive parity 계획 | PASS | API parity covered | -| permit 위조 검증 | PASS | provenance verification covered | -| 토폴로지 task 선행 등록 | PASS | test tasks available before contracts | -| 환경 파일 생명주기 일관성 | PASS | create once, extend twice | -| 잘못된 Persistent factory 없음 | PASS | constructor usage consistent | -| 공유 async primitive | PASS | sync/reactive executor share invocation | -| Raw 문자열 API 금지 | PASS | guardrail fixed | -| R4 차단 | PASS | blocked in plan and design | -| 완료 정의 존재 | PASS | definition and release task present | diff --git a/redis-superpowers-package/docs/superpowers/plans/2026-08-07-redis-wrapper-typed-api-implementation-plan.md b/redis-superpowers-package/docs/superpowers/plans/2026-08-07-redis-wrapper-typed-api-implementation-plan.md deleted file mode 100644 index dfb103b..0000000 --- a/redis-superpowers-package/docs/superpowers/plans/2026-08-07-redis-wrapper-typed-api-implementation-plan.md +++ /dev/null @@ -1,2233 +0,0 @@ -# Redis Wrapper and Typed API Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Spring 기반 Backend Skeleton에 Redis classic 자료구조 전체, 동기·Reactive Typed API, 위험 통제형 Raw Gateway, Standalone·Sentinel·Cluster 지원, Redis 8 확장 모듈을 운영 가능한 공통 SDK로 구현한다. - -**Architecture:** `redis-core-api`에 Redis 또는 Spring 타입이 새지 않는 공개 계약을 두고, `redis-core-lettuce`가 Spring Data Redis 4.1과 Lettuce 7.6으로 이를 구현한다. 모든 명령은 command catalog와 policy guard를 통과하며, R1은 기본 Typed API, R2는 permit와 budget, R3는 별도 admin plane, R4는 전체 차단한다. - -**Tech Stack:** Java 21, Gradle Kotlin DSL, Spring Data Redis 4.1, Lettuce 7.6, Reactor, Micrometer, OpenTelemetry, JUnit 5, AssertJ, ArchUnit, Testcontainers, Toxiproxy, Awaitility, Jackson. - -## Global Constraints - -- 기능 최소 버전은 Redis 7.2다. -- 주 인증 버전은 Redis 7.4 최신 패치와 Redis 8.2 최신 패치다. -- Redis 8.10은 최신 호환성 job에서 검증한다. -- Standalone과 Sentinel은 완전 지원한다. -- Cluster는 DB 0, same-slot 다중 키, node-aware pipeline을 전제로 지원한다. -- 공개 프로그래밍 모델은 동기와 Reactive다. Lettuce native async는 공개 기본 API로 만들지 않는다. -- 일반 명령은 R1, 고비용·Blocking·다중 키는 R2, 운영 명령은 R3, 파괴적 명령은 R4로 분류한다. -- R1은 기본 Typed API, R2는 `AdvancedOperationPermit`와 `OperationBudget`, R3는 별도 admin plane, R4는 차단한다. -- 임의 문자열 기반 `execute(String, byte[]...)` API를 만들지 않는다. -- Java native serialization을 사용하지 않는다. -- 실제 key와 value를 metric label, trace attribute, 일반 log에 기록하지 않는다. -- Pipeline은 원자적이지 않으며 partial result를 반환한다. -- timeout 후 write는 자동 retry하지 않고 ambiguous execution을 표현한다. -- Blocking, transaction, Pub/Sub, admin 명령은 일반 shared connection에서 실행하지 않는다. -- Raw Gateway는 core guardrail 구현 뒤에 추가한다. -- 각 작업은 테스트를 먼저 추가하고, 해당 테스트의 실패를 확인한 뒤 구현한다. -- 각 작업은 독립적으로 검토 가능한 커밋 하나로 종료한다. - ---- - -## 1. 확정 파일 구조 - -```text -backend-skeleton/ -├── settings.gradle.kts -├── build.gradle.kts -├── gradle/libs.versions.toml -├── build-logic/ -│ └── src/main/kotlin/redis-library-conventions.gradle.kts -├── modules/redis/ -│ ├── redis-core-api/ -│ ├── redis-core-lettuce/ -│ ├── redis-cluster/ -│ ├── redis-programmability/ -│ ├── redis-raw-gateway/ -│ ├── redis-admin-plane/ -│ ├── redis-spring-boot-starter/ -│ ├── redis-testkit/ -│ └── extensions/ -│ ├── redis-json/ -│ ├── redis-search/ -│ ├── redis-timeseries/ -│ └── redis-probabilistic/ -├── infra/redis/ -│ ├── standalone/compose.yml -│ ├── sentinel/compose.yml -│ ├── cluster/compose.yml -│ └── acl/ -├── docs/redis/ -│ ├── support-matrix.md -│ ├── command-policy.md -│ ├── operations.md -│ └── upgrade-guide.md -└── docs/superpowers/specs/2026-08-07-redis-wrapper-typed-api-design.md -``` - -## 2. 핵심 패키지 - -```text -io.backend.skeleton.redis.api -io.backend.skeleton.redis.api.key -io.backend.skeleton.redis.api.codec -io.backend.skeleton.redis.api.command -io.backend.skeleton.redis.api.error -io.backend.skeleton.redis.api.operations -io.backend.skeleton.redis.api.reactive -io.backend.skeleton.redis.lettuce -io.backend.skeleton.redis.lettuce.command -io.backend.skeleton.redis.lettuce.connection -io.backend.skeleton.redis.lettuce.observability -io.backend.skeleton.redis.cluster -io.backend.skeleton.redis.programmability -io.backend.skeleton.redis.raw -io.backend.skeleton.redis.admin -io.backend.skeleton.redis.autoconfigure -io.backend.skeleton.redis.testkit -``` - ---- - -### Task 1: Gradle 멀티모듈과 공통 품질 규칙 구성 - -**Files:** -- Modify: `settings.gradle.kts` -- Modify: `gradle/libs.versions.toml` -- Create: `build-logic/src/main/kotlin/redis-library-conventions.gradle.kts` -- Create: `modules/redis/redis-core-api/build.gradle.kts` -- Create: `modules/redis/redis-core-lettuce/build.gradle.kts` -- Create: `modules/redis/redis-cluster/build.gradle.kts` -- Create: `modules/redis/redis-programmability/build.gradle.kts` -- Create: `modules/redis/redis-raw-gateway/build.gradle.kts` -- Create: `modules/redis/redis-admin-plane/build.gradle.kts` -- Create: `modules/redis/redis-spring-boot-starter/build.gradle.kts` -- Create: `modules/redis/redis-testkit/build.gradle.kts` -- Create: `modules/redis/extensions/redis-json/build.gradle.kts` -- Create: `modules/redis/extensions/redis-search/build.gradle.kts` -- Create: `modules/redis/extensions/redis-timeseries/build.gradle.kts` -- Create: `modules/redis/extensions/redis-probabilistic/build.gradle.kts` -- Test: `modules/redis/redis-core-api/src/test/java/io/backend/skeleton/redis/api/ModuleSmokeTest.java` - -**Interfaces:** -- Produces Gradle project paths used by every later task. -- Java toolchain is fixed to 21. -- `redis-core-api` has no Spring Data Redis or Lettuce dependency. - -- [ ] **Step 1: Write the failing module smoke test** - -```java -package io.backend.skeleton.redis.api; - -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; - -class ModuleSmokeTest { - @Test - void coreApiModuleLoads() { - assertThat(ModuleSmokeTest.class.getModule()).isNotNull(); - } -} -``` - -- [ ] **Step 2: Register module paths and verify the build fails before build files exist** - -Add to `settings.gradle.kts`: - -```kotlin -include( - ":modules:redis:redis-core-api", - ":modules:redis:redis-core-lettuce", - ":modules:redis:redis-cluster", - ":modules:redis:redis-programmability", - ":modules:redis:redis-raw-gateway", - ":modules:redis:redis-admin-plane", - ":modules:redis:redis-spring-boot-starter", - ":modules:redis:redis-testkit", - ":modules:redis:extensions:redis-json", - ":modules:redis:extensions:redis-search", - ":modules:redis:extensions:redis-timeseries", - ":modules:redis:extensions:redis-probabilistic" -) -``` - -Run: - -```bash -./gradlew :modules:redis:redis-core-api:test -``` - -Expected: FAIL because Redis module build files or source sets do not exist. - -- [ ] **Step 3: Add the version catalog and convention plugin** - -Add to `gradle/libs.versions.toml`: - -```toml -[versions] -java = "21" -spring-data-redis = "4.1.0" -lettuce = "7.6.0.RELEASE" -reactor = "3.8.0" -junit = "5.12.2" -assertj = "3.27.3" -archunit = "1.4.1" -testcontainers = "1.21.3" -awaitility = "4.3.0" -jackson = "2.20.0" - -[libraries] -spring-data-redis = { module = "org.springframework.data:spring-data-redis", version.ref = "spring-data-redis" } -lettuce-core = { module = "io.lettuce:lettuce-core", version.ref = "lettuce" } -reactor-core = { module = "io.projectreactor:reactor-core", version.ref = "reactor" } -junit-bom = { module = "org.junit:junit-bom", version.ref = "junit" } -junit-jupiter = { module = "org.junit.jupiter:junit-jupiter" } -assertj = { module = "org.assertj:assertj-core", version.ref = "assertj" } -archunit = { module = "com.tngtech.archunit:archunit-junit5", version.ref = "archunit" } -testcontainers-bom = { module = "org.testcontainers:testcontainers-bom", version.ref = "testcontainers" } -testcontainers-junit = { module = "org.testcontainers:junit-jupiter" } -toxiproxy = { module = "org.testcontainers:toxiproxy" } -awaitility = { module = "org.awaitility:awaitility", version.ref = "awaitility" } -jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson" } -``` - -Create `redis-library-conventions.gradle.kts`: - -```kotlin -plugins { - `java-library` - jacoco -} - -java { - toolchain.languageVersion.set(JavaLanguageVersion.of(21)) - withSourcesJar() - withJavadocJar() -} - -tasks.withType().configureEach { - useJUnitPlatform() -} - -dependencies { - "testImplementation"(platform(libs.junit.bom)) - "testImplementation"(libs.junit.jupiter) - "testImplementation"(libs.assertj) -} -``` - -Apply the convention plugin to every Redis module and set dependency directions exactly as defined in the design document. - -- [ ] **Step 4: Run the module test and dependency report** - -```bash -./gradlew :modules:redis:redis-core-api:test \ - :modules:redis:redis-core-api:dependencies --configuration runtimeClasspath -``` - -Expected: PASS. The runtime classpath must not contain `spring-data-redis` or `lettuce-core`. - -- [ ] **Step 5: Commit** - -```bash -git add settings.gradle.kts gradle/libs.versions.toml build-logic modules/redis -git commit -m "build: add redis sdk module graph" -``` - ---- - -### Task 2: Command policy catalog와 metadata diff 도구 구현 - -**Files:** -- Create: `modules/redis/redis-core-lettuce/src/main/resources/redis-command-policy.yml` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/command/RedisCommandPolicy.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/command/RedisCommandPolicyLoader.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/command/RedisCommandMetadataDiff.java` -- Create: `modules/redis/redis-core-lettuce/src/test/java/io/backend/skeleton/redis/lettuce/command/RedisCommandPolicyLoaderTest.java` -- Create: `modules/redis/redis-core-lettuce/src/test/java/io/backend/skeleton/redis/lettuce/command/RedisCommandMetadataDiffTest.java` - -**Interfaces:** - -```java -public record RedisCommandPolicy( - String command, - Optional subcommand, - RedisVersion minimumVersion, - RedisRiskLevel riskLevel, - CommandSupport support, - CommandAccess access, - boolean blocking, - boolean readOnly, - boolean retrySafe, - boolean mayBeAmbiguous, - TimeoutProfile timeoutProfile -) {} -``` - -- [ ] **Step 1: Write failing YAML loader tests** - -```java -@Test -void loadsGetAndBlocksKeys() { - RedisCommandPolicyLoader loader = new RedisCommandPolicyLoader(); - Map policies = loader.load( - new ClassPathResource("redis-command-policy.yml") - ); - - assertThat(policies.get(CommandId.of("GET")).riskLevel()).isEqualTo(RedisRiskLevel.R1); - assertThat(policies.get(CommandId.of("KEYS")).support()).isEqualTo(CommandSupport.BLOCKED); -} -``` - -- [ ] **Step 2: Run the loader test** - -```bash -./gradlew :modules:redis:redis-core-lettuce:test \ - --tests "*RedisCommandPolicyLoaderTest" -``` - -Expected: FAIL because the loader and policy resource do not exist. - -- [ ] **Step 3: Implement policy schema, loader, and initial mandatory policies** - -The initial YAML must include at least `GET`, `SET`, `HGETALL`, `SMEMBERS`, `BLPOP`, `XREAD`, `INFO`, `CONFIG`, `KEYS`, `FLUSHALL`, `SHUTDOWN`, and `DEBUG`. Implement duplicate command detection and reject unknown enum values. - -```java -public final class RedisCommandPolicyLoader { - private final ObjectMapper mapper = new ObjectMapper(new YAMLFactory()); - - public Map load(Resource resource) { - try (InputStream input = resource.getInputStream()) { - PolicyDocument document = mapper.readValue(input, PolicyDocument.class); - return document.commands().entrySet().stream() - .map(entry -> Map.entry(CommandId.parse(entry.getKey()), entry.getValue().toPolicy(entry.getKey()))) - .collect(Collectors.toUnmodifiableMap(Map.Entry::getKey, Map.Entry::getValue)); - } catch (IOException exception) { - throw new IllegalStateException("Cannot load Redis command policy", exception); - } - } -} -``` - -- [ ] **Step 4: Add metadata diff behavior and run tests** - -`RedisCommandMetadataDiff.compare()` must report: - -```java -public record RedisCommandMetadataDiff( - Set added, - Set removed, - Set changedKeySpecs, - Set changedAclCategories, - Set deprecatedChanges -) { - public boolean requiresReview() { - return !(added.isEmpty() - && removed.isEmpty() - && changedKeySpecs.isEmpty() - && changedAclCategories.isEmpty() - && deprecatedChanges.isEmpty()); - } -} -``` - -Run: - -```bash -./gradlew :modules:redis:redis-core-lettuce:test \ - --tests "*RedisCommandPolicyLoaderTest" \ - --tests "*RedisCommandMetadataDiffTest" -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/redis/redis-core-lettuce -git commit -m "feat(redis): add command policy catalog" -``` - ---- - -### Task 3: Redis version, topology, risk, permit, budget 모델 구현 - -**Files:** -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/RedisVersion.java` -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/RedisCapability.java` -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/RedisCapabilities.java` -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/RedisDeploymentMode.java` -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/command/RedisRiskLevel.java` -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/command/CommandSupport.java` -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/command/OperationBudget.java` -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/command/AdvancedOperationPermit.java` -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/command/MultiKeyPermit.java` -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/command/PersistentKeyPermit.java` -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/command/RedisPolicyAuthority.java` -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/command/RedisPermitVerifier.java` -- Test: `modules/redis/redis-core-api/src/test/java/io/backend/skeleton/redis/api/RedisVersionTest.java` -- Test: `modules/redis/redis-core-api/src/test/java/io/backend/skeleton/redis/api/command/OperationBudgetTest.java` - -**Interfaces:** - -```java -public record RedisVersion(int major, int minor, int patch) implements Comparable {} -public record OperationBudget(int maxElements, long maxRequestBytes, long maxReplyBytes, Duration timeout) {} -``` - -- [ ] **Step 1: Write failing value-object tests** - -```java -@Test -void parsesAndOrdersVersions() { - assertThat(RedisVersion.parse("8.2.1")).isGreaterThan(RedisVersion.parse("7.4.9")); -} - -@Test -void rejectsNonPositiveBudget() { - assertThatThrownBy(() -> new OperationBudget(0, 1, 1, Duration.ofMillis(1))) - .isInstanceOf(IllegalArgumentException.class); -} -``` - -- [ ] **Step 2: Run tests** - -```bash -./gradlew :modules:redis:redis-core-api:test \ - --tests "*RedisVersionTest" \ - --tests "*OperationBudgetTest" -``` - -Expected: FAIL because the types do not exist. - -- [ ] **Step 3: Implement immutable models** - -Implement strict semantic version parsing, natural ordering, and strictly positive budget validation. Define permits as public marker contracts in `redis-core-api`; only `redis-spring-boot-starter` may provide package-private granted implementations through `RedisPolicyAuthority`. This preserves module boundaries while preventing application code from constructing approved grants directly. - -```java -public interface AdvancedOperationPermit { - String policyName(); -} - -public interface MultiKeyPermit { - String policyName(); -} - -public interface PersistentKeyPermit { - String policyName(); -} -``` - -The starter later provides package-private signed implementations and a configured authority/verifier pair. `RedisPermitVerifier` is invoked by every guarded executor path; a caller-created implementation of a permit interface must fail provenance verification. - -- [ ] **Step 4: Run API tests** - -```bash -./gradlew :modules:redis:redis-core-api:test -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/redis/redis-core-api -git commit -m "feat(redis): add capability and policy value objects" -``` - ---- - -### Task 4: Key namespace와 slot-safe typed key 구현 - -**Files:** -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/key/RedisKeyRules.java` -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/key/RedisNamespace.java` -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/key/RedisKeyName.java` -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/key/RedisSlotTag.java` -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/key/QualifiedRedisKey.java` -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/key/RedisKeyRenderer.java` -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/key/TypedRedisKeys.java` -- Test: `modules/redis/redis-core-api/src/test/java/io/backend/skeleton/redis/api/key/RedisKeyRendererTest.java` -- Test: `modules/redis/redis-core-api/src/test/java/io/backend/skeleton/redis/api/key/RedisKeyRulesTest.java` - -**Interfaces:** - -```java -public record QualifiedRedisKey( - RedisNamespace namespace, - RedisKeyName name, - Optional slotTag -) {} -``` - -- [ ] **Step 1: Write failing rendering and privacy tests** - -```java -@Test -void rendersClusterSlotTagOnlyInsideBraces() { - QualifiedRedisKey key = new QualifiedRedisKey( - new RedisNamespace("prod", "order", "shared"), - new RedisKeyName("summary", "42"), - Optional.of(new RedisSlotTag("customer-7")) - ); - - assertThat(new RedisKeyRenderer(512).render(key)) - .isEqualTo("prod:order:shared:{customer-7}:summary:42"); -} - -@Test -void rejectsEmailInIdentifier() { - assertThatThrownBy(() -> new RedisKeyName("user", "person@example.com")) - .isInstanceOf(IllegalArgumentException.class); -} -``` - -- [ ] **Step 2: Run tests** - -```bash -./gradlew :modules:redis:redis-core-api:test --tests "*RedisKey*Test" -``` - -Expected: FAIL. - -- [ ] **Step 3: Implement validation and typed key records** - -Create `ValueKey`, `HashKey`, `ListKey`, `SetKey`, `SortedSetKey`, `BitmapKey`, `HyperLogLogKey`, `GeoKey`, and `StreamKey`. Each record stores `QualifiedRedisKey` plus the required codec references. - -- [ ] **Step 4: Run tests and ArchUnit package rule** - -```bash -./gradlew :modules:redis:redis-core-api:test -``` - -Expected: PASS. `key` package must not depend on Spring or Lettuce packages. - -- [ ] **Step 5: Commit** - -```bash -git add modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/key \ - modules/redis/redis-core-api/src/test/java/io/backend/skeleton/redis/api/key -git commit -m "feat(redis): add namespaced typed keys" -``` - ---- - -### Task 5: Codec registry와 versioned envelope 구현 - -**Files:** -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/codec/RedisCodec.java` -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/codec/RedisEnvelope.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/codec/RedisCodecRegistry.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/codec/Utf8StringCodec.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/codec/LongCodec.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/codec/VersionedJsonCodec.java` -- Test: `modules/redis/redis-core-lettuce/src/test/java/io/backend/skeleton/redis/lettuce/codec/VersionedJsonCodecTest.java` -- Test: `modules/redis/redis-core-lettuce/src/test/resources/golden/order-summary-v1.json` - -**Interfaces:** - -```java -public interface RedisCodec { - String id(); - byte[] encode(T value); - T decode(byte[] bytes); -} -``` - -- [ ] **Step 1: Write failing golden-byte compatibility test** - -```java -private record OrderSummary(String orderId, long amount) {} - -@Test -void readsVersionOneGoldenPayload() throws Exception { - VersionedJsonCodec codec = orderSummaryCodec(); - byte[] bytes = Files.readAllBytes(Path.of( - "src/test/resources/golden/order-summary-v1.json" - )); - - assertThat(codec.decode(bytes)).isEqualTo(new OrderSummary("order-1", 12000L)); -} -``` - -- [ ] **Step 2: Run codec test** - -```bash -./gradlew :modules:redis:redis-core-lettuce:test --tests "*VersionedJsonCodecTest" -``` - -Expected: FAIL. - -- [ ] **Step 3: Implement codec registry and envelope validation** - -`VersionedJsonCodec` must reject unknown schema IDs, support configured reader versions, measure encoded bytes before Redis execution, and throw `RedisSerializationException` on corruption. Do not use Java native serialization. - -- [ ] **Step 4: Run codec tests** - -```bash -./gradlew :modules:redis:redis-core-lettuce:test --tests "*codec*" -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/codec \ - modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/codec \ - modules/redis/redis-core-lettuce/src/test -git commit -m "feat(redis): add versioned codec registry" -``` - ---- - -### Task 6: 안정된 오류 모델과 ambiguous execution 구현 - -**Files:** -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/error/RedisFailureMetadata.java` -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/error/RedisOperationException.java` -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/error/RedisTimeoutException.java` -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/error/RedisConnectionException.java` -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/error/RedisCrossSlotException.java` -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/error/RedisAmbiguousExecutionException.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/command/LettuceExceptionTranslator.java` -- Test: `modules/redis/redis-core-lettuce/src/test/java/io/backend/skeleton/redis/lettuce/command/LettuceExceptionTranslatorTest.java` - -**Interfaces:** - -```java -public record RedisFailureMetadata( - String commandCategory, - CommandAccess access, - boolean readOperation, - boolean retryable, - boolean ambiguousExecution, - RedisVersion serverVersion, - RedisDeploymentMode deploymentMode, - OptionalInt slot, - Duration elapsed -) {} -``` - -- [ ] **Step 1: Write failing translation tests** - -```java -@Test -void marksWriteTimeoutAsAmbiguousAndNotRetryable() { - RedisOperationException translated = translator.translate( - new RedisCommandTimeoutException("timeout"), - CommandExecutionContext.write("INCR") - ); - - assertThat(translated).isInstanceOf(RedisAmbiguousExecutionException.class); - assertThat(translated.metadata().retryable()).isFalse(); - assertThat(translated.metadata().ambiguousExecution()).isTrue(); -} -``` - -- [ ] **Step 2: Run translator tests** - -```bash -./gradlew :modules:redis:redis-core-lettuce:test --tests "*LettuceExceptionTranslatorTest" -``` - -Expected: FAIL. - -- [ ] **Step 3: Implement exception hierarchy and translation matrix** - -Translate timeout, connection, ACL, CROSSSLOT, MOVED/ASK, BUSY, NOSCRIPT, WRONGTYPE, serialization, policy rejection, capability absence, and ambiguous execution. Sanitize messages so command arguments, key, value, password are absent. - -- [ ] **Step 4: Run tests** - -```bash -./gradlew :modules:redis:redis-core-api:test :modules:redis:redis-core-lettuce:test -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/error \ - modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/command \ - modules/redis/redis-core-lettuce/src/test/java/io/backend/skeleton/redis/lettuce/command -git commit -m "feat(redis): add stable failure semantics" -``` - ---- - -### Task 7: 동기·Reactive 공개 API와 parity test 구현 - -**Files:** -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/RedisOperations.java` -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/ReactiveRedisOperations.java` -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/operations/*.java` -- Create: `modules/redis/redis-core-api/src/main/java/io/backend/skeleton/redis/api/reactive/*.java` -- Create: `modules/redis/redis-core-api/src/test/java/io/backend/skeleton/redis/api/ApiParityInspector.java` -- Create: `modules/redis/redis-core-api/src/test/java/io/backend/skeleton/redis/api/ApiParityReport.java` -- Create: `modules/redis/redis-core-api/src/test/java/io/backend/skeleton/redis/api/ApiParityTest.java` -- Create: `modules/redis/redis-core-api/src/test/java/io/backend/skeleton/redis/api/NoDriverLeakArchitectureTest.java` - -**Interfaces:** -- Use the exact method sets from design sections 8 and 10. -- Sync and Reactive names and parameter types are identical. -- Reactive return types are `Mono` for single result and `Flux` only for streaming subscription or cursor consumption. - -- [ ] **Step 1: Write failing parity and architecture tests** - -```java -@Test -void everySyncOperationHasReactiveCounterpart() { - ApiParityReport report = ApiParityInspector.compare( - RedisValueOperations.class, - ReactiveRedisValueOperations.class - ); - assertThat(report.differences()).isEmpty(); -} -``` - -```java -@ArchTest -static final ArchRule apiMustNotDependOnDrivers = noClasses() - .that().resideInAPackage("io.backend.skeleton.redis.api..") - .should().dependOnClassesThat() - .resideInAnyPackage("org.springframework.data.redis..", "io.lettuce.core.."); -``` - -- [ ] **Step 2: Run API tests** - -```bash -./gradlew :modules:redis:redis-core-api:test \ - --tests "*ApiParityTest" \ - --tests "*NoDriverLeakArchitectureTest" -``` - -Expected: FAIL because interfaces are incomplete. - -- [ ] **Step 3: Add all public interface signatures and supporting models** - -Create operation models such as `Expiration`, `ScanRequest`, `ScanPage`, `PageRequest`, `ScoreRange`, `StreamTrimPolicy`, `StreamRecord`, `GeoSearchRequest`, `BatchOptions`, and `BatchItemResult`. Keep them immutable and driver-independent. - -- [ ] **Step 4: Run all core API tests** - -```bash -./gradlew :modules:redis:redis-core-api:test -``` - -Expected: PASS with zero parity differences and zero driver dependency violations. - -- [ ] **Step 5: Commit** - -```bash -git add modules/redis/redis-core-api -git commit -m "feat(redis): define sync and reactive typed api" -``` - ---- - -### Task 8: Spring Boot properties, topology probe, connection isolation 구현 - -**Files:** -- Create: `modules/redis/redis-spring-boot-starter/src/main/java/io/backend/skeleton/redis/autoconfigure/BackendRedisProperties.java` -- Create: `modules/redis/redis-spring-boot-starter/src/main/java/io/backend/skeleton/redis/autoconfigure/RedisCapabilityProbe.java` -- Create: `modules/redis/redis-spring-boot-starter/src/main/java/io/backend/skeleton/redis/autoconfigure/RedisConnectionAutoConfiguration.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/connection/RedisConnectionKind.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/connection/RedisConnectionRegistry.java` -- Create: `modules/redis/redis-spring-boot-starter/src/main/java/io/backend/skeleton/redis/autoconfigure/ConfiguredRedisPolicyAuthority.java` -- Create: `modules/redis/redis-spring-boot-starter/src/main/java/io/backend/skeleton/redis/autoconfigure/GrantedAdvancedOperationPermit.java` -- Create: `modules/redis/redis-spring-boot-starter/src/main/java/io/backend/skeleton/redis/autoconfigure/GrantedMultiKeyPermit.java` -- Create: `modules/redis/redis-spring-boot-starter/src/main/java/io/backend/skeleton/redis/autoconfigure/GrantedPersistentKeyPermit.java` -- Create: `modules/redis/redis-spring-boot-starter/src/main/java/io/backend/skeleton/redis/autoconfigure/ConfiguredRedisPermitVerifier.java` -- Create: `modules/redis/redis-testkit/src/main/java/io/backend/skeleton/redis/testkit/StandaloneRedisEnvironment.java` -- Create: `modules/redis/redis-testkit/src/main/java/io/backend/skeleton/redis/testkit/SentinelRedisEnvironment.java` -- Create: `modules/redis/redis-testkit/src/main/java/io/backend/skeleton/redis/testkit/ClusterRedisEnvironment.java` -- Create: `modules/redis/redis-testkit/src/main/kotlin/io/backend/skeleton/redis/testkit/RedisTopologyTestTasksPlugin.kt` -- Modify: `modules/redis/redis-testkit/build.gradle.kts` -- Test: `modules/redis/redis-spring-boot-starter/src/test/java/io/backend/skeleton/redis/autoconfigure/BackendRedisPropertiesTest.java` -- Test: `modules/redis/redis-spring-boot-starter/src/test/java/io/backend/skeleton/redis/autoconfigure/RedisCapabilityProbeTest.java` - -**Interfaces:** - -```java -public enum RedisConnectionKind { REGULAR, BLOCKING, TRANSACTION, PUBSUB, ADMIN } -``` - -- [ ] **Step 1: Write failing property validation tests** - -```java -@Test -void clusterRejectsDatabaseOtherThanZero() { - BackendRedisProperties properties = validProperties(); - properties.setMode(RedisDeploymentMode.CLUSTER); - properties.setDatabase(1); - - assertThatThrownBy(properties::validate) - .hasMessageContaining("Cluster supports database 0 only"); -} -``` - -- [ ] **Step 2: Run starter tests** - -```bash -./gradlew :modules:redis:redis-spring-boot-starter:test --tests "*BackendRedisPropertiesTest" -``` - -Expected: FAIL. - -- [ ] **Step 3: Implement properties, validation, policy authority, topology test bootstrap, and five connection kinds** - -Use the exact defaults from design section 23. `RedisCapabilityProbe` must read server version, deployment mode, command availability, DB index, and enabled extension capabilities. Startup must fail when an explicitly enabled capability is unavailable. - -`ConfiguredRedisPolicyAuthority` implements the core `RedisPolicyAuthority` contract. It issues package-private signed permit implementations only for configured policy names. `ConfiguredRedisPermitVerifier` validates implementation provenance, issuer ID, signature, and required policy; application-created fake permit implementations are rejected. These beans exist only when advanced operations are enabled. - -Create baseline Testcontainers environments and register these Gradle tasks now, before any data-structure contract uses them: - -```text -redis72Test -redis74Test -redis82Test -redis810Test -sentinel74Test -sentinel82Test -cluster74Test -cluster82Test -redis82ExtensionsTest -``` - -At this stage the environments only need deterministic startup, endpoint/credential export, readiness checks, cleanup, and test filtering. Later Sentinel, Cluster, fault, ACL, and performance tasks extend these same classes rather than recreating them. - -- [ ] **Step 4: Run starter tests and context runner tests** - -```bash -./gradlew :modules:redis:redis-spring-boot-starter:test -``` - -Expected: PASS. A normal application context must not create ADMIN or Raw Gateway beans unless enabled. - -- [ ] **Step 5: Commit** - -```bash -git add modules/redis/redis-spring-boot-starter \ - modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/connection \ - modules/redis/redis-testkit -git commit -m "feat(redis): add topology aware connection configuration" -``` - ---- - -### Task 9: Policy-aware command executor와 관측성 구현 - -**Files:** -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/command/CommandRequest.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/command/CommandPolicyGuard.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/command/SyncRedisCommandExecutor.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/command/ReactiveRedisCommandExecutor.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/observability/RedisObservation.java` -- Test: `modules/redis/redis-core-lettuce/src/test/java/io/backend/skeleton/redis/lettuce/command/CommandPolicyGuardTest.java` -- Test: `modules/redis/redis-core-lettuce/src/test/java/io/backend/skeleton/redis/lettuce/observability/RedisObservationTest.java` - -**Interfaces:** - -```java -public record CommandRequest( - CommandId commandId, - List keys, - long requestBytes, - long expectedReplyBytes, - Optional advancedPermit, - Optional budget, - Supplier> invocation -) {} -``` - -- [ ] **Step 1: Write failing guard tests** - -```java -@Test -void rejectsR2WithoutPermitAndBudget() { - assertThatThrownBy(() -> guard.validate(requestFor("HGETALL"))) - .isInstanceOf(RedisCommandRejectedException.class) - .hasMessageContaining("R2 command requires permit and budget"); -} - -@Test -void rejectsCallerImplementedPermitThatWasNotIssuedByAuthority() { - AdvancedOperationPermit fake = () -> "collection-full-read"; - - assertThatThrownBy(() -> guard.validate(requestFor("HGETALL", fake, boundedBudget()))) - .isInstanceOf(RedisCommandRejectedException.class) - .hasMessageContaining("permit provenance"); -} - -@Test -void neverAddsRawKeyToMetricTags() { - RedisObservation observation = observationFor("prod:order:user:42"); - assertThat(observation.lowCardinalityTags()).doesNotContainKey("redis.key"); -} -``` - -- [ ] **Step 2: Run executor tests** - -```bash -./gradlew :modules:redis:redis-core-lettuce:test \ - --tests "*CommandPolicyGuardTest" \ - --tests "*RedisObservationTest" -``` - -Expected: FAIL. - -- [ ] **Step 3: Implement the fixed execution pipeline** - -`CommandPolicyGuard` receives `RedisPermitVerifier`; permit presence alone is insufficient. It verifies provenance and the command policy's required policy name before continuing. - -Execution order must be: - -```text -capability -> risk/permit provenance -> namespace -> slot -> request budget -> connection kind --> timeout/retry policy -> invocation -> reply budget -> exception translation --> metric/trace/audit close -``` - -Metric names and low-cardinality tags must match design section 21. `SyncRedisCommandExecutor` waits on the shared `CompletionStage` using the selected timeout profile; `ReactiveRedisCommandExecutor` adapts the same stage with `Mono.fromCompletionStage`, so command policy and driver invocation remain single-sourced. - -- [ ] **Step 4: Run executor tests** - -```bash -./gradlew :modules:redis:redis-core-lettuce:test -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/redis/redis-core-lettuce -git commit -m "feat(redis): enforce command policy execution pipeline" -``` - ---- - -### Task 10: String와 Key·TTL operations 구현 - -**Files:** -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/operations/LettuceRedisValueOperations.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/operations/LettuceReactiveRedisValueOperations.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/operations/LettuceRedisKeyOperations.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/operations/LettuceReactiveRedisKeyOperations.java` -- Test: `modules/redis/redis-testkit/src/test/java/io/backend/skeleton/redis/contract/RedisValueOperationsContract.java` -- Test: `modules/redis/redis-testkit/src/test/java/io/backend/skeleton/redis/contract/RedisKeyOperationsContract.java` - -**Interfaces:** -- Implement every method declared in design sections 10.1 and 10.11. -- `set` and expiration must be atomic. -- `KEYS` is absent from the public API. - -- [ ] **Step 1: Write failing contract tests** - -```java -@Test -void setWithExpirationNeverCreatesPersistentKey() { - ValueKey key = keys.value("cache", "one", codecs.string()); - operations.values().set(key, "value", new Expiration.After(Duration.ofSeconds(2))); - - assertThat(operations.keys().ttl(key.key())).hasValueSatisfying(ttl -> - assertThat(ttl).isPositive().isLessThanOrEqualTo(Duration.ofSeconds(2)) - ); -} -``` - -```java -@Test -void incrementWithInitialExpirationIsAtomic() { - ValueKey key = keys.value("counter", "one", codecs.longCodec()); - assertThat(operations.values().increment(key, 1, new Expiration.After(Duration.ofMinutes(1)))) - .isEqualTo(1L); - assertThat(operations.keys().ttl(key.key())).isPresent(); -} -``` - -- [ ] **Step 2: Run contracts against Standalone 7.4** - -```bash -./gradlew :modules:redis:redis-testkit:test --tests "*RedisValueOperationsContract" --tests "*RedisKeyOperationsContract" -``` - -Expected: FAIL. - -- [ ] **Step 3: Implement sync and Reactive adapters** - -Use `SET` options for atomic TTL. Use a registered script for increment-plus-initial-TTL on Redis 7.2–8.2 and a version-gated optimized path when `INCREX` is available. `SCAN` requires R2 permit and bounded count. - -- [ ] **Step 4: Run contracts on Redis 7.2, 7.4, and 8.2** - -```bash -./gradlew :modules:redis:redis-testkit:redis72Test \ - :modules:redis:redis-testkit:redis74Test \ - :modules:redis:redis-testkit:redis82Test \ - --tests "*RedisValueOperationsContract" \ - --tests "*RedisKeyOperationsContract" -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/redis/redis-core-lettuce modules/redis/redis-testkit -git commit -m "feat(redis): implement string and key ttl operations" -``` - ---- - -### Task 11: Hash operations와 field TTL version gate 구현 - -**Files:** -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/operations/LettuceRedisHashOperations.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/operations/LettuceReactiveRedisHashOperations.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/operations/LettuceRedisHashFieldExpirationOperations.java` -- Test: `modules/redis/redis-testkit/src/test/java/io/backend/skeleton/redis/contract/RedisHashOperationsContract.java` -- Test: `modules/redis/redis-testkit/src/test/java/io/backend/skeleton/redis/contract/RedisHashFieldExpirationContract.java` - -**Interfaces:** -- Implement design section 10.2 exactly. -- `entries` is R2 and requires budget. -- field TTL bean requires Redis 7.4 or later. - -- [ ] **Step 1: Write failing hash contracts** - -```java -@Test -void entriesRejectsReplyAboveBudget() { - HashKey key = keys.hash("profile", "1", codecs.string(), codecs.string()); - operations.hashes().putAll(key, Map.of("a", "1", "b", "2")); - - assertThatThrownBy(() -> operations.hashes().entries( - key, - permits.advanced("test"), - new OperationBudget(1, 1024, 1024, Duration.ofSeconds(1)) - )).isInstanceOf(RedisCommandRejectedException.class); -} -``` - -- [ ] **Step 2: Run hash contracts** - -```bash -./gradlew :modules:redis:redis-testkit:test --tests "*RedisHash*Contract" -``` - -Expected: FAIL. - -- [ ] **Step 3: Implement hash CRUD, scan, bounded entries, and field TTL** - -For Redis 7.2, the starter must not register `RedisHashFieldExpirationOperations`. For Redis 7.4+, register it after capability probe. For Redis 8.0+, enable get/set-plus-field-expiration optimized commands without changing the public contract. - -- [ ] **Step 4: Run version-gated tests** - -```bash -./gradlew :modules:redis:redis-testkit:redis72Test \ - :modules:redis:redis-testkit:redis74Test \ - :modules:redis:redis-testkit:redis82Test \ - --tests "*RedisHash*Contract" -``` - -Expected: PASS. Redis 7.2 test asserts the field-expiration bean is absent. - -- [ ] **Step 5: Commit** - -```bash -git add modules/redis/redis-core-lettuce modules/redis/redis-testkit -git commit -m "feat(redis): implement hash operations and field ttl" -``` - ---- - -### Task 12: Set와 Sorted Set operations 구현 - -**Files:** -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/operations/LettuceRedisSetOperations.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/operations/LettuceReactiveRedisSetOperations.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/operations/LettuceRedisSortedSetOperations.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/operations/LettuceReactiveRedisSortedSetOperations.java` -- Test: `modules/redis/redis-testkit/src/test/java/io/backend/skeleton/redis/contract/RedisSetOperationsContract.java` -- Test: `modules/redis/redis-testkit/src/test/java/io/backend/skeleton/redis/contract/RedisSortedSetOperationsContract.java` - -**Interfaces:** -- Implement design sections 10.4 and 10.5. -- Union, intersection, difference and store variants are R2. -- Every multi-key operation validates same-slot before server execution. - -- [ ] **Step 1: Write failing same-slot and bounded-result tests** - -```java -@Test -void crossSlotIntersectionFailsBeforeRedisCall() { - SetKey one = keys.setWithSlot("set", "one", "slot-a", codecs.string()); - SetKey two = keys.setWithSlot("set", "two", "slot-b", codecs.string()); - - assertThatThrownBy(() -> operations.sets().intersection( - List.of(one, two), - permits.advanced("test"), - budgets.collection() - )).isInstanceOf(RedisCrossSlotException.class); -} -``` - -- [ ] **Step 2: Run contracts** - -```bash -./gradlew :modules:redis:redis-testkit:test \ - --tests "*RedisSetOperationsContract" \ - --tests "*RedisSortedSetOperationsContract" -``` - -Expected: FAIL. - -- [ ] **Step 3: Implement set and sorted-set adapters** - -Do not add `members()` or unbounded `rangeAll()` convenience methods. Use scan and bounded range models. Normalize reverse range commands through `SortDirection` rather than deprecated command-specific method names. - -- [ ] **Step 4: Run Standalone and Cluster contracts** - -```bash -./gradlew :modules:redis:redis-testkit:redis74Test \ - :modules:redis:redis-testkit:cluster74Test \ - --tests "*RedisSetOperationsContract" \ - --tests "*RedisSortedSetOperationsContract" -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/redis/redis-core-lettuce modules/redis/redis-testkit -git commit -m "feat(redis): implement set and sorted set operations" -``` - ---- - -### Task 13: List operations와 Blocking 전용 pool 구현 - -**Files:** -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/operations/LettuceRedisListOperations.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/operations/LettuceReactiveRedisListOperations.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/operations/LettuceRedisBlockingListOperations.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/connection/BlockingConnectionPool.java` -- Test: `modules/redis/redis-testkit/src/test/java/io/backend/skeleton/redis/contract/RedisListOperationsContract.java` -- Test: `modules/redis/redis-testkit/src/test/java/io/backend/skeleton/redis/contract/RedisBlockingListOperationsContract.java` - -**Interfaces:** -- Implement design section 10.3. -- Maximum server block is 30 seconds by default. -- Client timeout is server block plus 2 seconds. - -- [ ] **Step 1: Write failing cancellation and pool-isolation tests** - -```java -@Test -void cancellingBlockingPopReturnsConnectionToBlockingPool() { - Disposable subscription = reactiveBlockingLists.pop( - List.of(key), ListSide.LEFT, Duration.ofSeconds(10) - ).subscribe(); - - subscription.dispose(); - - await().atMost(Duration.ofSeconds(2)).untilAsserted(() -> - assertThat(blockingPool.borrowedCount()).isZero() - ); -} -``` - -- [ ] **Step 2: Run list contracts** - -```bash -./gradlew :modules:redis:redis-testkit:test --tests "*Redis*ListOperationsContract" -``` - -Expected: FAIL. - -- [ ] **Step 3: Implement list and blocking adapters** - -Map deprecated `RPOPLPUSH/BRPOPLPUSH` semantics to `LMOVE/BLMOVE`. Reject infinite block durations. Ensure blocking commands never use the regular connection registry entry. - -- [ ] **Step 4: Run tests with connection metrics assertions** - -```bash -./gradlew :modules:redis:redis-testkit:redis74Test --tests "*Redis*ListOperationsContract" -``` - -Expected: PASS. Regular pending command count remains unaffected during a blocking test. - -- [ ] **Step 5: Commit** - -```bash -git add modules/redis/redis-core-lettuce modules/redis/redis-testkit -git commit -m "feat(redis): add list and isolated blocking operations" -``` - ---- - -### Task 14: Bitmap, Bitfield, HyperLogLog, Geo operations 구현 - -**Files:** -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/operations/LettuceRedisBitmapOperations.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/operations/LettuceRedisBitFieldOperations.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/operations/LettuceRedisHyperLogLogOperations.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/operations/LettuceRedisGeoOperations.java` -- Create: matching Reactive adapters -- Test: `modules/redis/redis-testkit/src/test/java/io/backend/skeleton/redis/contract/RedisSpecializedStructuresContract.java` - -**Interfaces:** -- Implement design sections 10.6–10.8. -- Bitmap offset and Geo count limits are configuration-backed. -- HyperLogLog contract states approximate cardinality. - -- [ ] **Step 1: Write failing boundary tests** - -```java -@Test -void bitmapRejectsOffsetAboveConfiguredMaximum() { - assertThatThrownBy(() -> operations.bitmaps().set(bitmapKey, 10_000_001L, true)) - .isInstanceOf(RedisCommandRejectedException.class); -} - -@Test -void geoSearchRequiresBoundedCount() { - assertThatThrownBy(() -> operations.geo().search( - geoKey, - GeoSearchRequest.withoutCount(origin, radius), - budgets.collection() - )).isInstanceOf(IllegalArgumentException.class); -} -``` - -- [ ] **Step 2: Run specialized structure contracts** - -```bash -./gradlew :modules:redis:redis-testkit:test --tests "*RedisSpecializedStructuresContract" -``` - -Expected: FAIL. - -- [ ] **Step 3: Implement sync and Reactive adapters** - -Normalize deprecated Geo radius commands to `GEOSEARCH`. Require explicit `BitFieldOverflow`. Validate same-slot for `BITOP`, HLL merge, and Geo store. - -- [ ] **Step 4: Run Standalone and Cluster tests** - -```bash -./gradlew :modules:redis:redis-testkit:redis74Test \ - :modules:redis:redis-testkit:cluster74Test \ - --tests "*RedisSpecializedStructuresContract" -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/redis/redis-core-lettuce modules/redis/redis-testkit -git commit -m "feat(redis): add bitmap hll and geo operations" -``` - ---- - -### Task 15: Batch와 Pipeline 구현 - -**Files:** -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/batch/RedisBatchBuilder.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/batch/LettuceRedisBatchOperations.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/batch/ClusterBatchPartitioner.java` -- Test: `modules/redis/redis-testkit/src/test/java/io/backend/skeleton/redis/contract/RedisBatchOperationsContract.java` -- Test: `modules/redis/redis-testkit/src/test/java/io/backend/skeleton/redis/contract/RedisClusterBatchContract.java` - -**Interfaces:** - -```java -public record RedisBatchResult(List> items) {} -``` - -- [ ] **Step 1: Write failing partial-result and ordering tests** - -```java -@Test -void preservesInputIndexAcrossNodePartitioning() { - RedisBatch batch = batchBuilder - .get(keyOnSlotOne) - .get(keyOnSlotTwo) - .wrongType(keyOnSlotOne) - .build(); - - RedisBatchResult result = operations.batches().execute(batch, batchOptions()); - - assertThat(result.items()).extracting(BatchItemResult::index) - .containsExactly(0, 1, 2); - assertThat(result.items().get(2).failed()).isTrue(); -} -``` - -- [ ] **Step 2: Run batch contracts** - -```bash -./gradlew :modules:redis:redis-testkit:test --tests "*Redis*Batch*Contract" -``` - -Expected: FAIL. - -- [ ] **Step 3: Implement command/byte caps, node partitioning, backpressure, and partial results** - -Do not wrap pipeline in transaction. Do not retry write batches. Reject batches over 500 commands, 4 MiB request, or 16 MiB expected reply using default configuration. - -- [ ] **Step 4: Run Standalone and Cluster batch tests** - -```bash -./gradlew :modules:redis:redis-testkit:redis74Test \ - :modules:redis:redis-testkit:cluster74Test \ - --tests "*Redis*Batch*Contract" -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/batch \ - modules/redis/redis-testkit -git commit -m "feat(redis): add bounded node aware pipelines" -``` - ---- - -### Task 16: Stream operations, pending recovery, version gate 구현 - -**Files:** -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/stream/LettuceRedisStreamOperations.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/stream/LettuceReactiveRedisStreamOperations.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/stream/LettuceRedisBlockingStreamOperations.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/stream/Redis82StreamExtensions.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/stream/Redis88StreamExtensions.java` -- Test: `modules/redis/redis-testkit/src/test/java/io/backend/skeleton/redis/contract/RedisStreamOperationsContract.java` -- Test: `modules/redis/redis-testkit/src/test/java/io/backend/skeleton/redis/contract/RedisStreamRecoveryContract.java` - -**Interfaces:** -- Implement design section 10.9. -- Append requires `MAXLEN` or `MINID` trim policy. -- 8.2 and 8.8 extensions are separate conditional beans. - -- [ ] **Step 1: Write failing trim and pending recovery tests** - -```java -@Test -void appendRequiresTrimPolicy() { - assertThatThrownBy(() -> operations.streams().append( - streamKey, - event, - StreamAppendOptions.withoutTrim() - )).isInstanceOf(IllegalArgumentException.class); -} - -@Test -void autoClaimRecoversIdlePendingMessage() { - StreamRecord record = appendAndReadWithoutAck(); - ClaimResult claimed = operations.streams().autoClaim( - streamKey, group, consumerTwo, Duration.ofMillis(10), StreamId.ZERO, 10 - ); - assertThat(claimed.records()).extracting(StreamRecord::id).contains(record.id()); -} -``` - -- [ ] **Step 2: Run stream contracts** - -```bash -./gradlew :modules:redis:redis-testkit:test --tests "*RedisStream*Contract" -``` - -Expected: FAIL. - -- [ ] **Step 3: Implement stream CRUD, groups, pending, claim, blocking read, and metrics** - -Register `Redis82StreamExtensions` only when `XACKDEL` and `XDELEX` are present. Register `Redis88StreamExtensions` only when `XNACK` is present. Expose pending count, oldest idle duration, claim count, and consumer lag metrics without stream key labels. - -- [ ] **Step 4: Run version and recovery tests** - -```bash -./gradlew :modules:redis:redis-testkit:redis74Test \ - :modules:redis:redis-testkit:redis82Test \ - :modules:redis:redis-testkit:redis810Test \ - --tests "*RedisStream*Contract" -``` - -Expected: PASS with version-specific beans asserted. - -- [ ] **Step 5: Commit** - -```bash -git add modules/redis/redis-core-lettuce modules/redis/redis-testkit -git commit -m "feat(redis): implement streams and pending recovery" -``` - ---- - -### Task 17: Pub/Sub과 Sharded Pub/Sub 구현 - -**Files:** -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/pubsub/LettuceRedisPubSubOperations.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/pubsub/LettuceRedisShardedPubSubOperations.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/pubsub/SubscriptionRegistry.java` -- Test: `modules/redis/redis-testkit/src/test/java/io/backend/skeleton/redis/contract/RedisPubSubOperationsContract.java` -- Test: `modules/redis/redis-testkit/src/test/java/io/backend/skeleton/redis/contract/RedisPubSubLossSemanticsTest.java` - -**Interfaces:** -- Implement design section 10.10. -- Pub/Sub uses dedicated connection. -- Cluster defaults to Sharded Pub/Sub. - -- [ ] **Step 1: Write failing subscription lifecycle test** - -```java -@Test -void closeUnsubscribesAndReturnsConnection() { - Subscription subscription = operations.pubSub().subscribe( - List.of(channel), messages::add - ); - - subscription.close(); - - await().untilAsserted(() -> assertThat(subscriptionRegistry.activeCount()).isZero()); -} -``` - -- [ ] **Step 2: Run Pub/Sub contracts** - -```bash -./gradlew :modules:redis:redis-testkit:test --tests "*RedisPubSub*" -``` - -Expected: FAIL. - -- [ ] **Step 3: Implement regular and sharded subscription adapters** - -Handle reconnect and resubscribe without claiming recovery of missed messages. Reject use of Pub/Sub API as a `DurableMessagePublisher` through type separation and architecture test. - -- [ ] **Step 4: Run Standalone and Cluster tests** - -```bash -./gradlew :modules:redis:redis-testkit:redis74Test \ - :modules:redis:redis-testkit:cluster74Test \ - --tests "*RedisPubSub*" -``` - -Expected: PASS. Loss-semantics test confirms messages sent during disconnect are not synthesized after reconnect. - -- [ ] **Step 5: Commit** - -```bash -git add modules/redis/redis-core-lettuce modules/redis/redis-testkit -git commit -m "feat(redis): add pubsub and sharded pubsub" -``` - ---- - -### Task 18: Sentinel failover와 결과 상태 분류 구현 - -**Files:** -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/connection/SentinelFailoverObserver.java` -- Create: `modules/redis/redis-core-lettuce/src/main/java/io/backend/skeleton/redis/lettuce/command/ExecutionCertainty.java` -- Modify: `modules/redis/redis-testkit/src/main/java/io/backend/skeleton/redis/testkit/SentinelRedisEnvironment.java` -- Test: `modules/redis/redis-testkit/src/test/java/io/backend/skeleton/redis/fault/SentinelFailoverContract.java` - -**Interfaces:** - -```java -public enum ExecutionCertainty { - CONFIRMED_SUCCESS, - CONFIRMED_FAILURE, - SAFE_TO_RETRY_FAILURE, - AMBIGUOUS_FAILURE -} -``` - -- [ ] **Step 1: Write failing promotion tests** - -```java -@Test -void nonIdempotentWriteIsNeverBlindlyRetriedDuringPromotion() { - faultController.pausePrimaryAfterCommandRead(); - - assertThatThrownBy(() -> operations.values().increment(counterKey, 1, new Expiration.Persistent(testPermit()))) - .isInstanceOf(RedisAmbiguousExecutionException.class); - - assertThat(metrics.retryCountFor("INCR")).isZero(); -} -``` - -- [ ] **Step 2: Run Sentinel fault test** - -```bash -./gradlew :modules:redis:redis-testkit:sentinel74Test --tests "*SentinelFailoverContract" -``` - -Expected: FAIL. - -- [ ] **Step 3: Implement failover observer, bounded reconnect queue, and certainty classification** - -The observer records primary switch, reconnect duration, queued command count, and ambiguous write count. Reads may retry according to the fixed retry matrix; writes may not retry after possible server execution. - -- [ ] **Step 4: Run Sentinel 7.4 and 8.2 tests** - -```bash -./gradlew :modules:redis:redis-testkit:sentinel74Test \ - :modules:redis:redis-testkit:sentinel82Test \ - --tests "*SentinelFailoverContract" -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/redis/redis-core-lettuce modules/redis/redis-testkit -git commit -m "feat(redis): model sentinel failover certainty" -``` - ---- - -### Task 19: Cluster slot, redirect, topology, node-local scan 구현 - -**Files:** -- Create: `modules/redis/redis-cluster/src/main/java/io/backend/skeleton/redis/cluster/RedisSlotCalculator.java` -- Create: `modules/redis/redis-cluster/src/main/java/io/backend/skeleton/redis/cluster/SameSlotValidator.java` -- Create: `modules/redis/redis-cluster/src/main/java/io/backend/skeleton/redis/cluster/ClusterTopologyObserver.java` -- Create: `modules/redis/redis-cluster/src/main/java/io/backend/skeleton/redis/cluster/ClusterScanCursor.java` -- Modify: `modules/redis/redis-testkit/src/main/java/io/backend/skeleton/redis/testkit/ClusterRedisEnvironment.java` -- Test: `modules/redis/redis-cluster/src/test/java/io/backend/skeleton/redis/cluster/RedisSlotCalculatorTest.java` -- Test: `modules/redis/redis-testkit/src/test/java/io/backend/skeleton/redis/fault/RedisClusterContract.java` - -**Interfaces:** - -```java -public interface SameSlotValidator { - int requireSameSlot(Collection keys); -} -``` - -- [ ] **Step 1: Write failing hash-tag and CROSSSLOT tests** - -```java -@Test -void bracesControlSlotCalculation() { - assertThat(slotCalculator.slot("prod:svc:{user-1}:a")) - .isEqualTo(slotCalculator.slot("prod:svc:{user-1}:b")); -} -``` - -- [ ] **Step 2: Run cluster tests** - -```bash -./gradlew :modules:redis:redis-cluster:test \ - :modules:redis:redis-testkit:cluster74Test --tests "*RedisClusterContract" -``` - -Expected: FAIL. - -- [ ] **Step 3: Implement slot validation, redirect metrics, topology refresh, node-local scan aggregation** - -Handle `MOVED`, `ASK`, and bounded `TRYAGAIN` retries. `ClusterScanCursor` must retain per-node cursors and mark completion only after every current primary cursor reaches zero. It is not a snapshot. - -- [ ] **Step 4: Run resharding and promotion tests** - -```bash -./gradlew :modules:redis:redis-testkit:cluster74Test \ - :modules:redis:redis-testkit:cluster82Test \ - --tests "*RedisClusterContract" -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/redis/redis-cluster modules/redis/redis-testkit -git commit -m "feat(redis): add slot aware cluster support" -``` - ---- - -### Task 20: WATCH/MULTI/EXEC transaction 구현 - -**Files:** -- Create: `modules/redis/redis-programmability/src/main/java/io/backend/skeleton/redis/programmability/RedisTransactionOperations.java` -- Create: `modules/redis/redis-programmability/src/main/java/io/backend/skeleton/redis/programmability/LettuceRedisTransactionOperations.java` -- Create: `modules/redis/redis-programmability/src/main/java/io/backend/skeleton/redis/programmability/TransactionConnectionScope.java` -- Test: `modules/redis/redis-testkit/src/test/java/io/backend/skeleton/redis/contract/RedisTransactionContract.java` - -**Interfaces:** - -```java -public interface RedisTransactionOperations { - TransactionResult watchAndExecute( - Collection watchedKeys, - RedisTransactionCallback callback, - TransactionOptions options - ); -} -``` - -- [ ] **Step 1: Write failing conflict and connection cleanup tests** - -```java -@Test -void watchConflictReturnsNotExecutedWithoutRollbackClaim() { - TransactionResult result = concurrentWatchConflict(); - assertThat(result.executed()).isFalse(); - assertThat(result.conflict()).isTrue(); -} - -@Test -void failedCallbackDoesNotLeaveConnectionInMultiState() { - assertThatThrownBy(this::executeFailingTransaction).isInstanceOf(RuntimeException.class); - assertThat(transactionPool.borrowAndPing()).isTrue(); -} -``` - -- [ ] **Step 2: Run transaction contracts** - -```bash -./gradlew :modules:redis:redis-testkit:redis74Test --tests "*RedisTransactionContract" -``` - -Expected: FAIL. - -- [ ] **Step 3: Implement dedicated connection scope and same-slot guard** - -Use `finally` to `DISCARD` or reset the connection. Preserve runtime command errors per result item and never describe them as rollback. Translate lost `EXEC` replies to ambiguous execution. - -- [ ] **Step 4: Run Standalone, Sentinel, and Cluster transaction tests** - -```bash -./gradlew :modules:redis:redis-testkit:redis74Test \ - :modules:redis:redis-testkit:sentinel74Test \ - :modules:redis:redis-testkit:cluster74Test \ - --tests "*RedisTransactionContract" -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/redis/redis-programmability modules/redis/redis-testkit -git commit -m "feat(redis): add optimistic redis transactions" -``` - ---- - -### Task 21: 등록 Lua Script와 Redis Function 구현 - -**Files:** -- Create: `modules/redis/redis-programmability/src/main/java/io/backend/skeleton/redis/programmability/RegisteredRedisScript.java` -- Create: `modules/redis/redis-programmability/src/main/java/io/backend/skeleton/redis/programmability/RedisScriptRegistry.java` -- Create: `modules/redis/redis-programmability/src/main/java/io/backend/skeleton/redis/programmability/LettuceRedisScriptOperations.java` -- Create: `modules/redis/redis-programmability/src/main/java/io/backend/skeleton/redis/programmability/RedisFunctionLibrary.java` -- Create: `modules/redis/redis-programmability/src/main/resources/redis/scripts/increment-with-expiry.lua` -- Test: `modules/redis/redis-testkit/src/test/java/io/backend/skeleton/redis/contract/RedisProgrammabilityContract.java` - -**Interfaces:** - -```java -public record RegisteredRedisScript( - String id, - String sha256, - int maxKeys, - Duration timeout, - long maxReplyBytes, - RedisResultDecoder decoder -) {} -``` - -- [ ] **Step 1: Write failing allowlist and NOSCRIPT tests** - -```java -@Test -void rejectsUnregisteredScriptSource() { - assertThatThrownBy(() -> scripts.executeRaw("return 1", List.of(), List.of())) - .isInstanceOf(RedisCommandRejectedException.class); -} - -@Test -void reloadsRegisteredScriptOnceAfterNoScript() { - server.flushScriptCacheForTest(); - assertThat(scripts.execute(incrementWithExpiry, List.of(key), List.of(arg("1"), arg("60000")))) - .isEqualTo(1L); -} -``` - -- [ ] **Step 2: Run programmability tests** - -```bash -./gradlew :modules:redis:redis-testkit:redis74Test --tests "*RedisProgrammabilityContract" -``` - -Expected: FAIL. - -- [ ] **Step 3: Implement registry, checksum, key declaration, same-slot, timeout, reply budget** - -Do not expose raw script source execution. Function libraries use ID, semantic version, and checksum. Startup verifies enabled function libraries and server capability. - -- [ ] **Step 4: Run Standalone and Cluster tests** - -```bash -./gradlew :modules:redis:redis-testkit:redis74Test \ - :modules:redis:redis-testkit:cluster74Test \ - --tests "*RedisProgrammabilityContract" -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/redis/redis-programmability modules/redis/redis-testkit -git commit -m "feat(redis): add registered scripts and functions" -``` - ---- - -### Task 22: 승인형 Raw Command Gateway 구현 - -**Files:** -- Create: `modules/redis/redis-raw-gateway/src/main/java/io/backend/skeleton/redis/raw/RedisRawGateway.java` -- Create: `modules/redis/redis-raw-gateway/src/main/java/io/backend/skeleton/redis/raw/ApprovedRawCommand.java` -- Create: `modules/redis/redis-raw-gateway/src/main/java/io/backend/skeleton/redis/raw/RawCommandPolicyToken.java` -- Create: `modules/redis/redis-raw-gateway/src/main/java/io/backend/skeleton/redis/raw/RawCommandAllowlist.java` -- Create: `modules/redis/redis-raw-gateway/src/main/java/io/backend/skeleton/redis/raw/RawCommandKeyExtractor.java` -- Create: `modules/redis/redis-raw-gateway/src/main/resources/redis/raw-command-allowlist.yml` -- Test: `modules/redis/redis-raw-gateway/src/test/java/io/backend/skeleton/redis/raw/RedisRawGatewaySecurityTest.java` - -**Interfaces:** - -```java -public interface RedisRawGateway { - R execute( - ApprovedRawCommand command, - List arguments, - RawCommandPolicyToken policyToken - ); -} -``` - -- [ ] **Step 1: Write failing security tests** - -```java -@Test -void blocksR3AndR4CommandsEvenWhenNamedInExternalFile() { - assertThatThrownBy(() -> gateway.execute( - approved("FLUSHALL"), List.of(), token - )).isInstanceOf(RedisCommandRejectedException.class); -} - -@Test -void rejectsKeyOutsideNamespace() { - assertThatThrownBy(() -> gateway.execute( - approved("GET"), List.of(arg("prod:other-service:key")), token - )).isInstanceOf(RedisCommandRejectedException.class); -} -``` - -- [ ] **Step 2: Run gateway tests** - -```bash -./gradlew :modules:redis:redis-raw-gateway:test --tests "*RedisRawGatewaySecurityTest" -``` - -Expected: FAIL. - -- [ ] **Step 3: Implement immutable approved descriptors and full guard chain** - -Enforce command/subcommand allowlist, version, official key extraction, namespace, same-slot, risk, request/reply bytes, timeout, registered decoder, and audit. Do not create an overload accepting arbitrary command strings. - -- [ ] **Step 4: Run unit and integration security tests** - -```bash -./gradlew :modules:redis:redis-raw-gateway:test \ - :modules:redis:redis-testkit:redis74Test \ - --tests "*RawGateway*" -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/redis/redis-raw-gateway modules/redis/redis-testkit -git commit -m "feat(redis): add policy controlled raw gateway" -``` - ---- - -### Task 23: 별도 Admin Plane 구현 - -**Files:** -- Create: `modules/redis/redis-admin-plane/src/main/java/io/backend/skeleton/redis/admin/RedisAdminDiagnostics.java` -- Create: `modules/redis/redis-admin-plane/src/main/java/io/backend/skeleton/redis/admin/LettuceRedisAdminDiagnostics.java` -- Create: `modules/redis/redis-admin-plane/src/main/java/io/backend/skeleton/redis/admin/AdminCommandProjection.java` -- Test: `modules/redis/redis-admin-plane/src/test/java/io/backend/skeleton/redis/admin/RedisAdminDiagnosticsTest.java` -- Test: `modules/redis/redis-admin-plane/src/test/java/io/backend/skeleton/redis/admin/RedisAdminForbiddenCommandsTest.java` - -**Interfaces:** - -```java -public interface RedisAdminDiagnostics { - RedisInfoSnapshot info(Set sections); - OptionalLong memoryUsage(QualifiedRedisKey key); - List slowLog(int count); - List latencyLatest(); - ClusterDiagnostics clusterDiagnostics(); - AclDryRunResult aclDryRun(String username, ApprovedRawCommand command, List arguments); -} -``` - -- [ ] **Step 1: Write failing bean-isolation and forbidden-command tests** - -```java -@Test -void adminBeanIsAbsentInNormalApplicationProfile() { - contextRunner.run(context -> assertThat(context).doesNotHaveBean(RedisAdminDiagnostics.class)); -} - -@Test -void moduleHasNoFlushOrShutdownMethod() { - assertThat(Arrays.stream(RedisAdminDiagnostics.class.getMethods()).map(Method::getName)) - .noneMatch(name -> name.contains("flush") || name.contains("shutdown")); -} -``` - -- [ ] **Step 2: Run admin tests** - -```bash -./gradlew :modules:redis:redis-admin-plane:test -``` - -Expected: FAIL. - -- [ ] **Step 3: Implement read-only projections and separate connection factory requirement** - -Sanitize `CLIENT LIST` and `INFO` fields. Require `backend.redis.admin.enabled=true` and separate admin credentials. Block mutating admin commands in the module and policy catalog. - -- [ ] **Step 4: Run tests** - -```bash -./gradlew :modules:redis:redis-admin-plane:test \ - :modules:redis:redis-spring-boot-starter:test --tests "*Admin*" -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/redis/redis-admin-plane modules/redis/redis-spring-boot-starter -git commit -m "feat(redis): add isolated readonly admin plane" -``` - ---- - -### Task 24: Redis JSON과 Search 확장 모듈 구현 - -**Files:** -- Create: `modules/redis/extensions/redis-json/src/main/java/io/backend/skeleton/redis/json/RedisJsonOperations.java` -- Create: `modules/redis/extensions/redis-json/src/main/java/io/backend/skeleton/redis/json/LettuceRedisJsonOperations.java` -- Create: `modules/redis/extensions/redis-search/src/main/java/io/backend/skeleton/redis/search/RedisSearchOperations.java` -- Create: `modules/redis/extensions/redis-search/src/main/java/io/backend/skeleton/redis/search/LettuceRedisSearchOperations.java` -- Test: `modules/redis/redis-testkit/src/test/java/io/backend/skeleton/redis/extensions/RedisJsonContract.java` -- Test: `modules/redis/redis-testkit/src/test/java/io/backend/skeleton/redis/extensions/RedisSearchContract.java` - -**Interfaces:** -- JSON provides typed path get/set/delete/array/object operations. -- Search provides declared index schemas, query, aggregation, pagination, and vector query. -- Both modules require capability probe success. - -- [ ] **Step 1: Write failing conditional-bean tests** - -```java -@Test -void jsonBeanIsAbsentOnClassicRedisWithoutJsonCapability() { - classicRedisContext.run(context -> assertThat(context).doesNotHaveBean(RedisJsonOperations.class)); -} - -@Test -void enabledSearchFailsStartupWhenCapabilityIsMissing() { - classicRedisContext.withPropertyValues("backend.redis.search.enabled=true") - .run(context -> assertThat(context.getStartupFailure()) - .isInstanceOf(RedisCapabilityUnavailableException.class)); -} -``` - -- [ ] **Step 2: Run extension tests** - -```bash -./gradlew :modules:redis:extensions:redis-json:test \ - :modules:redis:extensions:redis-search:test -``` - -Expected: FAIL. - -- [ ] **Step 3: Implement independent capability-gated operations** - -Do not add JSON/Search commands to `redis-core-api`. Use the same namespace, codec, policy guard, timeout, exception, metric, trace, and ACL mechanisms as classic operations. - -- [ ] **Step 4: Run Redis 8 integrated extension tests** - -```bash -./gradlew :modules:redis:redis-testkit:redis82ExtensionsTest \ - --tests "*RedisJsonContract" \ - --tests "*RedisSearchContract" -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/redis/extensions/redis-json modules/redis/extensions/redis-search modules/redis/redis-testkit -git commit -m "feat(redis): add json and search extensions" -``` - ---- - -### Task 25: Time Series와 Probabilistic 확장 모듈 구현 - -**Files:** -- Create: `modules/redis/extensions/redis-timeseries/src/main/java/io/backend/skeleton/redis/timeseries/RedisTimeSeriesOperations.java` -- Create: `modules/redis/extensions/redis-timeseries/src/main/java/io/backend/skeleton/redis/timeseries/LettuceRedisTimeSeriesOperations.java` -- Create: `modules/redis/extensions/redis-probabilistic/src/main/java/io/backend/skeleton/redis/probabilistic/RedisBloomOperations.java` -- Create: `modules/redis/extensions/redis-probabilistic/src/main/java/io/backend/skeleton/redis/probabilistic/RedisCuckooOperations.java` -- Create: `modules/redis/extensions/redis-probabilistic/src/main/java/io/backend/skeleton/redis/probabilistic/RedisCountMinSketchOperations.java` -- Create: `modules/redis/extensions/redis-probabilistic/src/main/java/io/backend/skeleton/redis/probabilistic/RedisTopKOperations.java` -- Create: `modules/redis/extensions/redis-probabilistic/src/main/java/io/backend/skeleton/redis/probabilistic/RedisTDigestOperations.java` -- Test: `modules/redis/redis-testkit/src/test/java/io/backend/skeleton/redis/extensions/RedisTimeSeriesContract.java` -- Test: `modules/redis/redis-testkit/src/test/java/io/backend/skeleton/redis/extensions/RedisProbabilisticContract.java` - -**Interfaces:** -- Each probabilistic structure exposes its approximation/error contract in model types and Javadoc. -- Time Series range queries require bounded time range and result budget. - -- [ ] **Step 1: Write failing capability and approximation-contract tests** - -```java -@Test -void bloomResultIsTypedAsProbabilisticDecision() { - ProbabilisticDecision decision = bloom.mightContain(filterKey, "value"); - assertThat(decision).isIn(ProbabilisticDecision.POSSIBLY_PRESENT, ProbabilisticDecision.DEFINITELY_ABSENT); -} -``` - -- [ ] **Step 2: Run extension contracts** - -```bash -./gradlew :modules:redis:extensions:redis-timeseries:test \ - :modules:redis:extensions:redis-probabilistic:test -``` - -Expected: FAIL. - -- [ ] **Step 3: Implement independent extension adapters and budgets** - -Reuse core guardrails. Do not represent approximate structures as exact membership or exact count APIs. - -- [ ] **Step 4: Run Redis 8 extension tests** - -```bash -./gradlew :modules:redis:redis-testkit:redis82ExtensionsTest \ - --tests "*RedisTimeSeriesContract" \ - --tests "*RedisProbabilisticContract" -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/redis/extensions/redis-timeseries modules/redis/extensions/redis-probabilistic modules/redis/redis-testkit -git commit -m "feat(redis): add timeseries and probabilistic extensions" -``` - ---- - -### Task 26: Testkit topology, network fault, ACL, performance harness 완성 - -**Files:** -- Modify: `modules/redis/redis-testkit/src/main/java/io/backend/skeleton/redis/testkit/StandaloneRedisEnvironment.java` -- Modify: `modules/redis/redis-testkit/src/main/java/io/backend/skeleton/redis/testkit/SentinelRedisEnvironment.java` -- Modify: `modules/redis/redis-testkit/src/main/java/io/backend/skeleton/redis/testkit/ClusterRedisEnvironment.java` -- Create: `modules/redis/redis-testkit/src/main/java/io/backend/skeleton/redis/testkit/RedisFaultController.java` -- Create: `modules/redis/redis-testkit/src/test/java/io/backend/skeleton/redis/security/RedisAclContract.java` -- Create: `modules/redis/redis-testkit/src/test/java/io/backend/skeleton/redis/performance/RedisGuardrailPerformanceTest.java` -- Create: `infra/redis/standalone/compose.yml` -- Create: `infra/redis/sentinel/compose.yml` -- Create: `infra/redis/cluster/compose.yml` -- Create: `infra/redis/acl/application.acl` -- Create: `infra/redis/acl/application-advanced.acl` -- Create: `infra/redis/acl/admin-readonly.acl` - -**Interfaces:** -- Test environments expose endpoint, credentials, deployment mode, fault controller, and cleanup. -- Fault controller injects latency, packet loss, disconnect, response loss, promotion, and partial node partition. - -- [ ] **Step 1: Write failing ACL and fault tests** - -```java -@Test -void applicationUserCannotExecuteKeysOrFlushAll() { - assertThat(command("ACL", "DRYRUN", applicationUser, "KEYS", "*")).contains("command not allowed"); - assertThat(command("ACL", "DRYRUN", applicationUser, "FLUSHALL")).contains("command not allowed"); -} -``` - -```java -@Test -void responseLossOnIncrementProducesAmbiguousFailureWithoutRetry() { - faults.dropNextResponseAfterServerExecution(); - assertThatThrownBy(() -> operations.values().increment(counterKey, 1, expiration)) - .isInstanceOf(RedisAmbiguousExecutionException.class); -} -``` - -- [ ] **Step 2: Run security and fault tests** - -```bash -./gradlew :modules:redis:redis-testkit:test \ - --tests "*RedisAclContract" \ - --tests "*RedisGuardrailPerformanceTest" -``` - -Expected: FAIL. - -- [ ] **Step 3: Complete the Task 8 topology environments with Toxiproxy faults, ACL files, and guardrail datasets** - -Datasets must include: - -```text -1 MiB String -100,000-field Hash -100,000-member Set -100,000-member Sorted Set -1,000,000-entry Stream with trim policy -500-command pipeline -``` - -Performance assertions record p50, p95, p99, max, JVM allocation, Redis CPU/memory, request/reply bytes, and pending queue. Tests fail on limit bypass, not on absolute production throughput. - -- [ ] **Step 4: Run the full topology suite** - -```bash -./gradlew \ - :modules:redis:redis-testkit:redis72Test \ - :modules:redis:redis-testkit:redis74Test \ - :modules:redis:redis-testkit:redis82Test \ - :modules:redis:redis-testkit:redis810Test \ - :modules:redis:redis-testkit:sentinel74Test \ - :modules:redis:redis-testkit:sentinel82Test \ - :modules:redis:redis-testkit:cluster74Test \ - :modules:redis:redis-testkit:cluster82Test -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add modules/redis/redis-testkit infra/redis -git commit -m "test(redis): add topology fault and acl harness" -``` - ---- - -### Task 27: CI matrix, support matrix, upgrade gate, 운영 문서 연결 - -**Files:** -- Create: `.github/workflows/redis-pr.yml` -- Create: `.github/workflows/redis-nightly.yml` -- Create: `.github/workflows/redis-release.yml` -- Create: `docs/redis/support-matrix.md` -- Create: `docs/redis/command-policy.md` -- Create: `docs/redis/operations.md` -- Create: `docs/redis/upgrade-guide.md` -- Create: `modules/redis/redis-core-lettuce/src/test/java/io/backend/skeleton/redis/lettuce/command/CommandCatalogDriftTest.java` -- Create: `modules/redis/redis-core-api/src/test/java/io/backend/skeleton/redis/api/PublicApiCompatibilityTest.java` - -**Interfaces:** -- PR matrix: Standalone 7.4 and 8.2. -- Nightly matrix: Standalone 7.2·7.4·8.2·8.10, Sentinel 7.4·8.2, Cluster 7.4·8.2. -- Release adds network faults, ACL, extensions, and performance guardrail jobs. - -- [ ] **Step 1: Write failing catalog drift and documentation sync tests** - -```java -@Test -void commandCatalogHasNoUnreviewedServerCommands() { - RedisCommandMetadataDiff diff = metadataClient.diffAgainstPolicy(); - assertThat(diff.requiresReview()) - .as(diff.toMarkdown()) - .isFalse(); -} -``` - -```java -@Test -void supportMatrixContainsEveryPublishedModule() { - assertThat(SupportMatrixParser.parse(Path.of("docs/redis/support-matrix.md")).modules()) - .containsAll(PublishedRedisModules.names()); -} -``` - -- [ ] **Step 2: Run drift tests** - -```bash -./gradlew :modules:redis:redis-core-lettuce:test --tests "*CommandCatalogDriftTest" \ - :modules:redis:redis-core-api:test --tests "*PublicApiCompatibilityTest" -``` - -Expected: FAIL because generated metadata and docs are not connected. - -- [ ] **Step 3: Implement workflows and generated support artifacts** - -`support-matrix.md` must list module, minimum Redis version, certified versions, topology, risk exposure, sync/reactive support, and known limitations. `upgrade-guide.md` must require command metadata diff, ACL regression, serializer golden bytes, topology suite, and rollback procedure before changing Redis or client versions. - -- [ ] **Step 4: Run the complete release verification locally** - -```bash -./gradlew clean check \ - :modules:redis:redis-testkit:redis72Test \ - :modules:redis:redis-testkit:redis74Test \ - :modules:redis:redis-testkit:redis82Test \ - :modules:redis:redis-testkit:redis810Test \ - :modules:redis:redis-testkit:sentinel74Test \ - :modules:redis:redis-testkit:sentinel82Test \ - :modules:redis:redis-testkit:cluster74Test \ - :modules:redis:redis-testkit:cluster82Test \ - :modules:redis:redis-testkit:redis82ExtensionsTest -``` - -Expected: exit code 0 and zero failed tests. - -- [ ] **Step 5: Commit** - -```bash -git add .github/workflows docs/redis modules/redis -git commit -m "ci(redis): enforce support and upgrade gates" -``` - ---- - -## 3. 작업 간 의존 순서 - -```text -Task 1 - -> Task 2 - -> Tasks 3, 4, 5, 6 - -> Task 7 - -> Task 8 - -> Task 9 - -> Tasks 10, 11, 12, 13, 14 - -> Task 15 - -> Tasks 16, 17 - -> Tasks 18, 19 - -> Tasks 20, 21 - -> Task 22 - -> Task 23 - -> Tasks 24, 25 - -> Task 26 - -> Task 27 -``` - -Task 10–14는 Task 9 이후 병렬 구현할 수 있다. Task 18과 Task 19도 독립 topology 환경에서 병렬 구현할 수 있다. Raw Gateway는 Task 2, 4, 6, 8, 9, 19가 완료된 이후에만 시작한다. - ---- - -## 4. 단계별 release 기준 - -### Milestone A — Core Alpha - -포함 Task: 1–9 - -완료 기준: - -- module graph -- command policy catalog -- key, codec, error, capability, permit, budget -- sync/reactive API -- topology probe -- policy-aware executor - -### Milestone B — Classic Structures Beta - -포함 Task: 10–17 - -완료 기준: - -- classic 자료구조 Typed API -- bounded collection operations -- batch/pipeline -- Stream -- Pub/Sub -- Standalone 7.4·8.2 contract suite - -### Milestone C — Distributed RC - -포함 Task: 18–23 - -완료 기준: - -- Sentinel failover semantics -- Cluster slot·redirect·topology -- transaction, script, function -- Raw Gateway -- Admin Plane -- ACL tests - -### Milestone D — Extensions and Release - -포함 Task: 24–27 - -완료 기준: - -- Redis 8 extensions -- full topology and fault suite -- command catalog drift gate -- CI and operations documentation -- release verification exit code 0 - ---- - -## 5. 구현자가 임의로 변경하면 안 되는 결정 - -- `RedisOperations`와 `ReactiveRedisOperations`를 하나의 generic async abstraction으로 합치지 않는다. -- `RedisTemplate` 또는 Lettuce command interface를 application에 직접 노출하지 않는다. -- convenience를 이유로 unbounded `entries`, `members`, `rangeAll`, `keys`를 추가하지 않는다. -- R2 permit와 budget을 optional parameter로 만들지 않는다. -- Raw Gateway에 arbitrary command string overload를 추가하지 않는다. -- Cluster cross-slot write를 자동 fan-out하지 않는다. -- non-idempotent write timeout을 자동 retry하지 않는다. -- Pub/Sub을 message durability abstraction에 연결하지 않는다. -- transaction result에 rollback 의미를 추가하지 않는다. -- Java serialization fallback을 추가하지 않는다. -- metric 또는 trace에 실제 key를 추가하지 않는다. - ---- - -## 6. 계획 자체 검증 체크리스트 - -- [ ] 설계서의 모든 module이 Task 1 또는 Task 24–25에 포함되어 있다. -- [ ] 설계서의 모든 classic 자료구조가 Task 10–17에 포함되어 있다. -- [ ] Standalone·Sentinel·Cluster가 각각 test task를 가진다. -- [ ] R1·R2·R3·R4 정책이 Task 2, 9, 22, 23, 26에 연결되어 있다. -- [ ] namespace, codec, TTL, timeout, retry, error, telemetry가 구현 task를 가진다. -- [ ] transaction, pipeline, script, function의 비보장이 테스트에 포함되어 있다. -- [ ] Raw Gateway가 core guardrail 뒤에 위치한다. -- [ ] command metadata drift와 ACL upgrade regression이 CI에 포함되어 있다. -- [ ] 계획에 미확정 표식이나 구현자 재판단 지시가 없다. -- [ ] 최종 release 명령이 전체 suite를 실행한다. - diff --git a/redis-superpowers-package/docs/superpowers/specs/2026-08-07-redis-wrapper-typed-api-design.md b/redis-superpowers-package/docs/superpowers/specs/2026-08-07-redis-wrapper-typed-api-design.md deleted file mode 100644 index 0d1fe53..0000000 --- a/redis-superpowers-package/docs/superpowers/specs/2026-08-07-redis-wrapper-typed-api-design.md +++ /dev/null @@ -1,1497 +0,0 @@ -# Redis Wrapper 및 Typed API 설계서 - -- **상태:** 구현 기준선 확정 -- **작성일:** 2026-08-07 -- **대상:** Spring 기반 Backend Skeleton의 공통 Redis SDK -- **입력 근거:** `붙여넣은 마크다운(1)(7).md` — Redis Open Source, Lettuce, Spring Data Redis 공식 문서 및 운영 사례를 정리한 심층 리서치 -- **문서 목적:** 구현 중 추가 설계 판단이나 반복 질문 없이 모듈 구조, 공개 API, 명령 노출 정책, 장애 의미론, 운영 통제, 테스트 및 완료 조건을 확정한다. - ---- - -## 1. 요약 - -이 설계는 Redis 자료구조와 명령을 폭넓게 즉시 사용할 수 있도록 제공하되, 모든 명령을 동일한 권한과 형태로 노출하지 않는다. - -최종 노출 모델은 다음 네 단계다. - -1. **Typed API:** 자료구조별 R1 명령과 bounded operation을 기본 제공한다. -2. **Advanced Typed API:** R2 고비용·Blocking·다중 키 명령은 명시적 permit와 `OperationBudget`을 요구한다. -3. **Approved Raw Gateway:** Typed API에 아직 포함되지 않은 R1·R2 명령을 사전 등록된 command descriptor로만 실행한다. -4. **Admin Plane:** R3 운영·관리 명령은 별도 모듈·계정·연결·배포 경로로 분리한다. R4 파괴적 명령은 SDK에서 실행하지 못한다. - -핵심 원칙은 다음과 같다. - -> 자료구조와 명령 지원 폭은 넓히되, namespace·직렬화·TTL·timeout·Cluster slot·위험 등급·관측성·ACL을 우회할 수 있는 범용 문자열 실행 API는 제공하지 않는다. - ---- - -## 2. 범위 - -### 2.1 포함 범위 - -- Redis Open Source classic 자료구조 - - String - - Hash - - List - - Set - - Sorted Set - - Bitmap - - Bitfield - - HyperLogLog - - Geospatial - - Stream - - Pub/Sub 및 Sharded Pub/Sub - - Key·TTL -- Pipeline과 명시적 Batch -- `WATCH/MULTI/EXEC` -- 등록형 Lua Script와 Redis Function -- Standalone, Sentinel, Cluster -- 동기 API와 Reactive API -- 명령 위험 등급 R1~R4 -- ACL, namespace, 직렬화, version gate, timeout, retry, 오류 변환, metric, trace, audit -- Raw Command Gateway -- Redis 8 확장 기능의 독립 모듈 - - JSON - - Search 및 Vector Query - - Time Series - - Probabilistic 자료구조 -- 계약·통합·동시성·장애·성능·보안 테스트 - -### 2.2 제외 범위 - -- 비즈니스 정책 - - 도메인별 TTL - - 사용자 등급별 요청 제한 - - 주문·결제·채팅 등의 업무 흐름 -- Redis를 업무의 유일한 강한 정합성 저장소로 가정하는 기능 -- 임의 문자열 기반 `execute(String, byte[]...)` -- R4 파괴적 명령 실행 -- Redis Cluster에서 cross-slot 다중 키 연산의 자동 분산 실행 -- Pipeline을 transaction으로 표현하는 API -- Redis transaction을 관계형 데이터베이스 rollback 모델로 표현하는 API -- Pub/Sub을 durable messaging으로 표현하는 API -- 자동 blind retry로 결과 불명 write를 재실행하는 기능 - ---- - -## 3. 입력 자료의 제약과 처리 원칙 - -첨부된 Markdown은 309개 명령·기능 항목이 포함된 Excel 워크북을 참조하지만, 현재 작업 공간에는 Markdown만 존재한다. 따라서 다음 원칙을 적용한다. - -1. 이 설계서는 Markdown에 명시된 지원 기준, 위험 등급, API 방향, 운영 정책, 테스트 및 구현 순서를 그대로 기준선으로 사용한다. -2. 309행의 정확한 초기 분류는 구현 과정에서 Redis 공식 `COMMAND DOCS`, `COMMAND INFO`, `COMMAND GETKEYSANDFLAGS` 결과로 재생성한다. -3. 공식 metadata로 결정할 수 없는 조직 정책은 `redis-command-policy.yml` 오버레이에 명시한다. -4. 향후 Excel 워크북이 제공되면 오버레이 import 도구로 병합하되, 코드에 수작업으로 중복 입력하지 않는다. - ---- - -## 4. 설계 결정 - -| ID | 결정 | 근거와 결과 | -|---|---|---| -| D-01 | 기본 API는 자료구조별 Typed API로 한다. | 타입 안전성, namespace, codec, TTL, 위험 통제를 일관되게 강제한다. | -| D-02 | Typed API에 없는 기능은 승인형 Raw Gateway로 제공한다. | 최대 지원 폭을 확보하되 정책 우회는 차단한다. | -| D-03 | deprecated 명령명은 공개 API에 남기지 않는다. | `SETEX`, `SETNX`, 역방향 range 등은 최신 의미의 메서드와 옵션으로 통합한다. | -| D-04 | R1은 기본, R2는 permit+budget, R3는 admin plane, R4는 차단한다. | 성능과 운영 위험을 권한·구성·ACL에 반영한다. | -| D-05 | 공개 프로그래밍 모델은 동기와 Reactive 두 축이다. | Lettuce native async는 내부 구현 또는 명시적 고급 API로만 사용한다. | -| D-06 | 기능 최소 버전은 Redis 7.2다. | 주 인증은 7.4·8.2, 최신 호환성은 8.10으로 검증한다. | -| D-07 | Standalone·Sentinel은 완전 지원, Cluster는 slot 제약을 공개 계약에 반영한 조건부 완전 지원이다. | 다중 키는 same-slot을 사전 검증하고 DB 0만 허용한다. | -| D-08 | classic Redis와 Redis 8 확장 기능을 모듈로 분리한다. | Redis 7, managed Redis, Redis 8 통합 배포 간 호환성을 보존한다. | -| D-09 | 일반·Blocking·Transaction·Pub/Sub·Admin 연결을 분리한다. | shared connection 오염과 장애 전파를 방지한다. | -| D-10 | timeout 후 write는 `ambiguousExecution`을 구분한다. | 자동 retry 여부를 호출자가 정확히 판단할 수 있게 한다. | -| D-11 | command catalog와 지원 매트릭스는 서버 metadata와 정책 파일로 생성한다. | 새 명령, deprecated, ACL category, key spec 변화를 CI에서 탐지한다. | -| D-12 | 모든 collection read와 batch는 bounded API로 설계한다. | Big Key, 응답 폭증, JVM heap pressure를 구조적으로 제한한다. | - ---- - -## 5. 지원 기준 - -### 5.1 Redis 버전 - -| 프로파일 | 용도 | 지원 정책 | -|---|---|---| -| Redis 7.2 | 기능 최소선 | 기본 API가 반드시 동작해야 한다. | -| Redis 7.4 | 주 인증 | Hash field TTL 기능을 version-gated module로 인증한다. | -| Redis 8.2 | 주 인증 | Redis 8 LTS 및 향상된 Stream 기능을 인증한다. | -| Redis 8.10 | 최신 호환성 | 기존 API와 command policy가 깨지지 않는지 검증한다. | -| Redis 6.2 | 제한적 유지 | 신규 기능은 제공하지 않고 마이그레이션 호환성만 별도 job에서 확인한다. | -| Redis 7.2 미만 | 기본 비지원 | 신규 프로젝트 대상에서 제외한다. | - -### 5.2 클라이언트와 프레임워크 - -- 공개 Spring 통합: Spring Data Redis 4.1 계열 -- 드라이버: Lettuce 7.6 계열 -- 테스트: JUnit 5, Testcontainers, Toxiproxy -- Reactive 계약: Reactor `Mono`와 `Flux` -- Java 기준선: Java 21 -- 빌드: Gradle Kotlin DSL 멀티모듈 - -Java·Gradle 기준선은 현재 저장소가 제공되지 않은 상태에서 이 문서를 실행 가능한 기준으로 만들기 위한 구현 가정이다. 실제 저장소가 더 높은 기준선을 사용하면 상향 적용하되 API 계약은 변경하지 않는다. - -### 5.3 배포 모드 - -| 기능 | Standalone | Sentinel | Cluster | -|---|---|---|---| -| 단일 키 read/write | 지원 | 지원 | 지원 | -| 다중 키 명령 | 지원 | 지원 | same-slot만 지원 | -| Pipeline | 지원 | 지원 | node별 분할 | -| Transaction | 지원 | 지원 | same-slot만 지원 | -| Lua/Function | 지원 | 지원 | 선언 key same-slot | -| Blocking | 전용 연결 | 전용 연결·failover 처리 | slot별 전용 연결 | -| Pub/Sub | 지원 | 재구독 손실 의미 노출 | Sharded Pub/Sub 우선 | -| Replica read | 선택 | stale 정책 필수 | stale 정책 필수 | -| DB index | 설정 가능 | 설정 가능 | 0만 허용 | -| SCAN | instance 범위 | 현재 primary 범위 | node별 scan aggregation | - ---- - -## 6. 전체 아키텍처 - -```mermaid -flowchart TB - APP[Application Modules] - - subgraph Public API - SYNC[redis-core-api\nSync Typed API] - REACTIVE[redis-core-api\nReactive Typed API] - ADV[Advanced Typed API\nR2 Permit + Budget] - RAW[redis-raw-gateway\nApproved Commands] - end - - subgraph Policy and Runtime - CAT[Command Catalog\nVersion/Risk/Key Spec] - GUARD[Policy Guard\nNamespace/Slot/Size/ACL] - CODEC[Codec Registry\nSchema Envelope] - EXEC[Command Executor\nTimeout/Error/Retry/Telemetry] - end - - subgraph Connections - REG[Regular Connection] - BLOCK[Blocking Pool] - TX[Transaction Connection] - PUB[Pub/Sub Connection] - ADMIN[Admin Connection] - end - - subgraph Redis Deployment - STD[Standalone] - SEN[Sentinel] - CLU[Cluster] - end - - APP --> SYNC - APP --> REACTIVE - APP --> ADV - APP --> RAW - SYNC --> GUARD - REACTIVE --> GUARD - ADV --> GUARD - RAW --> GUARD - GUARD --> CAT - GUARD --> CODEC - GUARD --> EXEC - EXEC --> REG - EXEC --> BLOCK - EXEC --> TX - EXEC --> PUB - EXEC --> ADMIN - REG --> STD - REG --> SEN - REG --> CLU - BLOCK --> STD - BLOCK --> SEN - BLOCK --> CLU - TX --> STD - TX --> SEN - TX --> CLU - PUB --> STD - PUB --> SEN - PUB --> CLU -``` - -### 6.1 실행 흐름 - -1. 호출자는 자료구조별 Typed API 또는 승인형 Raw Gateway를 호출한다. -2. API는 `CommandRequest`를 생성한다. -3. `CommandPolicyGuard`가 서버 capability, 위험 등급, permit, namespace, key slot, request/reply 예산을 검증한다. -4. `RedisCodecRegistry`가 key·field·value를 직렬화한다. -5. `RedisCommandExecutor`가 명령 유형에 맞는 연결을 선택한다. -6. timeout, retry, exception translation, metric, trace, audit가 실행 경로 전체를 감싼다. -7. 결과는 드라이버 타입이 아닌 안정된 SDK 타입으로 반환한다. - ---- - -## 7. 모듈 구조 - -```text -backend-skeleton/ -├── modules/redis/ -│ ├── redis-core-api/ -│ ├── redis-core-lettuce/ -│ ├── redis-cluster/ -│ ├── redis-programmability/ -│ ├── redis-raw-gateway/ -│ ├── redis-admin-plane/ -│ ├── redis-spring-boot-starter/ -│ ├── redis-testkit/ -│ └── extensions/ -│ ├── redis-json/ -│ ├── redis-search/ -│ ├── redis-timeseries/ -│ └── redis-probabilistic/ -├── infra/redis/ -│ ├── standalone/ -│ ├── sentinel/ -│ ├── cluster/ -│ └── acl/ -└── docs/redis/ -``` - -| 모듈 | 책임 | 의존 규칙 | -|---|---|---| -| `redis-core-api` | 공개 타입, 동기·Reactive 자료구조 API, 오류 모델 | Spring Data·Lettuce에 의존하지 않는다. Reactor만 reactive package에 사용한다. | -| `redis-core-lettuce` | Spring Data Redis·Lettuce 구현, policy guard, codec, executor | `redis-core-api`에만 공개적으로 의존한다. | -| `redis-cluster` | CRC16 slot 계산, hash tag, same-slot, topology·redirect 관측 | Cluster 기능을 사용하지 않는 서비스에서 제외 가능하다. | -| `redis-programmability` | Transaction, 등록 Lua, Redis Function | 임의 script source를 받지 않는다. | -| `redis-raw-gateway` | allowlist 기반 R1·R2 Raw 실행 | core policy와 catalog를 우회하지 않는다. | -| `redis-admin-plane` | R3 진단·운영 조회 | 별도 계정·연결·배포 경로를 요구한다. | -| `redis-spring-boot-starter` | properties, auto-configuration, capability probe, bean 조건 | application module이 직접 Lettuce를 구성하지 않게 한다. | -| `redis-testkit` | Testcontainers topology, contract suite, fault injection | production module에서 의존하지 않는다. | -| `extensions/*` | Redis 8 또는 Stack 확장 기능 | classic core와 독립적으로 capability probe를 수행한다. | - ---- - -## 8. 공개 API 기본 모델 - -### 8.1 Key 모델 - -```java -package io.backend.skeleton.redis.api.key; - -public record RedisNamespace( - String environment, - String service, - String domain -) { - public RedisNamespace { - RedisKeyRules.requireToken("environment", environment); - RedisKeyRules.requireToken("service", service); - RedisKeyRules.requireToken("domain", domain); - } -} - -public record RedisKeyName(String entity, String identifier) { - public RedisKeyName { - RedisKeyRules.requireToken("entity", entity); - RedisKeyRules.requireIdentifier(identifier); - } -} - -public record RedisSlotTag(String value) { - public RedisSlotTag { - RedisKeyRules.requireIdentifier(value); - } -} - -public record QualifiedRedisKey( - RedisNamespace namespace, - RedisKeyName name, - Optional slotTag -) {} -``` - -렌더링 규칙은 다음과 같다. - -```text -일반 key: {environment}:{service}:{domain}:{entity}:{identifier} -slot key: {environment}:{service}:{domain}:{{slotTag}}:{entity}:{identifier} -``` - -제약: - -- UTF-8 기준 최대 512 bytes -- 이메일, 전화번호, access token, refresh token 원문 금지 -- 동적 전체 raw key 문자열 입력 금지 -- slot tag는 `RedisSlotTag`를 통해서만 생성 -- 저카디널리티 tag로 전체 tenant를 한 slot에 고정하는 사용을 금지 - -### 8.2 자료구조별 Typed Key - -```java -public sealed interface RedisTypedKey permits - ValueKey, HashKey, ListKey, SetKey, SortedSetKey, - BitmapKey, HyperLogLogKey, GeoKey, StreamKey { - QualifiedRedisKey key(); -} - -public record ValueKey(QualifiedRedisKey key, RedisCodec valueCodec) - implements RedisTypedKey {} - -public record HashKey( - QualifiedRedisKey key, - RedisCodec fieldCodec, - RedisCodec valueCodec -) implements RedisTypedKey {} -``` - -List, Set, Sorted Set, Bitmap, HyperLogLog, Geo, Stream도 동일한 원칙으로 자료구조별 key 타입을 제공한다. 서로 다른 자료구조 key는 컴파일 단계에서 같은 operations에 전달할 수 없다. - -### 8.3 Expiration - -```java -public sealed interface Expiration permits Expiration.Persistent, Expiration.After, Expiration.At { - record Persistent(PersistentKeyPermit permit) implements Expiration {} - record After(Duration duration) implements Expiration {} - record At(Instant instant) implements Expiration {} -} - -public enum ExpirationUpdatePolicy { - KEEP_EXISTING, - REPLACE, - ONLY_IF_NO_EXPIRY, - ONLY_IF_HAS_EXPIRY -} -``` - -- Cache, session, lock, idempotency, rate-limit API에서는 `Persistent`를 받지 않는다. -- `SET`과 TTL은 한 command 또는 등록 script로 원자화한다. -- 정확한 만료가 필요한 기능에는 TTL jitter를 적용하지 않는다. - -### 8.4 Permit 발급과 검증 - -Permit는 편의용 boolean flag가 아니라 R2·다중 키·영구 key 사용을 명시적으로 승인했다는 capability token이다. 다만 같은 JVM 안의 Java 타입만으로 보안 경계를 만들 수는 없으므로 최종 강제 수단은 Redis ACL과 bean 노출 정책이다. SDK 내부에서는 위조 permit가 guardrail을 우회하지 못하도록 발급자와 검증자를 분리한다. - -```java -public interface AdvancedOperationPermit { - String policyName(); -} - -public interface MultiKeyPermit { - String policyName(); -} - -public interface PersistentKeyPermit { - String policyName(); -} - -public interface RedisPolicyAuthority { - AdvancedOperationPermit issueAdvanced(String policyName); - MultiKeyPermit issueMultiKey(String policyName); - PersistentKeyPermit issuePersistentKey(String policyName); -} - -public interface RedisPermitVerifier { - void verify(AdvancedOperationPermit permit, String requiredPolicy); - void verify(MultiKeyPermit permit, String requiredPolicy); - void verify(PersistentKeyPermit permit, String requiredPolicy); -} -``` - -- permit 구현체는 starter 내부 package-private 클래스로 둔다. -- authority는 활성화된 정책 이름만 발급하며, 발급자 식별자와 서명을 permit 내부에 보관한다. -- verifier는 구현 타입, 발급자, 서명, 정책 이름을 모두 검사한다. -- 애플리케이션이 permit 인터페이스를 임의 구현해도 verifier를 통과하지 못한다. -- permit는 Redis ACL 권한을 확대하지 않는다. 해당 계정에 명령 권한이 없으면 실행은 실패한다. -- permit와 verifier bean은 `backend.redis.advanced.enabled=true`일 때만 등록한다. - -### 8.5 OperationBudget - -```java -public record OperationBudget( - int maxElements, - long maxRequestBytes, - long maxReplyBytes, - Duration timeout -) { - public OperationBudget { - if (maxElements < 1 || maxRequestBytes < 1 || maxReplyBytes < 1 || timeout.isZero() || timeout.isNegative()) { - throw new IllegalArgumentException("Operation budget must be positive"); - } - } -} -``` - -R2 API는 반드시 `AdvancedOperationPermit`와 `OperationBudget`을 요구한다. - -### 8.6 동기·Reactive 진입점 - -```java -public interface RedisOperations { - RedisValueOperations values(); - RedisHashOperations hashes(); - RedisListOperations lists(); - RedisSetOperations sets(); - RedisSortedSetOperations sortedSets(); - RedisBitmapOperations bitmaps(); - RedisBitFieldOperations bitFields(); - RedisHyperLogLogOperations hyperLogLogs(); - RedisGeoOperations geo(); - RedisStreamOperations streams(); - RedisKeyOperations keys(); - RedisBatchOperations batches(); -} - -public interface ReactiveRedisOperations { - ReactiveRedisValueOperations values(); - ReactiveRedisHashOperations hashes(); - ReactiveRedisListOperations lists(); - ReactiveRedisSetOperations sets(); - ReactiveRedisSortedSetOperations sortedSets(); - ReactiveRedisBitmapOperations bitmaps(); - ReactiveRedisBitFieldOperations bitFields(); - ReactiveRedisHyperLogLogOperations hyperLogLogs(); - ReactiveRedisGeoOperations geo(); - ReactiveRedisStreamOperations streams(); - ReactiveRedisKeyOperations keys(); - ReactiveRedisBatchOperations batches(); -} -``` - -동기와 Reactive API는 의미·이름·옵션 모델을 동일하게 유지한다. 반환 타입만 `Optional/List/...`와 `Mono/Flux`로 다르다. - ---- - -## 9. 명령 노출 정책 - -### 9.1 위험 등급 - -| 등급 | 의미 | 공개 정책 | -|---|---|---| -| R1 | bounded, 단일 키, 일반적인 빠른 명령 | 기본 Typed API | -| R2 | O(N), 무제한 반환 가능, Blocking, 다중 키, 큰 payload | Advanced Typed API 또는 승인형 Raw Gateway | -| R3 | 서버·클라이언트·ACL·토폴로지 운영 명령 | `redis-admin-plane`만 | -| R4 | 데이터 삭제, 서버 중단, replication/module 변경 등 파괴적 명령 | SDK 전체 차단 | - -### 9.2 명령 지원 상태 - -```java -public enum CommandSupport { - TYPED, - ADVANCED_TYPED, - RAW_ONLY, - ADMIN_ONLY, - VERSION_GATED, - BLOCKED -} -``` - -### 9.3 Command descriptor - -```java -public record RedisCommandDescriptor( - String command, - Optional subcommand, - RedisVersion minimumVersion, - RedisRiskLevel riskLevel, - CommandSupport support, - CommandAccess access, - boolean blocking, - boolean readOnly, - boolean retrySafe, - boolean mayBeAmbiguous, - KeySpec keySpec, - TimeoutProfile timeoutProfile -) {} -``` - -### 9.4 정책 SSOT - -`modules/redis/redis-core-lettuce/src/main/resources/redis-command-policy.yml`을 조직 정책 SSOT로 둔다. - -```yaml -commands: - GET: - minimum-version: "7.2" - risk: R1 - support: TYPED - access: APPLICATION - blocking: false - read-only: true - retry-safe: true - timeout-profile: FAST - HGETALL: - minimum-version: "7.2" - risk: R2 - support: ADVANCED_TYPED - access: APPLICATION_ADVANCED - blocking: false - read-only: true - retry-safe: true - timeout-profile: COLLECTION - KEYS: - minimum-version: "7.2" - risk: R4 - support: BLOCKED - access: NONE - blocking: false - read-only: true - retry-safe: false - timeout-profile: ADMIN -``` - -빌드 task는 공식 metadata와 이 파일을 비교한다. - -- 신규 command 또는 subcommand 탐지 -- deprecated 변경 탐지 -- ACL category 변경 탐지 -- key specification 변경 탐지 -- movable key 탐지 -- 위험 명령의 자동 허용 방지 - ---- - -## 10. 자료구조별 Typed API - -### 10.1 String - -```java -public interface RedisValueOperations { - Optional get(ValueKey key); - List> multiGet(List> keys, MultiKeyPermit permit); - void set(ValueKey key, V value, Expiration expiration); - boolean setIfAbsent(ValueKey key, V value, Expiration expiration); - boolean setIfPresent(ValueKey key, V value, Expiration expiration); - Optional getAndSet(ValueKey key, V value, Expiration expiration); - Optional getAndDelete(ValueKey key); - Optional getAndExpire(ValueKey key, Expiration expiration); - long increment(ValueKey key, long delta, Expiration expiration); - double increment(ValueKey key, double delta, Expiration expiration); - long append(ValueKey key, String suffix, OperationBudget budget); - long length(ValueKey key); - byte[] getRange(ValueKey key, long start, long end, OperationBudget budget); - long setRange(ValueKey key, long offset, byte[] value, OperationBudget budget); -} -``` - -정책: - -- `SETNX`, `SETEX`, `PSETEX`는 별도 메서드로 노출하지 않는다. -- `MGET/MSET/MSETNX`는 same-slot 또는 node grouping 정책을 명시하며, 원자성이 필요한 경우 same-slot만 허용한다. -- `LCS`는 R2 Advanced API로 둔다. -- `INCR`와 최초 TTL 설정은 script fallback 또는 version-gated `INCREX`로 한 번에 실행한다. - -### 10.2 Hash - -```java -public interface RedisHashOperations { - Optional get(HashKey key, F field); - Map> multiGet(HashKey key, Collection fields); - void put(HashKey key, F field, V value); - void putAll(HashKey key, Map values); - boolean putIfAbsent(HashKey key, F field, V value); - long delete(HashKey key, Collection fields); - boolean exists(HashKey key, F field); - long increment(HashKey key, F field, long delta); - double increment(HashKey key, F field, double delta); - long size(HashKey key); - ScanPage> scan(HashKey key, ScanRequest request); - Map entries(HashKey key, AdvancedOperationPermit permit, OperationBudget budget); -} -``` - -Version-gated module: - -```java -public interface RedisHashFieldExpirationOperations { - Map expireFields(HashKey key, Collection fields, Duration ttl); - Map> ttl(HashKey key, Collection fields); - Map persistFields(HashKey key, Collection fields, PersistentKeyPermit permit); -} -``` - -- `entries()`는 R2이며 budget 없이 호출할 수 없다. -- field TTL API는 Redis 7.4 이상에서만 bean이 등록된다. -- `HGETEX/HSETEX` 기반 복합 연산은 Redis 8.0 profile에서만 활성화한다. - -### 10.3 List - -```java -public interface RedisListOperations { - long pushLeft(ListKey key, Collection values); - long pushRight(ListKey key, Collection values); - long pushLeftIfPresent(ListKey key, V value); - long pushRightIfPresent(ListKey key, V value); - Optional popLeft(ListKey key); - Optional popRight(ListKey key); - List popLeft(ListKey key, int count); - List popRight(ListKey key, int count); - Optional index(ListKey key, long index); - void set(ListKey key, long index, V value); - long remove(ListKey key, long count, V value); - void trim(ListKey key, long start, long end); - List range(ListKey key, long start, long end, OperationBudget budget); - Optional move(ListKey source, ListKey destination, ListSide from, ListSide to, MultiKeyPermit permit); -} - -public interface RedisBlockingListOperations { - Optional> pop(Collection> keys, ListSide side, Duration block); - Optional move(ListKey source, ListKey destination, ListSide from, ListSide to, Duration block, MultiKeyPermit permit); -} -``` - -- Blocking API는 별도 bean과 전용 pool을 사용한다. -- 무한 block은 금지한다. -- `LRANGE 0 -1`은 budget이 충분하고 실제 length가 제한 이내일 때만 허용한다. - -### 10.4 Set - -```java -public interface RedisSetOperations { - long add(SetKey key, Collection values); - long remove(SetKey key, Collection values); - boolean isMember(SetKey key, V value); - Map multiIsMember(SetKey key, Collection values); - long size(SetKey key); - Optional pop(SetKey key); - List pop(SetKey key, int count); - List randomMembers(SetKey key, int count, boolean distinct); - ScanPage scan(SetKey key, ScanRequest request); - boolean move(SetKey source, SetKey destination, V value, MultiKeyPermit permit); - Set difference(Collection> keys, AdvancedOperationPermit permit, OperationBudget budget); - Set intersection(Collection> keys, AdvancedOperationPermit permit, OperationBudget budget); - Set union(Collection> keys, AdvancedOperationPermit permit, OperationBudget budget); -} -``` - -- `SMEMBERS` 대응 전체 반환은 제공하지 않는다. `scan` 또는 budget이 있는 set operation을 사용한다. -- 다중 키 연산은 same-slot을 사전 검증한다. -- store variants는 Advanced API로 제공한다. - -### 10.5 Sorted Set - -```java -public interface RedisSortedSetOperations { - boolean add(SortedSetKey key, V value, double score, SortedSetAddOptions options); - long addAll(SortedSetKey key, Collection> values, SortedSetAddOptions options); - double incrementScore(SortedSetKey key, V value, double delta); - long remove(SortedSetKey key, Collection values); - OptionalDouble score(SortedSetKey key, V value); - Map scores(SortedSetKey key, Collection values); - OptionalLong rank(SortedSetKey key, V value, SortDirection direction); - long size(SortedSetKey key); - long countByScore(SortedSetKey key, ScoreRange range); - List> rangeByRank(SortedSetKey key, RankRange range, SortDirection direction, OperationBudget budget); - List> rangeByScore(SortedSetKey key, ScoreRange range, PageRequest page, SortDirection direction, OperationBudget budget); - List rangeByLex(SortedSetKey key, LexRange range, PageRequest page, SortDirection direction, OperationBudget budget); - List> popMin(SortedSetKey key, int count); - List> popMax(SortedSetKey key, int count); - ScanPage> scan(SortedSetKey key, ScanRequest request); -} -``` - -Union, intersection, difference, store, blocking pop은 Advanced/Blocking API로 분리한다. - -### 10.6 Bitmap 및 Bitfield - -```java -public interface RedisBitmapOperations { - boolean get(BitmapKey key, long offset); - boolean set(BitmapKey key, long offset, boolean value); - long count(BitmapKey key, Optional byteRange); - OptionalLong position(BitmapKey key, boolean value, Optional byteRange); - long bitOperation(BitmapOperation operation, BitmapKey destination, Collection sources, MultiKeyPermit permit, OperationBudget budget); -} - -public interface RedisBitFieldOperations { - List execute(BitmapKey key, List commands, BitFieldOverflow overflow, OperationBudget budget); -} -``` - -- 최대 offset은 설정값으로 제한한다. -- `BITOP`은 same-slot과 reply budget을 검증한다. -- Bitfield overflow mode는 호출 시 명시한다. - -### 10.7 HyperLogLog - -```java -public interface RedisHyperLogLogOperations { - boolean add(HyperLogLogKey key, Collection values); - long count(Collection> keys, MultiKeyPermit permit); - void merge(HyperLogLogKey destination, Collection> sources, MultiKeyPermit permit); -} -``` - -반환값은 근사치이며 정확 cardinality 용도로 사용하지 않는다는 계약을 API 문서에 고정한다. - -### 10.8 Geospatial - -```java -public interface RedisGeoOperations { - long add(GeoKey key, Collection> locations); - Optional distance(GeoKey key, V from, V to, DistanceUnit unit); - Map> positions(GeoKey key, Collection members); - List> search(GeoKey key, GeoSearchRequest request, OperationBudget budget); - long searchStore(GeoKey source, GeoKey destination, GeoSearchRequest request, MultiKeyPermit permit, OperationBudget budget); -} -``` - -deprecated radius 계열은 공개하지 않고 `GEOSEARCH` 의미로 통합한다. - -### 10.9 Stream - -```java -public interface RedisStreamOperations { - StreamId append(StreamKey key, V value, StreamAppendOptions options); - long delete(StreamKey key, Collection ids); - long trim(StreamKey key, StreamTrimPolicy policy); - List> range(StreamKey key, StreamRange range, int count); - List> reverseRange(StreamKey key, StreamRange range, int count); - List> read(StreamKey key, StreamReadOffset offset, int count); - List> readGroup(StreamKey key, StreamGroup group, StreamConsumer consumer, StreamReadOffset offset, int count); - long acknowledge(StreamKey key, StreamGroup group, Collection ids); - PendingSummary pendingSummary(StreamKey key, StreamGroup group); - List pending(StreamKey key, StreamGroup group, PendingQuery query); - ClaimResult autoClaim(StreamKey key, StreamGroup group, StreamConsumer consumer, Duration minIdle, StreamId start, int count); - void createGroup(StreamKey key, StreamGroup group, StreamReadOffset offset, boolean createStream); - void destroyGroup(StreamKey key, StreamGroup group); - void createConsumer(StreamKey key, StreamGroup group, StreamConsumer consumer); - void deleteConsumer(StreamKey key, StreamGroup group, StreamConsumer consumer); -} - -public interface RedisBlockingStreamOperations { - List> read(StreamKey key, StreamReadOffset offset, int count, Duration block); - List> readGroup(StreamKey key, StreamGroup group, StreamConsumer consumer, StreamReadOffset offset, int count, Duration block); -} -``` - -정책: - -- `StreamAppendOptions`는 `MAXLEN` 또는 `MINID`를 반드시 요구한다. -- Consumer group 사용 시 pending age와 count metric을 제공한다. -- 중복 전달 가능성을 계약에 명시한다. -- Redis 8.2의 `XACKDEL/XDELEX`, 8.8의 `XNACK`은 별도 capability bean으로 제공한다. - -### 10.10 Pub/Sub - -```java -public interface RedisPubSubOperations { - long publish(PubSubChannel channel, V message); - Subscription subscribe(Collection> channels, RedisMessageHandler handler); - Subscription patternSubscribe(Collection> patterns, RedisMessageHandler handler); -} - -public interface RedisShardedPubSubOperations { - long publish(ShardedPubSubChannel channel, V message); - Subscription subscribe(Collection> channels, RedisMessageHandler handler); -} -``` - -- at-most-once 의미를 인터페이스 Javadoc과 문서에 명시한다. -- durable 업무 이벤트, 결제·주문·재처리 작업에는 사용하지 않는다. -- Cluster에서는 Sharded Pub/Sub을 기본 bean으로 우선한다. - -### 10.11 Key·TTL - -```java -public interface RedisKeyOperations { - boolean exists(QualifiedRedisKey key); - long exists(Collection keys, MultiKeyPermit permit); - RedisDataType type(QualifiedRedisKey key); - boolean touch(QualifiedRedisKey key); - long delete(Collection keys, MultiKeyPermit permit); - long unlink(Collection keys, MultiKeyPermit permit); - ExpirationResult expire(QualifiedRedisKey key, Duration ttl, ExpirationCondition condition); - ExpirationResult expireAt(QualifiedRedisKey key, Instant instant, ExpirationCondition condition); - Optional ttl(QualifiedRedisKey key); - boolean persist(QualifiedRedisKey key, PersistentKeyPermit permit); - boolean rename(QualifiedRedisKey source, QualifiedRedisKey destination, RenameMode mode, MultiKeyPermit permit); - ScanPage scan(ScanRequest request, AdvancedOperationPermit permit); -} -``` - -- `KEYS`는 차단한다. -- `SCAN`도 전체 비용이 O(N)이므로 R2 permit, page size, rate limit을 요구한다. -- 대형 key 삭제는 `UNLINK`를 우선하지만 batch와 rate limit을 적용한다. - ---- - -## 11. Batch와 Pipeline - -```java -public interface RedisBatchOperations { - RedisBatchResult execute(RedisBatch batch, BatchOptions options); -} - -public record BatchOptions( - int maxCommands, - long maxRequestBytes, - long maxReplyBytes, - int maxInFlightPerNode, - Duration timeout -) {} - -public record RedisBatchResult(List> items) { - public boolean hasPartialFailure() { - return items.stream().anyMatch(BatchItemResult::failed); - } -} -``` - -정책: - -- Pipeline은 원자적이지 않다. -- input index와 result index를 보존한다. -- Cluster에서는 node별로 분할하고 결과를 원래 순서로 재조합한다. -- write batch는 자동 retry하지 않는다. -- 최대 command 수, request bytes, 예상 reply bytes, in-flight를 모두 제한한다. -- 기본값: - - 최대 500 commands - - request 4 MiB - - reply 16 MiB - - node별 in-flight 2 - - timeout 2초 - ---- - -## 12. Transaction 및 서버 프로그래밍 - -### 12.1 Transaction - -```java -public interface RedisTransactionOperations { - TransactionResult watchAndExecute( - Collection watchedKeys, - RedisTransactionCallback callback, - TransactionOptions options - ); -} -``` - -- 전용 connection을 사용한다. -- `finally`에서 `DISCARD` 또는 connection reset을 보장한다. -- rollback이 없음을 공개 계약에 명시한다. -- Cluster에서는 watched key와 transaction key가 same-slot이어야 한다. -- `EXEC` 응답 유실은 `RedisAmbiguousExecutionException`으로 반환한다. - -### 12.2 등록 Lua Script - -```java -public record RegisteredRedisScript( - String id, - String sha256, - int maxKeys, - Duration timeout, - long maxReplyBytes, - RedisResultDecoder decoder -) {} - -public interface RedisScriptOperations { - R execute(RegisteredRedisScript script, List keys, List arguments); -} -``` - -- 런타임 script source 문자열을 받지 않는다. -- key는 전부 `KEYS` 인자로 선언한다. -- Cluster same-slot을 사전 검증한다. -- loop bound, 실행시간, reply size를 리뷰한다. -- `NOSCRIPT`는 등록 script에 한해 load 후 한 번 재실행한다. - -### 12.3 Redis Function - -Function library는 ID와 semantic version으로 관리한다. 배포 시 capability probe와 library checksum을 확인하며, 운영 중 동적 임의 function 등록은 지원하지 않는다. - ---- - -## 13. Raw Command Gateway - -### 13.1 공개 계약 - -```java -public interface RedisRawGateway { - R execute( - ApprovedRawCommand command, - List arguments, - RawCommandPolicyToken policyToken - ); -} - -public record ApprovedRawCommand( - String policyId, - RedisCommandDescriptor descriptor, - RedisResultDecoder decoder -) {} -``` - -### 13.2 강제 통제 - -1. command와 subcommand allowlist -2. 최소 Redis version 확인 -3. 공식 key specification 또는 `COMMAND GETKEYSANDFLAGS`로 key 추출 -4. namespace 확인 -5. same-slot 확인 -6. R3·R4 거부 -7. argument 수·request bytes·reply bytes 제한 -8. timeout profile 적용 -9. 등록 decoder만 허용 -10. 호출자·policyId·command family·결과·latency audit -11. key/value 원문 로그 금지 -12. Raw Gateway 전용 ACL user 사용 가능 - -일반 애플리케이션에는 `execute(String, byte[]...)` 형태를 제공하지 않는다. - ---- - -## 14. Admin Plane - -`redis-admin-plane`은 애플리케이션 request path와 분리한다. - -### 14.1 제공 범위 - -- read-only 진단 - - `INFO` - - `MEMORY USAGE` - - `SLOWLOG GET` - - `LATENCY LATEST` - - `CLIENT LIST`의 제한된 projection - - `CLUSTER INFO`, `CLUSTER SLOTS`, `CLUSTER SHARDS` - - `ACL DRYRUN` - - `COMMAND INFO` -- 운영 도구가 사용하는 node-local scan 및 big-key 후보 수집 - -### 14.2 차단 범위 - -- `FLUSHDB`, `FLUSHALL` -- `SHUTDOWN` -- `DEBUG` -- module unload -- replication·topology 변경 -- 광범위한 `CONFIG SET` -- 일반 애플리케이션 계정으로 ACL 변경 - -관리 plane은 별도 ACL account, 별도 connection factory, 별도 deployment profile을 요구한다. - ---- - -## 15. Connection 및 실행 모델 - -| 연결 종류 | 용도 | 공유 여부 | -|---|---|---| -| Regular | 일반 R1/R2 non-blocking 명령 | thread-safe shared 또는 제한 pool | -| Blocking | `BLPOP`, `BZPOP*`, `XREAD BLOCK` | 전용 pool | -| Transaction | `WATCH/MULTI/EXEC` | 호출당 전용 connection | -| Pub/Sub | subscribe lifecycle | subscription별 또는 제한 pool | -| Admin | R3 진단 | 별도 account·factory | - -기본 pool 제한: - -- Regular pending command queue: 1,000 -- Blocking 최대 동시 연결: 32 -- Transaction 최대 동시 연결: 16 -- Pub/Sub subscription connection: 16 -- queue 상한 초과 시 즉시 `RedisCommandRejectedException` - -Lettuce offline queue는 무제한으로 사용하지 않는다. timeout되거나 이미 취소된 command는 reconnect 후 replay하지 않는다. - ---- - -## 16. Timeout, Retry, 오류 의미론 - -### 16.1 Timeout profile - -| Profile | 기본값 | 대상 | -|---|---:|---| -| FAST | 500 ms | 단일 키 GET/SET, membership, score | -| COLLECTION | 2 s | bounded range, scan page, union/intersection | -| SCRIPT | 1 s | 등록 Lua/Function | -| BATCH | 2 s | pipeline/batch | -| ADMIN | 3 s | read-only 운영 조회 | -| BLOCKING | server block + 2 s | blocking API | - -기본값은 skeleton guardrail이며 서비스 SLO에 따라 더 짧게 재정의할 수 있다. 더 길게 설정할 때는 configuration validation 경고를 낸다. - -### 16.2 Retry matrix - -| 상황 | 자동 retry | -|---|---| -| 전송 전 실패가 확인된 read | 최대 2회, jittered backoff | -| idempotent read | 최대 2회 | -| `MOVED`, `ASK` | cluster client 처리 | -| resharding 중 `TRYAGAIN` | 최대 2회, 짧은 backoff | -| write 후 timeout | 금지 | -| `INCR`, `LPUSH`, `XADD` 결과 불명 | 금지 | -| transaction `EXEC` 결과 유실 | 금지 | -| script 결과 불명 | 금지 | -| 등록 script의 `NOSCRIPT` | load 후 1회 | - -### 16.3 예외 모델 - -```java -public class RedisOperationException extends RuntimeException { - private final RedisFailureMetadata metadata; -} - -public record RedisFailureMetadata( - String commandCategory, - CommandAccess access, - boolean readOperation, - boolean retryable, - boolean ambiguousExecution, - RedisVersion serverVersion, - RedisDeploymentMode deploymentMode, - OptionalInt slot, - Duration elapsed -) {} -``` - -하위 예외: - -- `RedisTimeoutException` -- `RedisConnectionException` -- `RedisAccessDeniedException` -- `RedisCrossSlotException` -- `RedisRedirectionException` -- `RedisBusyException` -- `RedisNoScriptException` -- `RedisSerializationException` -- `RedisDataTypeMismatchException` -- `RedisCommandRejectedException` -- `RedisCapabilityUnavailableException` -- `RedisAmbiguousExecutionException` - -key, value, credential, 전체 argument는 메시지에 포함하지 않는다. - ---- - -## 17. 직렬화와 schema - -### 17.1 기본 codec - -- key: UTF-8 String -- counter: Redis integer/double native representation -- object: versioned JSON 기본 -- 선택: CBOR, Protobuf -- Java native serialization: 금지 - -```java -public interface RedisCodec { - String id(); - byte[] encode(T value); - T decode(byte[] bytes) throws RedisSerializationException; -} - -public record RedisEnvelope( - String schema, - int version, - Instant createdAt, - byte[] payload -) {} -``` - -### 17.2 schema 변경 - -1. 호환 reader를 먼저 배포한다. -2. 필요 시 dual write 또는 read repair를 사용한다. -3. migration은 rate-limited SCAN으로 실행한다. -4. version별 read와 deserialize failure를 관측한다. -5. 기존 TTL 만료 또는 migration 완료 후 old reader를 제거한다. - -역직렬화 실패 처리: - -- Cache: miss fallback + corruption metric -- Session·idempotency·workflow: data corruption 예외 -- Raw Gateway: decoder failure로 명시 - -### 17.3 기본 크기 제한 - -- key: 512 bytes -- object value: 1 MiB -- Stream payload: 256 KiB -- Hash field value: 512 KiB -- Raw argument total: 4 MiB -- Raw reply: 16 MiB - -초과 시 Redis 호출 전에 거부한다. - ---- - -## 18. Cluster 설계 - -### 18.1 Slot-aware key codec - -- CRC16 slot을 client side에서 계산한다. -- multi-key 요청은 서버 호출 전에 same-slot을 검증한다. -- hash tag는 `RedisSlotTag`를 통해서만 지정한다. -- 모든 key가 동일 slot이어야 하는 API에는 `MultiKeyPermit`을 요구한다. - -### 18.2 Redirect와 topology - -관측 항목: - -- `MOVED` -- `ASK` -- `TRYAGAIN` -- topology refresh -- slot cache refresh -- node connection failure -- replica promotion - -### 18.3 제한 - -- DB 0 이외 설정은 startup failure -- cluster-wide SCAN은 node별 cursor를 가진 `ClusterScanCursor`로만 제공 -- node-local command를 전체 cluster 결과로 오인하지 않도록 결과 타입에 node id를 포함 -- cross-slot operation 자동 fan-out은 조회-only batch에서만 허용하고 원자성을 보장하지 않는다고 표시 - ---- - -## 19. Sentinel 및 failover - -- primary·replica·Sentinel endpoint를 startup에 검증한다. -- promotion 구간의 결과를 다음 네 가지로 분류한다. - - confirmed success - - confirmed failure - - safe-to-retry failure - - ambiguous failure -- non-idempotent write는 자동 retry하지 않는다. -- reconnect queue는 상한을 가진다. -- failover 후 stale replica read 허용 여부는 별도 `ReadConsistencyPolicy`로 명시한다. -- `WAIT`는 durability 가능성을 높이는 선택 기능일 뿐 강한 일관성으로 표현하지 않는다. - ---- - -## 20. ACL과 접근 제한 - -### 20.1 계정 분리 - -| 계정 | 권한 | -|---|---| -| application | R1 Typed API | -| application-advanced | 승인된 R2 command | -| raw-gateway | 등록된 R1·R2 command 및 namespace | -| admin-readonly | R3 read-only diagnostics | -| extension-* | JSON/Search/TimeSeries 등 사용 명령만 | - -### 20.2 원칙 - -- allowlist 방식 -- key pattern과 Pub/Sub channel pattern 제한 -- `+@all -@dangerous` 사용 금지 -- Redis 업그레이드 시 ACL regression test -- `ACL DRYRUN`과 실제 제한 계정 integration test를 모두 수행 - -예시: - -```text -on ->secret-from-runtime -~prod:order-service:* -&prod:order-events:* -+get +set +del +unlink -+hget +hset +hdel +hscan -+xadd +xreadgroup +xack +xautoclaim -``` - ---- - -## 21. 관측성 - -### 21.1 Metric - -| 이름 | 핵심 tag | -|---|---| -| `backend.redis.command.duration` | family, outcome, mode, risk | -| `backend.redis.command.request.bytes` | family, mode | -| `backend.redis.command.reply.bytes` | family, mode | -| `backend.redis.connection.active` | connection-kind, node | -| `backend.redis.connection.pending` | connection-kind | -| `backend.redis.connection.reconnects` | mode, node | -| `backend.redis.cluster.redirects` | type | -| `backend.redis.retry.count` | reason, ambiguous | -| `backend.redis.batch.size` | mode, outcome | -| `backend.redis.stream.pending` | namespace, group | -| `backend.redis.policy.rejections` | reason, risk | -| `backend.redis.serialization.failures` | codec, schema | - -실제 key, field, member, user ID는 tag에 넣지 않는다. - -### 21.2 Trace - -Span 이름: `redis.command` - -속성: - -- command family -- risk level -- read/write -- deployment mode -- connection kind -- slot 또는 node의 low-cardinality projection -- outcome -- retry count -- ambiguous execution - -### 21.3 Log와 audit - -- key와 value는 기본 마스킹 -- 식별이 필요하면 HMAC fingerprint -- Raw/Admin 호출은 caller, policyId, command family, result, elapsed를 audit -- authentication material은 절대 기록하지 않는다. - ---- - -## 22. Redis 8 확장 모듈 - -| 모듈 | 범위 | 활성화 조건 | -|---|---|---| -| `redis-json` | JSON get/set/path/array/object operations | capability probe 성공 | -| `redis-search` | index lifecycle, query, aggregation, vector query | Search capability와 schema 선언 | -| `redis-timeseries` | series create/add/range/aggregation/rules | Time Series capability | -| `redis-probabilistic` | Bloom, Cuckoo, CMS, Top-K, t-digest | capability별 bean | - -원칙: - -- classic core에 명령을 섞지 않는다. -- 시작 시 `COMMAND INFO` 또는 capability probe를 수행한다. -- 명시적으로 enable한 모듈의 capability가 없으면 startup failure다. -- Redis 8 통합 배포와 Redis 7 Stack 환경을 모두 테스트한다. -- 새 자료구조는 client 지원과 운영 안정성 검증 후 독립 API로 추가한다. - ---- - -## 23. Spring Boot 설정 - -```yaml -backend: - redis: - enabled: true - mode: standalone - nodes: - - localhost:6379 - database: 0 - ssl: - enabled: false - namespace: - environment: local - service: sample-service - domain: shared - timeout: - fast: 500ms - collection: 2s - script: 1s - batch: 2s - admin: 3s - limits: - max-key-bytes: 512 - max-value-bytes: 1MiB - max-stream-payload-bytes: 256KiB - max-collection-elements: 1000 - max-scan-count: 500 - max-batch-commands: 500 - max-batch-request-bytes: 4MiB - max-batch-reply-bytes: 16MiB - offline-queue-commands: 1000 - blocking: - max-connections: 32 - max-block: 30s - transaction: - max-connections: 16 - raw: - enabled: false - admin: - enabled: false -``` - -Validation: - -- Cluster에서 `database != 0`이면 startup failure -- namespace token 형식 위반 시 startup failure -- Fast timeout이 5초를 넘으면 warning, 30초를 넘으면 startup failure -- 무한 blocking 금지 -- Raw Gateway enable 시 allowlist와 별도 ACL credential 필수 -- extension enable 시 capability 미지원이면 startup failure - ---- - -## 24. 테스트 전략 - -### 24.1 토폴로지 매트릭스 - -| 실행 주기 | 환경 | -|---|---| -| PR | Standalone 7.4, Standalone 8.2 | -| Nightly | Standalone 7.2·7.4·8.2·8.10, Sentinel 7.4·8.2, Cluster 7.4·8.2 | -| Release | Nightly 전체 + Toxiproxy 장애 + Redis 8 extensions | -| Compatibility | Redis 6.2 제한 job | - -### 24.2 계약 테스트 - -각 Typed API 구현체는 동일한 contract suite를 통과한다. - -- 정상 결과 -- 없는 key/field -- WRONGTYPE -- 잘못된 argument -- 크기 경계 -- serialization 실패 -- ACL 거부 -- version 미지원 -- CROSSSLOT -- timeout - -### 24.3 동시성·원자성 - -- `INCR` -- `SET NX` -- `WATCH` conflict -- 등록 Lua conditional update -- rate limit window boundary -- idempotency script -- Stream duplicate delivery - -### 24.4 장애 - -- connection refused -- DNS 실패 -- connect/read timeout -- half-open TCP -- packet loss·latency -- 응답만 유실 -- Sentinel promotion -- Cluster replica promotion -- resharding과 `TRYAGAIN` -- 일부 node partition - -### 24.5 성능과 guardrail - -- p50/p95/p99/max -- Redis CPU·memory·output buffer -- JVM heap·allocation·GC -- request/reply bytes -- pipeline batch size와 in-flight -- big key delete/expire tail latency -- 대형 String, Hash, Set, ZSet, Stream, pipeline - -### 24.6 보안 - -- 금지 command/subcommand -- Raw Gateway 우회 -- Lua/Function 우회 -- namespace 밖 key -- channel pattern 위반 -- movable key extraction -- Redis 업그레이드 후 ACL category 변화 -- R3/R4 deny - ---- - -## 25. CI 품질 Gate - -모든 release는 다음을 통과해야 한다. - -1. command metadata diff가 승인됨 -2. Typed API와 support matrix가 일치함 -3. sync/reactive API parity test 통과 -4. unit/contract/integration test 통과 -5. Sentinel·Cluster 장애 test 통과 -6. ACL regression test 통과 -7. forbidden API 검사 통과 - - raw string command - - native Java serialization - - key/value metric tag - - 무한 blocking -8. API binary compatibility 검사 통과 -9. 문서의 support matrix와 생성된 catalog가 일치함 -10. performance baseline의 허용 regression 이내 - ---- - -## 26. 배포 및 사용 방식 - -### 26.1 기본 서비스 - -```kotlin -dependencies { - implementation(project(":modules:redis:redis-spring-boot-starter")) -} -``` - -기본으로 노출: - -- R1 Typed API -- Sync/Reactive -- Standalone/Sentinel -- 설정 시 Cluster -- metric, trace, health - -### 26.2 Advanced API - -```yaml -backend.redis.advanced.enabled: true -``` - -- R2 bean 등록 -- `AdvancedOperationPermit` 발급 bean 필요 -- ACL account에 승인된 R2 command만 추가 - -### 26.3 Raw Gateway - -```yaml -backend.redis.raw.enabled: true -backend.redis.raw.policy-resource: classpath:redis/raw-command-allowlist.yml -``` - -- 별도 credential 필수 -- 임의 command string 불가 - -### 26.4 Admin Plane - -일반 service process에는 포함하지 않는다. 운영 tool 또는 별도 profile에서만 실행한다. - ---- - -## 27. 비지원 및 오해 방지 문구 - -문서와 Javadoc에 다음 내용을 명시한다. - -- Redis Sentinel·Cluster의 승인 write가 failover 중 유실될 수 있다. -- timeout 후 write 결과는 알 수 없을 수 있다. -- Pipeline은 원자적이지 않다. -- Redis transaction은 rollback을 제공하지 않는다. -- `SCAN`은 snapshot이 아니며 중복·변경 영향을 받을 수 있다. -- Pub/Sub은 at-most-once이며 재연결 중 메시지가 유실된다. -- Stream consumer는 중복 전달을 처리해야 한다. -- HyperLogLog는 근사치다. -- Cluster multi-key는 same-slot이 필요하다. -- Raw Gateway는 안전성 보장이 아니라 제한된 확장 경로다. - ---- - -## 28. 완료 정의 - -| 산출물 | 완료 조건 | -|---|---| -| command 지원 매트릭스 | target Redis metadata와 자동 비교되고 신규 command가 CI를 실패시킨다. | -| 자료구조별 Typed API | classic 자료구조 전체에 sync/reactive API가 있으며 contract test를 통과한다. | -| 위험 등급 정책 | R1~R4가 code, bean exposure, ACL, Raw Gateway에 반영된다. | -| version gate | 7.2·7.4·8.2·8.10 capability가 자동 판별된다. | -| topology | Standalone·Sentinel·Cluster test가 통과한다. | -| common policy | namespace, codec, TTL, timeout, retry, error, telemetry가 모든 경로에 적용된다. | -| Blocking 분리 | 일반 connection과 blocking/transaction/pubsub/admin 연결이 격리된다. | -| Raw Gateway | allowlist, key extraction, slot, size, version, audit가 강제된다. | -| Extensions | 독립 module과 capability probe가 존재한다. | -| 테스트 | 계약·동시성·장애·성능·ACL suite가 CI 또는 정기 job에 연결된다. | -| 운영 문서 | 사용 기준, 비보장, alert, upgrade, rollback 절차가 포함된다. | - ---- - -## 29. 구현 순서 - -1. Gradle 모듈과 공통 규칙 -2. command catalog와 policy schema -3. core type, key, codec, exception, version capability -4. Spring Data/Lettuce 연결과 auto-configuration -5. policy-aware executor와 telemetry -6. String, Hash, Set, Sorted Set, Key·TTL -7. Batch·Pipeline -8. List, Bitmap, Bitfield, HLL, Geo -9. Stream과 Blocking connection -10. Pub/Sub과 Sharded Pub/Sub -11. Sentinel failover 의미론 -12. Cluster slot·redirect·topology -13. Transaction, Lua, Function -14. Raw Gateway -15. Admin Plane -16. Redis 8 확장 모듈 -17. CI matrix, chaos, performance, release documentation - -이 순서는 정책 우회 경로인 Raw Gateway가 core guardrail보다 먼저 생기지 않도록 강제한다. diff --git a/scripts/verify-httpclient-docs.py b/scripts/verify-httpclient-docs.py deleted file mode 100755 index 6fa8726..0000000 --- a/scripts/verify-httpclient-docs.py +++ /dev/null @@ -1,135 +0,0 @@ -#!/usr/bin/env python3 -"""Fail when the HTTP Client Platform's code and documentation have drifted. - -The design (§33 "Documentation") requires the support matrix, configuration reference, security -guide, runbook, and migration guide to match the code. Review cannot hold that line by itself, so -this verifier extracts the names that are part of the public contract -- stable exceptions, metric -names, configuration properties, startup violation codes, and transports -- and fails when one -exists in code but nowhere in the documentation. - -It deliberately checks one direction only. A name documented but not yet implemented is a plan; a -name implemented but undocumented is a surprise for whoever is on call. -""" - -from __future__ import annotations - -import re -import sys -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parent.parent -PLATFORM = REPO_ROOT / "src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient" -BOOTSTRAP = REPO_ROOT / "src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient" -DOCS_DIR = REPO_ROOT / "docs/httpclient" -ENV_FIELD_MANIFEST = REPO_ROOT / "docs/httpclient/env-fields.yaml" - -REQUIRED_DOCS = [ - "support-matrix.md", - "configuration-reference.md", - "retry-and-ambiguity.md", - "security.md", - "streaming.md", - "operations.md", - "migration-guide.md", - "release-checklist.md", - "performance-baseline.md", - "repository-adaptation.md", -] - - -def read_docs() -> str: - return "\n".join( - (DOCS_DIR / name).read_text(encoding="utf-8") for name in REQUIRED_DOCS - ) - - -def stable_exceptions() -> list[str]: - error_dir = PLATFORM / "api/error" - return sorted( - path.stem - for path in error_dir.glob("Http*Exception.java") - if path.stem != "HttpClientException" - ) - - -def metric_names() -> list[str]: - source = (PLATFORM / "observation/HttpClientObservationNames.java").read_text(encoding="utf-8") - return sorted(set(re.findall(r'"(http\.client\.[a-z_.]+)"', source))) - - -def violation_codes() -> list[str]: - codes: set[str] = set() - for source_file in [ - PLATFORM / "profile/ClientProfileValidator.java", - PLATFORM / "security/TlsPolicyValidator.java", - BOOTSTRAP / "HttpClientStartupValidator.java", - ]: - source = source_file.read_text(encoding="utf-8") - codes.update(re.findall(r'"([A-Z][A-Z0-9_]{4,})"', source)) - return sorted(codes) - - -def configuration_properties() -> list[str]: - """Every leaf property under `app.httpclient`, nested and dynamic blocks included. - - Read from the environment-field manifest rather than from the record source. The manifest is - derived from `HttpClientPlatformSettings` by `HttpClientEnvironmentKeys` and held to it in both - directions by `HttpClientPlatformEnvManifestTest`, so it cannot drift from the code; parsing the - record here a second time, with a regex, could only agree with it by luck. The previous version - of this function did exactly that and saw eighteen top-level names, which is why a nested pool, - timeout or TLS setting could be added and documented nowhere. - """ - names: set[str] = set() - for line in ENV_FIELD_MANIFEST.read_text(encoding="utf-8").splitlines(): - stripped = line.strip() - if not stripped.startswith("- field:"): - continue - path = stripped[len("- field:") :].strip() - leaf = path.split(".")[-1] - # `clients[N]` and `allowed-hosts[M]` are documented by name, not by position. - names.add(re.sub(r"\[[NM]\]$", "", leaf)) - return sorted(names) - - -def transports() -> list[str]: - source = (PLATFORM / "profile/TransportType.java").read_text(encoding="utf-8") - body = source[source.index("public enum TransportType") :] - return sorted(set(re.findall(r"^\s{2}([A-Z][A-Z_]*),?$", body, flags=re.MULTILINE))) - - -def main() -> int: - missing_docs = [name for name in REQUIRED_DOCS if not (DOCS_DIR / name).is_file()] - if missing_docs: - print("FAIL missing documentation file(s): " + ", ".join(missing_docs)) - return 1 - - documentation = read_docs() - failures: list[str] = [] - - checks = { - "stable exception": stable_exceptions(), - "metric": metric_names(), - "startup violation code": violation_codes(), - "configuration property": configuration_properties(), - "transport": transports(), - } - for kind, names in checks.items(): - for name in names: - if name not in documentation: - failures.append(f"{kind} '{name}' exists in code but is not documented") - - if failures: - print(f"FAIL httpclient documentation drift ({len(failures)} finding(s)):") - for failure in failures: - print(" - " + failure) - return 1 - - total = sum(len(names) for names in checks.values()) - print(f"PASS httpclient documentation covers {total} code-derived name(s):") - for kind, names in checks.items(): - print(f" {kind}: {len(names)}") - return 0 - - -if __name__ == "__main__": - sys.exit(main())