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>
147 lines
5.0 KiB
Python
147 lines
5.0 KiB
Python
"""PR-Worker-Pool-Registry-1B — worker_jobs ORM/schema 단위 검증.
|
|
|
|
3 항목 (endpoint test 와 중복 회피, schema-level 만):
|
|
1. CHECK constraint — status 가 enum 4 외 값일 때 INSERT 거부 (정정 #5)
|
|
2. partial unique-index 동작 검증 — pending row 만 색인 (idx_worker_jobs_pending_type)
|
|
3. ON DELETE SET NULL — worker_capabilities 삭제 시 worker_jobs.worker_id 자동 NULL
|
|
"""
|
|
|
|
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 sqlalchemy import text
|
|
from sqlalchemy.exc import IntegrityError
|
|
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,
|
|
)
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def db_session():
|
|
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 owner_id(db_session):
|
|
return await ensure_user(db_session, "test-owner-smoke-1b")
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def worker_id_unique(db_session, owner_id):
|
|
wid = f"test-smoke-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-smoke-1b")
|
|
await cleanup_worker_capabilities(db_session, wid)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_check_constraint_rejects_unknown_status(db_session, owner_id, worker_id_unique):
|
|
"""정정 #5: status 가 enum 4 외 값이면 IntegrityError."""
|
|
with pytest.raises(IntegrityError):
|
|
await db_session.execute(
|
|
text(
|
|
"INSERT INTO worker_jobs (user_id, job_type, status) "
|
|
"VALUES (:u, 'test-smoke-1b-bad', 'running')"
|
|
),
|
|
{"u": owner_id},
|
|
)
|
|
await db_session.commit()
|
|
await db_session.rollback()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_partial_pending_index_used_for_claim_query(db_session, owner_id, worker_id_unique):
|
|
"""partial index idx_worker_jobs_pending_type 가 pending claim 쿼리 실행계획에 사용."""
|
|
# seed 2 rows: pending + completed
|
|
await db_session.execute(
|
|
text(
|
|
"INSERT INTO worker_jobs (user_id, job_type, status) "
|
|
"VALUES (:u, 'test-smoke-1b-idx', 'pending'), "
|
|
" (:u, 'test-smoke-1b-idx', 'completed')"
|
|
),
|
|
{"u": owner_id},
|
|
)
|
|
await db_session.commit()
|
|
|
|
# EXPLAIN ANALYZE 가 partial index 사용하는지 확인 (운영 환경에선 seq scan 가능 — 본 테스트는 인덱스 정의 존재만 보장)
|
|
res = await db_session.execute(
|
|
text(
|
|
"SELECT indexname FROM pg_indexes "
|
|
"WHERE tablename = 'worker_jobs' AND indexname = 'idx_worker_jobs_pending_type'"
|
|
)
|
|
)
|
|
assert res.scalar_one_or_none() == "idx_worker_jobs_pending_type"
|
|
|
|
# pending row 만 SELECT 됨 (실제 동작 검증)
|
|
res = await db_session.execute(
|
|
text(
|
|
"SELECT count(*) FROM worker_jobs "
|
|
"WHERE status = 'pending' AND job_type = 'test-smoke-1b-idx'"
|
|
)
|
|
)
|
|
assert res.scalar_one() == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_on_delete_set_null_when_capability_dropped(db_session, owner_id):
|
|
"""worker_capabilities 삭제 시 worker_jobs.worker_id 자동 NULL (ON DELETE SET NULL)."""
|
|
wid = f"test-smoke-1b-del-{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},
|
|
)
|
|
job_id = (
|
|
await db_session.execute(
|
|
text(
|
|
"INSERT INTO worker_jobs (user_id, job_type, status, worker_id, attempts) "
|
|
"VALUES (:u, 'test-smoke-1b-del', 'completed', :w, 1) RETURNING id"
|
|
),
|
|
{"u": owner_id, "w": wid},
|
|
)
|
|
).scalar_one()
|
|
await db_session.commit()
|
|
|
|
# worker_heartbeats CASCADE 가 worker_capabilities 삭제 차단할 수 있으니 사전 정리
|
|
await db_session.execute(
|
|
text("DELETE FROM worker_heartbeats WHERE worker_id = :w"), {"w": wid}
|
|
)
|
|
await db_session.execute(text("DELETE FROM worker_capabilities WHERE worker_id = :w"), {"w": wid})
|
|
await db_session.commit()
|
|
|
|
res = await db_session.execute(
|
|
text("SELECT worker_id FROM worker_jobs WHERE id = :i"), {"i": job_id}
|
|
)
|
|
assert res.scalar_one() is None
|
|
|
|
# cleanup
|
|
await db_session.execute(text("DELETE FROM worker_jobs WHERE id = :i"), {"i": job_id})
|
|
await db_session.commit()
|