Skip to content

feat(uploads): per-user file count, total size and retention in a multi-user deployment - #1046

Merged
yeongseon merged 2 commits into
mainfrom
feat/issue-1045-upload-limits
Oct 6, 2026
Merged

yeongseon merged 2 commits into
mainfrom
feat/issue-1045-upload-limits

Conversation

@Eomdahyeon

@Eomdahyeon Eomdahyeon commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #1045

kpubdata#812 §3 의 업로드 결정을 구현한다: 다중 사용자 배포에서 사용자별 파일 50개 / 합계 1 GiB / 보관 30일, 셋 다 환경변수로 바꿀 수 있고, 단일 사용자 배포에는 걸지 않는다. 파일당 20 MiB 는 그대로다.

리뷰에서 볼 곳: 보관 기간은 데이터를 지운다. 30일이 지난 업로드는 삭제되고, 그것을 가리키는 저장 스펙의 다음 빌드는 업로드를 찾지 못한다. 결정이 정한 동작이지만 되돌릴 수 없는 종류라 제가 머지하지 않는다.

변경

  • service/upload_limits.py (새 파일): 세 한도와 그 환경변수. multi_user_mode() 가 아니면 한도 자체가 없다(None). 0 은 그 한도를 끈다. 숫자가 아니거나 음수면 기본값으로 떨어진다.
  • uploads/store.py: usage_for_owner (개수·합계), purge_created_before (기준 시각 이전 업로드와 그 payload 파일 삭제 — 한 소유자 또는 전체).
  • service/uploads_service.py create_upload:
    1. 그 소유자의 보관 기간 지난 업로드를 먼저 지운다 — 지난 것은 한도에 세지 않는다.
    2. 개수나 합계를 넘기면 409 upload_quota_exceeded, 아무것도 저장하지 않는다. 본문이 어느 한도인지(limit: max_files / max_total_bytes), 그 값(limit_value), 지금 쓰는 양(used)을 말한다.
    3. 확인과 저장은 잠금 하나 안에서 한다 — 동시에 온 두 업로드가 한 자리에 함께 들어가지 않게.
  • 서비스가 뜰 때(serve) 모든 소유자의 지난 업로드를 지운다. 업로드를 한 번도 받지 않은 작업 공간에는 그 때문에 저장소 파일을 만들지 않는다(feat(source): Public API, File and URL source contract and ingestion integration #498 의 lazy 생성 유지).
  • 계약 1.84.0 (additive): createUpload 에 409. 이름 붙은 예시는 더하지 않았다 — Studio 의 drift 검사에 선행 변경이 필요 없다(Studio main 을 이 계약으로 돌려 577 passed).
  • docs/deployment.md, .env.app.example, compose 통과(값은 비워 둠 → 코드 기본값), CHANGELOG.

상태 코드를 409 로 한 이유

413 은 이 라우트에서 "이 요청의 본문이 너무 크다"로 이미 쓰인다. 한도 초과는 요청이 아니라 계정이 이미 가진 것 때문이고, 하나를 지우면 같은 요청이 통과한다 — 그래서 409 다. 다른 코드가 맞다고 보시면 바꾸겠다.

테스트 (tests/unit/test_upload_limits.py, 16개)

  • 기본값이 50 / 1 GiB / 30일이고, 각각 바꿀 수 있고, 0 이 끄고, 잘못된 값은 기본값으로 간다. 단일 사용자 배포에서는 한도가 없다.
  • 개수: 한도를 넘는 파일은 409(본문 전체를 고정) — 하나 지우면 다시 들어간다. 한 사용자의 업로드는 다른 사용자의 한도에 세지 않는다.
  • 합계: 합계를 넘기는 업로드는 409, 그 뒤 더 작은 것은 들어간다. 정확히 한도에 닿는 업로드는 들어간다.
  • 보관: 31일 된 업로드는 그 사용자의 다음 업로드 때 사라지고(404) 한도에 세지 않는다. 29일 된 것은 남는다. 기동 시 정리는 모든 소유자의 지난 업로드를 지우고 디스크의 payload 파일도 함께 지운다(파일로 내려간 payload 3개 → 1개). 업로드가 없는 작업 공간에는 저장소가 생기지 않는다. 보관을 끄면 400일 된 것도 남는다.
  • 단일 사용자: 한도 변수를 1 로 줘도 두 번째 업로드가 들어가고 오래된 것도 남는다.

"오래됨"은 테스트가 created_at 을 직접 과거로 고쳐서 만든다.

검증

  • pytest tests/unit → 4385 passed, 9 skipped (8분).
  • check_contract_compat.py --base origin/main → contract compatible with origin/main: 1.83.0 -> 1.84.0. fixture 는 생성기로 다시 만들었다.
  • ruff, mypy src, mkdocs build --strict 통과. # type: ignore 없음.
  • 실제 다중 사용자 배포에서 돌려 보지 않았다. 1 GiB 근처의 실제 크기로 올려 보지도 않았다 — 테스트는 한도를 몇 바이트로 낮춰서 본다.

알아 둘 것

  • fix(api): refuse an interrupted run's id to its submitter too #1043 이 먼저 들어가 1.83.0 을 썼으므로 이 PR 은 1.84.0 으로 올렸다.
  • 세 숫자는 docs: point title rules at kpubdata POLICY 2.1.3 and drop the [#issue] PR title rule #812 가 "측정 없이 고른 출발값"이라고 적은 그대로다.
  • 보관 정리는 업로드할 때와 기동할 때만 돈다. 오래 떠 있는 서비스에서 업로드를 하지 않는 사용자의 지난 파일은 다음 기동까지 남는다. 주기적으로 도는 정리는 넣지 않았다.
  • 한도에 걸린 업로드도 본문은 이미 서버가 다 받은 뒤다(형식 검증이 한도 확인보다 먼저다). 받기 전에 끊는 것은 하지 않았다.
  • 사용자가 자기 사용량을 볼 방법(목록 라우트, Studio 화면)은 이슈의 Non-goal 이다. 409 의 used 가 지금 유일한 안내다.

🤖 Generated with Claude Code

…ti-user deployment

Nothing bounded what one account could upload, and an upload was kept until
its owner deleted it. In a multi-user deployment each owner may now hold 50
files and 1 GiB in total - an upload past either is refused with 409
upload_quota_exceeded - and an upload older than 30 days is deleted when the
service starts and when its owner next uploads. Three variables override the
numbers; 0 turns one off. A single-user deployment applies none.

Closes #1045

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@yeongseon yeongseon left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

리뷰 (코멘트 — 승인은 계약 버전을 맞추고 CI 가 녹색인 뒤에).

#1043 이 방금 머지돼서 1.83.0 이 쓰였습니다. 본문대로 이 PR 을 1.84.0 으로 올려 주세요.

diff 는 읽었고 걸리는 로직은 없습니다.

  • 한도는 다중 사용자 배포에서만 걸리고, 0 이 끄고, 잘못된 값은 기본값으로 갑니다. 단일 사용자 배포에서 한도 변수를 줘도 걸리지 않는다는 테스트가 있습니다.
  • 확인과 저장이 잠금 하나 안에 있어 동시에 온 두 업로드가 한 자리에 함께 들어가지 않습니다.
  • 지난 업로드를 먼저 지우고 나서 한도를 세는 순서가 맞습니다. 기동 시 정리가 디스크의 payload 파일까지 지우는 것을 테스트가 확인합니다.
  • 409 를 고른 이유(413 은 "이 요청의 본문이 크다" 로 이미 쓰인다, 하나를 지우면 같은 요청이 통과한다)에 동의합니다.

"보관 기간은 데이터를 지운다" 에 대해: 결정(kpubdata#812)이 정한 동작이고 그대로 갑니다. 지금은 다중 사용자 배포가 하나도 없어서 이 PR 이 머지되는 순간 지워질 업로드는 없습니다. 다만 사용자가 언제 지워지는지 미리 알 방법이 없습니다 — 업로드 메타데이터에 만료 시각을 싣고 Studio 가 보여 주는 것을 후속 이슈로 올려 주세요. 그것이 들어가기 전에 첫 배포를 한다면 그 배포에서는 KPUBDATA_BUILDER_UPLOAD_RETENTION_DAYS=0 으로 시작하는 것이 안전합니다.

본문이 남긴 나머지 — 주기적 정리가 없다, 한도에 걸린 업로드도 본문은 이미 다 받은 뒤다 — 는 알아 두는 것으로 충분합니다. 뒤의 것은 10-06 계획의 "인증 전에 대용량 본문 전체를 읽지 않도록" 항목과 함께 다룰 일입니다.

…d-limits

# Conflicts:
#	contract/builder-api.yaml
#	src/kpubdata_builder/service/app.py
@Eomdahyeon

Copy link
Copy Markdown
Collaborator Author

계약 버전은 1.84.0 으로 올려 두었습니다 (5a56d32, check_contract_compat.py: 1.83.0 -> 1.84.0). CI 는 녹색입니다. 만료 시각을 메타데이터에 싣고 Studio 가 보여 주는 후속 이슈는 바로 올리겠습니다.

@yeongseon yeongseon left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

계약 버전이 1.84.0 으로 맞춰진 것을 확인했습니다. 그 밖의 diff 는 앞서 읽은 것과 같고 CLEAN 입니다. 승인합니다. 만료 시각을 알리는 후속 이슈(#1047)도 확인했습니다.

@yeongseon
yeongseon merged commit ebdb0b4 into main Oct 6, 2026
26 checks passed
yeongseon added a commit that referenced this pull request Oct 6, 2026
…-id refusal (#1048)

Closes #1042 — 2부(`retry_of`). 1부(재사용 거절)는 #1043.

> **머지 순서:** ① kpubdata-studio#756 (새 오류 예시 두 개의 선행 등록) → ② #1046 (업로드
한도, 계약 1.84.0) → ③ 이 PR (계약 1.85.0). 이 PR 은 **#1046 브랜치 위에 쌓여 있다** —
base 를 그 브랜치로 두었고, #1046 이 머지되면 `main` 으로 옮긴다. 그 전에는 diff 에 이 PR 의 변경만
보인다.

## 무엇을

kpubdata#812 §3: "새 시도는 앞선 시도를 `retry_of` 로 가리킨다". #1043 리뷰의 후속 제안(거절
본문에 `run_id_ended` 코드)도 함께 넣었다. 이슈 Notes 에 적은 제안 그대로다 — 그 제안에 답을 받지는
못했고, #1043 리뷰가 "`retry_of` 를 올릴 때"라고 한 것을 진행 신호로 읽었다. 기록 위치가 다르길 원하시면
여기서 바꾸면 된다.

- **요청:** `POST /build`, `POST /builds` 의 본문에 선택 필드 `retry_of` (경로 안전한
run id; `null` 은 없는 것과 같다).
- **검증:** 가리키는 run 은 호출자가 **읽을 수 있는** run 이어야 한다. 아니면 그 run 을 읽을 때 받는
답(다중 사용자 배포에서는 404, 없는 run 과 구분되지 않는다)을 그대로 받는다 — 링크가 다시 보이는 값이라, 이름을 대
보는 것이 그 run 에 대해 묻는 방법이 되면 안 된다. 자기 자신은 가리킬 수 없다(400).
- **기록:** 매니페스트(`retry_of`, 있을 때만 — 기존 매니페스트는 그대로), 제출
기록(`run_submissions.retry_of`, 컬럼 추가), 작업 스냅샷.
- **표시:** `GET /builds/{run_id}` 의 `BuildJob.retry_of`. 레지스트리가 들고 있을 때도,
매니페스트에서 읽을 때도(재기동·eviction 뒤), 이벤트에서 읽을 때도(중단된 run) 같은 값이다.
- **`run_id_ended`:** 이미 끝난 run id 의 거절 본문에 `code` 를 넣고, 두 라우트에 이름 붙은 예시
`RunIdEnded` 를 더했다.
- 계약 1.85.0 (additive).

## 호환성 — 하나 짚어 둘 것

`BuilderService.build` 를 재정의한 하위 클래스가 있다(테스트에 12곳). 새 인자를 항상 넘기면 그것들이
`TypeError` 로 깨진다 — 실제로 깨졌다(`12 failed`). 그래서 **`retry_of` 가 있을 때만** 인자를
넘긴다(`functools.partial`). 재정의된 `build` 는 재시도가 아닌 모든 요청에서 전처럼 돈다.

이벤트 저장소는 컬럼 하나를 `ALTER TABLE … ADD COLUMN` 으로 더한다. 행은 건드리지 않고, 예전 저장소의
행은 `retry_of` 가 NULL 로 읽힌다.

## 테스트 (`tests/unit/test_interrupted_run_status.py`)

- 재시도로 제출한 run 은 202 응답, 끝난 뒤의 상태, **매니페스트**, 그리고 **재기동한 서비스의 상태 조회**에서
모두 `retry_of` 를 말한다. 앞선 시도는 끝난 그대로다.
- 아무것도 재시도하지 않는 run 은 응답·상태·매니페스트 어디에도 그 키가 없다.
- 동기 라우트도 매니페스트에 기록한다.
- 다른 사용자의 run 을 가리키면 404 — 없는 run 을 가리켰을 때와 같고, 작업도 제출 기록도 생기지 않는다.
- 빈 문자열·공백·숫자·경로 탈출은 400. 자기 자신은 400. `null` 은 없는 것과 같다.
- 컬럼이 없던 저장소를 다시 열면 컬럼이 생기고 기존 행이 남는다.
- 거절 본문에 `code: run_id_ended` (기존 테스트의 기대 본문을 고쳤다).

## 검증

- `pytest tests/unit` → `4387 passed, 9 skipped` (#1046 을 합치기 전). 합친 뒤
계약·재시도·업로드·작업 5개 파일 → `525 passed`. **합친 뒤 전체 스위트는 다시 돌리지 않았다** — CI 에
맡긴다.
- `check_contract_compat.py --base feat/issue-1045-upload-limits` →
`contract compatible …: 1.84.0 -> 1.85.0`.
- kpubdata-studio#756 브랜치의 `contractDrift.test.ts` 를 Builder 의 세 계약으로:
1.83.0 `577 passed`, 1.84.0 `577 passed`, 1.85.0(이 브랜치) `579 passed`.
- `ruff`, `mypy src` 통과. `# type: ignore` 없음.

## 이 PR 이 하지 않는 것

- 가리키는 run 이 **끝났는지**는 보지 않는다 — 아직 도는 run 을 `retry_of` 로 가리킬 수 있다. 막을
이유를 결정 기록에서 찾지 못해 두었다.
- 한 run 을 가리키는 재시도가 여럿이어도 막지 않는다.
- Studio 가 재시도할 때 `retry_of` 를 보내는 것 — Studio 쪽 변경이고 따로 이슈가 필요하다.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Eomdahyeon <213566566+Eomdahyeon@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Yeongseon Choe <yeongseon.choe@gmail.com>
yeongseon pushed a commit that referenced this pull request Oct 6, 2026
Closes #1047

#1046 리뷰에서 요청하신 후속이다: 보관 기간이 업로드를 지우는데 사용자가 언제인지 미리 알 방법이 없었다.

## 변경

- `service/uploads_service.py`: 업로드 메타데이터에 `expires_at`. `created_at` +
지금 적용 중인 보관 일수이고, **정리가 쓰는 것과 같은 경계**다. 아무것도 지우지 않는 경우 — 단일 사용자 배포, 보관을
끈 배포 — 에는 `null`. 키는 항상 있다.
  - `POST /uploads` 의 응답과 `GET /uploads/{upload_id}` 둘 다.
  - 답할 때 계산한다. 운영자가 보관 일수를 바꾸면 날짜도 바뀐다 — 저장해 두면 실제 삭제 시점과 어긋난다.
- 계약 1.86.0 (additive): `UploadMetadata.expires_at` (`string | null`, 필수
아님), 업로드 예시에 값 하나.
- CHANGELOG.

## 테스트 (`tests/unit/test_upload_limits.py`)

- 업로드 응답의 `expires_at - created_at` 이 30일이고, 다시 조회해도 같다. 보관 일수를 7로 바꾸면
7일이다.
- **필드가 말하는 시점과 정리가 실제로 지우는 시점이 같다:** 29일 된 업로드는 `expires_at` 이 미래이고 정리에
남고, 31일 된 업로드는 `expires_at` 이 과거이고 정리에 지워진다.
- 보관을 끄면 `null`(키는 있다). 단일 사용자 배포에서도 `null`.

## 검증

- `pytest tests/unit -k "upload or contract or fixture or file_source or
preview"` → `808 passed, 1 skipped`.
- `check_contract_compat.py --base origin/main` → `contract compatible
with origin/main: 1.85.0 -> 1.86.0`.
- Studio `main` 의 `contractDrift.test.ts` 를 이 계약으로 → `Tests 579 passed
(579)`. 새 필드는 additive 이고 이름 붙은 오류 예시가 없어 Studio 쪽 선행 변경이 필요 없다.
- `ruff`, `mypy src` 통과. 전체 스위트는 CI 에 맡긴다.

## 작업 중 실수 하나 — 고쳤고, 남은 질문

계약 예시에 `expires_at` 을 넣으면서 같은 `created_at` 값을 가진 **작업 상태 예시
6곳**(`Running`, `Succeeded`, `Accepted` 등)에도 잘못 넣었다가 되돌렸다. 지금 계약 diff 에서
예시는 업로드 하나뿐이다(`git diff` 로 확인).

짚어 둘 것은 그 잘못된 상태에서도 **계약 테스트 457개가 통과했다**는 점이다. `BuildJob` 예시에 스키마에 없는
필드가 있어도 걸리지 않는다 — 예시와 스키마의 대조가 추가 필드를 보지 않는 것으로 보인다. 원인은 확인하지 않았다. 필요하면
이슈로 올리겠다.

## 이 PR 이 하지 않는 것

Studio 가 이 값을 보여 주는 것 — 필드가 생겼으니 kpubdata-studio 이슈로 따로 올린다.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Eomdahyeon <213566566+Eomdahyeon@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@yeongseon
yeongseon deleted the feat/issue-1045-upload-limits branch October 7, 2026 22:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(uploads): per-user file count, total size and retention limits in a multi-user deployment

2 participants