Files
group_xinghuo_jinrong/tests/test_locks_redis.py
T
GaoYiYuan_0626 f056fcd007 基金转换 T+1 模型:T-1~T-6 落地(DDL/数据层/语义收窄/锁/受理事务)
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,零回归。
2026-09-11 19:21:15 +08:00

211 lines
8.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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