f60d6e52fc
worker-pool-policy §B 1B 영역 완료. 1A scaffold (mig 270~274 + 503 stub) 위에:
- mig 275/276: worker_jobs (status CHECK + user_id=owner) + pending partial index
- create_laptop_worker_bot_token + require_worker_user dependency (voice-memo 동형)
- /internal/worker/{register,heartbeat,claim,result,drain} 5 endpoint 실 구현
- /claim FOR UPDATE SKIP LOCKED + 204 body 0
- /result 소유권 검증 (worker_id 매칭, 404) + failed 재시도 (attempts/max)
- explicit failure 시 request.result 무시 (DB result NULL 유지)
- 테스트 22 항목 7 파일
policy §B.2 5 invariant 보존: voice-memo wrapper 변경 0, drain advisory,
result raw JSONB, ProcessingQueue 무변경, 운영 자동 분기 변경 0.
활용처 (recap context + /jobs/recap + payload 100KB guard) = Registry-1C 영역.
stale recovery / 노트북 client / canonical promote = Notebook-Pilot-1 영역.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
186 lines
6.0 KiB
Python
186 lines
6.0 KiB
Python
"""PR-Worker-Pool-Registry-1B 정정 #3 — explicit /result failed 재시도 정책.
|
|
|
|
3 case:
|
|
A. initial attempts=0, max_attempts=3 → claim 후 attempts=1 → failed → status='pending' 복귀.
|
|
(worker_id=NULL, claimed_at=NULL, completed_at=NULL, error_message 저장)
|
|
B. initial attempts=2, max_attempts=3 → claim 후 attempts=3 → failed → final 'failed'.
|
|
(completed_at=now)
|
|
C. failed 요청에 result={"partial":"..."} 포함 → DB result IS NULL 유지 (request.result 무시).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import sys
|
|
import uuid
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "app"))
|
|
|
|
from httpx import ASGITransport, AsyncClient
|
|
from sqlalchemy import text
|
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
|
|
from _worker_pool_helpers import (
|
|
cleanup_worker_capabilities,
|
|
cleanup_worker_jobs,
|
|
ensure_user,
|
|
get_database_url,
|
|
mint_access_token,
|
|
)
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def db_session(monkeypatch):
|
|
monkeypatch.setenv("LAPTOP_WORKER_BOT_USERNAME", "laptop-worker-bot")
|
|
engine = create_async_engine(get_database_url())
|
|
sm = async_sessionmaker(engine, expire_on_commit=False)
|
|
async with sm() as session:
|
|
yield session
|
|
await engine.dispose()
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def worker_token(db_session):
|
|
await ensure_user(db_session, "laptop-worker-bot")
|
|
return mint_access_token("laptop-worker-bot")
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def owner_id(db_session):
|
|
return await ensure_user(db_session, "test-owner-retry-1b")
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def worker_id_unique(db_session, owner_id):
|
|
wid = f"test-retry-1b-{uuid.uuid4().hex[:8]}"
|
|
await db_session.execute(
|
|
text(
|
|
"INSERT INTO worker_capabilities (worker_id, user_id, device_label, "
|
|
"worker_class, tier) VALUES (:w, :u, 'lbl', 'laptop', 'laptop_small')"
|
|
),
|
|
{"w": wid, "u": owner_id},
|
|
)
|
|
await db_session.commit()
|
|
yield wid
|
|
await cleanup_worker_jobs(db_session, "test-retry-1b")
|
|
await cleanup_worker_capabilities(db_session, wid)
|
|
|
|
|
|
async def _seed_processing_job(
|
|
db_session, owner_id: int, worker_id: str, attempts: int, max_attempts: int = 3
|
|
) -> int:
|
|
job_id = (
|
|
await db_session.execute(
|
|
text(
|
|
"INSERT INTO worker_jobs (user_id, job_type, status, worker_id, "
|
|
"attempts, max_attempts, claimed_at) "
|
|
"VALUES (:u, :j, 'processing', :w, :a, :m, NOW()) RETURNING id"
|
|
),
|
|
{
|
|
"u": owner_id,
|
|
"j": f"test-retry-1b-{uuid.uuid4().hex[:8]}",
|
|
"w": worker_id,
|
|
"a": attempts,
|
|
"m": max_attempts,
|
|
},
|
|
)
|
|
).scalar_one()
|
|
await db_session.commit()
|
|
return job_id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_case_a_failed_then_pending(
|
|
db_session, worker_token, worker_id_unique, owner_id
|
|
):
|
|
"""Case A: attempts(=1) < max_attempts(=3) → pending 복귀."""
|
|
from main import app
|
|
|
|
job_id = await _seed_processing_job(db_session, owner_id, worker_id_unique, attempts=1)
|
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
|
r = await c.post(
|
|
"/internal/worker/result",
|
|
json={
|
|
"job_id": job_id,
|
|
"worker_id": worker_id_unique,
|
|
"status": "failed",
|
|
"error_message": "case A error",
|
|
},
|
|
headers={"Authorization": f"Bearer {worker_token}"},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
|
|
res = await db_session.execute(
|
|
text(
|
|
"SELECT status, worker_id, claimed_at, completed_at, error_message "
|
|
"FROM worker_jobs WHERE id = :i"
|
|
),
|
|
{"i": job_id},
|
|
)
|
|
s, w_id, c_at, comp_at, em = res.first()
|
|
assert s == "pending"
|
|
assert w_id is None
|
|
assert c_at is None
|
|
assert comp_at is None
|
|
assert em == "case A error"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_case_b_failed_max_attempts_final(
|
|
db_session, worker_token, worker_id_unique, owner_id
|
|
):
|
|
"""Case B: attempts(=3) >= max_attempts(=3) → final 'failed' + completed_at."""
|
|
from main import app
|
|
|
|
job_id = await _seed_processing_job(db_session, owner_id, worker_id_unique, attempts=3)
|
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
|
r = await c.post(
|
|
"/internal/worker/result",
|
|
json={
|
|
"job_id": job_id,
|
|
"worker_id": worker_id_unique,
|
|
"status": "failed",
|
|
"error_message": "case B final",
|
|
},
|
|
headers={"Authorization": f"Bearer {worker_token}"},
|
|
)
|
|
assert r.status_code == 200
|
|
|
|
res = await db_session.execute(
|
|
text("SELECT status, completed_at FROM worker_jobs WHERE id = :i"),
|
|
{"i": job_id},
|
|
)
|
|
s, comp_at = res.first()
|
|
assert s == "failed"
|
|
assert comp_at is not None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_case_c_failed_ignores_result_field(
|
|
db_session, worker_token, worker_id_unique, owner_id
|
|
):
|
|
"""Case C: failed 요청 result={'partial':...} 포함해도 DB result IS NULL 유지."""
|
|
from main import app
|
|
|
|
job_id = await _seed_processing_job(db_session, owner_id, worker_id_unique, attempts=1)
|
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
|
r = await c.post(
|
|
"/internal/worker/result",
|
|
json={
|
|
"job_id": job_id,
|
|
"worker_id": worker_id_unique,
|
|
"status": "failed",
|
|
"result": {"partial": "should be ignored"},
|
|
"error_message": "case C",
|
|
},
|
|
headers={"Authorization": f"Bearer {worker_token}"},
|
|
)
|
|
assert r.status_code == 200
|
|
res = await db_session.execute(
|
|
text("SELECT result FROM worker_jobs WHERE id = :i"), {"i": job_id}
|
|
)
|
|
assert res.scalar_one() is None # 정정 #3 엄격 규칙
|