step 자동 재시도(publish 제외) + terminal failed의 실패 step 수동 재개(텔레그램 [재시도]). orchestrator + retry 엔드포인트 + youtube_publisher 실패 알림. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
106 lines
7.7 KiB
Markdown
106 lines
7.7 KiB
Markdown
# music/YouTube 파이프라인 신뢰성·복구 — 설계
|
|
|
|
> 작성 2026-06-12. YouTube 자동화 파이프라인의 step 실패를 자동 재시도(일시적)하고, 영구 실패는 실패 step부터 수동 재개(텔레그램 [🔄재시도])할 수 있게 한다. "music/YouTube 파이프라인 고도화" 중 **신뢰성/복구** 슬라이스.
|
|
|
|
## 1. 목표
|
|
|
|
파이프라인 step(`cover→video→thumb→meta→review→publish`) 실패가 ① 일시적이면 자동 재시도로 흡수하고, ② 영구적이면 terminal `failed`로 둔 뒤 **이전 산출물을 보존한 채 실패 step부터 재개**할 수 있게 한다. 현재는 step 한 번 실패하면 전체 파이프라인이 terminal `failed`가 되고 복구 경로가 없어 처음부터 다시 만들어야 한다.
|
|
|
|
## 2. 배경 (현재 동작)
|
|
|
|
- `orchestrator.run_step(pipeline_id, step, feedback)`: `pipeline_jobs` row 생성 → step 실행 → 성공 시 `update_pipeline_state(next_state)`, 예외 시 `pipeline_jobs.status='failed'` + 파이프라인 `state='failed'` + `failed_reason="{step}: {e}"`. **재시도/재개 없음.**
|
|
- 항상 `bg.add_task(orchestrator.run_step, pid, step, ...)`로 BackgroundTask 호출(start_pipeline→cover, feedback→next_step, publish_pipeline→publish).
|
|
- 이전 step 산출물(`cover_url`/`video_url`/`thumbnail_url`/`metadata_json`/`review_json`)은 파이프라인 row에 **보존**됨 → 실패 step만 재실행하면 이어갈 수 있는 구조.
|
|
- `state_machine`: STEPS, `_APPROVE_NEXT`, TERMINAL_STATES={published, cancelled, **failed**, awaiting_manual}.
|
|
- `agent-office youtube_publisher.poll_state_changes`: `*_pending` 신규 진입만 텔레그램 알림. **`failed`는 무알림(silent)** — 사용자가 실패를 모름.
|
|
|
|
## 3. 요구사항 (확정)
|
|
|
|
- **자동 재시도**: step 실행 실패 시 `STEP_MAX_RETRIES`(기본 2 → 총 3회)까지 backoff 재시도. 소진 후 terminal `failed`.
|
|
- `_resolve_input` 에러(입력/설정)는 재시도 안 함(재시도해도 안 고쳐짐).
|
|
- **`publish` step은 자동 재시도 제외** — youtube 업로드는 비멱등(중복 업로드 위험). 1회 시도 후 실패면 즉시 terminal.
|
|
- 재시도 대상 = `cover/video/thumb/meta/review`.
|
|
- **수동 재개**: terminal `failed` 파이프라인을 실패 step부터 재실행. 이전 산출물 보존.
|
|
- publish 재개 가드: `youtube_video_id`가 이미 있으면 재개 거부(원 업로드 성공 가능성 → 중복 방지).
|
|
- **실패 알림**: 영구 실패 시 텔레그램 알림 + 인라인 `[🔄재시도]` 버튼(현재 silent 갭 해소).
|
|
- **범위 밖(YAGNI)**: stuck 감지(*_running hang / *_pending 방치). 수동 재시도로 복구 가능하므로 이번 슬라이스 제외.
|
|
|
|
## 4. 아키텍처
|
|
|
|
3 컴포넌트:
|
|
```
|
|
[music-lab orchestrator] run_step: step 실행을 재시도 루프로 (publish 제외) → 소진 시 failed
|
|
[music-lab API] POST /api/music/pipeline/{id}/retry → 실패 step부터 run_step 재트리거
|
|
[agent-office] youtube_publisher: failed 감지 → 텔레그램 알림+[🔄재시도]
|
|
webhook: ytpub_retry_{pid} → service_proxy.pipeline_retry → music-lab retry
|
|
```
|
|
|
|
## 5. music-lab 상세
|
|
|
|
### 5.1 자동 재시도 (`pipeline/orchestrator.py`)
|
|
- 상수: `STEP_MAX_RETRIES = 2`, `STEP_RETRY_BACKOFF_SEC = [5, 15]`(시도 간 대기), `NON_RETRY_STEPS = {"publish"}`.
|
|
- `run_step`의 step 실행부(현재 try lines 31-47)를 루프로:
|
|
```
|
|
attempts = 1 if step in NON_RETRY_STEPS else (STEP_MAX_RETRIES + 1)
|
|
for i in range(attempts):
|
|
try:
|
|
result = await _dispatch_step(step, p, ctx, feedback)
|
|
update_pipeline_job(job_id, status="succeeded")
|
|
update_pipeline_state(pipeline_id, result["next_state"], **fields)
|
|
return
|
|
except Exception as e:
|
|
last = e
|
|
if i < attempts - 1:
|
|
add_log/pipeline_job note "retry {i+1}"
|
|
await asyncio.sleep(STEP_RETRY_BACKOFF_SEC[min(i, len-1)])
|
|
# 소진
|
|
update_pipeline_job(job_id, status="failed", error=str(last))
|
|
update_pipeline_state(pipeline_id, "failed", failed_reason=f"{step}: {last}")
|
|
```
|
|
- `_resolve_input` 실패는 루프 진입 전 early-return(현행 유지, 재시도 X).
|
|
- 재시도 시도 가시화: `pipeline_jobs`에 attempt별 기록(또는 error 메시지에 "attempt n/N").
|
|
|
|
### 5.2 resume 엔드포인트 (`main.py`)
|
|
- `POST /api/music/pipeline/{id}/retry`:
|
|
- 파이프라인 조회 없으면 404.
|
|
- `state != "failed"` → 409 "재개 불가 (state=...)".
|
|
- 실패 step 판별: `db.get_last_failed_step(pipeline_id)` (pipeline_jobs에서 status='failed' 최신 step). 없으면 `failed_reason.split(":")[0].strip()` 폴백.
|
|
- 실패 step이 `publish`이고 `youtube_video_id`가 이미 있으면 → 409 "이미 업로드됨 (중복 방지)".
|
|
- `bg.add_task(orchestrator.run_step, pid, failed_step)` 재트리거. 반환 `{ok: true, retrying_step}`.
|
|
- `db.get_last_failed_step(pipeline_id) -> str | None` 헬퍼 신규.
|
|
|
|
## 6. agent-office 상세
|
|
|
|
### 6.1 실패 알림 (`agents/youtube_publisher.py`)
|
|
- `poll_state_changes`: `_STEP_TITLES`(*_pending) 처리 후, `state == "failed"` 인 파이프라인도 검사.
|
|
- 신규 failed(중복 방지: `self._notified_failed: set[int]`, 또는 기존 dict에 ('failed', reason_hash))면 텔레그램 발송:
|
|
`⚠️ [{track_title}] 파이프라인 #{id} '{step}' 실패\n사유: {failed_reason}` + 인라인 `[🔄 재시도]` (callback_data `ytpub_retry_{id}`).
|
|
- 발송 후 notified 기록.
|
|
- `service_proxy.list_active_pipelines()`가 failed를 포함하는지 확인 — 미포함이면 failed도 반환하도록 보강(또는 별도 조회). (plan에서 확인.)
|
|
|
|
### 6.2 재시도 콜백 (`telegram/webhook.py`)
|
|
- `_handle_callback`에 `callback_id.startswith("ytpub_retry_")` 분기 → `_handle_ytpub_retry`.
|
|
- `_handle_ytpub_retry`: `pid = int(callback_id.removeprefix("ytpub_retry_"))` → `service_proxy.pipeline_retry(pid)` → 결과 텔레그램 회신("재개: {step}" / 거부 사유).
|
|
- `service_proxy.pipeline_retry(pid)` 신규: `POST {MUSIC_LAB_URL}/api/music/pipeline/{pid}/retry`.
|
|
|
|
## 7. 에러 처리 / 엣지
|
|
|
|
- 재시도 backoff 중 컨테이너 재시작 → 해당 step 작업 유실, 파이프라인 비-terminal stuck. 범위 밖이나 수동 [🔄재시도]로 복구 가능(안전망).
|
|
- resume 시 state≠failed → 409(중복 재개·동시성 방지). 텔레그램 [🔄재시도] 중복 탭도 멱등 거부.
|
|
- pipeline_jobs에 failed row 없고 state만 failed → `failed_reason` prefix 폴백.
|
|
- publish 재개 + `youtube_video_id` 존재 → 409(중복 업로드 방지).
|
|
- 알림 중복: notified 기록으로 같은 failed 1회만 발송.
|
|
|
|
## 8. 테스트
|
|
|
|
- **orchestrator (재시도)**: step 2회 실패 후 성공 → next_state 도달(3시도). 끝까지 실패 → failed. publish는 1시도 후 즉시 failed(재시도 X). `_resolve_input` 실패 → 재시도 없이 failed.
|
|
- **API retry**: failed→run_step 재트리거(mock 확인) + retrying_step 반환. 비-failed→409. publish+youtube_video_id→409.
|
|
- **db**: `get_last_failed_step` — 최신 failed job step 반환, 없으면 None.
|
|
- **agent-office**: poll 신규 failed→텔레그램 발송(중복 방지). `_handle_ytpub_retry`→service_proxy.pipeline_retry 호출 + pid 파싱.
|
|
|
|
## 9. 영향받는 파일
|
|
|
|
- music-lab: `app/pipeline/orchestrator.py`(재시도 루프 + `_dispatch_step` 추출), `app/main.py`(retry 엔드포인트), `app/db.py`(`get_last_failed_step`), `tests/`.
|
|
- agent-office: `app/agents/youtube_publisher.py`(failed 알림), `app/telegram/webhook.py`(ytpub_retry 디스패치), `app/service_proxy.py`(`pipeline_retry`, 필요 시 `list_active_pipelines` failed 포함), `tests/`.
|
|
- web-backend/CLAUDE.md music API 표 + `service_music.md` 메모리 갱신.
|