batch_service.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227
  1. """启动批次、网页及月度调度共用串行队列;退出时取消并保存进度。"""
  2. import asyncio
  3. from contextlib import asynccontextmanager
  4. from datetime import datetime
  5. import hashlib
  6. import json
  7. from pathlib import Path
  8. from uuid import uuid4
  9. from zoneinfo import ZoneInfo
  10. import config
  11. from extensions.main_fan.batch import BatchRunner
  12. from extensions.main_fan.batch_models import BatchRequest
  13. from extensions.main_fan.exploration import _write
  14. from extensions.main_fan import mine_catalog
  15. from extensions.main_fan.monthly_schedule import MonthlyBatchPlanner
  16. from extensions.main_fan.workflow import VERSION as WORKFLOW_VERSION, publish_enabled
  17. from obs import get_logger
  18. logger = get_logger(__name__)
  19. def configured_departments():
  20. depts = [value.strip() for value in config.get_str('MAIN_FAN_BATCH_DEPT_IDS', 'all').split(',') if value.strip()]
  21. if not depts or ('all' in depts and depts != ['all']):
  22. raise ValueError('MAIN_FAN_BATCH_DEPT_IDS 必须为 all 或逗号分隔的部门编号')
  23. return depts
  24. def configured_startup_exclusions():
  25. name = 'MAIN_FAN_BATCH_STARTUP_EXCLUDE_DEPT_IDS'
  26. try:
  27. values = json.loads(config.get_str(name, '[]').strip() or '[]')
  28. except json.JSONDecodeError as exc:
  29. raise ValueError(name + ' 必须为 JSON 数组,例如 [1045,1106]') from exc
  30. if not isinstance(values, list):
  31. raise ValueError(name + ' 必须为 JSON 数组,例如 [1045,1106]')
  32. excluded = set()
  33. for value in values:
  34. if type(value) not in (int, str):
  35. raise ValueError(name + ' 的每项必须是数字部门编号或其字符串')
  36. dept = str(value).strip()
  37. if not dept.isascii() or not dept.isdigit() or len(dept) > 64 or int(dept) == 0:
  38. raise ValueError(name + ' 的每项必须是非零、最多64位的数字部门编号')
  39. excluded.add(dept)
  40. return excluded
  41. class LocalBatchController:
  42. def __init__(self):
  43. output = Path(config.get_str('MAIN_FAN_BATCH_OUTPUT_DIR', 'outputs/main_fan_batches'))
  44. self.root = (output if output.is_absolute() else config.ROOT / output).resolve()
  45. self.pdf = None
  46. if config.get_str('MAIN_FAN_REPORT_MODE', 'crawler').lower() == 'mock':
  47. source = Path(config.get_str('MAIN_FAN_MOCK_REPORT_PDF'))
  48. self.pdf = (source if source.is_absolute() else config.ROOT / source).resolve()
  49. from extensions.main_fan.crawler_reports import report_source
  50. report_source(self.pdf) # 启动时校验配置,不发请求。
  51. self.max_mines = config.get_int('MAIN_FAN_BATCH_MAX_MINES', 2)
  52. if self.max_mines < 0:
  53. raise ValueError('MAIN_FAN_BATCH_MAX_MINES 不能小于0;0表示不限制批次矿数')
  54. self.startup_enabled = config.get_bool('MAIN_FAN_BATCH_RUN_ON_STARTUP', True)
  55. self.startup_max_mines = config.get_int('MAIN_FAN_BATCH_STARTUP_MAX_MINES', 2)
  56. if self.startup_enabled and self.startup_max_mines <= 0:
  57. raise ValueError('MAIN_FAN_BATCH_STARTUP_MAX_MINES 必须大于0')
  58. self.tasks = {}
  59. self.lock = asyncio.Semaphore(1)
  60. self.startup_task = None
  61. self.monthly_task = None
  62. self.closed = False
  63. self.monthly = MonthlyBatchPlanner(self)
  64. def submit(self, request, *, key=None, trigger='manual'):
  65. if self.closed:
  66. raise RuntimeError('主通风机批次队列正在关闭')
  67. request = BatchRequest.model_validate(request)
  68. if request.all_producing_mines and not config.get_bool('MAIN_FAN_BATCH_ALLOW_ALL', False):
  69. raise ValueError('全矿研判未启用,请设置 MAIN_FAN_BATCH_ALLOW_ALL=True 或显式选择煤矿')
  70. if self.max_mines and len(request.dept_ids) > self.max_mines:
  71. raise ValueError('本地批次超过配置的矿井数量上限')
  72. identifier = hashlib.sha256(key.encode()).hexdigest()[:32] if key else uuid4().hex
  73. folder = self.root / identifier
  74. job = folder / 'job.json'
  75. if job.is_file():
  76. original = json.loads(job.read_text())
  77. if original['request'] != request.model_dump(mode='json'):
  78. raise ValueError('同一批次标识的请求内容发生变化')
  79. state_file = folder / 'batch.json'
  80. if identifier in self.tasks or (state_file.is_file() and json.loads(state_file.read_text())['status'] == 'completed'):
  81. return {'batch_id': identifier, 'status': original['status'], 'output': str(folder)}
  82. _write(job, {'batch_id': identifier, 'status': 'queued', 'request': request.model_dump(mode='json'),
  83. 'publish': publish_enabled(), 'trigger': trigger})
  84. self.tasks[identifier] = asyncio.create_task(self.work(identifier, request), name='main-fan-batch-' + identifier)
  85. return {'batch_id': identifier, 'status': 'queued', 'output': str(folder)}
  86. async def work(self, identifier, request):
  87. folder = self.root / identifier
  88. job = json.loads((folder / 'job.json').read_text())
  89. try:
  90. async with self.lock:
  91. job['status'] = 'running'
  92. _write(folder / 'job.json', job)
  93. result = await BatchRunner(folder, self.pdf,
  94. max_mines=self.max_mines or None).run(request)
  95. job['status'] = result['status']
  96. except asyncio.CancelledError:
  97. job['status'] = 'interrupted'
  98. raise
  99. except Exception as exc:
  100. logger.exception('批次队列执行失败', extra={'kv': {'event': 'batch.queue.failed', 'batch_id': identifier}})
  101. job.update(status='failed', error=type(exc).__name__ + ': ' + str(exc)[:500])
  102. finally:
  103. _write(folder / 'job.json', job)
  104. self.tasks.pop(identifier, None)
  105. def get(self, identifier):
  106. if len(identifier) != 32 or any(char not in '0123456789abcdef' for char in identifier):
  107. raise ValueError('无效批次标识')
  108. folder = self.root / identifier
  109. job = json.loads((folder / 'job.json').read_text())
  110. state = folder / 'batch.json'
  111. return {**job, 'output': str(folder), 'batch': json.loads(state.read_text()) if state.is_file() else None}
  112. def start_on_startup(self):
  113. """服务和任务执行器就绪后调用;每个进程生命周期只启动一批,不阻塞就绪。"""
  114. if (not self.closed and self.monthly.days > 1 and config.get_str('MAIN_FAN_BATCH_CRON')
  115. and self.monthly_task is None):
  116. self.monthly_task = asyncio.create_task(self._resume_monthly(), name='main-fan-monthly-resume')
  117. if not self.closed and self.startup_enabled and self.startup_task is None:
  118. self.startup_task = asyncio.create_task(self._startup_once(), name='main-fan-startup-selection')
  119. async def _resume_monthly(self):
  120. try:
  121. await self.monthly.dispatch(resume_only=True)
  122. except asyncio.CancelledError:
  123. raise
  124. except Exception:
  125. logger.exception('恢复月度分日批次失败,日调度仍保留',
  126. extra={'kv': {'event': 'batch.monthly.resume.failed'}})
  127. async def _startup_once(self):
  128. try:
  129. depts = configured_departments()
  130. excluded = configured_startup_exclusions()
  131. snapshot = await mine_catalog.refresh()
  132. available = {mine['dept_id']: mine for mine in snapshot['mines']}
  133. if depts == ['all']:
  134. eligible = list(available)
  135. else:
  136. if len(set(depts)) != len(depts) or any(dept not in available for dept in depts):
  137. raise ValueError('启动批次的部门编号重复或不在正常生产煤矿清单中')
  138. eligible = depts
  139. skipped = [dept for dept in eligible if dept in excluded]
  140. eligible = [dept for dept in eligible if dept not in excluded]
  141. if excluded:
  142. logger.info('启动批次已应用部门排除列表', extra={'kv': {
  143. 'event': 'batch.startup.filtered', 'configured_excluded_dept_ids': sorted(excluded),
  144. 'excluded_dept_ids': skipped, 'excluded_count': len(skipped), 'eligible_count': len(eligible)}})
  145. limit = min(self.startup_max_mines, self.max_mines) if self.max_mines else self.startup_max_mines
  146. selected = eligible[:limit]
  147. if not selected:
  148. logger.info('没有可研判的正常生产煤矿,跳过启动批次',
  149. extra={'kv': {'event': 'batch.startup.empty'}})
  150. return
  151. request = BatchRequest(dept_ids=selected, snapshot_id=snapshot['snapshot_id'])
  152. receipt = self.submit(request, trigger='startup')
  153. logger.info('启动研判批次已入队', extra={'kv': {'event': 'batch.startup.queued',
  154. 'batch_id': receipt['batch_id'], 'selected_dept_ids': selected, 'mine_count': len(selected),
  155. 'excluded_dept_ids': skipped,
  156. 'eligible_count': len(eligible), 'startup_max_mines': self.startup_max_mines,
  157. 'effective_limit': limit}})
  158. except asyncio.CancelledError:
  159. raise
  160. except Exception:
  161. logger.exception('启动研判批次创建失败,月度定时仍保留',
  162. extra={'kv': {'event': 'batch.startup.failed'}})
  163. async def close(self):
  164. self.closed = True
  165. selections = [task for task in (self.startup_task, self.monthly_task) if task is not None]
  166. for task in selections:
  167. task.cancel()
  168. if selections:
  169. await asyncio.gather(*selections, return_exceptions=True)
  170. pending = list(self.tasks.items())
  171. tasks = [task for _, task in pending]
  172. for task in tasks:
  173. task.cancel()
  174. if tasks:
  175. await asyncio.gather(*tasks, return_exceptions=True)
  176. # 尚未获得执行机会的Task在进入work前就可被取消,仍须保存明确状态。
  177. for identifier, _ in pending:
  178. path = self.root / identifier / 'job.json'
  179. job = json.loads(path.read_text())
  180. if job['status'] in {'queued', 'running'}:
  181. job['status'] = 'interrupted'
  182. _write(path, job)
  183. self.tasks.clear()
  184. @asynccontextmanager
  185. async def service(dependencies):
  186. controller = LocalBatchController()
  187. try:
  188. yield controller
  189. finally:
  190. await controller.close()
  191. async def monthly_callback():
  192. from extensions import get_service
  193. depts = configured_departments()
  194. controller = get_service('main_fan_batches')
  195. if controller.monthly.days > 1:
  196. return await controller.monthly.start(depts)
  197. request = BatchRequest(all_producing_mines=depts == ['all'], dept_ids=[] if depts == ['all'] else depts)
  198. month = datetime.now(ZoneInfo('Asia/Shanghai')).strftime('%Y-%m')
  199. key = 'main-fan-monthly:' + WORKFLOW_VERSION + ':' + month + ':' + ','.join(depts)
  200. receipt = controller.submit(request, key=key, trigger='monthly')
  201. logger.info('月度研判批次已入队', extra={'kv': {'event': 'batch.monthly.queued',
  202. 'batch_id': receipt['batch_id'], 'month': month, 'departments': depts}})
  203. return receipt
  204. async def daily_callback():
  205. from extensions import get_service
  206. return await get_service('main_fan_batches').monthly.dispatch()