ADR-0042: 학습 데이터셋 청크 업로드 — API 릴레이 + 서버측 S3 MPU 조립
- 상태: 승인
- 날짜: 2026-08-07
- 관련: ADR-0039(쿼터·원장), ADR-0041(다운로드 프록시), ADR-0038(MLflow mpu 405), 이슈 #2989 M3
맥락
파일당 상한은 100MB(MAX_FILE_SIZE) — dev ingress proxy-body-size 50m 가 그보다
먼저 걸린다. 100MB 초과 학습 데이터(대형 parquet/JSONL)를 올릴 경로가 없다.
presigned URL 은 3회 비채택된 축(SeaweedFS ClusterIP 전용 — ADR-0039/0041, 에픽 #2989)
이라, 남는 설계는 API 청크 릴레이뿐이다.
ADR-0038 의 "mpu 405" 와의 구분: 그때 금지한 것은 MLflow SDK 가 presigned URL 을 발급해 클라이언트가 프록시를 우회해 S3 에 직접 PUT 하는 경로다. 본 ADR 의 MPU 는 API 서버 프로세스 내부에서 boto3 로 SeaweedFS 에 조립하는 것으로, S3 는 여전히 외부에 비노출이다 — 충돌하지 않는다.
SeaweedFS 3.80 MPU 실측 (2026-08-07, prod): create/upload_part/complete/abort/
list_parts/list_multipart_uploads 전부 정상, 마지막 part 5MB 미만 허용, 2-part
6MB 조립 결과 sha256 일치. 단 Flink 경로에 MPU 간헐 500 이력(docs/GUIDE_STREAMING.md)
→ part PUT/complete 는 재시도 가능하게 설계한다.
결정
- 표면 3+1:
POST /{id}/uploads(세션 개시, 파일명·declared_bytes·kind 선언) →PUT /{id}/uploads/{sid}/parts/{n}(raw body ≤45MB — dev ingress 50m 이하) →POST /{id}/uploads/{sid}/complete(MPU 조립 + 기존 ingest 경로 접합) +DELETE /{id}/uploads/{sid}(abort). 모두 기존 datasets router·require_analyst·_scoped_dataset(cross-ws 404) 경유. 세션 조회는 반드시dataset_idAND 조건 (session_id 단독 = cross-dataset IDOR). - 쿼터 3중 시행 (ADR-0039 "사전 보수적 상한" 준수):
① 개시 시
declared_bytes로 hard 판정(413) ② part PUT 마다 누적uploaded_bytes > declared_bytes즉시 413 — 선언을 강제하지 않으면 1B 선언 후 5GB 릴레이 우회가 열린다 ③ complete 시 실측 재판정 + 원장bytes_added는 실측. - 세션 상태 = PG
dataset_upload_sessions테이블이 진실 소스 (csv_staging 동형- 상태머신). S3 고아 MPU 는
list_objects_v2에 보이지 않으므로 세션 행 없이는 찾을 수 없다 —abort_multipart_upload성공 후에만 행을 정리한다 (역순이면 SeaweedFS 볼륨 영구 점유 = 쿼터 미계상 hard cap 우회).
- 상태머신). S3 고아 MPU 는
- 무결성 = 개시 선언 sha256 + complete 재해시: 개시 시 클라이언트가 전체
sha256 을 선언(필수), complete 에서 서버가 조립 객체를 스트리밍 재해시해 대조 —
불일치는 409(세션 failed 마킹). 검증된 해시가 Bronze raw 키
raw/{sha256}가 되어 결정성(ADR-0039 결정 3 — uuid 키는 재업로드 무한 축적)을 보장한다. 증분 해시 상태 누적은 hashlib 상태 직렬화가 비공개 API 라 비채택. - complete 접합 = parquet 전용 (M3 범위): MPU 를
ingest_parquet이 기대하는 staging 키({staging}part-000.parquet)에 직접 조립하고, S3-이후 구간(external table→dedup anti-join→snapshot)을 함수 추출해 그대로 탄다. Bronze raw 보존은 S3 server-side copy (bytes 불요). JSONL 대용량·이미지 단일 100MB 초과는 명시적 후속 — JSONL 은 정규화 external SQL 층 신설이 필요해(파싱이 bytes 전제) 범위 분리, 대용량 텍스트의 표준 컨테이너는 parquet 이다._record_ingestkind 는 기존 값('jsonl') 재사용 (CHECK 제약 폐쇄 — 신값은 ORM+리비전 동시 수정 비용). - GC:
gc_dataset_orphans.py에 세션 패스 별도 추가 — 기존 고아 패스는 live 데이터셋 prefix 를 안 보므로 만료 세션을 영원히 못 잡는다. 만료(expires_at, TTL 24h) 세션을 abort→행 마킹. csv_staging 의 "GC 없는 TTL" 부채를 복제하지 않는다. - MPU 전용 boto3 Config: 기존
_s3_client()의 read_timeout=10s 는 45MB part 에 부족 — read/write 300s 별도 클라이언트 (mlflow_ARTIFACT_TIMEOUT동형). - write-gate(
check_write_target)는 태우지 않는다 — 게이트는 dataset 생성 시점 1회가 선례 (/files·/jsonl도 미호출). 신규 mutation 3+1 은 coverage allowlist 등록 (CLI 우선, UI 배선 후속).
비채택 대안
- presigned URL — 3회째 동일 사유(ClusterIP 전용·보안 표면) 비채택.
- 단일 PUT 상한 상향(ingress 200m+) — DDoS 표면·메모리 피크 확대, 재개 불가.
- 이어붙임 없는 part 저장 후 Trino 다중 파일 external — parquet 은 성립하지만 JSONL 라인 경계가 part 경계와 어긋나면 파싱 깨짐. MPU 조립이 단순하고 실측 검증됨.
결과
- CLI
gend dataset upload는 100MB 초과 파일을 자동으로 청크 경로로 전환 (기존 사전 rejected 삽입점 대체). part 단위 재전송이 재시도 단위 (반감 불필요). - 알려진 한계: 동시 개시 세션들의 declared_bytes 는 예약(reserve)으로 합산되지 않는다 — 각 세션의 complete 재판정이 최종 게이트라 hard 초과 적재는 불가하며, 경합 창의 노출은 staging 점유(TTL 24h 유계)뿐. 예약 회계는 실수요 발생 시 후속.
- 알려진 트레이드오프: complete 의 dataset FOR UPDATE 가 Trino 조립 동안(수 분) 동일 데이터셋 업로드를 직렬화 — 기존 업로드 계약과 동일하며 세션당 1회.
- 서버 상한 정본:
training_dataset_service.py(CHUNK_PART_MAX_BYTES45MB·CHUNKED_MAX_FILE_BYTES기본 5GB,GEND_DATASET_MAX_CHUNKED_FILE_SIZE로 조정).