From 38da3e55fedd33afc0413efac5d5d18dd7c83c2c Mon Sep 17 00:00:00 2001 From: thxforall <113906780+thxforall@users.noreply.github.com> Date: Sat, 11 Jul 2026 22:53:10 +0900 Subject: [PATCH] =?UTF-8?q?docs(briefs):=20#928=20ARQ=20=ED=81=90=20?= =?UTF-8?q?=EC=9E=AC=EC=82=AC=EC=9A=A9=20=ED=83=80=EB=8B=B9=EC=84=B1=20?= =?UTF-8?q?=EB=A6=AC=EC=84=9C=EC=B9=98=20=EB=B8=8C=EB=A6=AC=ED=94=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ai-server ARQ를 VTON/StyleDNA 백그라운드 잡 백엔드로 재사용 가능한지 5-agent Workflow(read×3 → synth → adversarial verify)로 조사. 핵심 5개 주장 전부 코드 대조 검증(8/8, 반증 0) + origin/dev 재확인. Verdict=hybrid-recommended: 소유권 비대칭(VTON=서버 owner 없음 / StyleDNA=api-server(Rust)→ai-server gRPC seam) 때문에 두 기능 모두 네이티브 잡이 저렴. 하드 게이트 2개(max_jobs=1 · progress terminal-only). 태스크 전제 "5분 timeout" 오류 정정 → 실제 job_timeout=600(10분). #929 아키텍처 결정 입력. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/superpowers/briefs/928-arq-reuse.md | 189 +++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 docs/superpowers/briefs/928-arq-reuse.md diff --git a/docs/superpowers/briefs/928-arq-reuse.md b/docs/superpowers/briefs/928-arq-reuse.md new file mode 100644 index 000000000..71bc37d81 --- /dev/null +++ b/docs/superpowers/briefs/928-arq-reuse.md @@ -0,0 +1,189 @@ +# Brief — #928 ARQ 큐 재사용 타당성 (tryon/StyleDNA 잡 백엔드) + +**브랜치**: `feature/928-arq` +**워크트리**: `.claude/worktrees/928-arq` (base `origin/dev` 0ef6bd18) +**작성일**: 2026-07-11 +**이슈**: [#928](https://github.com/decodedcorp/decoded/issues/928) (research) · parent [#919](https://github.com/decodedcorp/decoded/issues/919) 백그라운드 잡 UX wayfinder 맵 +**Verdict**: `hybrid-recommended` — ARQ 재사용은 기술적으로 가능하나 소유권 비대칭 때문에 두 기능 모두 네이티브(api-server) 잡이 저렴. +**다음 단계**: [#929](https://github.com/decodedcorp/decoded/issues/929) 아키텍처 결정(공유 ARQ vs per-feature 네이티브 잡 · 잡 소유 · transport · progress 영속성)의 입력 자료. +**방법**: Workflow 5-agent — read(ARQ 인프라+기존 소비자 ‖ VTON 경로 ‖ StyleDNA 경로) → synthesize → adversarial verify. 아래 모든 load-bearing 주장은 코드 대조로 검증됨(8/8 성립, 반증 0). + +--- + +> **검증 상태(2026-07-11, adversarial):** 아래 본문의 load-bearing 주장 5개 + 보조 주장 모두 실제 코드 대조로 성립 확인. 정정 3건만 in-body 반영(VTON 도메인 read-only 카탈로그 추가, progress 주장 정밀화, arq 핀 갭 CLOSE). 상세는 문서 말미 `## Verification (adversarial)`. +> +> **origin/dev 재검증(2026-07-11):** 리더는 read-base `docs/821`에서 인용을 생성했으나 이 브리프는 `feature/928-arq`(origin/dev) 위에 있어 4개 파일이 드리프트(`metadata_servicer.py`·`ai.proto`·`handlers.rs`·`vton/mod.rs`). **핵심 주장 전부 origin/dev 코드로 재확인 성립** — VTON 여전히 owner 없음(`ai-server`에 vertex/vton 생성 0매치, `vton/mod.rs`=`list_items/list_posts/consent/save_try`만·생성함수 0), `build_my_style_dna` seam `handlers.rs:132` 유지, `worker.py` `max_jobs=1`/`job_timeout=600` 동일. **라인 앵커**: `worker.py`·`queue_manager.py`·`route.ts`·`handlers.rs`·`ai.proto` 정확, `metadata_servicer.py`의 enqueue 구역(`:227-245`)도 정확; 단 `ExtractStyleDna` 구역만 origin/dev에서 ~2줄 앞당겨짐(`def :845`, 본문은 `:847` 표기). + +## Verdict (TL;DR) + +**재사용 가능 여부는 "잡이 아니라 소유권"이 결정한다.** ARQ 인프라 자체(enqueue backbone, worker registry, gRPC servicer→job_id, idempotent `_job_id`, startup wiring)는 완전히 generic해서 새 job type을 붙이는 데 코드 3줄 수준의 변경으로 충분하다 — 하지만 이건 "잡 함수가 이미 Python ai-server에 있을 때" 얘기다. **StyleDNA**는 이미 api-server(Rust)→ai-server(gRPC)→Gemini seam이 서버사이드에 존재하므로 "async 잡 레코드 추가"가 목표라면 저렴하다 — 다만 그 저렴한 길은 **ARQ를 재사용하지 않는다**(api-server가 Rust라 ARQ Python/Redis에 직접 enqueue는 비현실적 — ARQ의 job 직렬화 wire format을 Rust에서 재구현해야 해 brittle; api-server-owned Postgres 잡 + `tokio::spawn`이 정답). **VTON**은 정반대로 서버사이드 owner가 아예 없다(Next.js route가 Vertex를 직접 동기 호출, ai-server·api-server 어디에도 Vertex 능력 ZERO). VTON에서 "ARQ 재사용"은 얇은 enqueue가 아니라 **Python VTON 잡 함수 + Vertex 포팅 + GCP auth + enqueue/status/result 표면 신설** = 그린필드 owner 구축이다. 공통 하드 갭 두 가지: (1) `max_jobs=1` 글로벌 단일 concurrency — user-facing VTON이 10분짜리 editorial 잡 뒤에 직렬 대기, (2) ARQ는 **intermediate progress granularity가 없고 이 코드베이스 wrapper는 terminal-only** → progress bar는 무조건 별도 DB 잡-레코드 테이블을 직접 만들어야 한다. (참고: 태스크 전제의 "5분 timeout"은 **틀렸다** — 실제 `job_timeout=600`=10분.) + +--- + +## ARQ 인프라 실태 + +| 축 | 실제 값 (file:line) | 비고 | +| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | +| **Concurrency** | `max_jobs = 1` (`worker.py:73`) | **글로벌 단일 슬롯**, 워커 프로세스 1개, 모든 job type 공유·직렬 | +| **왜 1인가** | `worker.py:72` 주석 | post_editorial이 Gemini/Perplexity 과다 호출 → 동시 실행 시 API rate limit | +| **Timeout** | `job_timeout = 600` (`worker.py:74`) | **10분**(5분 아님). 워커 전역 기본값, 모든 잡에 동일 적용(`worker.py:157`) | +| **Result TTL** | `keep_result = 3600` (`worker.py:75`) | Redis 결과 1시간 후 소멸 | +| **Enqueue** | `enqueue_job(job_name, *args, **kwargs)->Optional[str]` (`queue_manager.py:64`, delegate `:89`, `job_id :90`) | job_id 문자열 반환. reusable primitive | +| **job_id→caller** | servicer가 enqueue 후 gRPC 응답 `batch_id`에 실어 반환 (`metadata_servicer.py:227-245`, 5개 RPC 동일) | | +| **Idempotency** | `_job_id=f'publish_{job_id}'` (`metadata_servicer.py:403`), `_job_id=f'editorial_article_{article_id}'` (`editorial_article/scheduler.py:109`) | 같은 id in-flight면 dedupe. VTON/StyleDNA 공짜 idempotency key | +| **Status/Progress** | **없음.** `get_job_status`→`get_job_result` (`queue_manager.py:102-131`)는 완료 전 `None`, 완료 후에만 dict. 중간 percent 없음 | **게다가 dead code** — src 어디서도 호출 안 함. `GetJobStatus` gRPC RPC 부재 | +| **Retries** | per-fn `max_tries`: post_editorial/editorial_article/regen_thumbnail/regen_ig_carousel/publish 모두 `=1`(무재시도, `worker.py:32-56` func 블록); analyze_link만 미설정→ARQ 기본 5회(`worker.py:31`) | opt-in per job type | +| **Crash recovery** | `orphan_recovery.py:59` `recover()`가 worker 시작 **전** 실행(`main.py:236` → `:241` create_worker → `:254` async_run). `arq:in-progress:*` 좀비 락 스캔(`:84`) | SIGKILL/OOM 시 락 고아화 → `max_jobs=1` 때문에 락 TTL 동안 **큐 전체 블록** | + +**핵심 함정 두 가지.** (1) ARQ result store는 실질적으로 죽어 있다 — `get_job_status`/`get_job_result` 정의만 있고 호출처 0, 게다가 1시간 후 소멸(`worker.py:75`). 여기에 progress UX를 못 얹는다. (2) 크래시 복구는 `post_editorial_job`만 DB 화해한다(`orphan_recovery.py:43,91`). 다른 모든 함수는 else 브랜치(`:104-105`)에서 **status 정리 없이 조용히 폐기** — 새 VTON/StyleDNA 잡을 ARQ에 얹으면 크래시 후 DB row가 `in-progress`에 고착될 위험(현행 코드 사실이 아니라 신규 잡에 대한 설계 함의). + +--- + +## 기존 소비자 패턴 (editorial-publish / raw_posts / metadata) + +**새 job type이 꽂히는 minimal path (변경 3곳, ARQ backbone 무수정):** + +1. **pickle-able async 잡 함수 작성** — module-level 또는 `@staticmethod` `job(ctx, *args)->dict` (`worker.py` 주석). `ctx`는 `worker.py:130-150`에서 주입되며 제공: `metadata_extract_service`, `failed_items_manager`, `result_batch_service`, `environment`, `assets_database_manager`, `operation_database_manager`, `telegram_notifier`, `nano_banana_client`, `openai_image_client`, `r2_client`. 새 의존성(VTON 모델 client, StyleDNA embedder)은 `create_worker`의 ctx dict(`worker.py:137-150`)에 직접 추가해야 함. +2. **registry 등록** — `worker.py` `_get_functions()`(`worker.py:30-58`)에 `func(fn, name='vton_job', max_tries=1)`. +3. **enqueue** — caller/servicer에서 `queue_manager.enqueue_job('vton_job', ...args, _job_id=...)`. + +**복붙 템플릿 = `publish_ig_carousel_job`** (`publish_service.py:138-156`): 잡 wrapper 함수 + resume-safe checkpoint 서비스가 DB 잡-레코드 테이블에 named stage를 기록. + +**진짜 UX는 ARQ가 아니라 per-job-type DB 테이블에 산다.** `editorial_publish_jobs`(`repository.py` set_children/set_parent/set_published/set_failed)의 `status TEXT` 컬럼이 named stage를 밟는다: `children_created→parent_created→published`, 또는 `failed_terminal`/`failed_retryable`. **이건 coarse STEP 이름이지 numeric percent가 아니다.** 잡 type마다 자기 테이블을 소유하며 **generic 잡 테이블은 없다**. (post_editorial은 `post_magazines.status`(generating/published/failed)를 레코드로 씀 — `orphan_recovery.py:209-216`.) + +**완료 통지 = 2패턴, generic callback 없음.** (a) **DB write-back + caller polls**: `publish_service.py:135 set_published`/`set_failed`가 terminal status를 쓰고 admin UI가 폴링. (b) **backend push**: `analyze_link_job`이 `result_batch_service`로 결과를 백엔드에 push. ARQ return-dict(`publish_service.py:156`)는 Redis에 쓰이지만 아무도 안 읽음. + +--- + +## 소유권 비대칭 (핵심) + +이 비대칭이 비용을 전적으로 결정한다. + +| 축 | **VTON** | **StyleDNA** | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| server-side owner | **없음** — client→Next.js route→Vertex 직결 (`useVtonTryOn.ts`→`route.ts:84`→`callVtonApi route.ts:15-43`→Vertex `:predict`) | **있음** — api-server(Rust)가 seam 소유 (`handlers.rs:132` build_my_style_dna) | +| 생성 실행 위치 | TypeScript, Next.js node runtime (`route.ts:4`) | Rust→gRPC→Python Gemini (`handlers.rs:153-157`→`metadata_servicer.py:847`→`style_dna_synthesizer.py`) | +| DB 소유 | 없음 (api-server vton 도메인은 save_try·photo-consent·**read-only 카탈로그**(list_items/list_posts)만, **생성 안 함** — `domains/vton/mod.rs:251/203/68/118`) | api-server가 cody_analysis read(`handlers.rs:140`) + users.style_dna persist(`:207`). ai-server는 의도적으로 DB-less pure compute (`ai.proto:59-60`) | +| ai-server 관여 | **ZERO** — Vertex/GCP 이미지 능력 전무 (grep `aiplatform\|vertex\|imagen\|:predict` in ai-server src = exit 1 no match; api-server src도 동일 no match) | 있음 — gRPC pure compute owner | +| GCP 자격증명 | web/Node runtime에만 존재 (`route.ts:2` `@/lib/gcp/auth`, `getAccessToken`); ai-server/api-server 부재 | 무관 (Gemini는 ai-server 기존 경로) | +| 동기성/잡레코드 | 동기 blocking, job id 없음 (`route.ts:178-183` 이미지 inline 반환) | 동기 blocking, job 레코드 없음 (`handlers.rs:153-157` `.await`, servicer inline `metadata_servicer.py:882-884`) | +| "ARQ 재사용" 성격 | **그린필드 owner 구축** (잡 함수 자체가 없음) | **선택지 분기** — api-server tokio 잡(ARQ 미사용) vs ai-server ARQ(seam 파괴) | +| 예상 비용 | **HIGH** (~1주+) | **INCREMENTAL** api-server-side ~2-3일 / **HEAVY** ARQ 경로 | + +**왜 이게 비용을 결정하나:** ARQ는 ai-server(Python) 안에 산다. StyleDNA seam은 이미 서버사이드에 있어 "잡 레코드"만 얹으면 되지만 그 owner가 Rust라 ARQ에 못 넣는다(→ api-server Postgres 잡이 정답, ARQ 미재사용). VTON은 owner가 프론트라 무엇을 하든 먼저 서버사이드 owner를 세워야 하고, 그걸 ai-server에 세우려면 Vertex를 Python으로 포팅해야 한다. + +--- + +## 재사용 비용 — VTON + +**HIGH / greenfield-owner.** "얇은 enqueue"가 아님. ai-server에 ARQ 잡으로 올리려면: + +1. **Python VTON 잡 함수 신규 작성** — `route.ts:15-75`의 predict body(personImage/productImages, `sampleCount`/`baseSteps=16`/`personGeneration`/`safetySetting`/`addWatermark`), multi-item chaining loop(`route.ts:146-167`), `fetchImageAsBase64`(`route.ts:77-82`), RAI/empty-prediction 에러 처리(`route.ts:53-68`)를 전부 Python으로 이식 (~150줄 로직). +2. **Python GCP auth 신설** — `@/lib/gcp/auth`의 google-auth 등가물 + `GCP_SERVICE_ACCOUNT_KEY`/`GCP_PROJECT_ID`를 ai-server env에 provision (현재 web/Vercel에만). +3. **worker.py 등록** + `max_jobs=1` 직렬화 해결 — VTON은 latency-sensitive user-interactive인데 10분짜리 editorial 잡 뒤에 줄 섬. 별도 queue/worker 필요 가능성 높음(하지만 `queue_name`은 `worker.py:81`의 hardcoded 상수). +4. **enqueue HTTP/gRPC 엔드포인트 신설** — 현재 in-process enqueue만 존재, web proxy가 부를 표면 없음. +5. **status+result 엔드포인트 신설** — 없음. 큰 base64 이미지는 Redis(`keep_result` 1h) 대신 `operation_storage.upload`(이미 `domains/vton/mod.rs:293` save_try가 사용)로 올리고 URL 반환. +6. **web client 재작성** — `useVtonTryOn.handleTryOn`을 sync-fetch→enqueue+poll로, `vtonStore`의 단일 in-memory 슬롯을 job-id-keyed·server-backed로 (refresh 손실 + 2nd-job overwrite 두 gap 동시 해소). + +> **더 가벼운 대안 (ARQ 미재사용):** api-server(Rust) async 엔드포인트가 Vertex 호출 + 잡 테이블을 소유. 기존 `operation_storage` + `user_tryon_history`(`domains/vton/mod.rs:308`) 재사용, **Python Vertex 포팅과 단일 워커 경합을 둘 다 회피**. 단 ARQ는 안 씀. 지연 자체는 블로커 아님 — 현재 멀티아이템 체인 전체가 이미 `maxDuration=60`(`route.ts:5`) 안에 완료되므로 실제 600s timeout에 넉넉히 맞음. + +--- + +## 재사용 비용 — StyleDNA + +**INCREMENTAL (api-server-side, 권장) — 단 이 저렴한 길은 ARQ를 재사용하지 않는다.** api-server가 이미 seam 전체(DB read + gRPC + persist)를 한 Rust 핸들러에 소유하므로: + +1. `style_dna_jobs` 테이블 신설 (id, user_id, status, result_json/error, timestamps) + SeaORM entity + Supabase migration. +2. `build_my_style_dna`(`handlers.rs:132`) 리팩토링 — 잡 row INSERT 후 즉시 `202 {job_id}` 반환, 이어서 오늘의 동기 body(`fetch_cody_analyses`→`extract_style_dna` gRPC→`merge_style_dna` save)를 `tokio::spawn`(owned state/pool clone), 완료 시 status=done/failed 기록. **패턴 선례 있음**: `style_dna_recompute_queue` Postgres 테이블(`onboarding/service.rs:131`). +3. `GET /users/me/style-dna/jobs/{id}` status 엔드포인트 + DTO + openapi 등록. +4. Orval regen. +5. web — fire-and-forget mutation을 enqueue→poll로 교체, job_id를 localStorage/URL에 persist해 복귀 사용자 reattach. 가짜 `setInterval` 게이지(`StyleDnaAnalyzing.tsx`, UX theater)를 coarse 실제 상태(pending/running/done)로 대체. (현재 nav-away 시 `reset()`(`StyleDnaFlow.tsx`, `styleDnaBuilderStore.ts`)이 client 상태 폐기하지만 서버 POST는 완료·persist될 수 있어 client가 reattach 못 하는 orphaned-completion 존재.) + +**ARQ 재사용 경로는 HEAVIER이며 아키텍처를 깬다.** api-server(Rust)는 ARQ(Python/Redis)에 enqueue 불가 → (a) enqueue RPC 신설(`EnqueueStyleDna`), (b) status/poll RPC 신설(api-server가 Redis 결과 직접 못 읽음), (c) **seam 파괴** — DB read/persist를 ai-server로 옮기거나(ai-server가 `users.style_dna` write 권한 획득) 완료 callback RPC를 api-server로 되돌림. 셋 다 새 표면. + +> **범위 밖:** `recompute_user_dna`(`onboarding/service.rs:29`)는 like/save 시 mood 벡터 로컬 산술만 하고 Gemini/gRPC/ai-server를 안 탐 — 이미 DB-queue(`style_dna_recompute_queue`, `:131`)+Rust-drainer로 async지 ARQ 아님. user-facing synthesis가 아닌 내부 feed-personalization loop. + +--- + +## 하드 게이트 2개 + +### (a) job_timeout — VTON/StyleDNA 생성이 들어오나? + +**태스크 전제 "5분"은 틀렸다.** 실제 `job_timeout = 600`s = **10분**(`worker.py:74`). ARQ_MIGRATION.md의 "타임아웃 5분"은 **stale/wrong** — 코드를 믿을 것. + +- **VTON**: maxDuration=60(`route.ts:5`)이 현재 전체 멀티아이템 요청을 캡핑 → 체인 전체가 이미 60s 안에 완료(그렇지 않으면 Vercel이 죽임). 실제 600s에 **여유롭게 fit**. 지연은 블로커 아님. +- **StyleDNA**: 단일 text-only gemini-2.5-flash 호출, gRPC 60s deadline(`client.rs:469`, 주석 `client.rs:455`). 600s에 **넉넉히 fit**. + +주의: `job_timeout`은 모든 잡에 동일한 단일 전역 기본값(`worker.py:157`). VTON 생성이 만약 초과하면(ARQ 포팅 시엔 maxDuration cap이 사라짐) `func(...,timeout=)`(현재 코드베이스 미사용) 또는 enqueue별 `_job_timeout=` override 필요 — **전역 기본값을 올리면 `max_jobs=1` 하에서 크래시-락이 큐를 막는 창이 넓어짐**. + +### (b) progress 노출 — ARQ는 terminal-only인가? + +**intermediate progress granularity는 없다.** ARQ 라이브러리 자체는 `Job.status()`로 lifecycle(queued/in_progress/complete)은 노출하나 **중간 percent/step 진행률은 제공하지 않으며**, 이 코드베이스 wrapper(`queue_manager.py:102-131`)는 그마저도 `get_job_result` 기반이라 **완료 전 `None`, 완료 후에만 dict** = 실질 terminal-only. progress bar를 ARQ result store로 구동 **불가**. 게다가 그 API는 dead code + 1h 후 소멸. + +**필요한 것:** + +- **coarse named stage로 충분하면** → `editorial_publish_jobs` 패턴(`repository.py` set_children/set_parent/set_published)의 DB 잡-레코드 테이블을 복사, 잡 내부에서 status transition을 씀. 잡 type마다 **자기 테이블 소유**(generic 테이블 없음). +- **numeric % progress가 필요하면** → 코드베이스에 **선례가 없다**. `progress_pct`/`step_index` 컬럼을 직접 추가하고 잡 함수가 갱신해야 함. ARQ도 named-stage 패턴도 이걸 주지 않음. +- 프론트는 api-server가 그 status 테이블을 읽는 poll 엔드포인트에 연결. (StyleDNA는 이 테이블 자체가 api-server-owned 잡 모델의 본체 → ARQ 없이 자연스러움. VTON은 이 테이블+엔드포인트를 처음부터 세워야 함.) + +--- + +## 트레이드오프: 신규 잡 인프라 vs ARQ 재사용 + +| 기준 | **ai-server ARQ 재사용** | **신규/네이티브 잡 인프라** | +| --------------- | ------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------- | +| VTON 적합도 | 나쁨 — Python Vertex 포팅 + GCP auth + owner 신설(그린필드) | **좋음** — api-server(Rust) async 잡 + Vertex 유지, `operation_storage`/`user_tryon_history` 재사용 | +| StyleDNA 적합도 | 나쁨 — Rust→ARQ enqueue 불가, seam 파괴(enqueue+status RPC, DB 소유 이전) | **좋음** — api-server-owned Postgres 잡 + `tokio::spawn`, ai-server 무변경 | +| Concurrency | `max_jobs=1` 글로벌 직렬, editorial 10분 잡과 슬롯 공유 → user-facing 잡엔 부적합 (별도 queue = `queue_name` 파라미터화 필요, 1-2일) | 독립 — Vercel/Rust async가 자체 처리 | +| Progress UX | intermediate 없음, DB 테이블 별도 구축 필수 | 어차피 DB 잡 테이블이 본체라 progress 자연 노출 | +| Crash recovery | post_editorial만 화해, 새 type은 `orphan_recovery.py` 분기 확장 필요 | api-server 잡 테이블 status로 자체 관리 | +| 재사용 이득 | enqueue backbone/idempotency는 generic (그러나 위 문제들이 압도) | ARQ 이점(durability/retry) 미확보 — 직접 설계 | +| 순 비용 | VTON ~1주+, StyleDNA HEAVY+아키텍처 파괴 | VTON ~2-3일(serial DB-poll)~1주(격리 concurrent), StyleDNA ~2-3일 | + +**권장:** 두 기능 모두 **소유권이 이미 있는 곳에 잡을 세우는 것**이 저렴하고 아키텍처 정합적이다. + +- **StyleDNA** → api-server-owned Postgres 잡 + `tokio::spawn` (ARQ 미사용). seam이 이미 Rust에 있음. +- **VTON** → api-server(Rust) async 잡 owner 신설 (ARQ 미사용). Python Vertex 포팅과 단일 워커 경합을 둘 다 회피. +- **ARQ 재사용이 정당한 유일 케이스** = "durability/retry/worker isolation을 반드시 ai-server ARQ로 통일해야 한다"는 명시적 제약이 있을 때. 그 경우 VTON은 별도 queue/worker로 격리하고 crash recovery 분기를 확장해야 하며, StyleDNA는 seam 재설계를 감수해야 한다. + +--- + +## #929로 넘길 열린 결정 + +이 메모가 내릴 수 없는 아키텍처 콜: + +- **재사용 대상의 본질**: "ai-server ARQ 특정 재사용"이 하드 요구인가, 아니면 "durable server-side 백그라운드 잡 + nav 생존 잡 레코드"가 목표인가? 후자면 두 기능 모두 ARQ 미재사용 경로가 strictly 저렴. +- **VTON concurrency**: `max_jobs=1` 공유 워커의 직렬 실행이 user-facing VTON latency에 허용되나? 아니면 별도 ARQ queue/worker(=`queue_name`/`max_jobs` `WorkerSettings` 상수 파라미터화 + `main.py` asyncio.gather에 2번째 워커 task, `worker.py:73,81`) 필요? 이게 ARQ 경로의 최대 lift. +- **Progress granularity**: numeric %가 제품 요구인가, coarse named stage(editorial `children_created→published` 식)로 충분한가? % needs 선례 없는 새 컬럼. +- **Caller/완료 통지 형태**: api-server HTTP poll(editorial_publish 패턴) vs gRPC status RPC(부재) vs backend push(analyze_link 패턴)? 어느 템플릿을 복사할지 결정. +- **VTON 결과 반환**: ARQ Redis(1h) vs `operation_storage`/GCS 업로드 후 URL(선호, save_try가 이미 사용)? status/result 엔드포인트 shape을 좌우. +- **GCP 자격증명 위치**: ARQ 경로면 `GCP_SERVICE_ACCOUNT_KEY`/`GCP_PROJECT_ID`를 ai-server에 provision해야 함 (현재 web/Vercel 전용) — secret 관리·blast radius 영향. +- **Retry 시맨틱**: 실패 VTON/StyleDNA 잡을 auto-retry(`max_tries>1`, 단 idempotent 필수)할지 fail-and-user-retry(`max_tries=1`, editorial 잡 전부의 패턴)할지. +- **StyleDNA orphaned-completion**: nav-away 시 서버 POST가 완료·persist되는 현 동작이 허용인가, 아니면 idempotency/dedup(recompute_queue upsert 패턴 재사용)로 재synthesize 방지할지. 동시 build in-flight lock 부재도 함께. +- **VTON asymmetry baseline 확정**: VTON 잡 레코드를 결국 ai-server ARQ에 둘지 api-server에 둘지 — 이게 "VTON과의 일관성" 논거가 StyleDNA를 무거운 ARQ 경로로 끌지 여부를 고정. + +### 검증 갭 (명시) + +- **ARQ_MIGRATION.md는 신뢰 금지** — `max_jobs 10 / 5분` 주장이 코드(`worker.py:73/74`: `1 / 600s`)와 불일치. 코드가 정본. +- **arq 버전 핀** `>=0.26.0,<0.27.0`은 **`pyproject.toml:38`에서 재확인 완료(갭 CLOSE)**. abort/progress 등 신규 API 의존 전 이 핀 범위 확인 필요. +- **실측 latency 부재** — VTON/StyleDNA 모두 코드에 로깅만 있고(`route.ts:169` latencyMs, `client.rs:469` 60s deadline) prod p50/p95 수치 없음. timeout headroom·poll interval sizing에 필요. (VTON의 600s fit는 maxDuration=60 cap으로 논증했으나 prod 실측은 여전히 부재 — 이 헤지 유지.) +- 세 조사 JSON 모두 non-null — 누락 조사 없음. + +--- + +## Verification (adversarial) + +각 항목은 실제 코드 대조로 검증(main tree, 메모의 line 번호는 main과 정확히 일치). `holds` = 코드가 확인. + +| # | Claim | Holds | Evidence (file:line) | +| --- | --------------------------------------------------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | **VTON은 server-side owner가 없다** (Next→Vertex 직결, ai-server·api-server 모두 owner 부재). [task hard-check] | ✅ | `web/app/api/v1/vton/route.ts:4`(nodejs)/`:5`(maxDuration=60)/`:12`(aiplatform `:predict`)/`:84`(POST)/`:153-167`(Vertex 직접 체인)/`:178-183`(base64 inline, job id 없음). `grep -rniE 'aiplatform\|vertex\|imagen\|virtual-try-on\|:predict' packages/ai-server/src` → **exit 1 (no match)**. 동일 grep `packages/api-server/src` → **exit 1 (no match)**. `api-server/src/domains/vton/mod.rs`는 save_try(`:251`, user_tryon_history INSERT `:308`)·consent(`:203-249`)·read-only 카탈로그(`:68`/`:118`)만 — 생성 로직 전무. | +| 2 | **StyleDNA는 api-server→ai-server gRPC seam이 존재** (build_my_style_dna) | ✅ | `api-server/src/domains/users/handlers.rs:132` build_my_style_dna → `:140` fetch_cody_analyses → `:153-157` `decoded_ai_client.extract_style_dna` gRPC `.await` → `:207` merge_style_dna persist. `api-server/proto/ai.proto:59-61` `rpc ExtractStyleDna`(주석 "ai-server 는 pure compute"). ai-server `metadata_servicer.py:847` ExtractStyleDna, `:879` synthesizer, `:882-884` inline resp. | +| 3 | **max_jobs=1 + job_timeout=600s(10분, "5분" 오류) + 이 timeout이 VTON/StyleDNA를 실제 게이팅(둘 다 < 600s)** | ✅ | `worker.py:73` max_jobs=1(주석 `:72`), `:74` job_timeout=600, `:75` keep_result=3600, `:81` queue_name, `:157` 전역 단일값 모든 잡 적용. VTON: `route.ts:5` maxDuration=60이 현재 전체 체인 캡핑 → << 600s. StyleDNA: `client.rs:455` 주석 "deadline 60s", `:469` `set_timeout(Duration::from_secs(60))` → 60s < 600s. task 전제 "5분" 반증(코드=600s). | +| 4 | **ARQ는 intermediate progress 미노출; 코드베이스 wrapper는 terminal-only** | ✅ (정밀화) | `queue_manager.py:102-131` get_job_status→get_job_result: 완료 전 None, 중간 percent 없음. src 내 외부 호출처 0(dead code). proto에 GetJobStatus RPC 부재. [정밀화] ARQ 라이브러리는 lifecycle(queued/in_progress/complete)은 있으나 intermediate progress granularity 없음; 이 wrapper는 그마저 미노출 → progress bar엔 별도 DB 테이블 필수. | +| 5 | **재사용 템플릿(새 job fn + 등록 + enqueue)이 정확** | ✅ | (1) `publish_service.py:138` `async def publish_ig_carousel_job(ctx, job_id)->dict` 선례. (2) `worker.py:30-58` `_get_functions()` 의 `func(fn, name=..., max_tries=1)`; ctx 주입 `worker.py:130-150`. (3) `queue_manager.py:64` `enqueue_job(...)->Optional[str]`(`:89`/`:90`). 멱등키 `metadata_servicer.py:403`, `editorial_article/scheduler.py:109`. | +| 6 | (보강) editorial_publish = per-job-type DB 테이블의 named-stage, numeric % 선례 없음 | ✅ | `editorial_publish/repository.py` set_children→`children_created`, set_parent→`parent_created`, set_published→`published`, set_failed→`failed_terminal`/`failed_retryable`. 잡 type마다 자기 테이블(`public.editorial_publish_jobs`), generic 테이블 없음. (full stage 문자열 시퀀스는 메커니즘만 확인.) | +| 7 | (보강) 크래시 복구는 post_editorial만 DB 화해, worker 시작 전 실행 | ✅ | `orphan_recovery.py:43` `_POST_EDITORIAL_FN`, `:59` recover(), `:91` 분기만 `_mark_magazine_failed`, else `:104-105` 폐기. `main.py:236` recover() → `:241` create_worker → `:254` async_run(). "새 잡 in-progress 고착"은 신규 잡에 대한 추론(현행 사실 아님). | +| 8 | (검증갭 정정) arq 핀 `>=0.26,<0.27` pyproject.toml 재확인 | ✅ CLOSE | `pyproject.toml:38` `arq (>=0.26.0,<0.27.0)`. ARQ_MIGRATION.md "10개/5분"은 코드(1/600s)와 불일치 확인 → "ARQ_MIGRATION.md 신뢰 금지" 옳음. | + +**종합:** 메모의 load-bearing 주장 5개 + 보조 3개 모두 코드로 확인. 반증된 주장 없음. 정정은 (i) VTON 도메인이 read-only 카탈로그 엔드포인트도 가짐(생성은 여전히 없음), (ii) progress 주장을 "no intermediate granularity + wrapper terminal-only"로 정밀화, (iii) arq 핀 갭 CLOSE — 셋 다 결론(하이브리드/네이티브 권장)을 강화하는 방향이며 뒤집지 않음. 미검증으로 남는 것: prod 실측 latency(메모도 갭으로 명시).