Repository navigation
feat(uploads): per-user file count, total size and retention in a multi-user deployment - #1046
Merged
Merged
Conversation
…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
reviewed
Oct 5, 2026
yeongseon
left a comment
Collaborator
There was a problem hiding this comment.
리뷰 (코멘트 — 승인은 계약 버전을 맞추고 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
Collaborator
Author
|
계약 버전은 1.84.0 으로 올려 두었습니다 ( |
This was referenced Oct 6, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #1045
kpubdata#812 §3 의 업로드 결정을 구현한다: 다중 사용자 배포에서 사용자별 파일 50개 / 합계 1 GiB / 보관 30일, 셋 다 환경변수로 바꿀 수 있고, 단일 사용자 배포에는 걸지 않는다. 파일당 20 MiB 는 그대로다.
변경
service/upload_limits.py(새 파일): 세 한도와 그 환경변수.multi_user_mode()가 아니면 한도 자체가 없다(None).0은 그 한도를 끈다. 숫자가 아니거나 음수면 기본값으로 떨어진다.uploads/store.py:usage_for_owner(개수·합계),purge_created_before(기준 시각 이전 업로드와 그 payload 파일 삭제 — 한 소유자 또는 전체).service/uploads_service.pycreate_upload:upload_quota_exceeded, 아무것도 저장하지 않는다. 본문이 어느 한도인지(limit:max_files/max_total_bytes), 그 값(limit_value), 지금 쓰는 양(used)을 말한다.serve) 모든 소유자의 지난 업로드를 지운다. 업로드를 한 번도 받지 않은 작업 공간에는 그 때문에 저장소 파일을 만들지 않는다(feat(source): Public API, File and URL source contract and ingestion integration #498 의 lazy 생성 유지).createUpload에 409. 이름 붙은 예시는 더하지 않았다 — Studio 의 drift 검사에 선행 변경이 필요 없다(Studiomain을 이 계약으로 돌려577 passed).docs/deployment.md,.env.app.example, compose 통과(값은 비워 둠 → 코드 기본값), CHANGELOG.상태 코드를 409 로 한 이유
413 은 이 라우트에서 "이 요청의 본문이 너무 크다"로 이미 쓰인다. 한도 초과는 요청이 아니라 계정이 이미 가진 것 때문이고, 하나를 지우면 같은 요청이 통과한다 — 그래서 409 다. 다른 코드가 맞다고 보시면 바꾸겠다.
테스트 (
tests/unit/test_upload_limits.py, 16개)0이 끄고, 잘못된 값은 기본값으로 간다. 단일 사용자 배포에서는 한도가 없다."오래됨"은 테스트가
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없음.알아 둘 것
used가 지금 유일한 안내다.🤖 Generated with Claude Code