test_refund_worker_singleton.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560
  1. from collections.abc import Callable
  2. from threading import Event
  3. from typing import Any
  4. from zbt.commands.refund_worker_runtime import (
  5. run_parallel_worker,
  6. run_resilient_singleton_worker,
  7. run_singleton_worker,
  8. )
  9. from zbt.infrastructure.redis.worker_lock import RedisWorkerLease
  10. class FakeRedis:
  11. def __init__(self, clock: Callable[[], float]) -> None:
  12. self._clock = clock
  13. self._values: dict[str, tuple[str, float]] = {}
  14. def set(
  15. self,
  16. name: str,
  17. value: str,
  18. *,
  19. nx: bool,
  20. ex: int,
  21. ) -> bool | None:
  22. self._discard_expired(name)
  23. if nx and name in self._values:
  24. return None
  25. self._values[name] = (value, self._clock() + ex)
  26. return True
  27. def eval(self, script: str, numkeys: int, *keys_and_args: Any) -> int:
  28. assert numkeys == 1
  29. name = str(keys_and_args[0])
  30. owner_id = str(keys_and_args[1])
  31. self._discard_expired(name)
  32. current = self._values.get(name)
  33. if current is None or current[0] != owner_id:
  34. return 0
  35. if "'DEL'" in script:
  36. del self._values[name]
  37. return 1
  38. if "'EXPIRE'" in script:
  39. ttl_seconds = int(keys_and_args[2])
  40. self._values[name] = (owner_id, self._clock() + ttl_seconds)
  41. return 1
  42. raise NotImplementedError
  43. def _discard_expired(self, name: str) -> None:
  44. current = self._values.get(name)
  45. if current is not None and current[1] <= self._clock():
  46. del self._values[name]
  47. def test_only_one_refund_worker_can_acquire_the_same_lease() -> None:
  48. now = 100.0
  49. redis = FakeRedis(lambda: now)
  50. first = RedisWorkerLease(
  51. redis,
  52. key="ins:s3:test:worker:lock:refund",
  53. owner_id="worker-1",
  54. ttl_seconds=30,
  55. )
  56. second = RedisWorkerLease(
  57. redis,
  58. key="ins:s3:test:worker:lock:refund",
  59. owner_id="worker-2",
  60. ttl_seconds=30,
  61. )
  62. assert first.acquire() is True
  63. assert second.acquire() is False
  64. def test_releasing_the_owned_lease_allows_a_new_worker_to_take_over() -> None:
  65. now = 100.0
  66. redis = FakeRedis(lambda: now)
  67. first = RedisWorkerLease(
  68. redis,
  69. key="ins:s3:test:worker:lock:refund",
  70. owner_id="worker-1",
  71. ttl_seconds=30,
  72. )
  73. second = RedisWorkerLease(
  74. redis,
  75. key="ins:s3:test:worker:lock:refund",
  76. owner_id="worker-2",
  77. ttl_seconds=30,
  78. )
  79. assert first.acquire() is True
  80. assert first.release() is True
  81. assert second.acquire() is True
  82. def test_renewing_the_lease_prevents_takeover_until_the_new_expiry() -> None:
  83. now = 100.0
  84. redis = FakeRedis(lambda: now)
  85. first = RedisWorkerLease(
  86. redis,
  87. key="ins:s3:test:worker:lock:refund",
  88. owner_id="worker-1",
  89. ttl_seconds=30,
  90. )
  91. second = RedisWorkerLease(
  92. redis,
  93. key="ins:s3:test:worker:lock:refund",
  94. owner_id="worker-2",
  95. ttl_seconds=30,
  96. )
  97. assert first.acquire() is True
  98. now = 125.0
  99. assert first.renew() is True
  100. now = 131.0
  101. assert second.acquire() is False
  102. now = 156.0
  103. assert second.acquire() is True
  104. def test_expired_owner_cannot_release_the_new_owners_lease() -> None:
  105. now = 100.0
  106. redis = FakeRedis(lambda: now)
  107. first = RedisWorkerLease(
  108. redis,
  109. key="ins:s3:test:worker:lock:refund",
  110. owner_id="worker-1",
  111. ttl_seconds=30,
  112. )
  113. second = RedisWorkerLease(
  114. redis,
  115. key="ins:s3:test:worker:lock:refund",
  116. owner_id="worker-2",
  117. ttl_seconds=30,
  118. )
  119. third = RedisWorkerLease(
  120. redis,
  121. key="ins:s3:test:worker:lock:refund",
  122. owner_id="worker-3",
  123. ttl_seconds=30,
  124. )
  125. assert first.acquire() is True
  126. now = 131.0
  127. assert second.acquire() is True
  128. assert first.release() is False
  129. assert third.acquire() is False
  130. class DelayedLease:
  131. owner_id = "worker-2"
  132. def __init__(self) -> None:
  133. self.acquire_count = 0
  134. self.release_count = 0
  135. def acquire(self) -> bool:
  136. self.acquire_count += 1
  137. return self.acquire_count >= 3
  138. def renew(self) -> bool:
  139. return True
  140. def release(self) -> bool:
  141. self.release_count += 1
  142. return True
  143. def test_duplicate_worker_waits_and_takes_over_without_early_initialization() -> None:
  144. stop_event = Event()
  145. lease = DelayedLease()
  146. initialized_after_attempt = 0
  147. output: list[str] = []
  148. def create_worker() -> Any:
  149. nonlocal initialized_after_attempt
  150. initialized_after_attempt = lease.acquire_count
  151. class OneShotWorker:
  152. def run_once(self, limit: int = 20) -> dict[str, Any]:
  153. del limit
  154. stop_event.set()
  155. return {"processed": 0, "items": []}
  156. return OneShotWorker()
  157. result = run_singleton_worker(
  158. create_worker,
  159. lease,
  160. stop_event=stop_event,
  161. output=output.append,
  162. poll_interval_seconds=0.001,
  163. heartbeat_interval_seconds=0.001,
  164. acquire_retry_interval_seconds=0.001,
  165. )
  166. assert result == "stopped"
  167. assert lease.acquire_count == 3
  168. assert initialized_after_attempt == 3
  169. assert lease.release_count == 1
  170. assert "已有退款工作器持有单实例锁,当前实例进入待命并等待接管。" in output
  171. assert "原实例租约已释放,待命工作器开始接管:worker-2" in output
  172. def test_worker_keeps_waiting_when_redis_temporarily_rejects_lease_acquisition() -> None:
  173. stop_event = Event()
  174. output: list[str] = []
  175. class TemporarilyUnavailableLease:
  176. owner_id = "worker-recovering"
  177. def __init__(self) -> None:
  178. self.acquire_count = 0
  179. def acquire(self) -> bool:
  180. self.acquire_count += 1
  181. if self.acquire_count == 1:
  182. raise ConnectionError("Redis connection reset")
  183. return True
  184. def renew(self) -> bool:
  185. return True
  186. def release(self) -> bool:
  187. return True
  188. lease = TemporarilyUnavailableLease()
  189. class OneShotWorker:
  190. def run_once(self, limit: int = 20) -> dict[str, Any]:
  191. del limit
  192. stop_event.set()
  193. return {"processed": 0, "items": []}
  194. result = run_singleton_worker(
  195. OneShotWorker,
  196. lease,
  197. stop_event=stop_event,
  198. output=output.append,
  199. poll_interval_seconds=0.001,
  200. heartbeat_interval_seconds=0.01,
  201. acquire_retry_interval_seconds=0.001,
  202. )
  203. assert result == "stopped"
  204. assert lease.acquire_count == 2
  205. assert (
  206. "退款工作器获取单实例锁异常,将继续等待:Redis connection reset"
  207. in output
  208. )
  209. def test_standby_worker_can_stop_without_initializing_business_dependencies() -> None:
  210. stop_event = Event()
  211. initialized = False
  212. class RejectedLease:
  213. owner_id = "worker-standby"
  214. def acquire(self) -> bool:
  215. stop_event.set()
  216. return False
  217. def renew(self) -> bool:
  218. raise AssertionError("未持锁的工作器不应续约")
  219. def release(self) -> bool:
  220. raise AssertionError("未持锁的工作器不应释放租约")
  221. def create_worker() -> Any:
  222. nonlocal initialized
  223. initialized = True
  224. raise AssertionError("待命工作器不应初始化业务依赖")
  225. result = run_singleton_worker(
  226. create_worker,
  227. RejectedLease(),
  228. stop_event=stop_event,
  229. output=lambda _: None,
  230. acquire_retry_interval_seconds=0.001,
  231. )
  232. assert result == "stopped"
  233. assert initialized is False
  234. class HeldLease:
  235. owner_id = "worker-1"
  236. def __init__(self) -> None:
  237. self.release_count = 0
  238. def acquire(self) -> bool:
  239. return True
  240. def renew(self) -> bool:
  241. return True
  242. def release(self) -> bool:
  243. self.release_count += 1
  244. return True
  245. def test_lock_owner_processes_work_and_releases_the_lease_on_stop() -> None:
  246. stop_event = Event()
  247. lease = HeldLease()
  248. processed = 0
  249. output: list[str] = []
  250. class OneShotWorker:
  251. def run_once(self, limit: int = 20) -> dict[str, Any]:
  252. nonlocal processed
  253. del limit
  254. processed += 1
  255. stop_event.set()
  256. return {"processed": 1, "items": []}
  257. result = run_singleton_worker(
  258. OneShotWorker,
  259. lease,
  260. stop_event=stop_event,
  261. output=output.append,
  262. poll_interval_seconds=0.001,
  263. heartbeat_interval_seconds=0.001,
  264. )
  265. assert result == "stopped"
  266. assert processed == 1
  267. assert lease.release_count == 1
  268. assert "退款工作器初始化完成,开始轮询任务。" in output
  269. def test_active_worker_renews_the_lease_while_business_processing_is_running() -> None:
  270. stop_event = Event()
  271. renewed = Event()
  272. class HeartbeatLease(HeldLease):
  273. def renew(self) -> bool:
  274. renewed.set()
  275. return True
  276. class BlockingWorker:
  277. def run_once(self, limit: int = 20) -> dict[str, Any]:
  278. del limit
  279. assert renewed.wait(0.5), "业务执行期间没有续约单实例锁"
  280. stop_event.set()
  281. return {"processed": 0, "items": []}
  282. result = run_singleton_worker(
  283. BlockingWorker,
  284. HeartbeatLease(),
  285. stop_event=stop_event,
  286. output=lambda _: None,
  287. poll_interval_seconds=0.001,
  288. heartbeat_interval_seconds=0.01,
  289. )
  290. assert result == "stopped"
  291. def test_worker_stops_claiming_tasks_after_losing_the_singleton_lease() -> None:
  292. stop_event = Event()
  293. renewal_attempted = Event()
  294. class LostLease(HeldLease):
  295. def renew(self) -> bool:
  296. renewal_attempted.set()
  297. return False
  298. class WorkerFinishingCurrentBatch:
  299. def run_once(self, limit: int = 20) -> dict[str, Any]:
  300. del limit
  301. assert renewal_attempted.wait(0.5)
  302. return {"processed": 0, "items": []}
  303. result = run_singleton_worker(
  304. WorkerFinishingCurrentBatch,
  305. LostLease(),
  306. stop_event=stop_event,
  307. output=lambda _: None,
  308. poll_interval_seconds=0.001,
  309. heartbeat_interval_seconds=0.01,
  310. )
  311. assert result == "lease_lost"
  312. def test_resilient_worker_waits_for_the_lease_and_resumes_after_losing_it() -> None:
  313. stop_event = Event()
  314. first_renewal = Event()
  315. initialized = 0
  316. lease_lost_count = 0
  317. output: list[str] = []
  318. class RecoverableLease(HeldLease):
  319. def __init__(self) -> None:
  320. super().__init__()
  321. self.acquire_count = 0
  322. self.renew_count = 0
  323. def acquire(self) -> bool:
  324. self.acquire_count += 1
  325. return True
  326. def renew(self) -> bool:
  327. self.renew_count += 1
  328. if self.renew_count == 1:
  329. first_renewal.set()
  330. return False
  331. return True
  332. lease = RecoverableLease()
  333. def create_worker() -> Any:
  334. nonlocal initialized
  335. initialized += 1
  336. current_generation = initialized
  337. class RecoveringWorker:
  338. def run_once(self, limit: int = 20) -> dict[str, Any]:
  339. del limit
  340. if current_generation == 1:
  341. assert first_renewal.wait(0.5)
  342. else:
  343. stop_event.set()
  344. return {"processed": 0, "items": []}
  345. return RecoveringWorker()
  346. def record_lease_loss() -> None:
  347. nonlocal lease_lost_count
  348. lease_lost_count += 1
  349. result = run_resilient_singleton_worker(
  350. create_worker,
  351. lease,
  352. stop_event=stop_event,
  353. on_lease_lost=record_lease_loss,
  354. output=output.append,
  355. poll_interval_seconds=0.001,
  356. heartbeat_interval_seconds=0.01,
  357. acquire_retry_interval_seconds=0.001,
  358. )
  359. assert result == "stopped"
  360. assert initialized == 2
  361. assert lease.acquire_count == 2
  362. assert lease_lost_count == 1
  363. assert "退款工作器进入待命,等待重新获取单实例锁。" in output
  364. def test_resilient_worker_recovers_when_releasing_a_lost_lease_also_fails() -> None:
  365. stop_event = Event()
  366. renewal_attempted = Event()
  367. initialized = 0
  368. output: list[str] = []
  369. class UnstableRedisLease:
  370. owner_id = "worker-unstable-redis"
  371. def __init__(self) -> None:
  372. self.renew_count = 0
  373. self.release_count = 0
  374. def acquire(self) -> bool:
  375. return True
  376. def renew(self) -> bool:
  377. self.renew_count += 1
  378. if self.renew_count == 1:
  379. renewal_attempted.set()
  380. raise ConnectionError("Redis unavailable during renew")
  381. return True
  382. def release(self) -> bool:
  383. self.release_count += 1
  384. if self.release_count == 1:
  385. raise ConnectionError("Redis unavailable during release")
  386. return True
  387. lease = UnstableRedisLease()
  388. def create_worker() -> Any:
  389. nonlocal initialized
  390. initialized += 1
  391. current_generation = initialized
  392. class RecoveringWorker:
  393. def run_once(self, limit: int = 20) -> dict[str, Any]:
  394. del limit
  395. if current_generation == 1:
  396. assert renewal_attempted.wait(0.5)
  397. else:
  398. stop_event.set()
  399. return {"processed": 0, "items": []}
  400. return RecoveringWorker()
  401. result = run_resilient_singleton_worker(
  402. create_worker,
  403. lease,
  404. stop_event=stop_event,
  405. output=output.append,
  406. poll_interval_seconds=0.001,
  407. heartbeat_interval_seconds=0.01,
  408. acquire_retry_interval_seconds=0.001,
  409. )
  410. assert result == "stopped"
  411. assert initialized == 2
  412. assert lease.release_count == 2
  413. assert (
  414. "退款工作器释放单实例锁异常,租约将由 Redis 自动过期:"
  415. "Redis unavailable during release"
  416. in output
  417. )
  418. def test_lease_is_released_when_worker_initialization_fails() -> None:
  419. lease = HeldLease()
  420. def fail_to_create_worker() -> Any:
  421. raise RuntimeError("数据库初始化失败")
  422. try:
  423. run_singleton_worker(
  424. fail_to_create_worker,
  425. lease,
  426. stop_event=Event(),
  427. output=lambda _: None,
  428. )
  429. except RuntimeError as error:
  430. assert str(error) == "数据库初始化失败"
  431. else:
  432. raise AssertionError("工作器初始化异常应继续向上抛出")
  433. assert lease.release_count == 1
  434. def test_parallel_mode_processes_tasks_without_a_global_singleton_lease() -> None:
  435. stop_event = Event()
  436. processed = 0
  437. class OneShotWorker:
  438. def run_once(self, limit: int = 20) -> dict[str, Any]:
  439. nonlocal processed
  440. del limit
  441. processed += 1
  442. stop_event.set()
  443. return {"processed": 1, "items": []}
  444. result = run_parallel_worker(
  445. OneShotWorker,
  446. stop_event=stop_event,
  447. output=lambda _: None,
  448. poll_interval_seconds=0.001,
  449. )
  450. assert result == "stopped"
  451. assert processed == 1