Skip to content

fix(api): refuse an interrupted run's id to its submitter too - #1043

Merged
yeongseon merged 2 commits into
mainfrom
fix/issue-1042-run-id-is-one-attempt
Oct 5, 2026
Merged

yeongseon merged 2 commits into
mainfrom
fix/issue-1042-run-id-is-one-attempt

Conversation

@Eomdahyeon

Copy link
Copy Markdown
Collaborator

Refs #1042 — 1부(재사용 거절). retry_of 는 이슈의 Notes 에 적은 제안을 확인받은 뒤 따로 올린다.

왜

kpubdata#812 §3: 한 run_id 는 한 번의 시도다. 종료 이벤트가 붙으면 바뀌지 않고, 재시도는 새 run_id 를 받는다. 그 기록이 직접 짚었듯 제가 넣은 #1027 과 #1037 이 반대로 동작한다 — 재시작으로 중단된 run 의 제출자가 같은 id 로 다시 빌드할 수 있었고, 그러면 두 번째 시도의 이벤트가 첫 시도의 run_failed 뒤에 붙어 서로의 종료가 상대의 상태로 읽힌다.

변경

  • routes/_guards.py check_existing_run_access: 매니페스트도 레지스트리 항목도 없는데 제출 기록이 있는 id 는 제출자 본인에게도 거절한다. 다른 사용자는 전처럼 403.
    • POST /builds → 409 {"error": "run_id already ended; submit the retry under a new run_id", "run_id": …}. 완료된 run 의 id 가 이미 받는 상태 코드다.
    • POST /build → 400, 같은 본문.
  • 중단된 run 의 메시지: "…; submit it again under a new run_id".
  • 레지스트리가 아직 들고 있는 작업(대기·실행 중, 매니페스트 없이 실패한 것)은 그대로다 — 그 id 를 대면 그 작업을 돌려준다(ADR 0008).
  • 계약 1.83.0 (additive): 두 라우트의 설명에 이 경우를 적었다. 이름 붙은 예시는 더하지 않았다 — Studio 의 drift 검사에 새 항목이 필요 없다.

POST /build 가 409 가 아니라 400 인 이유

처음에는 두 라우트 모두 409 로 했다. check_contract_compat.py 가 막았다:

error: the change breaks existing clients, which needs a MAJOR version raise …
  - POST /build 409: $ref #/components/schemas/BuildSuccessResponse -> None

