基金转换 T-4+T-5(并行组 A):convert_repository 代理侧 + locks.try_lock 非阻塞抢锁
T-4(convert_repository · agent 库 risk_convert_detail): - 新增 7 方法:insert_placeholder(占位 pending) / complete_convert(回写 completed+折算详情) / mark_failed / get_by_group_id / get_by_client_request_id(None 直接返回 None) / list_expired_candidates(hours) / mark_expired(S2 标记不硬删) - 引擎走 agent 库 rw(D20);status 5 值枚举;不与 risk_repository 混职责 - Decimal 折算字段经 _to_bind 转 float 绑定(sqlite 不支持直接绑 Decimal,同 T-3) T-5(locks.try_lock): - 单次非阻塞抢锁:抢到返回 _Token(with 进入 True),抢不到立即 _NoLock(进入 False,不等不降级) - Redis 不可用退回进程内 Lock.acquire(blocking=False);_NoLock.__enter__ 返回 False 避免 with None 报错 - 不改 run_locked(D4:其等 2s 后降级 fn(False),语义相反;3 处调用点零改动) 测试:test_convert_repository.py 6 用例 + test_locks_redis.py +4 用例(既有 6 例零改动) pytest 全量 634 passed / 3 skipped(基线 624 + 10,零回归) Co-Authored-By: WorkBuddy <workbuddy@tencent.com>
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
"""T-4 `convert_repository`(agent 侧 risk_convert_detail)单测(开发计划 §5.2 DoD)。
|
||||
|
||||
sqlite 内存库(conftest.sqlite_engine 含 agent 库表 + risk_convert_detail);
|
||||
数据由本文件经仓储自建,不依赖真 MySQL。
|
||||
|
||||
覆盖:
|
||||
1. 占位 → completed(阶段二回写详情,含 Decimal 折算字段)
|
||||
2. 占位 → failed
|
||||
3. 超时 → expired(list_expired_candidates + mark_expired,S2 标记不硬删)
|
||||
4. client_request_id 为 None 不受 uk_idem 约束(多次占位互不冲突)
|
||||
5. get_by_client_request_id 幂等命中 / None 直接返回 None
|
||||
6. 不与 risk_repository 混职责(convert 写操作不动 risk_alert)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.repository.convert_repository import ConvertRepository
|
||||
from app.repository.risk_repository import RiskRepository
|
||||
|
||||
|
||||
def _backdate_created_at(engine, group_id: str, hours_ago: int) -> None:
|
||||
"""把占位行的 created_at 回拨,模拟超时(测试不依赖真实时间漂移)。"""
|
||||
old = datetime.now() - timedelta(hours=hours_ago)
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
text("UPDATE risk_convert_detail SET created_at = :ts WHERE convert_group_id = :gid"),
|
||||
{"ts": old, "gid": group_id},
|
||||
)
|
||||
|
||||
|
||||
# ── 1. 占位 → completed ────────────────────────────────────────────────
|
||||
|
||||
def test_placeholder_then_complete(sqlite_engine):
|
||||
repo = ConvertRepository(engine=sqlite_engine)
|
||||
repo.insert_placeholder("G-1", "REQ-1")
|
||||
|
||||
pending = repo.get_by_group_id("G-1")
|
||||
assert pending is not None
|
||||
assert pending["status"] == "pending"
|
||||
assert pending["estimated"] == 0 # 真实请求,非预估单
|
||||
|
||||
repo.complete_convert(
|
||||
"G-1",
|
||||
out_trade_id="TRD-OUT-1",
|
||||
in_trade_id="TRD-IN-1",
|
||||
related_trade_id="TRD-REL-1",
|
||||
nav=Decimal("1.0234"),
|
||||
nav_date=date(2026, 9, 4),
|
||||
fee_amount=Decimal("252.40"),
|
||||
hold_days_min=3,
|
||||
hold_days_max=180,
|
||||
nav_stale=True,
|
||||
)
|
||||
|
||||
done = repo.get_by_group_id("G-1")
|
||||
assert done["status"] == "completed"
|
||||
assert done["out_trade_id"] == "TRD-OUT-1"
|
||||
assert done["in_trade_id"] == "TRD-IN-1"
|
||||
assert done["related_trade_id"] == "TRD-REL-1"
|
||||
assert done["nav"] == pytest.approx(1.0234) # Decimal→float 绑定,读回一致
|
||||
assert done["fee_amount"] == pytest.approx(252.40)
|
||||
assert date.fromisoformat(str(done["nav_date"])) == date(2026, 9, 4)
|
||||
assert done["hold_days_min"] == 3
|
||||
assert done["hold_days_max"] == 180
|
||||
assert done["nav_stale"] == 1
|
||||
|
||||
|
||||
# ── 2. 占位 → failed ───────────────────────────────────────────────────
|
||||
|
||||
def test_placeholder_then_failed(sqlite_engine):
|
||||
repo = ConvertRepository(engine=sqlite_engine)
|
||||
repo.insert_placeholder("G-2", "REQ-2")
|
||||
repo.mark_failed("G-2")
|
||||
|
||||
assert repo.get_by_group_id("G-2")["status"] == "failed"
|
||||
|
||||
|
||||
# ── 3. 超时 → expired(S2 标记不硬删) ───────────────────────────────────
|
||||
|
||||
def test_expired_lifecycle(sqlite_engine):
|
||||
repo = ConvertRepository(engine=sqlite_engine)
|
||||
repo.insert_placeholder("G-3", "REQ-3")
|
||||
_backdate_created_at(sqlite_engine, "G-3", hours_ago=48)
|
||||
|
||||
# 超 24h 的 pending 应被捞出
|
||||
candidates = repo.list_expired_candidates(24)
|
||||
assert any(c["convert_group_id"] == "G-3" for c in candidates)
|
||||
|
||||
repo.mark_expired("G-3")
|
||||
row = repo.get_by_group_id("G-3")
|
||||
assert row["status"] == "expired" # 标记,不硬删
|
||||
# 标记后不再出现在 pending 候选
|
||||
assert not any(c["convert_group_id"] == "G-3" for c in repo.list_expired_candidates(24))
|
||||
|
||||
|
||||
# ── 4. client_request_id 为 None 不受 uk_idem 约束 ───────────────────────
|
||||
|
||||
def test_client_request_id_none_not_unique(sqlite_engine):
|
||||
repo = ConvertRepository(engine=sqlite_engine)
|
||||
# 两次无幂等键占位都应成功(UNIQUE 允许多个 NULL)
|
||||
repo.insert_placeholder("G-4", None)
|
||||
repo.insert_placeholder("G-5", None)
|
||||
|
||||
assert repo.get_by_group_id("G-4")["status"] == "pending"
|
||||
assert repo.get_by_group_id("G-5")["status"] == "pending"
|
||||
# None 作为查询键永不命中
|
||||
assert repo.get_by_client_request_id(None) is None
|
||||
|
||||
|
||||
# ── 5. 幂等命中查询 ─────────────────────────────────────────────────────
|
||||
|
||||
def test_get_by_client_request_id_hit(sqlite_engine):
|
||||
repo = ConvertRepository(engine=sqlite_engine)
|
||||
repo.insert_placeholder("G-6", "REQ-6")
|
||||
|
||||
row = repo.get_by_client_request_id("REQ-6")
|
||||
assert row is not None
|
||||
assert row["convert_group_id"] == "G-6"
|
||||
|
||||
assert repo.get_by_client_request_id("REQ-NOPE") is None
|
||||
|
||||
|
||||
# ── 6. 不与 risk_repository 混职责 ──────────────────────────────────────
|
||||
|
||||
def test_no_cross_write_to_risk_tables(sqlite_engine):
|
||||
"""convert 占位只写 risk_convert_detail,绝不污染 risk_alert(职责隔离)。"""
|
||||
repo = ConvertRepository(engine=sqlite_engine)
|
||||
repo.insert_placeholder("G-7", "REQ-7")
|
||||
|
||||
with sqlite_engine.connect() as conn:
|
||||
alert_count = conn.execute(text("SELECT COUNT(*) FROM risk_alert")).scalar_one()
|
||||
assert alert_count == 0
|
||||
# 类型隔离:两个仓储是不同类,各自只碰自己的表
|
||||
assert isinstance(repo, ConvertRepository)
|
||||
assert not isinstance(repo, RiskRepository)
|
||||
@@ -136,3 +136,40 @@ def test_three_call_site_keys_have_lock_prefix(gateway):
|
||||
assert "lock:agg:event:CUST-X:2026-09-09" in redis_keys
|
||||
assert "lock:agg:suitability:CUST-X:P-Y:2026-09-09" in redis_keys
|
||||
assert "lock:l3:CUST-X" in redis_keys
|
||||
|
||||
|
||||
# ── T-5 · try_lock(单次非阻塞抢锁,按需用于 convert 幂等/重跑) ────────────
|
||||
|
||||
|
||||
def test_try_lock_acquired_idem_key(gateway):
|
||||
"""Redis 抢到 → with 进入 acquired=True,key 带 lock: 前缀(convert:idem:{cid_req} 形态)。"""
|
||||
with locks.try_lock("convert:idem:REQ-1") as acquired:
|
||||
assert acquired is True
|
||||
key, _token, ttl = gateway.captured[0]
|
||||
assert key == "lock:convert:idem:REQ-1"
|
||||
assert ttl == locks.LOCK_TTL_SECONDS
|
||||
|
||||
|
||||
def test_try_lock_not_acquired_is_nonblocking(gateway):
|
||||
"""锁被占用 → 立即 _NoLock(with 进入 False);非阻塞:只尝试一次、不重试。"""
|
||||
gateway.acquire_result = False
|
||||
with locks.try_lock("convert:rerun:GID-1") as acquired:
|
||||
assert acquired is False
|
||||
# 单次尝试,未进入 run_locked 的轮询循环
|
||||
assert len(gateway.captured) == 1
|
||||
|
||||
|
||||
def test_try_lock_redis_unavailable_falls_back_local(gateway):
|
||||
"""Redis 抛 ConnectionError → 归类 unavailable → 退回进程内锁,acquired=True。"""
|
||||
gateway.raise_on_acquire = ConnectionError("redis down")
|
||||
with locks.try_lock("convert:idem:REQ-2") as acquired:
|
||||
assert acquired is True
|
||||
|
||||
|
||||
def test_try_lock_releases_redis_lock_on_exit(gateway):
|
||||
"""抢到 Redis 锁后,``with`` 退出即释放(_Token.__exit__ 调 release_lock)。"""
|
||||
assert not gateway._store # 初始无锁
|
||||
with locks.try_lock("convert:rerun:GID-3") as acquired:
|
||||
assert acquired is True
|
||||
assert gateway.captured[0][0] in gateway._store # 持锁期间在册
|
||||
assert gateway.captured[0][0] not in gateway._store # 退出后已释放
|
||||
|
||||
Reference in New Issue
Block a user