Files
group_xinghuo_jinrong/tests/test_locks_redis.py
T

176 lines
6.5 KiB
Python
Raw Normal View History

"""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 # 退出后已释放