그 라우트의 409 는 "빌드는 됐지만 테이블이 커밋되지 않음"(#788)의 빌드 응답으로 선언돼 있어서, 거기에 오류 본문을 섞으면 409 를 빌드 응답으로 파싱하는 클라이언트가 깨진다. 그래서 그 라우트에서는 이미 Error 본문으로 선언된 400 을 쓴다. 상태 코드가 두 라우트에서 다른 것은 깔끔하지 않다 — 다른 선택(예: POST /build 의 409 를 major 로 바꾸기)이 맞다고 보시면 말씀해 달라.

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

예전 동작을 고정하던 두 테스트를 바꿨다 — test_the_submitter_can_submit_the_interrupted_run_again(202 기대)와 test_the_submitter_may_use_the_interrupted_run_id_again(가드 통과 기대). 결정이 뒤집은 바로 그 동작이다.

  • 제출자가 같은 id 로 POST /builds → 409, 작업이 생기지 않고, 그 run 은 여전히 failed / credentials_required 로 읽히고, run_submitted 이벤트는 1개다.
  • POST /build → 400, 같은 본문.
  • 새 id 로는 202 — 재시도 자체는 된다.
  • 중단 메시지가 "under a new run_id" 로 끝난다.
  • 다른 사용자의 403, 제출 기록이 없는 id 의 통과는 그대로다(기존 테스트).

검증

  • 가드만 고치고 테스트를 그대로 두면 위 두 테스트가 실패했다(2 failed, 9 passed) — 바뀐 것이 그 동작임을 보여 준다.
  • 관련 5개 파일 → 659 passed. 새 테스트 파일을 메모리 측정 테스트와 3회 → 3회 모두 30 passed.
  • check_contract_compat.py --base origin/main → contract compatible with origin/main: 1.82.0 -> 1.83.0.
  • Studio main 의 contractDrift.test.ts 를 이 브랜치의 계약(1.83.0)으로 → Tests 577 passed (577). Studio 쪽 선행 변경은 필요 없다.
  • ruff, mypy src 통과. 전체 스위트는 CI 에 맡긴다.

동작 변경 — 알아 둘 것

  • 중단된 run 을 예전 id 로 다시 제출하던 클라이언트는 이제 409/400 을 받는다. Studio 는 재시도에 같은 run_id 를 보내지 않는 것으로 보인다(features/ 에서 그런 경로를 찾지 못했다 — 전수 확인은 아니다).
  • 제출 기록은 있는데 종료 이벤트가 아직 없는 id(프로세스가 죽은 직후, 재기동 때 중단으로 표시되기 전)도 같은 답을 받는다. 재기동하면 곧 중단으로 표시되므로 따로 가르지 않았다.

🤖 Generated with Claude Code

A run id is one attempt (kpubdata#812). #1027 let the submitter of a run a
restart interrupted build under the same id again; the second attempt's events
followed the first attempt's run_failed and each ending read as the other's
state. Refuse the id as a completed run's id is refused - 409 on POST /builds,
400 on POST /build, whose 409 is a build response - and tell the client to
submit under a new run_id.

Refs #1042

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Eomdahyeon added a commit that referenced this pull request Oct 5, 2026
PR 마다 실패하는 두 검사를 푼다. 둘 다 저장소의 코드가 아니라 바깥에서 새로 공개된 취약점 때문이다.

## 1. `docker build and scan` — OS 패치 레이어

#1043 의 잡에서 Trivy 가 `Total: 7 (HIGH: 4, CRITICAL: 3)` 을 보고했다. 그중
`perl-base` 의 CVE-2026-13221, CVE-2026-42496 은 수정
버전(`5.36.0-7+deb12u4`)이 나와 있다.

`Dockerfile` 의 `OS_PATCH_DATE` 를 `2026-10-04` → `2026-10-06` 으로 올렸다.
#1006 이 만들어 둔 그 레이어의 캐시 키다 — 날짜를 올리면 `apt-get upgrade` 레이어가 다시 만들어진다.

**이 PR 의 첫 커밋에서 그 잡은 통과했다**(실패 목록에서 사라졌다).

## 2. `Dependency audit (pip-audit)` — fsspec

```
Found 1 known vulnerability in 1 package
Name   Version  ID              Fix Versions
fsspec 2026.3.0 CVE-2026-104851 2026.6.0
```

`uv lock --no-sources --upgrade-package fsspec` → `2026.3.0 → 2026.9.0`.
잠금 파일에서 바뀐 것은 fsspec 항목의 버전·sdist·wheel 네 줄뿐이다(`git diff` 로 확인). `uv
lock --no-sources --check` 통과.

처음에는 `--no-sources` 없이 돌려서 kpubdata 가 로컬 editable 소스로 잠기는 변경이 섞였다 — 버리고
다시 만들었다. CI 가 `--no-sources` 로 설치하므로 잠금도 그렇게 만들어야 한다.

## 검증

- 로컬에서 이미지를 빌드하거나 스캔하지 않았다(디스크 여유가 없다). 판정은 이 PR 의 두 잡이다.
- fsspec 2026.9.0 으로 테스트를 로컬에서 돌리지 않았다 — CI 에 맡긴다. fsspec 은 publish
extra(Hugging Face 쪽)를 통해 들어온다.
- Trivy 의 7건 중 이름을 확인한 것은 `perl-base` 의 둘이다.

🤖 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 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.

diff 를 읽었고 CLEAN 이라 승인합니다. kpubdata#812 의 결정("한 run_id 는 한 번의 시도다")을 따라 #1027·#1037 의 반대 동작을 뒤집었습니다.

  • 제출 기록이 있는데 매니페스트도 레지스트리 항목도 없는 id 는 제출자 본인에게도 거절합니다. 다른 사용자의 403 과, 레지스트리가 아직 들고 있는 작업을 돌려주는 동작은 그대로입니다.
  • 예전 동작을 고정하던 테스트 둘을 바꾼 것은 결정이 뒤집은 바로 그 동작이라 맞습니다. 가드만 고치면 그 둘이 실패한다는 것도 적혀 있습니다.
  • 새 id 로는 202 가 나온다는 테스트가 있어, 재시도 자체가 막히지 않는다는 것이 고정됩니다.

본문이 물은 POST /build 의 400: 받아들입니다. 그 라우트의 409 는 빌드 응답으로 선언돼 있어서 오류 본문을 섞으면 major 변경이고, 두 라우트의 상태 코드가 다른 것은 그보다 싼 값입니다.

계약에 이름 붙은 예시를 더하지 않았고, Studio main 의 drift 테스트를 이 계약(1.83.0)으로 돌려 577개가 통과했다는 것을 확인했습니다 — Studio 쪽 선행 변경이 필요 없습니다. 머지 뒤 Studio main 의 CI 를 한 번 다시 돌려 보겠습니다.

후속 제안(막지 않음): 이 거절 본문에는 code 가 없습니다. retry_of 를 올릴 때 run_id_ended 같은 코드를 함께 주면 클라이언트가 문장으로 분기하지 않아도 됩니다. 그때는 예시가 생기므로 Studio 의 등록이 먼저입니다.

@yeongseon
yeongseon merged commit 9aed0c4 into main Oct 5, 2026
24 checks passed
yeongseon pushed a commit that referenced this pull request Oct 6, 2026
…ti-user deployment (#1046)

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`) 모든 소유자의 지난 업로드를 지운다. 업로드를 한 번도 받지 않은 작업 공간에는 그 때문에
저장소 파일을 만들지 않는다(#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 근처의 실제 크기로 올려 보지도 않았다 — 테스트는 한도를 몇
바이트로 낮춰서 본다.

## 알아 둘 것

- #1043 이 먼저 들어가 1.83.0 을 썼으므로 이 PR 은 1.84.0 으로 올렸다.
- 세 숫자는 #812 가 "측정 없이 고른 출발값"이라고 적은 그대로다.
- 보관 정리는 **업로드할 때와 기동할 때만** 돈다. 오래 떠 있는 서비스에서 업로드를 하지 않는 사용자의 지난 파일은 다음
기동까지 남는다. 주기적으로 도는 정리는 넣지 않았다.
- 한도에 걸린 업로드도 본문은 이미 서버가 다 받은 뒤다(형식 검증이 한도 확인보다 먼저다). 받기 전에 끊는 것은 하지
않았다.
- 사용자가 자기 사용량을 볼 방법(목록 라우트, Studio 화면)은 이슈의 Non-goal 이다. 409 의 `used` 가
지금 유일한 안내다.

🤖 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 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
yeongseon deleted the fix/issue-1042-run-id-is-one-attempt branch October 7, 2026 22:25
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.

2 participants