新增 §12 定时下派通道:网关自己定期派 order_list / order_detail 查询,把账号
真实订单沉淀进新表 account_orders(回写网关),上游可直接查 GET /api/account/orders
拿到账号里实际有哪些订单,不必自己记 order_number。
- collector.py: OrderDiscoveryCollector 常驻后台任务(与 sweep 并列)。每隔
account_discovery_interval_seconds 派 order_list,收割结果后把「编目里没有的
订单」upsert 进编目,再逐笔派 order_detail 沉淀 delivery_status/order_state。
状态无痕:不新增编排跟踪表,仅内存 _pending + _detail_dispatched_this_day;
detail query_id 按天分段(discover-detail-<order>-<日期>),同天幂等防重复派。
边界:collector 只消费 worker 结果的规范化字段,绝不解析 raw/raw_pages。
- db.py: account_orders 表 + AccountOrderRow + upsert/single/list/stale 访问;
upsert 以 order_number 为主键,重复采集只刷新,列表重扫不抹详情节点。
- 路由: GET /api/account/orders(列编目)、GET /api/account/orders/{n}(6005)、
POST /api/account/discovery/trigger(手动立即派一轮 list)。
- config: RAKUTEN_ACCOUNT_DISCOVERY_ENABLED / _INTERVAL_SECONDS / _MAX_PAGES /
RAKUTEN_ACCOUNT_DETAIL_REFRESH_SECONDS。
- 既有网关测试夹具统一关 discovery(会启动即派扫描污染查询队列语义),
新表测试在 tests/test_gateway_discovery.py(13 用例,真实 QueryQueue+DB 装配)。
472 tests passed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
181 lines
6.0 KiB
Python
181 lines
6.0 KiB
Python
"""长轮询与并发抢任务测试
|
|
|
|
覆盖规格 §9 验收清单里需要时序观察的条目:
|
|
- 两个 worker 同时 lease,只有一个拿到任务(全局并发度 1)
|
|
- 有任务在 leased/running 时,lease 一律返回空
|
|
- 无任务时 lease 挂起到 wait 秒才返回,且返回 200 + data: null
|
|
- submit 期间正在 wait 的 lease 被唤醒
|
|
- worker 心跳:lease 请求刷新 last_seen_at,/health 据此判断失联
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app.shared.config import get_settings
|
|
|
|
TOKEN = get_settings().bearer_token
|
|
AUTH = {"Authorization": f"Bearer {TOKEN}"}
|
|
|
|
|
|
@pytest.fixture
|
|
def gateway_client(tmp_path: Path, monkeypatch):
|
|
db_path = tmp_path / "gw.db"
|
|
monkeypatch.setenv("RAKUTEN_GATEWAY_DB_PATH", str(db_path))
|
|
monkeypatch.setenv("RAKUTEN_ACCOUNT_DISCOVERY_ENABLED", "false")
|
|
# worker_offline_alert_seconds 设小一点,方便测 /health 失联判定
|
|
monkeypatch.setenv("RAKUTEN_WORKER_OFFLINE_ALERT_SECONDS", "1")
|
|
get_settings.cache_clear()
|
|
try:
|
|
from app.gateway.main import create_app
|
|
|
|
app = create_app()
|
|
with TestClient(app) as client:
|
|
yield client
|
|
# 让后台 sweep 任务退出后再清理
|
|
finally:
|
|
get_settings.cache_clear()
|
|
|
|
|
|
# ---- 全局并发度 1 ----
|
|
|
|
|
|
def test_concurrent_leases_only_one_gets_task(gateway_client):
|
|
"""两个 worker 同时 lease,只有一个拿到任务"""
|
|
gateway_client.post(
|
|
"/api/orders",
|
|
json={"task_id": "t1", "site": "rakuten", "intent": {}},
|
|
headers=AUTH,
|
|
)
|
|
|
|
# 同步发两个 lease,wait=0 立即返回
|
|
r1 = gateway_client.get("/api/orders/lease?worker_id=w1&wait=0", headers=AUTH)
|
|
r2 = gateway_client.get("/api/orders/lease?worker_id=w2&wait=0", headers=AUTH)
|
|
|
|
owned = [r for r in (r1, r2) if r.json()["data"] is not None]
|
|
assert len(owned) == 1
|
|
assert owned[0].json()["data"]["task_id"] == "t1"
|
|
|
|
|
|
def test_active_task_blocks_subsequent_leases(gateway_client):
|
|
"""leased 或 running 状态下,lease 立即返回空"""
|
|
gateway_client.post(
|
|
"/api/orders",
|
|
json={"task_id": "active", "site": "rakuten", "intent": {}},
|
|
headers=AUTH,
|
|
)
|
|
gateway_client.post(
|
|
"/api/orders",
|
|
json={"task_id": "queued", "site": "rakuten", "intent": {}},
|
|
headers=AUTH,
|
|
)
|
|
|
|
# 领走 active
|
|
first = gateway_client.get("/api/orders/lease?worker_id=w1&wait=0", headers=AUTH).json()
|
|
assert first["data"]["task_id"] == "active"
|
|
|
|
# 队列里还有 queued,但 active 还在执行 → 立即返回空
|
|
blocked = gateway_client.get("/api/orders/lease?worker_id=w2&wait=0", headers=AUTH).json()
|
|
assert blocked["data"] is None
|
|
|
|
|
|
# ---- 长轮询 ----
|
|
|
|
|
|
def test_lease_hangs_then_returns_null_when_no_task(gateway_client):
|
|
"""无任务时 lease 挂起到 wait 秒才返回"""
|
|
started = time.monotonic()
|
|
response = gateway_client.get("/api/orders/lease?worker_id=w1&wait=2", headers=AUTH)
|
|
elapsed = time.monotonic() - started
|
|
assert response.status_code == 200
|
|
assert response.json()["data"] is None
|
|
# 至少挂了 2 秒(允许少量提前)
|
|
assert elapsed >= 1.8
|
|
|
|
|
|
def test_lease_is_woken_up_when_task_submitted(gateway_client):
|
|
"""长轮询期间 submit 任务,lease 应被唤醒并立刻拿到"""
|
|
async def submit_after_delay():
|
|
await asyncio.sleep(0.3)
|
|
# TestClient 是同步的,在另一个线程里发请求
|
|
import threading
|
|
|
|
def _submit():
|
|
gateway_client.post(
|
|
"/api/orders",
|
|
json={"task_id": "t1", "site": "rakuten", "intent": {}},
|
|
headers=AUTH,
|
|
)
|
|
|
|
threading.Thread(target=_submit, daemon=True).start()
|
|
|
|
asyncio.run(submit_after_delay())
|
|
|
|
started = time.monotonic()
|
|
response = gateway_client.get("/api/orders/lease?worker_id=w1&wait=10", headers=AUTH)
|
|
elapsed = time.monotonic() - started
|
|
assert response.status_code == 200
|
|
data = response.json()["data"]
|
|
assert data is not None and data["task_id"] == "t1"
|
|
# 被唤醒应远早于 10 秒
|
|
assert elapsed < 5
|
|
|
|
|
|
# ---- 心跳与健康检查 ----
|
|
|
|
|
|
def test_lease_updates_worker_last_seen(gateway_client):
|
|
"""lease 请求兼作心跳,刷新 workers 表的 last_seen_at"""
|
|
gateway_client.get("/api/orders/lease?worker_id=w1&wait=0", headers=AUTH)
|
|
health = gateway_client.get("/health").json()["data"]
|
|
worker_ids = {w["worker_id"] for w in health["workers"]}
|
|
assert "w1" in worker_ids
|
|
|
|
|
|
def test_health_reports_offline_worker(gateway_client):
|
|
"""worker 心跳超阈值时 /health 报异常"""
|
|
gateway_client.get("/api/orders/lease?worker_id=w1&wait=0", headers=AUTH)
|
|
# 阈值被 fixture 设为 1 秒;时间戳为秒精度,需要 sleep 到 2 秒才能稳定越过阈值
|
|
import time as _time
|
|
|
|
_time.sleep(2.0)
|
|
|
|
health = gateway_client.get("/health").json()["data"]
|
|
assert health["status"] == "degraded"
|
|
offline_ids = {w["worker_id"] for w in health["offline_workers"]}
|
|
assert "w1" in offline_ids
|
|
|
|
|
|
def test_health_reports_stale_queued_task(gateway_client):
|
|
"""任务 queued 超过 worker_offline_alert_seconds 时报异常"""
|
|
gateway_client.post(
|
|
"/api/orders",
|
|
json={"task_id": "stuck", "site": "rakuten", "intent": {}},
|
|
headers=AUTH,
|
|
)
|
|
import time as _time
|
|
|
|
_time.sleep(2.0)
|
|
|
|
health = gateway_client.get("/health").json()["data"]
|
|
assert health["status"] == "degraded"
|
|
stuck_ids = {t["task_id"] for t in health["stale_queued_tasks"]}
|
|
assert "stuck" in stuck_ids
|
|
|
|
|
|
def test_health_lists_active_tasks(gateway_client):
|
|
gateway_client.post(
|
|
"/api/orders",
|
|
json={"task_id": "t1", "site": "rakuten", "intent": {}},
|
|
headers=AUTH,
|
|
)
|
|
gateway_client.get("/api/orders/lease?worker_id=w1&wait=0", headers=AUTH)
|
|
|
|
health = gateway_client.get("/health").json()["data"]
|
|
active_ids = {t["task_id"] for t in health["active_tasks"]}
|
|
assert "t1" in active_ids
|