본문으로 건너뛰기

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 는 재시도 가능하게 설계한다.

결정

  1. 표면 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_id AND 조건 (session_id 단독 = cross-dataset IDOR).
  2. 쿼터 3중 시행 (ADR-0039 "사전 보수적 상한" 준수): ① 개시 시 declared_bytes 로 hard 판정(413) ② part PUT 마다 누적 uploaded_bytes > declared_bytes 즉시 413 — 선언을 강제하지 않으면 1B 선언 후 5GB 릴레이 우회가 열린다 ③ complete 시 실측 재판정 + 원장 bytes_added 는 실측.
  3. 세션 상태 = PG dataset_upload_sessions 테이블이 진실 소스 (csv_staging 동형
    • 상태머신). S3 고아 MPU 는 list_objects_v2 에 보이지 않으므로 세션 행 없이는 찾을 수 없다 — abort_multipart_upload 성공 후에만 행을 정리한다 (역순이면 SeaweedFS 볼륨 영구 점유 = 쿼터 미계상 hard cap 우회).
  4. 무결성 = 개시 선언 sha256 + complete 재해시: 개시 시 클라이언트가 전체 sha256 을 선언(필수), complete 에서 서버가 조립 객체를 스트리밍 재해시해 대조 — 불일치는 409(세션 failed 마킹). 검증된 해시가 Bronze raw 키 raw/{sha256} 가 되어 결정성(ADR-0039 결정 3 — uuid 키는 재업로드 무한 축적)을 보장한다. 증분 해시 상태 누적은 hashlib 상태 직렬화가 비공개 API 라 비채택.
  5. 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_ingest kind 는 기존 값('jsonl') 재사용 (CHECK 제약 폐쇄 — 신값은 ORM+리비전 동시 수정 비용).
  6. GC: gc_dataset_orphans.py세션 패스 별도 추가 — 기존 고아 패스는 live 데이터셋 prefix 를 안 보므로 만료 세션을 영원히 못 잡는다. 만료(expires_at, TTL 24h) 세션을 abort→행 마킹. csv_staging 의 "GC 없는 TTL" 부채를 복제하지 않는다.
  7. MPU 전용 boto3 Config: 기존 _s3_client() 의 read_timeout=10s 는 45MB part 에 부족 — read/write 300s 별도 클라이언트 (mlflow _ARTIFACT_TIMEOUT 동형).
  8. 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_BYTES 45MB· CHUNKED_MAX_FILE_BYTES 기본 5GB, GEND_DATASET_MAX_CHUNKED_FILE_SIZE 로 조정).