128 KiB
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. 확정 파일 구조
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. 핵심 패키지
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-apimust have no Spring MVC, WebFlux, JPA, NIO filesystem implementation dependency. -
Java toolchain is 21.
-
Step 1: Write the failing core module smoke test
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:
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:
./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:
plugins {
`java-library`
id("java-test-fixtures")
}
java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(21))
}
}
tasks.withType<Test>().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:
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
./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
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
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");
}
}
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
./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
public final class DefaultFileStateMachine implements FileStateMachine {
private static final Map<FileState, Set<FileState>> 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
./gradlew :modules:fileserver:fileserver-core-api:test
Expected: PASS.
- Step 5: Commit
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 stableFileserverErrorCodevalues. -
HTTP adapters map these errors without inspecting storage-driver exceptions.
-
Step 1: Write a failing ambiguous execution test
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
./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
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;
}
}
public record FileserverFailureContext(
FileserverErrorCode code,
boolean retryable,
boolean ambiguous,
boolean reconciliationRequired,
Optional<FileId> fileId,
Optional<UploadId> uploadId,
OptionalLong expectedOffset,
OptionalLong currentOffset,
Optional<FileState> 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
./gradlew :modules:fileserver:fileserver-core-api:test \
--tests '*FileserverExceptionTest'
Expected: PASS.
- Step 5: Commit
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
class ContentStoreApiArchitectureTest {
@Test
void publicContentApiDoesNotExposeFrameworkOrFilesystemTypes() {
Set<String> 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
./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:
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();
}
public interface AsyncContentStore {
CompletionStage<UploadHandle> createUpload(CreateContentCommand command);
CompletionStage<AppendResult> append(
UploadHandle handle, long expectedOffset, Flow.Publisher<ByteBuffer> content);
CompletionStage<StoredContent> finalizeUpload(
UploadHandle handle, FinalizeContentCommand command);
CompletionStage<ContentMetadata> stat(ContentKey key);
Flow.Publisher<ByteBuffer> openRead(ContentKey key, ByteRange range);
CompletionStage<DeleteResult> delete(
ContentKey key, DeletePrecondition precondition);
ContentStoreCapabilities capabilities();
}
- Step 4: Run API and architecture tests
./gradlew :modules:fileserver:fileserver-core-api:test
Expected: PASS; jdeps or ArchUnit output confirms no forbidden adapter dependency.
- Step 5: Commit
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
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
./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
public interface FileMetadataStore {
FileRecord insert(FileRecordDraft draft);
Optional<FileRecord> find(FileId fileId);
FileRecord transition(
FileId fileId,
long expectedVersion,
FileState expectedState,
FileState targetState,
FileRecordMutation mutation
);
FileRecord markDeleting(FileId fileId, long expectedVersion);
List<FileRecord> findRecoverable(FileRecoveryQuery query);
}
public interface UploadSessionStore {
UploadSession create(UploadSessionDraft draft);
Optional<UploadSession> 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<UploadSession> findExpired(Instant cutoff, int limit);
}
- Step 4: Run the core API tests
./gradlew :modules:fileserver:fileserver-core-api:test
Expected: PASS.
- Step 5: Commit
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
@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
./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:
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
./gradlew :modules:fileserver:fileserver-metadata-jpa:test \
--tests '*FileserverMigrationTest'
Expected: PASS; Hibernate schema validation reports no mismatch.
- Step 5: Commit
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
@Test
void onlyOneReadyTransitionWinsForTheSameVersion() {
FileRecord record = fixture.insertVerifyingFile();
CompletableFuture<FileRecord> first = async(() -> store.transition(
record.fileId(), record.version(), FileState.VERIFYING, FileState.READY,
FileRecordMutation.publish(fixture.contentKey(), 10, fixture.sha256(), fixture.etag())));
CompletableFuture<FileRecord> 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);
}
@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
./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:
@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
./gradlew :modules:fileserver:fileserver-metadata-jpa:test
Expected: PASS; repeated concurrency runs produce one winner only.
- Step 5: Commit
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
PhysicalPathResolverdirectly. -
Step 1: Write failing malicious filename and root escape tests
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");
}
}
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
./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:
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:
staging/<first-two>/<next-two>/<upload-id>.part
content/<first-two>/<next-two>/<content-key>.bin
quarantine/<first-two>/<next-two>/<content-key>.bin
- Step 4: Run filename and path tests
./gradlew :modules:fileserver:fileserver-core-api:test \
:modules:fileserver:fileserver-storage-local:test
Expected: PASS.
- Step 5: Commit
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#createUploadfrom Task 4. -
Produces
LocalUploadHandleused by append and finalize tasks. -
Step 1: Write failing create-only and concurrent-create tests
@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");
}
@Test
void exactlyOneConcurrentCreateWinsForSameUploadId() {
CreateContentCommand command = fixture.commandWithFixedUploadId();
List<Throwable> 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
./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:
Set<OpenOption> 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
./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
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
ContentStoreCapabilitiesand selectedPublishMode. -
Later finalize logic must consume this result instead of assuming atomic move.
-
Step 1: Write failing same-FileStore and required-atomic tests
@Test
void reportsAtomicCreateAndSameFileStore() {
LocalStorageProbeResult result = probe.run();
assertThat(result.atomicCreate()).isTrue();
assertThat(result.sameFileStore()).isTrue();
assertThat(result.symlinkNoFollow()).isTrue();
}
@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
./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:
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
./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
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
@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());
}
@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
./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
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:
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
./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
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
FileQuotaServicefrom Task 5. -
Produces
TransferPermitrequired 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
@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);
}
@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
./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.
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
./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
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
PublishModeand probe results from Task 10. -
Implements
BlockingContentStore#finalizeUpload. -
Produces immutable
StoredContentand never exposes a partial final target. -
Step 1: Write failing publish strategy tests
@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");
}
@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
./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:
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
./gradlew :modules:fileserver:fileserver-storage-local:test \
--tests '*ContentPublisherTest' --rerun-tasks
Expected: PASS; observers see no partial final target.
- Step 5: Commit
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
FileVerificationServicePort 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
@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();
}
@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
./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:
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
./gradlew :modules:fileserver:fileserver-application:test \
--tests '*FinalizeUploadServiceTest'
Expected: PASS; every READY fixture has readable content and matching size/digest.
- Step 5: Commit
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, orUNRESOLVED. -
Step 1: Write failing ambiguous publish recovery tests
@Test
void confirmsSuccessWhenPhysicalObjectAndMetadataMatch() {
fixture.preparePhysicalObjectAndVerifyingMetadata();
ReconciliationResult result = service.reconcile(fixture.fileId());
assertThat(result.status()).isEqualTo(ReconciliationStatus.CONFIRMED_SUCCESS);
assertThat(fixture.fileState()).isEqualTo(FileState.READY);
}
@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
./gradlew :modules:fileserver:fileserver-application:test \
--tests '*FileReconciliationServiceTest'
Expected: FAIL because reconciliation is absent.
- Step 3: Implement deterministic reconciliation
Use the following decision rules:
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
./gradlew :modules:fileserver:fileserver-application:test \
--tests '*FileReconciliationServiceTest'
Expected: PASS; no unresolved case changes the file to READY.
- Step 5: Commit
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
@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
./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:
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
./gradlew :modules:fileserver:fileserver-verification:test
Expected: PASS; timeout, reject, quarantine, and accept paths are deterministic.
- Step 5: Commit
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
@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();
}
@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
./gradlew :modules:fileserver:fileserver-application:test \
--tests '*UploadApplicationServiceTest'
Expected: FAIL because upload orchestration is absent.
- Step 3: Implement create, append, status, cancel
Create sequence:
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:
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
./gradlew :modules:fileserver:fileserver-application:test \
--tests '*UploadApplicationServiceTest'
Expected: PASS; authorization denial creates no side effect and offset commits are monotonic.
- Step 5: Commit
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
DownloadDecisionused 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
@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);
}
@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
./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:
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
./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
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
UploadApplicationServiceandFinalizeUploadService. -
Implements
POST /v1/files,POST /v1/files:raw,POST /v1/files:batch. -
Step 1: Write failing MVC endpoint tests
@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());
}
@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
./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:
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
./gradlew :modules:fileserver:fileserver-mvc:test
Expected: PASS; saturation does not create unbounded threads or queues.
- Step 5: Commit
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/416behavior. -
Step 1: Write failing GET, HEAD, Range, and READY-gate tests
@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"));
}
@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}));
}
@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
./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:
ETag
Last-Modified
Accept-Ranges
Content-Type
Content-Disposition
Cache-Control
Content-Length or Content-Range
For 416, include Content-Range: bytes */<size>.
- Step 4: Run full MVC HTTP contract tests
./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
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
AsyncContentStorewhen available or adapts the blocking application service on a dedicated bounded scheduler. -
Every received pooled
DataBufferis forwarded or released exactly once. -
Step 1: Write failing upload, cancellation, and buffer-release tests
@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();
}
@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
./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.
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
./gradlew :modules:fileserver:fileserver-webflux:test
Expected: PASS; no unreleased buffers and no blocking call on event-loop threads.
- Step 5: Commit
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
DownloadDecisionfrom Task 18. -
Produces HTTP parity with Task 20.
-
Step 1: Write failing parity and backpressure tests
@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});
}
@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
./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<ByteBuffer> to Flux<DataBuffer> 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
./gradlew :modules:fileserver:fileserver-webflux:test
Expected: PASS; MVC and WebFlux golden HTTP snapshots are equal for shared scenarios.
- Step 5: Commit
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
@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);
}
@Test
void directAccessToInternalLocationIsRejected() {
nginxClient.get("/__files/ab/cd/0123456789abcdef.bin")
.expectStatus(404);
}
- Step 2: Run unit and integration tests to verify they fail
./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:
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
./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
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
@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();
}
@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
./gradlew :modules:fileserver:fileserver-application:test \
--tests '*FileLifecycleServiceTest' --tests '*CleanupServiceTest'
Expected: FAIL because lifecycle services do not exist.
- Step 3: Implement lifecycle operations
Delete:
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
./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
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
@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
./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
./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
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
@Test
void onlyOneNodeCanAppendTheSameUpload() {
UploadId uploadId = fixture.activeUpload();
CompletableFuture<AppendUploadResult> nodeA = node("a").append(uploadId, 0, "abc");
CompletableFuture<AppendUploadResult> 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);
}
@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
./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:
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
./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
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
UploadApplicationServicecreate/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
@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");
}
@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
./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:
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
./gradlew :modules:fileserver:fileserver-tus:test
Expected: PASS for create, append, resume after restart, checksum, expiry, termination, and concurrent offset conflict.
- Step 5: Commit
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
@Test
void disabledDraftDoesNotRegisterEndpoints() {
contextRunner.withPropertyValues("backend.fileserver.httpbis-draft12.enabled=false")
.run(context -> assertThat(context).doesNotHaveBean(Draft12UploadController.class));
}
@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
./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
./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
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
@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");
}
@ParameterizedTest
@ValueSource(strings = {"../x", "%2e%2e%2fx", "/etc/passwd", "C:\\Windows\\system.ini"})
void rejectsPathShapedInputs(String input) {
client.uploadWithFilename(input).expectNoStorageEscape();
}
@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
./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:
{
"type": "urn:fileserver:problem:<code>",
"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
./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
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
@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");
}
@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
./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
./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
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
@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);
});
}
@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
./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:
storage
upload
download
nginx
verification
quota
cleanup
tus
httpbis-draft12
mvc.executor
webflux
Validate:
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
./gradlew :modules:fileserver:fileserver-spring-boot-starter:test
Expected: PASS; invalid property combinations fail during context startup with stable validation messages.
- Step 5: Commit
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
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();
}
}
@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
./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:
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
./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
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
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<String> 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
./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:
unit and architecture tests
local ext4 contract
MVC Tomcat contract
WebFlux Reactor Netty contract
security suite
bounded-memory regression
Nightly runs:
XFS
NFSv4.1 and server restart
Windows NTFS compatibility
large-file performance
slow client
process-kill matrix
Release runs:
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
./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
git add .github/workflows docs/fileserver modules/fileserver/fileserver-testkit
git commit -m "docs: connect fileserver support claims to CI"
3. 작업 간 의존 순서
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까지다. 병렬 실행은 다음 묶음에서만 허용한다.
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
완료 작업:
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
완료 작업:
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
완료 작업:
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
완료 작업:
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를 적용한다.
1. 요구사항·설계 일치 review
2. 코드 품질·테스트 evidence review
동일 세션에서 실행할 경우 superpowers:executing-plans를 사용하고 Milestone A, B, C, D마다 전체 test·diff·문서 gate를 확인한다.