monthly_schedule.py 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147
  1. """冻结月度清单,均分到多天;每天最多投递一份,重启仅续跑已投递的份额。"""
  2. import asyncio
  3. from datetime import datetime, timedelta
  4. import hashlib
  5. import json
  6. from zoneinfo import ZoneInfo
  7. import config
  8. from extensions.main_fan import mine_catalog
  9. from extensions.main_fan.batch_models import BatchRequest
  10. from extensions.main_fan.exploration import _write
  11. from extensions.main_fan.workflow import VERSION as WORKFLOW_VERSION
  12. from obs import get_logger
  13. logger = get_logger(__name__)
  14. TIMEZONE = ZoneInfo('Asia/Shanghai')
  15. FINISHED = {'completed', 'partial', 'failed'}
  16. def configured_spread_days():
  17. days = config.get_int('MAIN_FAN_BATCH_SPREAD_DAYS', 1)
  18. if not 1 <= days <= 28:
  19. raise ValueError('MAIN_FAN_BATCH_SPREAD_DAYS 必须为1~28;1表示整批执行')
  20. return days
  21. def _identifier(key):
  22. return hashlib.sha256(key.encode()).hexdigest()[:32]
  23. class MonthlyBatchPlanner:
  24. def __init__(self, controller):
  25. self.controller = controller
  26. self.days = configured_spread_days()
  27. self.root = controller.root / 'monthly_plans'
  28. self.lock = asyncio.Lock()
  29. async def start(self, departments, *, now=None):
  30. """仅月度触发器创建计划;重复触发复用清单、分组和每日批次标识。"""
  31. now = (now or datetime.now(TIMEZONE)).astimezone(TIMEZONE)
  32. month = now.strftime('%Y-%m')
  33. key = 'main-fan-monthly:' + WORKFLOW_VERSION + ':' + month + ':' + ','.join(departments)
  34. plan_id = _identifier(key)
  35. path = self.root / (plan_id + '.json')
  36. async with self.lock:
  37. if self.controller.closed:
  38. raise RuntimeError('主通风机批次队列正在关闭')
  39. if path.is_file():
  40. plan = json.loads(path.read_text(encoding='utf-8'))
  41. else:
  42. self._check_all_allowed(departments)
  43. snapshot = await mine_catalog.refresh()
  44. available = {mine['dept_id']: mine for mine in snapshot['mines']}
  45. selected = list(available) if departments == ['all'] else list(departments)
  46. if len(set(selected)) != len(selected) or any(dept not in available for dept in selected):
  47. raise ValueError('月度批次的部门编号重复或不在正常生产煤矿清单中')
  48. quotient, remainder = divmod(len(selected), self.days)
  49. scheduled_start = now.replace(second=0, microsecond=0)
  50. rows, offset = [], 0
  51. for index in range(self.days):
  52. count = quotient + (index < remainder)
  53. depts = selected[offset:offset + count]
  54. offset += count
  55. if self.controller.max_mines and count > self.controller.max_mines:
  56. raise ValueError('月度计划的每日批次超过配置的矿井数量上限')
  57. request = (BatchRequest(dept_ids=depts, snapshot_id=snapshot['snapshot_id'])
  58. .model_dump(mode='json')) if depts else None
  59. daily_key = 'main-fan-monthly-day:' + plan_id + ':' + str(index + 1)
  60. rows.append({'day': index + 1, 'scheduled_at': (scheduled_start + timedelta(days=index)).isoformat(),
  61. 'dept_ids': depts, 'request': request, 'key': daily_key,
  62. 'batch_id': _identifier(daily_key), 'last_dispatched_on': None})
  63. plan = {'schema_version': 1, 'plan_id': plan_id, 'execution_mode': WORKFLOW_VERSION,
  64. 'month': month, 'created_at': now.isoformat(), 'departments': departments,
  65. 'snapshot_id': snapshot['snapshot_id'], 'spread_days': self.days,
  66. 'mine_count': len(selected), 'days': rows}
  67. _write(path, plan)
  68. logger.info('月度研判清单已冻结并分日', extra={'kv': {'event': 'batch.monthly.plan.created',
  69. 'plan_id': plan_id, 'month': month, 'mine_count': len(selected),
  70. 'spread_days': self.days, 'daily_mine_counts': [len(row['dept_ids']) for row in rows],
  71. 'plan_path': str(path)}})
  72. receipt = self._dispatch(now)
  73. return {'plan_id': plan_id, 'month': month, 'spread_days': plan['spread_days'],
  74. 'mine_count': plan['mine_count'], 'plan_path': str(path), 'daily_batch': receipt}
  75. async def dispatch(self, *, now=None, resume_only=False):
  76. now = (now or datetime.now(TIMEZONE)).astimezone(TIMEZONE)
  77. async with self.lock:
  78. return self._dispatch(now, resume_only=resume_only)
  79. @staticmethod
  80. def _check_all_allowed(departments):
  81. if departments == ['all'] and not config.get_bool('MAIN_FAN_BATCH_ALLOW_ALL', False):
  82. raise ValueError('全矿研判未启用,请设置 MAIN_FAN_BATCH_ALLOW_ALL=True 或显式选择煤矿')
  83. def _receipt(self, row):
  84. try:
  85. job = self.controller.get(row['batch_id'])
  86. except FileNotFoundError:
  87. return None
  88. # batch.json 可能先于 job.json 完成落盘,避免此时重启被当作中断重跑。
  89. status = (job.get('batch') or {}).get('status')
  90. return {'batch_id': row['batch_id'], 'status': status if status in FINISHED else job['status'],
  91. 'output': job['output']}
  92. def _dispatch(self, now, *, resume_only=False):
  93. if self.controller.closed:
  94. return None
  95. today = now.date().isoformat()
  96. rows = []
  97. for path in sorted(self.root.glob('*.json')):
  98. plan = json.loads(path.read_text(encoding='utf-8'))
  99. if plan['execution_mode'] == WORKFLOW_VERSION:
  100. rows.extend((path, plan, row) for row in plan['days'] if row['dept_ids'])
  101. rows.sort(key=lambda item: (item[1]['created_at'], item[2]['day']))
  102. # 不累积等待中的多日批次;先恢复已分配但未结束的那一份。
  103. for path, plan, row in rows:
  104. if row['last_dispatched_on']:
  105. receipt = self._receipt(row)
  106. if receipt is None or receipt['status'] not in FINISHED:
  107. return self._submit(path, plan, row, today)
  108. # 同一天重复触发或重启,即使当日已完成,也不提前领取下一份。
  109. for _, _, row in rows:
  110. if row['last_dispatched_on'] == today:
  111. return self._receipt(row)
  112. if resume_only or self.controller.tasks:
  113. return None
  114. for path, plan, row in rows:
  115. if not row['last_dispatched_on'] and datetime.fromisoformat(row['scheduled_at']) <= now:
  116. return self._submit(path, plan, row, today)
  117. return None
  118. def _submit(self, path, plan, row, today):
  119. if self.controller.tasks and row['batch_id'] not in self.controller.tasks:
  120. return None
  121. self._check_all_allowed(plan['departments'])
  122. if self.controller.max_mines and len(row['dept_ids']) > self.controller.max_mines:
  123. raise ValueError('月度计划的每日批次超过配置的矿井数量上限')
  124. # 先保存日期,突然退出后仍通过相同 key 恢复,不会重复领取下一份。
  125. row['last_dispatched_on'] = today
  126. _write(path, plan)
  127. receipt = self.controller.submit(row['request'], key=row['key'], trigger='monthly_daily')
  128. logger.info('月度分日研判批次已入队', extra={'kv': {'event': 'batch.monthly.day.queued',
  129. 'plan_id': plan['plan_id'], 'month': plan['month'], 'day': row['day'],
  130. 'scheduled_at': row['scheduled_at'], 'dispatched_on': today,
  131. 'mine_count': len(row['dept_ids']), 'batch_id': receipt['batch_id'], 'status': receipt['status']}})
  132. return receipt