AIcoding 第 5 步 todo 开发(开发计划 v2.0)前半段: - T-1 3 张新表 DDL(core_convert_request 6 态 ENUM / core_trade_calendar / core_share_rule)+ 种子 + sqlite 单一事实源同步 + 列清单断言 - T-2/T-2b calc 扩展(product_round/redeem_amount/partial_qty)+ 真实净值实算回填 - T-3 convert_request_repository(6 态 + 条件 UPDATE 守卫)+ core_ro 三读方法 + share_lot_repository.available_qty_with_inflight(R-3 在途占用推导) - T-4 convert_repository.sync_mirror 成为 risk_convert_detail 唯一进度镜像写入口 (旧三方法标 Deprecated,T-7 后删) - T-5 locks.py 锁键构造器 convert_req_lock_key / convert_confirm_lock_key - T-6 convert_service.accept_convert 受理事务(八步:锁→幂等→校验→受理日顺延 →在途占用校验→落单→镜像+审计→202;不扣份额/不折算/不写流水) + tests/test_convert_accept.py(16 用例) + scripts/dev/verify_convert_accept.py(真库 36/36 一致) + trading_calendar.py 纯函数包(R-5)+ 21 用例 T-6 真库实测暴露并修复:confirm_eta 在日历数据边界抛 ValueError,会让已落库 的受理单在调用方眼里变 500;改为展示性字段容错 + 单测守护。 基线:798 passed / 10 skipped,零回归。
211 lines
8.2 KiB
Python
211 lines
8.2 KiB
Python
"""T-201 · Redis 分布式锁双层(Redis 为主、进程内为备)单元测试。
|
||
|
||
不依赖本机 Redis:所有用例通过 monkeypatch `redis_gateway._gateway` 注入
|
||
可控假网关,覆盖「抢到 / 占用超时 / 不可用降级 / 释放只删自己锁 / 三处 key 前缀」。
|
||
降级路径任意异常(ConnectionError、Fake 缺方法 AttributeError)一律归类为
|
||
unavailable,绝不向上抛——保证 Redis 故障时安全退回进程内锁、业务照常完成。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import pytest
|
||
|
||
from app.service.risk import locks
|
||
from app.service.risk import redis_gateway
|
||
|
||
|
||
class FakeLockGateway:
|
||
"""可控假网关:内存字典模拟 SET NX;raise_on_acquire 模拟 Redis 故障。"""
|
||
|
||
def __init__(self, acquire_result: bool = True, raise_on_acquire: Exception | None = None) -> None:
|
||
self._store: dict[str, str] = {}
|
||
self.acquire_result = acquire_result
|
||
self.raise_on_acquire = raise_on_acquire
|
||
self.captured: list[tuple[str, str, int]] = []
|
||
|
||
def acquire_lock(self, key: str, token: str, ttl_seconds: int) -> bool:
|
||
self.captured.append((key, token, ttl_seconds))
|
||
if self.raise_on_acquire is not None:
|
||
raise self.raise_on_acquire
|
||
if not self.acquire_result:
|
||
return False
|
||
if key in self._store:
|
||
return False
|
||
self._store[key] = token
|
||
return True
|
||
|
||
def release_lock(self, key: str, token: str) -> bool:
|
||
if self._store.get(key) == token:
|
||
del self._store[key]
|
||
return True
|
||
return False
|
||
|
||
|
||
class NoAcquireGateway:
|
||
"""测试 Fake 缺方法场景:只有 publish 等旧方法,无 acquire_lock/release_lock。"""
|
||
|
||
def publish(self, channel, payload):
|
||
pass
|
||
|
||
|
||
@pytest.fixture()
|
||
def gateway(monkeypatch):
|
||
g = FakeLockGateway()
|
||
monkeypatch.setattr(redis_gateway, "_gateway", g)
|
||
return g
|
||
|
||
|
||
def test_redis_lock_acquired_runs_locked_true(gateway):
|
||
"""Redis 抢到锁 → fn(locked=True),且 key 带 TTL 与 lock: 前缀。"""
|
||
seen = {}
|
||
|
||
def _fn(locked: bool) -> dict:
|
||
seen["locked"] = locked
|
||
return {"ok": True}
|
||
|
||
result = locks.run_locked("agg:event:CUST-1:2026-09-09", _fn)
|
||
assert seen["locked"] is True
|
||
assert result == {"ok": True}
|
||
key, _token, ttl = gateway.captured[0]
|
||
assert key == "lock:agg:event:CUST-1:2026-09-09"
|
||
assert ttl == locks.LOCK_TTL_SECONDS
|
||
|
||
|
||
def test_redis_lock_occupied_times_out(gateway, monkeypatch):
|
||
"""锁被占用(抢不到)→ 等待超时降级 fn(locked=False),不抛异常。"""
|
||
gateway.acquire_result = False
|
||
monkeypatch.setattr(locks, "LOCK_TIMEOUT_SECONDS", 0.2)
|
||
monkeypatch.setattr(locks, "_RETRY_INTERVAL", 0.02)
|
||
seen = {}
|
||
|
||
def _fn(locked: bool) -> str:
|
||
seen["locked"] = locked
|
||
return "done"
|
||
|
||
assert locks.run_locked("agg:event:CUST-2:2026-09-09", _fn) == "done"
|
||
assert seen["locked"] is False
|
||
|
||
|
||
def test_redis_connection_error_falls_back_process_lock(gateway):
|
||
"""Redis 抛 ConnectionError → 归类 unavailable → 退回进程内锁,业务完成。"""
|
||
gateway.raise_on_acquire = ConnectionError("redis down")
|
||
seen = {}
|
||
|
||
def _fn(locked: bool) -> str:
|
||
seen["locked"] = locked
|
||
return "business-done"
|
||
|
||
assert locks.run_locked("agg:suitability:CUST-3:P-9:2026-09-09", _fn) == "business-done"
|
||
assert seen["locked"] is True # 进程内锁拿到 → locked=True
|
||
|
||
|
||
def test_redis_missing_method_attribute_error_falls_back(gateway, monkeypatch):
|
||
"""Fake 缺 acquire_lock(AttributeError)→ 归类 unavailable,测试不红。"""
|
||
monkeypatch.setattr(redis_gateway, "_gateway", NoAcquireGateway())
|
||
seen = {}
|
||
|
||
def _fn(locked: bool) -> str:
|
||
seen["locked"] = locked
|
||
return "ok"
|
||
|
||
assert locks.run_locked("l3:CUST-4", _fn) == "ok"
|
||
assert seen["locked"] is True
|
||
|
||
|
||
def test_release_only_owns_lock():
|
||
"""释放只删自己的锁:错误 token 返回 False 且锁仍在,正确 token 才删。"""
|
||
g = FakeLockGateway()
|
||
assert g.acquire_lock("lock:k", "tokA", 30) is True
|
||
assert g.release_lock("lock:k", "tokB") is False
|
||
assert "lock:k" in g._store # 锁仍在
|
||
assert g.release_lock("lock:k", "tokA") is True
|
||
assert "lock:k" not in g._store
|
||
|
||
|
||
def test_three_call_site_keys_have_lock_prefix(gateway):
|
||
"""三处调用点 key 形态全覆盖:agg:event: / agg:suitability: / l3: 均带 lock: 前缀。"""
|
||
keys = [
|
||
"agg:event:CUST-X:2026-09-09",
|
||
"agg:suitability:CUST-X:P-Y:2026-09-09",
|
||
"l3:CUST-X",
|
||
]
|
||
for k in keys:
|
||
locks.run_locked(k, lambda locked: True)
|
||
redis_keys = [c[0] for c in gateway.captured]
|
||
assert all(rk.startswith("lock:") for rk in redis_keys)
|
||
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 # 退出后已释放
|
||
|
||
|
||
# ── T-5 · convert 锁键构造(单一事实源 + R-回归 16) ─────────────────────
|
||
|
||
def test_convert_lock_key_builders(gateway):
|
||
"""convert 锁键构造器产出的键形态 + 前缀正确(R-回归 16 补用例 I1)。"""
|
||
from datetime import date
|
||
|
||
keys = [
|
||
(locks.convert_req_lock_key("CUST-9527"), "lock:convert:req:CUST-9527"),
|
||
(locks.convert_confirm_lock_key(date(2026, 9, 9)), "lock:convert:confirm:2026-09-09"),
|
||
]
|
||
for raw, expected in keys:
|
||
locks.run_locked(raw, lambda locked: True)
|
||
redis_keys = [c[0] for c in gateway.captured]
|
||
assert expected in redis_keys, f"期望 {expected} 在捕获锁键中,实际 {redis_keys}"
|
||
gateway.captured.clear()
|
||
|
||
|
||
def test_convert_req_lock_same_customer_excludes_other(gateway):
|
||
"""锁键粒度:同客户同键互斥;不同客户/不同业务日互不影响(I1 粒度用例)。"""
|
||
from datetime import date
|
||
|
||
locks.run_locked(locks.convert_req_lock_key("CUST-A"), lambda locked: True)
|
||
locks.run_locked(locks.convert_req_lock_key("CUST-B"), lambda locked: True)
|
||
locks.run_locked(locks.convert_confirm_lock_key(date(2026, 9, 9)), lambda locked: True)
|
||
locks.run_locked(locks.convert_confirm_lock_key(date(2026, 9, 10)), lambda locked: True)
|
||
|
||
redis_keys = [c[0] for c in gateway.captured]
|
||
# 四个键各不相同:身份/业务日维度隔离(互斥粒度正确,不过粗)
|
||
assert len(set(redis_keys)) == 4
|
||
assert "lock:convert:req:CUST-A" in redis_keys
|
||
assert "lock:convert:req:CUST-B" in redis_keys
|
||
assert "lock:convert:confirm:2026-09-09" in redis_keys
|
||
assert "lock:convert:confirm:2026-09-10" in redis_keys
|