146 lines
5.7 KiB
Python
146 lines
5.7 KiB
Python
"""持久任务队列单元测试(内存 SQLite,不依赖网络)。"""
|
|
from datetime import datetime, timedelta
|
|
|
|
from sqlmodel import Session, SQLModel, create_engine, select
|
|
|
|
from app.models import TaskQueueItem
|
|
from app import task_queue as tq
|
|
|
|
|
|
def _session():
|
|
engine = create_engine("sqlite://", connect_args={"check_same_thread": False})
|
|
SQLModel.metadata.create_all(engine)
|
|
return Session(engine)
|
|
|
|
|
|
def _past(seconds=10):
|
|
return (datetime.now() - timedelta(seconds=seconds)).isoformat()
|
|
|
|
|
|
def _future(seconds=60):
|
|
return (datetime.now() + timedelta(seconds=seconds)).isoformat()
|
|
|
|
|
|
def test_enqueue_idempotent():
|
|
with _session() as s:
|
|
id1 = tq.enqueue(s, TaskQueueItem, task_key="run:1", payload={"a": 1})
|
|
id2 = tq.enqueue(s, TaskQueueItem, task_key="run:1", payload={"a": 1})
|
|
assert id1 == id2
|
|
id3 = tq.enqueue(s, TaskQueueItem, task_key="run:2", payload={"a": 2})
|
|
assert id3 != id1
|
|
|
|
|
|
def test_lease_next_respects_next_run_at():
|
|
with _session() as s:
|
|
tq.enqueue(s, TaskQueueItem, task_key="run:1", payload={})
|
|
row = s.exec(select(TaskQueueItem)).first()
|
|
row.next_run_at = _future(60)
|
|
s.add(row); s.commit()
|
|
assert tq.lease_next(s, TaskQueueItem, "w1") is None
|
|
row.next_run_at = _past(1)
|
|
s.add(row); s.commit()
|
|
leased = tq.lease_next(s, TaskQueueItem, "w1")
|
|
assert leased is not None and leased.status == "running"
|
|
assert leased.attempt == 1 and leased.worker_id == "w1"
|
|
|
|
|
|
def test_lease_steals_expired_lease():
|
|
with _session() as s:
|
|
task_id = tq.enqueue(s, TaskQueueItem, task_key="run:1", payload={})
|
|
leased = tq.lease_next(s, TaskQueueItem, "w1")
|
|
assert leased.id == task_id
|
|
row = s.get(TaskQueueItem, task_id)
|
|
row.lease_until = _past(10)
|
|
s.add(row); s.commit()
|
|
stolen = tq.lease_next(s, TaskQueueItem, "w2")
|
|
assert stolen is not None and stolen.worker_id == "w2"
|
|
assert stolen.attempt == 2
|
|
|
|
|
|
def test_heartbeat_only_by_owner():
|
|
with _session() as s:
|
|
task_id = tq.enqueue(s, TaskQueueItem, task_key="run:1", payload={})
|
|
tq.lease_next(s, TaskQueueItem, "w1")
|
|
assert tq.heartbeat(s, TaskQueueItem, task_id, "w1") is True
|
|
assert tq.heartbeat(s, TaskQueueItem, task_id, "w2") is False
|
|
|
|
|
|
def test_fail_requeues_with_backoff_then_dead():
|
|
with _session() as s:
|
|
task_id = tq.enqueue(s, TaskQueueItem, task_key="run:1", payload={})
|
|
tq.lease_next(s, TaskQueueItem, "w1")
|
|
status = tq.fail(s, TaskQueueItem, task_id, "w1", "第一次失败")
|
|
assert status == "pending"
|
|
row = s.get(TaskQueueItem, task_id)
|
|
assert row.attempt == 1 and row.error == "第一次失败"
|
|
assert _parse(row.next_run_at) > datetime.now()
|
|
# 退避时间清零后连续失败到上限 → 死信
|
|
for _ in range(2):
|
|
row = s.get(TaskQueueItem, task_id)
|
|
row.next_run_at = _past(1)
|
|
s.add(row); s.commit()
|
|
row2 = tq.lease_next(s, TaskQueueItem, "w1")
|
|
tq.fail(s, TaskQueueItem, row2.id, "w1", "再失败")
|
|
row = s.get(TaskQueueItem, task_id)
|
|
assert row.status == "dead"
|
|
|
|
|
|
def test_requeue_dead():
|
|
with _session() as s:
|
|
task_id = tq.enqueue(s, TaskQueueItem, task_key="run:1", payload={})
|
|
row = s.get(TaskQueueItem, task_id)
|
|
row.status = "dead"
|
|
s.add(row); s.commit()
|
|
assert tq.requeue_dead(s, TaskQueueItem, task_id) is True
|
|
row = s.get(TaskQueueItem, task_id)
|
|
assert row.status == "pending" and row.attempt == 0
|
|
assert tq.requeue_dead(s, TaskQueueItem, task_id) is False
|
|
|
|
|
|
def test_complete_and_stats():
|
|
with _session() as s:
|
|
task_id = tq.enqueue(s, TaskQueueItem, task_key="run:1", payload={})
|
|
tq.lease_next(s, TaskQueueItem, "w1")
|
|
assert tq.complete(s, TaskQueueItem, task_id, "w1") is True
|
|
stats = tq.queue_stats(s, TaskQueueItem)
|
|
assert stats["total"] == 1
|
|
assert stats["by_status"]["done"] == 1
|
|
assert stats["done"] == 1
|
|
|
|
|
|
def _parse(value):
|
|
return datetime.fromisoformat(value)
|
|
|
|
|
|
def test_lease_next_filters_task_key_prefix():
|
|
with _session() as s:
|
|
tq.enqueue(s, TaskQueueItem, task_key="training-map:1", payload={})
|
|
tq.enqueue(s, TaskQueueItem, task_key="campaign-run:1", payload={})
|
|
assert tq.lease_next(s, TaskQueueItem, "dsh", task_key_prefix="campaign-%").task_key == "campaign-run:1"
|
|
|
|
|
|
def test_enqueue_updates_pending_payload_only():
|
|
with _session() as s:
|
|
task_id = tq.enqueue(s, TaskQueueItem, task_key="campaign-parse:1", payload={"advice": "old"})
|
|
tq.enqueue(s, TaskQueueItem, task_key="campaign-parse:1",
|
|
payload={"advice": "new"}, update_existing=True)
|
|
row = s.get(TaskQueueItem, task_id)
|
|
assert row.payload == {"advice": "new"} and row.status == "pending"
|
|
|
|
row.status = "running"
|
|
row.worker_id = "dsh1"
|
|
s.add(row); s.commit()
|
|
tq.enqueue(s, TaskQueueItem, task_key="campaign-parse:1",
|
|
payload={"advice": "ignored"}, update_existing=True)
|
|
s.refresh(row)
|
|
assert row.payload == {"advice": "new"} and row.worker_id == "dsh1"
|
|
|
|
|
|
def test_claim_by_key_pending_and_owner_only():
|
|
with _session() as s:
|
|
task_id = tq.enqueue(s, TaskQueueItem, task_key="campaign-run:9", payload={})
|
|
claimed = tq.claim_by_key(s, TaskQueueItem, "campaign-run:9", "inline-a")
|
|
assert claimed.id == task_id and claimed.worker_id == "inline-a"
|
|
assert tq.claim_by_key(s, TaskQueueItem, "campaign-run:9", "inline-b") is None
|
|
assert tq.claim_by_key(s, TaskQueueItem, "campaign-run:9", "inline-a") is not None
|