Skip to content

fix(builds): let the owner read a run a restart interrupted - #1022

Merged
Eomdahyeon merged 3 commits into
mainfrom
fix/issue-996-interrupted-run-status
Oct 4, 2026
Merged

Eomdahyeon merged 3 commits into
mainfrom
fix/issue-996-interrupted-run-status

Conversation

@Eomdahyeon

Copy link
Copy Markdown
Collaborator

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)을 받는다.
  • 계약 1.77.0 → 1.78.0 (additive): BuildJob.code(optional, credentials_required), BuildJob 설명. CHANGELOG [Unreleased] Fixed.

한계

  • 이 버전 이전에 제출된 run 은 제출 기록이 없어서 재시작 뒤 여전히 404 다. 기존 run 을 소급해 채울 근거(소유자)가 디스크에 없다.
  • mark_interrupted_runs 가 실패 이벤트를 남기는 조건은 바꾸지 않았다.

#1008 과의 관계

#1008 은 routes/_guards.py 의 check_existing_run_access docstring 과 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회: 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

Closes #996

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 를 읽었습니다. 방향과 구현 모두 타당합니다: 제출자를 이벤트 저장소에 남기고, 상태 조회를 레지스트리 → 매니페스트 → 종료 이벤트 순으로 내리고, 정상 종료는 매니페스트에만 맡긴 것. 다른 사용자가 없는 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>
@Eomdahyeon

Copy link
Copy Markdown
Collaborator Author

CI 실패 원인을 찾아 고쳤습니다 (7df0ed4).

tests/unit/test_json_array_reader.py::test_many_small_elements_still_stream 가 assert 1214043 < 1048576 로 실패했는데, 이 PR 의 restarted fixture 탓이었습니다. teardown 이 gate 를 풀고 shutdown(wait=False) 만 불러서, 풀려난 첫 서비스의 워커가 다음 테스트들 뒤에서 빌드를 계속 돌렸고, tracemalloc 은 모든 스레드의 할당을 세므로 그 테스트의 peak 에 섞였습니다. 이제 teardown 이 워커 종료를 기다립니다.

확인: 고치기 전 fixture 로 두 파일을 함께 돌리면 3회 모두 1 failed, 22 passed, 고친 뒤에는 3회 모두 23 passed.

check_existing_run_access 가 제출 기록을 보지 않는다는 지적은 맞습니다. 말씀대로 #1008 머지 뒤 후속으로 하겠습니다.

@Eomdahyeon
Eomdahyeon merged commit c8f5b3a into main Oct 4, 2026
22 checks passed
Eomdahyeon added a commit that referenced this pull request Oct 4, 2026
…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>
Eomdahyeon added a commit that referenced this pull request Oct 4, 2026
…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>
yeongseon pushed a commit that referenced this pull request Oct 5, 2026
)

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

fix(builds): a run interrupted by a restart answers 404 and its failure event cannot be read

2 participants