# 스텝 락 근본 수리 — 설계안 v1

## 0. 실측한 사실

```
backend/app/core/step_runner.py
  _evaluate_running_state()   727-784   경과 시간으로 죽음을 추측
  _try_claim_running()        917-981   SQL WHERE 가 running 을 못 뺏는다
  run()                      1070-1088  FORCE_EXPLICIT → is_stale_steal=False
backend/app/core/config.py:190          step_running_timeout_seconds = 3600
backend/app/api/v1/steps.py             run / run-all / snapshots / toggle
                                        / redo-shot — 정지·취소 엔드포인트 없음
alembic head                            009_image_asset_disposition
```

DB 실측 — `status='running'` 인 행이 **14개**. 가장 오래된 것이 2026-03-25,
그밖에 03-26 · 03-27(2) · 03-29(2) · 04-18(2) · 05-05 · 06-02 · 06-25 ·
06-30 · 08-17 · 08-25(현재 주행). 즉 **다섯 달치 시체가 쌓여 있다.**
`step_run.started_at` / `completed_at` / `created_at` / `updated_at` 은 전부
`text` 칸이다 (timestamptz 아님).

## 1. 뿌리

락은 `step_run.status='running'` 한 칸이다. 이 칸은
**누가 잡았는지**도 **그가 살아있는지**도 적지 않는다. 그래서:

1. 프로세스가 강제 종료되면 `running` 이 영원히 남는다 (위 14행)
2. 살아있음을 물어볼 데가 없어 **경과 시간으로 추측**한다 (3600초)
3. `force` 로도 못 뺏는다 — `FORCE_EXPLICIT` 은 `allow_stale_steal=False` 라
   `WHERE step_run.status != 'running'` 에 걸려 `claim_ok=False` → 409
4. **정지 엔드포인트가 없다.** 주행을 멈추려면 프로세스를 죽이는 수밖에
   없고, 그게 정확히 1번을 만든다. → **순환**

08-26 새벽 손실: 01:15 에 백엔드를 죽였는데 `started_at` 이 00:56 이라
01:56 까지 40분을 기다렸다. resume 2회·force 1회 전부 거부.

## 2. 고치는 법 — 다섯 갈래

### A. 소유자 신원을 적는다 (칸 5개 추가, migration `010_step_lock_ownership`)

| 칸 | 형 | 값 |
|---|---|---|
| `owner_host` | text | `socket.gethostname()` |
| `owner_pid` | integer | `os.getpid()` |
| `owner_boot_id` | text | 프로세스 기동 시 1회 생성하는 uuid4 |
| `heartbeat_at` | text | 하트비트 시각 (ISO8601 UTC, 기존 시각 칸과 같은 형) |
| `cancel_requested_at` | text | 협조적 정지 요청 시각 |

전부 nullable — 기존 14행과 하위 호환. `owner_boot_id` 가 PID 재사용을
막는다 (PID 는 재사용되지만 uuid4 는 아니다).

### B. 죽음을 추측하지 않고 확인한다 (판정 순서)

`status='running'` 을 만나면 위에서부터 첫 일치에서 멈춘다:

| # | 조건 | 판정 | 근거 |
|---|---|---|---|
| 1 | `owner_boot_id` == 이 프로세스 **and** 프로세스 안 등록부에 그 락이 **없다** | **죽음** — 즉시 회수 | 내 프로세스 안의 일은 내가 안다 |
| 2 | `owner_boot_id` == 이 프로세스 **and** 등록부에 **있다** | **살아있음** — 차단 | 진짜 동시 실행 |
| 3 | `owner_host` == 이 호스트 **and** `owner_pid` 가 죽은 프로세스 | **죽음** — 즉시 회수 | `os.kill(pid, 0)` → ESRCH |
| 4 | `owner_host` == 이 호스트 **and** PID 는 살아있으나 boot_id 가 다름 | 하트비트로 판정 | PID 재사용 의심 |
| 5 | 다른 호스트 / 신원 미상 | `heartbeat_at` 이 lease(기본 90초) 넘게 안 뛰면 **죽음** | 표준 lease |
| 6 | 신원·하트비트 둘 다 없음 (기존 14행) | 지금의 3600초 경과 시간 | 하위 호환 |

3번의 `os.kill(pid, 0)` 은 같은 사용자 소유 프로세스에만 정확하다.
다른 사용자 소유면 `PermissionError` 가 오는데 이는 **살아있다는 뜻**이므로
살아있음으로 읽는다 (fail-closed).

### C. 하트비트를 뛴다

프로세스당 daemon thread 하나. 30초마다 이 프로세스가 잡고 있는 모든 락의
`heartbeat_at` 을 갱신한다. 등록부(in-process set)는 claim 성공 시 등록,
finalize/예외 시 해제.

- 갱신 SQL 은 **소유자 일치 조건부** — `WHERE owner_boot_id = :boot AND
  run_id = :run_id`. 이미 빼앗긴 락을 되살리지 않는다.
- 하트비트 실패(DB 접속 끊김 등)는 로그만 남기고 계속 — 하트비트가
  주행을 죽이면 안 된다.

### D. 정지·해제 경로를 만든다 (엔드포인트 4개)

