fix(m5): close render completion loop — worker HTTP callback + /renders static serving
Worker published only Redis pub/sub events; backend status stayed QUEUED
forever (callback endpoint never called — gap masked by earlier Gemini 429).
- worker.py: POST /api/internal/render/callback (X-Worker-Token) after each
job with imageUrl=/renders/{key}, schemaHash, modelVersion, errorMessage
- backend: serve /renders/** from RENDER_OUTPUT_DIR + security permit
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
9099f7d0d4
commit
b5d29e6061
@ -56,8 +56,8 @@ public class SecurityConfig {
|
||||
"/api/auth/otp/enroll/setup", "/api/auth/otp/enroll/verify",
|
||||
"/api/auth/register", "/api/auth/password/forgot",
|
||||
"/api/auth/password/reset",
|
||||
// 공개 콘텐츠(로그인 슬라이드 등) + 업로드 정적 파일
|
||||
"/api/public/**", "/uploads/**",
|
||||
// 공개 콘텐츠(로그인 슬라이드 등) + 업로드/렌더 산출 정적 파일
|
||||
"/api/public/**", "/uploads/**", "/renders/**",
|
||||
// F099 인바운드 웹훅 수신(인증 없음 — 토큰 + HMAC 서명 이중 검증으로 방어)
|
||||
"/api/webhooks/in/**").permitAll()
|
||||
.anyRequest().authenticated())
|
||||
|
||||
@ -15,9 +15,12 @@ import java.nio.file.Paths;
|
||||
public class WebMvcConfig implements WebMvcConfigurer {
|
||||
|
||||
private final String uploadDir;
|
||||
private final String renderOutputDir;
|
||||
|
||||
public WebMvcConfig(@Value("${kintex.upload.dir:./data/uploads}") String uploadDir) {
|
||||
public WebMvcConfig(@Value("${kintex.upload.dir:./data/uploads}") String uploadDir,
|
||||
@Value("${kintex.render.output-dir:./output/visualizations}") String renderOutputDir) {
|
||||
this.uploadDir = uploadDir;
|
||||
this.renderOutputDir = renderOutputDir;
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -26,5 +29,10 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
||||
registry.addResourceHandler("/uploads/**")
|
||||
.addResourceLocations(location)
|
||||
.setCachePeriod(3600);
|
||||
// 나노바나나 워커 산출 이미지(NANOBANANA_OUTPUT_DIR와 동일 루트) — 결과는 불변 파일(UUID 키)이라 캐시 허용.
|
||||
String renders = Paths.get(renderOutputDir).toAbsolutePath().normalize().toUri().toString();
|
||||
registry.addResourceHandler("/renders/**")
|
||||
.addResourceLocations(renders)
|
||||
.setCachePeriod(3600);
|
||||
}
|
||||
}
|
||||
|
||||
@ -108,6 +108,8 @@ kintex:
|
||||
# RenderJob Redis 큐 이름(나노바나나 워커가 소비) — visualizer와 합의한 계약
|
||||
queue-key: ${RENDER_QUEUE_KEY:kintex:renderjob:queue}
|
||||
event-quota-default: ${RENDER_EVENT_QUOTA:500}
|
||||
# 워커 산출 이미지 루트(NANOBANANA_OUTPUT_DIR와 동일 경로 주입) — /renders/** 정적 서빙
|
||||
output-dir: ${RENDER_OUTPUT_DIR:./output/visualizations}
|
||||
|
||||
logging:
|
||||
level:
|
||||
|
||||
@ -79,6 +79,11 @@ OUTPUT_DIR = os.environ.get("NANOBANANA_OUTPUT_DIR", "output/visualizations")
|
||||
#: BLPOP 폴링 간격(초) — None 무한 대기 대신 짧은 폴링으로 graceful stop 가능.
|
||||
BLPOP_TIMEOUT_S = 5
|
||||
|
||||
#: 백엔드 완료 콜백(계약: RenderWorkerCallbackController). 토큰 미주입 시 콜백 생략(스모크/로컬).
|
||||
BACKEND_CALLBACK_BASE = os.environ.get("BACKEND_CALLBACK_BASE", "").rstrip("/")
|
||||
RENDER_WORKER_TOKEN = os.environ.get("RENDER_WORKER_TOKEN", "")
|
||||
CALLBACK_TIMEOUT_S = 10
|
||||
|
||||
|
||||
def is_live() -> bool:
|
||||
"""G1 게이트: 실 Gemini 호출 조건(PLANNING R12).
|
||||
@ -434,7 +439,7 @@ class RenderWorker:
|
||||
# 단건 처리 (테스트·스모크에서 직접 호출 가능 — Redis 불필요)
|
||||
# ------------------------------------------------------------------
|
||||
def process_job(self, job: RenderJob) -> RenderResult:
|
||||
"""RenderJob 1건 처리: 렌더 → 스토리지 적재 → 완료 이벤트 발행."""
|
||||
"""RenderJob 1건 처리: 렌더 → 스토리지 적재 → 완료 이벤트 발행 → 백엔드 콜백."""
|
||||
try:
|
||||
image = self._render(job)
|
||||
key = ObjectStore.key_for(job)
|
||||
@ -443,13 +448,54 @@ class RenderWorker:
|
||||
job_id=job.job_id, status="DONE", image_ref=image_ref, meta=image.metadata
|
||||
)
|
||||
self.publisher.publish(self._event("renderjob.completed", job, result))
|
||||
return result
|
||||
except Exception as e: # noqa: BLE001 — 분류 후 친화 에러(비밀 미노출)
|
||||
error = self._classify_error(e)
|
||||
result = RenderResult(job_id=job.job_id, status="FAILED", error=error)
|
||||
self.publisher.publish(self._event("renderjob.failed", job, result))
|
||||
self._callback_backend(result)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _callback_backend(result: RenderResult) -> None:
|
||||
"""백엔드 상태 갱신 콜백(POST /api/internal/render/callback, X-Worker-Token).
|
||||
|
||||
pub/sub 는 WS 릴레이용 신호일 뿐 — DB/조회 상태의 정본 갱신은 이 콜백이 담당한다.
|
||||
실패해도 렌더 결과를 무효화하지 않는다(로그만, 토큰·스택트레이스 미노출).
|
||||
"""
|
||||
if not BACKEND_CALLBACK_BASE or not RENDER_WORKER_TOKEN:
|
||||
print(f"[callback] skipped (base/token 미주입) job={result.job_id}")
|
||||
return
|
||||
image_url = None
|
||||
if result.image_ref and result.image_ref.get("key"):
|
||||
image_url = "/renders/" + str(result.image_ref["key"])
|
||||
payload = {
|
||||
"jobId": result.job_id,
|
||||
"status": result.status,
|
||||
"imageUrl": image_url,
|
||||
"schemaHash": result.meta.get("schema_hash"),
|
||||
"modelVersion": result.meta.get("model_version"),
|
||||
"errorMessage": (result.error or {}).get("message"),
|
||||
}
|
||||
url = BACKEND_CALLBACK_BASE + "/api/internal/render/callback"
|
||||
try:
|
||||
import urllib.request
|
||||
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-Worker-Token": RENDER_WORKER_TOKEN,
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=CALLBACK_TIMEOUT_S) as resp:
|
||||
print(f"[callback] job={result.job_id} status={result.status} http={resp.status}")
|
||||
except Exception as e: # noqa: BLE001 — 콜백 실패는 비치명(재시도는 후속 조회로 보완)
|
||||
print(
|
||||
f"[callback] FAILED job={result.job_id}: {type(e).__name__}", file=sys.stderr
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _classify_error(e: Exception) -> dict:
|
||||
"""예외 → {code,message}. 스택트레이스·키 미포함(PLANNING §6-5)."""
|
||||
|
||||
Loading…
Reference in New Issue
Block a user