fix(insta-render): F6 ReliableQueue 적용 — BLMOVE + ack/fail (F6 part 2)
- worker.py: BLPOP → ReliableQueue.dequeue / ack / fail / startup recovery - _process_one: 예외 시 webhook(failed) 후 raise — poll_once가 fail(raw, payload) 로 retry/dead-letter 처리 - poll_once 함수 추가 (테스트 단위) - Dockerfile: build context=services/ 로 올리고 _shared 포함, PYTHONPATH=/app - docker-compose.yml: insta-render build context 갱신 기존 webhook 호출 동작은 그대로 (멱등) — retry 시 매번 NAS에 failed 통보되어도 마지막 상태만 보임. dead-letter는 운영 모니터링으로 별도 처리. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -112,11 +112,88 @@ async def test_process_one_render_failure_reports_failed(monkeypatch, fake_slate
|
||||
worker.NAS_BASE_URL = "http://nas.test"
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
await worker._process_one(client, {
|
||||
"task_id": "t-3",
|
||||
"params": {"slate_id": 99},
|
||||
})
|
||||
# F6: _process_one은 webhook(failed) 호출 후 raise — poll_once가 fail(raw)로 retry/dead-letter.
|
||||
with pytest.raises(RuntimeError, match="Chromium"):
|
||||
await worker._process_one(client, {
|
||||
"task_id": "t-3",
|
||||
"params": {"slate_id": 99},
|
||||
})
|
||||
|
||||
last = calls[-1]
|
||||
assert last["status"] == "failed"
|
||||
assert "Chromium" in last["error"]
|
||||
|
||||
|
||||
# ----- F6: ReliableQueue (ack on success, fail on exception) -----
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_once_acks_on_success(monkeypatch):
|
||||
"""F6 — 성공 시 queue.ack(raw) 호출 + fail 안 부름."""
|
||||
fake_payload = {
|
||||
"task_id": "t-ok",
|
||||
"params": {"slate_id": 7, "theme": "default"},
|
||||
}
|
||||
fake_raw = json.dumps(fake_payload).encode()
|
||||
|
||||
fake_queue = AsyncMock()
|
||||
fake_queue.dequeue = AsyncMock(return_value=(fake_payload, fake_raw))
|
||||
fake_queue.ack = AsyncMock()
|
||||
fake_queue.fail = AsyncMock()
|
||||
|
||||
process_mock = AsyncMock()
|
||||
monkeypatch.setattr(worker, "_process_one", process_mock)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
handled = await worker.poll_once(fake_queue, client)
|
||||
|
||||
assert handled is True
|
||||
process_mock.assert_awaited_once()
|
||||
fake_queue.ack.assert_awaited_once_with(fake_raw)
|
||||
fake_queue.fail.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_once_calls_fail_on_exception(monkeypatch):
|
||||
"""F6 — _process_one 예외 시 queue.fail(raw, payload) 호출."""
|
||||
fake_payload = {
|
||||
"task_id": "t-err",
|
||||
"params": {"slate_id": 9, "theme": "default"},
|
||||
}
|
||||
fake_raw = json.dumps(fake_payload).encode()
|
||||
|
||||
fake_queue = AsyncMock()
|
||||
fake_queue.dequeue = AsyncMock(return_value=(fake_payload, fake_raw))
|
||||
fake_queue.ack = AsyncMock()
|
||||
fake_queue.fail = AsyncMock()
|
||||
|
||||
async def boom(client, payload):
|
||||
raise RuntimeError("simulated dispatch failure")
|
||||
|
||||
monkeypatch.setattr(worker, "_process_one", boom)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
handled = await worker.poll_once(fake_queue, client)
|
||||
|
||||
assert handled is True
|
||||
fake_queue.fail.assert_awaited_once_with(fake_raw, fake_payload)
|
||||
fake_queue.ack.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_once_returns_false_on_timeout(monkeypatch):
|
||||
"""F6 — dequeue가 None 반환(타임아웃)이면 False 리턴, ack/fail 안 부름."""
|
||||
fake_queue = AsyncMock()
|
||||
fake_queue.dequeue = AsyncMock(return_value=None)
|
||||
fake_queue.ack = AsyncMock()
|
||||
fake_queue.fail = AsyncMock()
|
||||
|
||||
process_mock = AsyncMock()
|
||||
monkeypatch.setattr(worker, "_process_one", process_mock)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
handled = await worker.poll_once(fake_queue, client)
|
||||
|
||||
assert handled is False
|
||||
process_mock.assert_not_awaited()
|
||||
fake_queue.ack.assert_not_awaited()
|
||||
fake_queue.fail.assert_not_awaited()
|
||||
|
||||
Reference in New Issue
Block a user