fix(builds): let the owner read a run a restart interrupted - #1022
Conversation
Closes #996 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
yeongseon
left a comment
There was a problem hiding this comment.
리뷰 (코멘트 — 테스트 잡이 아직 도는 중이라 승인은 녹색 확인 뒤에).
diff 를 읽었습니다. 방향과 구현 모두 타당합니다: 제출자를 이벤트 저장소에 남기고, 상태 조회를 레지스트리 → 매니페스트 → 종료 이벤트 순으로 내리고, 정상 종료는 매니페스트에만 맡긴 것. 다른 사용자가 없는 run 과 같은 답을 받는다는 것을 테스트가 본문까지 확인합니다. 본문에 적은 경합 수정도 설명과 일치합니다.
짚어 둘 것 하나 (이 PR 을 막지는 않음): check_existing_run_access 는 제출 기록을 보지 않습니다. 재시작으로 중단된 run 의 run_id 는 매니페스트도 레지스트리도 없어서 "새 run" 으로 통과하므로, 다른 사용자 B 가 같은 id 로 제출할 수 있습니다. 그러면
record_submission은INSERT OR IGNORE라 소유자는 A 로 남고,- B 의 작업이 레지스트리에 있는 동안 B 는 그
run_id의 이벤트 — A 의run_submitted/run_failed포함 — 를 읽고, - B 의 작업이 다시 중단되면 제출 기록상 소유자인 A 가 B 의 실패를 읽습니다.
id 재사용 자체는 이 PR 이전에도 가능했지만, 이제 판정할 기록이 생겼으니 check_existing_run_access 도 제출 기록을 보게 하면 닫힙니다. #1008 과 같은 함수라 그쪽이 머지된 뒤 후속으로 하는 편이 충돌이 적습니다.
계약 버전은 본문대로 #1009 와 겹칩니다(둘 다 1.78.0) — 나중에 들어가는 쪽이 올립니다. 로컬에서 테스트를 돌리지는 않았습니다.
The fixture released the first service's worker and returned. The worker then ran its build behind the following tests, and test_json_array_reader measures peak memory with tracemalloc, which counts every thread. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
CI 실패 원인을 찾아 고쳤습니다 (7df0ed4).
확인: 고치기 전 fixture 로 두 파일을 함께 돌리면 3회 모두
|
…pted-run-status # Conflicts: # CHANGELOG.md
…503 a stable code (#1023) Closes #1000 > **#1022(#996) 위에 쌓은 PR 이다.** base 를 그 브랜치로 잡아 이 PR 의 diff 만 보이게 했다. #1022 가 머지되면 base 를 `main` 으로 바꾼다. ## 문제 일부 에러 본문에 `code` 가 없어서 클라이언트가 문장으로 분기해야 한다. 특히 `POST /builds` 의 큐 초과 429 는 `auth_throttled` 429 와 문장으로만 구분됐다. ## 변경 내용 — 이슈의 네 가지 | 경우 | 상태 | `code` | |---|---|---| | async build 큐 초과 | 429 | `build_queue_full` | | 인증 실패 | 401 | `unauthorized`, 토큰의 `exp` 가 지났으면 `token_expired` | | JWKS 를 가져오지 못함 | 503 | `auth_unavailable` | | 과부하 (요청을 읽기 전 소켓 응답) | 503 | `server_overloaded` | | 재시작으로 중단된 run | — | `credentials_required` — #1022 에서 | - 본문의 `error` 문장은 그대로 둔다. `code` 만 추가된다. - 401 본문은 이슈가 "확인하지 않은 것"으로 남긴 부분이다. 확인: `{"error": <AuthError.reason>}` 이었고 `code` 가 없었다. `AuthError.code` 프로퍼티가 상태와 사유에서 코드를 정한다. - `token_expired` 를 따로 둔 이유: 클라이언트가 할 일이 다르다(토큰을 새로 받아 재시도). 나머지 401 은 `unauthorized` 하나다 — 어떤 검증이 실패했는지까지 코드로 나누지 않았다. - 계약 1.78.0 → **1.79.0** (additive): `Error.code` 설명에 코드 목록, `Unauthorized` 응답의 예시와 설명, `submitBuild` 429 예시. `Error` 스키마에는 이미 선택적 `code` 가 있어 스키마 구조는 바뀌지 않는다. ## 검증 - `tests/unit/test_stable_error_codes.py`(큐 초과 본문, 과부하 본문), `test_oidc_auth.py::TestStableAuthCodes`(거부·JWKS 불가·만료 토큰이 실제 응답 본문에 싣는 코드). - 인증·서비스·계약 fixture·job 관련 테스트 파일: `721 passed`. 전체 스위트는 돌리지 않았다 — CI 가 확인한다. - `check_contract_compat.py`: `origin/main` 대비 `1.77.0 -> 1.79.0` 호환. ruff, mypy 통과. ## 겹치는 것 #1009 가 계약의 같은 영역(공용 응답, 버전 줄)을 고친다. 나중에 머지되는 쪽에서 버전 줄을 맞춰야 한다. 🤖 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>
…03, and the request id (#1024) Refs #994 > **#1023(#1000) 위에 쌓은 PR 이다** (그 PR 은 #1022 위에 있다). base 를 그 브랜치로 잡아 이 PR 의 diff 만 보이게 했다. 과부하 503 의 `code` 가 #1023 에서 생기기 때문이다. ## 이 PR 이 하는 것 — #1009 가 "이 PR 에 없는 것"으로 남긴 세 항목 중 둘 1. **`X-Provider-Key` 를 header 파라미터로 선언** — `components.parameters.ProviderKey`. 프로바이더를 호출하는 다섯 operation(`previewBuild`, `createBuild`, `submitBuild`, `getProviderStatus`, `testProviderConnection`)에 참조를 달았다. Studio 가 이 헤더를 보내는 호출과 같은 다섯이다. 2. **429 `auth_throttled`, 과부하 503, `X-Request-ID`** — 어느 operation 에서나 나올 수 있으므로 `SignupNotApproved` 와 같은 방식(`x-status` 를 가진 공용 응답)으로 선언했다: `components.responses.AuthThrottled`, `ServerOverloaded`, 그리고 `components.headers.RequestId`. 계약 1.79.0 → **1.80.0**. wire 는 바뀌지 않는다(선언만). response fixture 를 생성기로 다시 만들었다. ## 하지 않은 것 3. **`_DISPATCH_ROUTES` 를 라우트 어댑터에서 생성** — #1009 본문이 적은 대로 라우트가 if 연쇄라 표로 바꾸는 리팩터링이 먼저다. 이 PR 범위에 넣지 않았다. 그래서 `Refs`. `X-Request-ID` 는 헤더 컴포넌트로만 선언했고 각 operation 의 응답마다 참조를 달지는 않았다 — 모든 응답(수백 개)에 다는 것은 계약을 읽기 어렵게 만든다고 판단했다. 설명에 "핸들러가 쓰는 모든 응답에 실린다"고 적었다. ## 검증 — 선언과 실제를 대조한다 `tests/unit/test_contract_shared_declarations.py`: - 파라미터의 이름이 코드의 `PROVIDER_KEY_HEADER` 와 같고, 참조한 operation 집합이 정확히 위 다섯이다 - `_overloaded_response()` 의 상태·본문·헤더가 `ServerOverloaded` 선언과 같다 - 인증 실패 한도를 넘긴 실제 응답의 상태·키·`code` 가 `AuthThrottled` 선언과 같다 - 실제 HTTP 응답에 `X-Request-ID` 가 있다 계약 관련 테스트 파일: `424 passed`. `check_contract_compat.py`: `origin/main` 대비 `1.77.0 -> 1.80.0` 호환. ruff, mypy 통과. 전체 스위트는 CI 가 돌린다. ## #1009 와의 관계 #1009 는 지금 `main` 과 충돌 상태다 — 내가 #1010 을 머지하면서 `main` 이 1.77.0 이 됐고, #1009 도 1.77.0 을 쓴다. 이 스택(#1022 → #1023 → 이 PR)은 1.78.0 ~ 1.80.0 을 쓴다. 어느 쪽이 먼저 들어가든 나중 쪽이 버전 줄을 다시 맞춰야 하니, 순서를 정해 주면 내 쪽을 그에 맞추겠다. 🤖 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>
) 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](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>
Closes #996
문제
재시작으로 중단된 run 은
mark_interrupted_runs가 이벤트 저장소에만run_failed(credentials_required)를 남긴다. 재시작 뒤에는 레지스트리가 비어 있고 매니페스트도 없어서GET /builds/{run_id}와GET /builds/{run_id}/events가 모두 404 다. 빌드를 폴링하던 클라이언트는 "다시 제출하라" 대신 "그런 run 은 없다"를 받는다.변경 내용
_build_events.sqlite)에run_submissions(run_id, owner_id, created_by, submitted_at)테이블을 추가하고, 비동기 제출 시run_submitted이벤트와 함께 기록한다. 소유자는 지금까지 메모리의 레지스트리와 (끝난 뒤의) 매니페스트에만 있었다. 테이블은CREATE TABLE IF NOT EXISTS로 추가되고 기존 행·스키마 버전은 건드리지 않는다.build_status: 레지스트리 → 매니페스트 → 이벤트 저장소의 종료 이벤트. 중단된 run 은status: failed,error(이벤트 메시지),code: credentials_required,created_at(제출 시각),updated_at(실패 시각)으로 답한다. 정상 종료(run_finished)는 여기서 답하지 않는다 — 그 경우의 근거는 매니페스트다.check_active_run_access: 매니페스트 → 레지스트리 → 제출 기록으로 소유권을 판정한다. 다른 사용자는 없는 run 과 같은 답(멀티유저에서 404)을 받는다.BuildJob.code(optional,credentials_required),BuildJob설명. CHANGELOG[Unreleased]Fixed.한계
mark_interrupted_runs가 실패 이벤트를 남기는 조건은 바꾸지 않았다.#1008 과의 관계
#1008 은
routes/_guards.py의check_existing_run_accessdocstring 과routes/builds.py를 고친다. 이 PR 은 같은 파일의check_active_run_access를 고치고routes/builds.py는 건드리지 않는다. 겹치는 것은 계약 버전 줄과 CHANGELOG 뿐이다 — 나중에 머지되는 쪽이 버전을 하나 올리면 된다.검증
tests/unit/test_interrupted_run_status.py: 소유자는failed+code를 받는다 / 소유자는run_failed이벤트를 본다 / 다른 사용자는 두 경로 모두 없는 run 과 같은 404 이고 본문에 사유가 없다 / 제출된 적 없는 run 은 404 / 제출 기록은 한 번만 쓰인다.1 failed, 4429 passed— 실패한 하나는 이 PR 의 새 테스트였다. 첫 번째 프로세스의 worker 를 테스트 도중에 풀어 줘서 그 worker 가 이벤트를 더 쓰는 경합이었고(실제 재시작에서는 그 프로세스가 죽는다), worker 를 테스트가 끝날 때까지 막아 두도록 고쳤다. 고친 뒤에는 이 파일과test_ephemeral_credentials.py를 3회 반복 실행해 모두 통과(19 passed×3)했고, 전체 스위트를 다시 돌리지는 않았다.check_contract_compat.py:1.77.0 -> 1.78.0호환. ruff, mypy 통과.🤖 Generated with Claude Code