test_main_fan_monthly_schedule.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373
  1. """月度20日均分、每日限额、冻结清单和磁盘恢复;不连接网关或模型。"""
  2. import asyncio
  3. from datetime import datetime
  4. import json
  5. from types import SimpleNamespace
  6. from zoneinfo import ZoneInfo
  7. import pytest
  8. import pytest_asyncio
  9. import config
  10. from extensions.main_fan import batch_service, mine_catalog, monthly_schedule
  11. TIMEZONE = ZoneInfo('Asia/Shanghai')
  12. def at(day, *, month=11, hour=2, microsecond=0):
  13. return datetime(2026, month, day, hour, tzinfo=TIMEZONE, microsecond=microsecond)
  14. @pytest_asyncio.fixture
  15. async def monthly(monkeypatch, tmp_path):
  16. monkeypatch.setattr(config, 'ROOT', tmp_path)
  17. for key, value in {'MAIN_FAN_BATCH_OUTPUT_DIR': str(tmp_path / 'batches'),
  18. 'MAIN_FAN_BATCH_SPREAD_DAYS': '20', 'MAIN_FAN_BATCH_MAX_MINES': '0',
  19. 'MAIN_FAN_BATCH_ALLOW_ALL': 'True', 'MAIN_FAN_BATCH_DEPT_IDS': 'all',
  20. 'MAIN_FAN_BATCH_RUN_ON_STARTUP': 'False', 'MAIN_FAN_BATCH_CRON': '0 2 1 * *',
  21. 'MAIN_FAN_REPORT_MODE': 'crawler', 'MAIN_FAN_REPORT_API_URL': 'http://reports.invalid'}.items():
  22. monkeypatch.setenv(key, value)
  23. state = SimpleNamespace(now=at(1), refreshes=0, calls=[], controllers=[], gate=None, status='completed')
  24. state.snapshot = {'snapshot_id': 'a' * 32, 'mines': [
  25. {'dept_id': str(1000 + i), 'mine_id': f'{1000 + i:08d}', 'mine_name': f'测试矿{i}'}
  26. for i in range(403)]}
  27. class Clock(datetime):
  28. @classmethod
  29. def now(cls, tz=None):
  30. return state.now.astimezone(tz)
  31. monkeypatch.setattr(monthly_schedule, 'datetime', Clock)
  32. async def refresh():
  33. state.refreshes += 1
  34. await asyncio.sleep(0)
  35. mine_catalog._write(mine_catalog._root() / state.snapshot['snapshot_id'] / 'selected-mines.json',
  36. state.snapshot)
  37. return state.snapshot
  38. class Runner:
  39. def __init__(self, folder, pdf, **kwargs):
  40. self.folder = folder
  41. async def run(self, request):
  42. state.calls.append(request.model_dump(mode='json'))
  43. assert mine_catalog.load(request.snapshot_id)['snapshot_id'] == 'a' * 32
  44. if state.gate is not None:
  45. await state.gate.wait()
  46. result = {'status': state.status}
  47. mine_catalog._write(self.folder / 'batch.json', result)
  48. return result
  49. monkeypatch.setattr(mine_catalog, 'refresh', refresh)
  50. monkeypatch.setattr(batch_service, 'BatchRunner', Runner)
  51. def controller():
  52. item = batch_service.LocalBatchController()
  53. state.controllers.append(item)
  54. return item
  55. async def drain():
  56. await asyncio.gather(*(task for item in state.controllers for task in list(item.tasks.values())))
  57. state.controller, state.drain = controller, drain
  58. yield state
  59. for item in state.controllers:
  60. await item.close()
  61. @pytest.mark.asyncio
  62. async def test_403_mines_are_frozen_and_processed_once_over_20_days(monthly):
  63. controller = monthly.controller()
  64. monthly.now = at(1, microsecond=850000)
  65. first = await controller.monthly.start(['all'])
  66. await monthly.drain()
  67. plan_path = controller.monthly.root / (first['plan_id'] + '.json')
  68. plan = json.loads(plan_path.read_text())
  69. assert [len(row['dept_ids']) for row in plan['days']] == [21] * 3 + [20] * 17
  70. assert first['spread_days'] == 20 and first['mine_count'] == 403
  71. monthly.snapshot['mines'].reverse() # 当月后续投递不能重新按上游清单切片。
  72. for day in range(1, 21):
  73. monthly.now = at(day)
  74. await controller.monthly.dispatch()
  75. await monthly.drain()
  76. repeated = await controller.monthly.start(['all'])
  77. assert repeated['plan_id'] == first['plan_id']
  78. assert len(monthly.calls) == day, '同一天完成后也不能领取下一份'
  79. assert monthly.refreshes == 1
  80. assert [dept for call in monthly.calls for dept in call['dept_ids']] == [str(1000 + i) for i in range(403)]
  81. assert {call['snapshot_id'] for call in monthly.calls} == {'a' * 32}
  82. assert all(call['cutoff'] is None for call in monthly.calls), '每份在实际执行时冻结自己的监测截止时间'
  83. monthly.now = at(21)
  84. assert await controller.monthly.dispatch() is None
  85. assert not controller.tasks
  86. @pytest.mark.asyncio
  87. @pytest.mark.parametrize('count', [0, 3, 40])
  88. async def test_empty_small_and_divisible_catalogs_have_no_invalid_or_missing_batches(monthly, count):
  89. monthly.snapshot['mines'] = monthly.snapshot['mines'][:count]
  90. controller = monthly.controller()
  91. receipt = await controller.monthly.start(['all'])
  92. for day in range(1, 21):
  93. monthly.now = at(day)
  94. await controller.monthly.dispatch()
  95. await monthly.drain()
  96. assert receipt['mine_count'] == count
  97. assert sum(len(call['dept_ids']) for call in monthly.calls) == count
  98. assert len(monthly.calls) == min(count, 20)
  99. assert all(call['dept_ids'] for call in monthly.calls)
  100. @pytest.mark.asyncio
  101. async def test_monthly_and_daily_triggers_racing_only_submit_one_portion(monthly):
  102. controller = monthly.controller()
  103. await asyncio.gather(controller.monthly.start(['all']), controller.monthly.start(['all']),
  104. controller.monthly.dispatch())
  105. await monthly.drain()
  106. assert monthly.refreshes == 1 and len(monthly.calls) == 1
  107. @pytest.mark.asyncio
  108. async def test_registered_callbacks_share_plan_and_daily_quota(monthly, monkeypatch):
  109. controller = monthly.controller()
  110. monkeypatch.setattr('extensions.get_service', lambda _: controller)
  111. first = await batch_service.monthly_callback()
  112. await monthly.drain()
  113. monthly.now = at(2)
  114. second = await batch_service.daily_callback()
  115. await monthly.drain()
  116. assert second['batch_id'] != first['daily_batch']['batch_id']
  117. assert monthly.refreshes == 1 and len(monthly.calls) == 2
  118. @pytest.mark.asyncio
  119. async def test_restart_after_completion_does_not_fetch_catalog_or_take_next_portion(monthly):
  120. original = monthly.controller()
  121. receipt = await original.monthly.start(['all'])
  122. await monthly.drain()
  123. await original.close()
  124. restarted = monthly.controller()
  125. restarted.start_on_startup()
  126. await restarted.monthly_task
  127. assert restarted.startup_task is None
  128. repeated = await restarted.monthly.start(['all'])
  129. assert repeated['daily_batch']['batch_id'] == receipt['daily_batch']['batch_id']
  130. assert repeated['daily_batch']['status'] == 'completed'
  131. assert not restarted.tasks
  132. assert monthly.refreshes == 1 and len(monthly.calls) == 1
  133. @pytest.mark.asyncio
  134. async def test_restart_resumes_interrupted_portion_with_same_id_and_consumes_that_days_quota(monthly):
  135. original = monthly.controller()
  136. receipt = await original.monthly.start(['all'])
  137. identifier = receipt['daily_batch']['batch_id']
  138. await original.close() # worker 尚未开始;job.json 仍必须可恢复。
  139. assert original.get(identifier)['status'] == 'interrupted'
  140. monthly.now = at(5)
  141. restarted = monthly.controller()
  142. restarted.start_on_startup()
  143. await restarted.monthly_task
  144. await monthly.drain()
  145. assert restarted.get(identifier)['status'] == 'completed'
  146. assert len(monthly.calls) == 1
  147. await restarted.monthly.dispatch()
  148. await monthly.drain()
  149. assert len(monthly.calls) == 1, '恢复上一份的当天不能再增加新份额'
  150. monthly.now = at(6)
  151. await restarted.monthly.dispatch()
  152. await monthly.drain()
  153. assert monthly.calls[1]['dept_ids'][0] == '1021'
  154. @pytest.mark.asyncio
  155. async def test_downtime_defers_missed_portions_one_per_day_without_burst(monthly):
  156. controller = monthly.controller()
  157. await controller.monthly.start(['all'])
  158. await monthly.drain()
  159. monthly.now = at(5)
  160. await controller.monthly.dispatch()
  161. await monthly.drain()
  162. await controller.monthly.dispatch()
  163. await monthly.drain()
  164. assert len(monthly.calls) == 2
  165. assert monthly.calls[1]['dept_ids'] == [str(i) for i in range(1021, 1042)]
  166. monthly.now = at(6)
  167. await controller.monthly.dispatch()
  168. await monthly.drain()
  169. assert len(monthly.calls) == 3
  170. @pytest.mark.asyncio
  171. async def test_slow_portion_does_not_accumulate_next_days_in_queue(monthly):
  172. monthly.gate = asyncio.Event()
  173. controller = monthly.controller()
  174. receipt = await controller.monthly.start(['all'])
  175. await asyncio.sleep(0)
  176. monthly.now = at(2)
  177. second = await controller.monthly.dispatch()
  178. assert second['batch_id'] == receipt['daily_batch']['batch_id']
  179. assert len(controller.tasks) == 1 and len(monthly.calls) == 1
  180. monthly.gate.set()
  181. await monthly.drain()
  182. await controller.monthly.dispatch()
  183. assert len(monthly.calls) == 1
  184. monthly.now = at(3)
  185. await controller.monthly.dispatch()
  186. await monthly.drain()
  187. assert len(monthly.calls) == 2
  188. @pytest.mark.asyncio
  189. @pytest.mark.parametrize('status', ['partial', 'failed'])
  190. async def test_failed_mines_are_recorded_without_blocking_the_next_days_portion(monthly, status):
  191. monthly.status = status
  192. controller = monthly.controller()
  193. first = await controller.monthly.start(['all'])
  194. await monthly.drain()
  195. assert controller.get(first['daily_batch']['batch_id'])['status'] == status
  196. monthly.now = at(2)
  197. await controller.monthly.dispatch()
  198. await monthly.drain()
  199. assert len(monthly.calls) == 2
  200. assert monthly.calls[1]['dept_ids'][0] == '1021'
  201. @pytest.mark.asyncio
  202. async def test_prior_month_backlog_and_new_plan_share_one_daily_quota(monthly):
  203. controller = monthly.controller()
  204. await controller.monthly.start(['all'])
  205. await monthly.drain()
  206. monthly.now = at(1, month=12)
  207. december = await controller.monthly.start(['all'])
  208. await monthly.drain()
  209. assert december['month'] == '2026-12'
  210. assert monthly.calls[-1]['dept_ids'][0] == '1021', '先处理上月积压,而非叠加两个月的日份额'
  211. await controller.monthly.dispatch()
  212. await monthly.drain()
  213. assert len(monthly.calls) == 2
  214. @pytest.mark.asyncio
  215. async def test_daily_or_startup_without_monthly_trigger_does_not_create_a_plan(monthly):
  216. controller = monthly.controller()
  217. monthly.now = at(15)
  218. controller.start_on_startup()
  219. await controller.monthly_task
  220. assert await controller.monthly.dispatch() is None
  221. assert monthly.refreshes == 0 and not monthly.calls
  222. @pytest.mark.asyncio
  223. async def test_empty_cron_disables_startup_recovery_of_an_existing_plan(monthly, monkeypatch):
  224. original = monthly.controller()
  225. await original.monthly.start(['all'])
  226. await original.close()
  227. monkeypatch.setenv('MAIN_FAN_BATCH_CRON', '')
  228. restarted = monthly.controller()
  229. restarted.start_on_startup()
  230. assert restarted.monthly_task is None and restarted.startup_task is None
  231. assert not restarted.tasks and not monthly.calls
  232. @pytest.mark.asyncio
  233. async def test_explicit_scope_keeps_order_and_rejects_unknown_or_duplicate_mines(monthly):
  234. controller = monthly.controller()
  235. for depts in (['1000', '1000'], ['999999']):
  236. with pytest.raises(ValueError, match='部门编号重复或不在'):
  237. await controller.monthly.start(depts)
  238. receipt = await controller.monthly.start(['1005', '1001', '1003'])
  239. await monthly.drain()
  240. plan = json.loads((controller.monthly.root / (receipt['plan_id'] + '.json')).read_text())
  241. assert [row['dept_ids'] for row in plan['days'][:3]] == [['1005'], ['1001'], ['1003']]
  242. @pytest.mark.asyncio
  243. async def test_daily_limit_checked_before_plan_creation_and_again_on_dispatch(monthly, monkeypatch):
  244. monkeypatch.setenv('MAIN_FAN_BATCH_MAX_MINES', '20')
  245. controller = monthly.controller()
  246. with pytest.raises(ValueError, match='每日批次超过'):
  247. await controller.monthly.start(['all'])
  248. assert not list(controller.monthly.root.glob('*.json')) and not controller.tasks
  249. controller.max_mines = 21
  250. await controller.monthly.start(['all'])
  251. await monthly.drain()
  252. controller.max_mines = 20
  253. monthly.now = at(2)
  254. with pytest.raises(ValueError, match='每日批次超过'):
  255. await controller.monthly.dispatch()
  256. assert len(monthly.calls) == 1
  257. @pytest.mark.asyncio
  258. async def test_all_mines_permission_is_not_bypassed_by_explicit_daily_slices(monthly, monkeypatch):
  259. controller = monthly.controller()
  260. monkeypatch.setenv('MAIN_FAN_BATCH_ALLOW_ALL', 'False')
  261. with pytest.raises(ValueError, match='全矿研判未启用'):
  262. await controller.monthly.start(['all'])
  263. assert monthly.refreshes == 0
  264. monkeypatch.setenv('MAIN_FAN_BATCH_ALLOW_ALL', 'True')
  265. await controller.monthly.start(['all'])
  266. await monthly.drain()
  267. monthly.now = at(2)
  268. monkeypatch.setenv('MAIN_FAN_BATCH_ALLOW_ALL', 'False')
  269. with pytest.raises(ValueError, match='全矿研判未启用'):
  270. await controller.monthly.dispatch()
  271. assert len(monthly.calls) == 1
  272. @pytest.mark.parametrize('days', [0, -1, 29])
  273. def test_invalid_spread_days_fail_early(monthly, monkeypatch, days):
  274. monkeypatch.setenv('MAIN_FAN_BATCH_SPREAD_DAYS', str(days))
  275. with pytest.raises(ValueError, match='1~28'):
  276. monthly.controller()
  277. @pytest.mark.asyncio
  278. @pytest.mark.parametrize(('spread_days', 'cron', 'count'), [(20, '15 3 1 * *', 2),
  279. (1, '15 3 1 * *', 1), (20, '', 0)])
  280. async def test_scheduler_uses_beijing_monthly_and_daily_triggers(monthly, monkeypatch, spread_days, cron, count):
  281. from scheduler import scheduler
  282. monkeypatch.setenv('MAIN_FAN_LOCAL_BATCH_ENABLED', 'True')
  283. monkeypatch.setenv('MAIN_FAN_BATCH_SPREAD_DAYS', str(spread_days))
  284. monkeypatch.setenv('MAIN_FAN_BATCH_CRON', cron)
  285. try:
  286. assert await scheduler.start() == count
  287. jobs = {job.id: job for job in scheduler.get_scheduler().get_jobs()}
  288. if count:
  289. job = jobs['main-fan:monthly-local']
  290. assert job.args == (batch_service.monthly_callback,)
  291. assert job.trigger.get_next_fire_time(None, at(2, hour=0)).isoformat() == '2026-12-01T03:15:00+08:00'
  292. if count == 2:
  293. job = jobs['main-fan:daily-local']
  294. assert job.args == (batch_service.daily_callback,)
  295. assert job.trigger.get_next_fire_time(None, at(2, hour=0)).isoformat() == '2026-11-02T03:15:00+08:00'
  296. finally:
  297. await scheduler.shutdown()
  298. await asyncio.sleep(0)
  299. @pytest.mark.asyncio
  300. async def test_scheduler_shutdown_cancels_plan_creation_before_closing_dependencies(monthly, monkeypatch):
  301. from scheduler import scheduler
  302. controller = monthly.controller()
  303. monkeypatch.setattr('extensions.get_service', lambda _: controller)
  304. monkeypatch.setenv('MAIN_FAN_LOCAL_BATCH_ENABLED', 'True')
  305. entered = asyncio.Event()
  306. async def waiting_refresh():
  307. entered.set()
  308. await asyncio.Event().wait()
  309. monkeypatch.setattr(mine_catalog, 'refresh', waiting_refresh)
  310. try:
  311. await scheduler.start()
  312. invocation = asyncio.create_task(scheduler._invoke_batch(batch_service.monthly_callback))
  313. await asyncio.wait_for(entered.wait(), timeout=3)
  314. await scheduler.shutdown()
  315. assert invocation.cancelled() and not scheduler._active
  316. assert not controller.tasks and not list(controller.monthly.root.glob('*.json'))
  317. finally:
  318. await scheduler.shutdown()
  319. await asyncio.sleep(0)