Skip to content

fix(api): refuse a build under another owner's interrupted run id - #1027

Merged
yeongseon merged 1 commit into
mainfrom
fix/issue-1025-interrupted-run-id-resubmission
Oct 5, 2026
Merged

yeongseon merged 1 commit into
mainfrom
fix/issue-1025-interrupted-run-id-resubmission

Conversation

@Eomdahyeon

Copy link
Copy Markdown
Collaborator

Refs #1025

문제

#1022 리뷰에서 짚은 경로다. check_existing_run_access 는 매니페스트와 작업 레지스트리만 본다. 재시작으로 중단된 run 은 둘 다 없어서 그 run_id 가 "새 run" 으로 통과하는데, 이벤트 저장소에는 제출자가 남아 있고(#996) 그 사람은 여전히 그 run 이 왜 끝났는지 읽을 수 있다. 다른 사용자가 그 id 로 빌드하면 자기 작업이 도는 동안 첫 제출자의 이벤트를 읽고, 다시 중단되면 자기 실패를 첫 제출자에게 남긴다.

변경

  • routes/_guards.py: check_existing_run_access 가 매니페스트 → 레지스트리 다음에 제출 기록을 본다. check_active_run_access 와 같은 소유 규칙(ownership_allows)이다.
    • 다른 소유자의 기록이 있으면 403 forbidden: not run owner (이 함수의 기존 거절과 같은 본문)
    • 제출자 본인은 통과한다 — 중단된 run 의 메시지가 안내하는 재제출이다
    • 기록이 없는 id 는 지금처럼 새 빌드다
  • CHANGELOG [Unreleased] Fixed. 계약은 바꾸지 않았다(403 은 이미 선언된 응답이고 본문도 같다).

#1008 과의 관계

지금 main 에서 이 함수를 부르는 것은 동기 POST /build 뿐이다(routes/core.py). 이슈가 적은 비동기 POST /builds 경로는 #1008 이 같은 함수를 연결해야 닫힌다. 그래서 Closes 가 아니라 Refs 로 두었다 — #1008 머지 뒤 POST /builds 로 같은 시나리오를 고정하는 테스트를 더하고 이슈를 닫겠다. #1008 은 routes/builds.py 와 이 파일의 주석 두 곳을 바꾸고 이 PR 은 함수 본문을 바꾸므로, 어느 쪽이 먼저 들어가도 충돌은 작을 것으로 본다(직접 합쳐 보지는 않았다).

테스트

tests/unit/test_interrupted_run_status.py 에 3개:

  • 다른 사용자가 중단된 run id 로 POST /build → 403, 제출 기록의 소유자는 그대로
  • 제출자 본인은 가드를 통과
  • 아무도 제출하지 않은 id 는 누구에게나 통과

검증

  • 수정 전 가드로 돌리면 새 테스트가 실패한다: FAILED …::test_another_user_cannot_build_under_the_interrupted_run_id, 1 failed, 8 passed.
  • 수정 후 pytest tests/unit/test_interrupted_run_status.py tests/unit/test_json_array_reader.py tests/unit/test_service.py tests/unit/test_service_jobs.py → 235 passed.
  • ruff check src tests, ruff format --check src tests, mypy src 통과.
  • 전체 스위트는 로컬에서 돌리지 않았다 — CI 에 맡긴다.

🤖 Generated with Claude Code

check_existing_run_access decided by the manifest and the job registry. A run a
restart interrupted has neither, so its id looked free although the event store
records who submitted it. Read that record, by the same ownership rule
check_active_run_access uses.

Refs #1025

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 이라 승인합니다. #1022 리뷰에서 짚은 경로를 그대로 닫습니다 — 매니페스트 → 레지스트리 → 제출 기록 순으로 같은 소유 규칙을 쓰고, 제출자 본인의 재제출은 통과시키고, 기록이 없는 id 는 새 빌드로 둡니다. 수정 전 가드에서 새 테스트가 실패하는 것도 확인돼 있습니다.

Refs 로 둔 판단도 맞습니다: 비동기 POST /builds 는 #1008 이 이 함수를 연결해야 닫힙니다. 이 PR 이 먼저 들어가면 #1008 은 _guards.py 의 docstring 에서 충돌할 수 있는데, 그쪽 브랜치는 제가 main 을 합쳐 맞추겠습니다.

로컬에서 테스트를 돌리지는 않았습니다.

@yeongseon
yeongseon merged commit 813827d into main Oct 5, 2026
21 checks passed
Eomdahyeon added a commit that referenced this pull request Oct 5, 2026
Closes #1025

#1027 이 `check_existing_run_access` 가 제출 기록을 보게 했고, #1008 이 비동기 `POST
/builds` 를 그 가드로 연결했다. 이슈가 적은 경로(다른 사용자가 중단된 run 의 id 로 **비동기** 제출)는 그
둘이 합쳐져야 닫히므로, #1027 은 `Refs` 로 두고 이 테스트를 #1008 뒤로 미뤘었다. 코드 변경은 없다.

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

- 다른 사용자가 중단된 run id 로 `POST /builds` → 403 `forbidden: not run owner`.
레지스트리에 작업이 생기지 않고, 그 사용자는 여전히 그 run 의 이벤트를 404 로 받으며, 원래 제출자가 읽는 이벤트에 두
번째 `run_submitted` 가 없고 제출 기록의 소유자가 그대로다.
- 제출자 본인은 같은 id 로 다시 제출할 수 있다 (202) — 중단된 run 의 메시지가 안내하는 동작.

## 검증

- 가드의 제출 기록 확인을 임시로 꺼 두고 돌리면 새 테스트가 실패한다: `FAILED
…::test_another_user_cannot_submit_under_the_interrupted_run_id` (동기 경로
테스트와 함께 `2 failed, 9 passed`). 되돌린 뒤 통과.
- `pytest tests/unit/test_interrupted_run_status.py
tests/unit/test_json_array_reader.py` 를 5회 → 5회 모두 `28 passed`. (이
fixture 가 한 번 메모리 측정 테스트를 건드린 적이 있어 함께 돌렸다.)
- 처음 쓴 단언 하나는 버렸다: 제출 전후의 이벤트 목록 전체를 비교했더니 3회 모두 실패했다. 테스트의 "첫 프로세스" 워커가
gate 에 닿기 전까지 자기 이벤트를 계속 쓰기 때문이다 — 실제 재시작에서는 죽었을 스레드다. 그래서
`run_submitted` 개수와 소유자만 본다.
- `ruff check tests`, `ruff format` 통과. 전체 스위트는 CI 에 맡긴다.

CHANGELOG 의 #1025 항목에서 "비동기 경로는 #1008 뒤에" 라는 문장을 지금 사실로 고쳤다.

🤖 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 pushed a commit that referenced this pull request Oct 5, 2026
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](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>
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