1. **`POST /steps/{step_id}/cancel`** — 협조적 정지.
   `cancel_requested_at` 을 적기만 한다. 일하는 쪽은 **안전 지점**(샷 경계,
   provider 호출 사이)에서 이를 읽고 깨끗이 멈추며 `status='cancelled'` 로
   적는다. ★**돈을 쓰는 중간에 안 끊는다** — 이미 접수한 이미지 요청은
   결과까지 가져온 뒤 멈춘다.
2. **`POST /steps/{step_id}/release`** — 락 해제.
   기본은 **소유자가 죽은 것이 B 판정으로 확인될 때만** 허용.
   `force=true` 는 운영자가 「내가 그 프로세스를 이미 죽였다」고 선언하는
   경우 (다른 호스트라 확인이 불가능할 때). `running` → `failed` 로 적고
   `last_recovery_reason` 에 사유를 남긴다. **행을 지우지 않는다.**
3. **`POST /steps/cancel-all`** — 에피소드 단위. run-all 주행 전체 정지.
4. **`GET /steps/locks`** — 진단. 지금 누가 무엇을 잡고 있고 B 판정 결과가
   무엇인지 (죽음/살아있음/미상)를 그대로 보여준다.

### E. 기동 시 자기 락 회수 (startup reclaim)

백엔드가 뜰 때 `owner_host` 가 이 호스트인 `running` 행 중
`owner_pid` 가 죽었거나 `owner_boot_id` 가 이전 것인 행을 전부 `failed` 로
적는다 (`last_recovery_reason='startup reclaim: owner process gone'`).

★**이것 하나가 40분 손실을 통째로 없앤다.** 새벽 사고는 「서버를 죽였는데
그 서버가 잡은 락이 남은」 것이었다. 다시 띄우는 순간 회수된다.

★기존 14행은 신원 칸이 NULL 이라 이 경로에 안 걸린다 — 의도한 대로다.
`/steps/locks` 로 보고 `release` 로 하나씩 처리한다 (자동 대량 정리 금지,
[[feedback-destructive-ops-past-data-loss]]).

## 3. 안 하는 것

- 행을 **지우지 않는다.** `running` → `failed` 는 **죽은 것을 죽었다고
  적는 것**이지 삭제가 아니다.
- 진행 중인 provider 호출을 중간에 안 끊는다. 이미 산 것은 가져온다.
- 기존 3600초 경로를 **없애지 않는다.** 신원·하트비트가 없는 행의
  마지막 그물로 남긴다.
- `force` 가 살아있는 락을 뺏게 만들지 **않는다.** 그건 지금 막고 있는
  진짜 동시 실행 사고를 되살린다. 대신 `release` 라는 **의도가 분명한**
  경로를 따로 준다.

## 4. 테스트 (deterministic 영역만)

| 대상 | 확인 |
|---|---|
| 판정 6갈래 | 각 조건에서 죽음/살아있음이 표대로 갈리는가 |
| PID 재사용 | 같은 host+pid 인데 boot_id 다르면 하트비트로 떨어지는가 |
| 조건부 하트비트 | 빼앗긴 락에 하트비트가 안 찍히는가 |
| steal 원자성 | 두 워커가 동시에 죽은 락을 노리면 하나만 이기는가 |
| startup reclaim | 이 호스트·죽은 PID 만 회수하고 다른 호스트는 안 건드리는가 |
| cancel | `cancel_requested_at` 이 찍히고 안전 지점에서 `cancelled` 로 끝나는가 |
| 하위 호환 | 신원 칸 NULL 인 행이 3600초 경로로 가는가 |

## 5. 묻고 싶은 것 (Codex)

1. **B 판정 1번**(내 프로세스 + 등록부에 없음 → 즉시 죽음)이 위험한
   경우가 있나? 등록부 등록과 DB claim 사이의 창(claim 성공 직후,
   등록 직전)에 다른 스레드가 이 판정을 하면 살아있는 락을 죽었다고
   읽는다. → **등록을 claim 보다 먼저** 하고 claim 실패 시 해제하는
   순서로 막으려는데 이게 맞나?
2. `os.kill(pid, 0)` 을 백엔드가 아닌 **다른 프로세스**(테스트 러너 등)가
   같은 PID 로 떠 있을 때 살아있다고 읽는 문제 — boot_id 로만 막히는데
   4번 갈래(하트비트 fallback)로 충분한가?
3. `cancel` 의 안전 지점을 **샷 경계**로 잡았다. `scene_image_pipeline`
   외의 팬아웃 스텝(scene_detail 등)에도 같은 지점이 있나, 아니면
   스텝별로 따로 심어야 하나?
4. 하트비트 lease 90초 / 간격 30초가 적절한가. 이미지 한 장이 90초 넘게
   걸리는데(reve 실측 82.8s·94.6s) 하트비트는 별도 스레드라 무관하다고
   봤다 — GIL 때문에 막힐 여지가 있나?
5. `step_run` 의 시각 칸이 전부 `text` 다. 새 칸도 `text` 로 맞추는 게
   맞나, 아니면 `heartbeat_at` 만 timestamptz 로 가서 SQL 에서 직접
   비교 가능하게 하는 게 나은가? (섞이는 게 더 나쁠 것 같아 text 로
   기울어 있다)
