| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227 |
- """启动批次、网页及月度调度共用串行队列;退出时取消并保存进度。"""
- import asyncio
- from contextlib import asynccontextmanager
- from datetime import datetime
- import hashlib
- import json
- from pathlib import Path
- from uuid import uuid4
- from zoneinfo import ZoneInfo
- import config
- from extensions.main_fan.batch import BatchRunner
- from extensions.main_fan.batch_models import BatchRequest
- from extensions.main_fan.exploration import _write
- from extensions.main_fan import mine_catalog
- from extensions.main_fan.monthly_schedule import MonthlyBatchPlanner
- from extensions.main_fan.workflow import VERSION as WORKFLOW_VERSION, publish_enabled
- from obs import get_logger
- logger = get_logger(__name__)
- def configured_departments():
- depts = [value.strip() for value in config.get_str('MAIN_FAN_BATCH_DEPT_IDS', 'all').split(',') if value.strip()]
- if not depts or ('all' in depts and depts != ['all']):
- raise ValueError('MAIN_FAN_BATCH_DEPT_IDS 必须为 all 或逗号分隔的部门编号')
- return depts
- def configured_startup_exclusions():
- name = 'MAIN_FAN_BATCH_STARTUP_EXCLUDE_DEPT_IDS'
- try:
- values = json.loads(config.get_str(name, '[]').strip() or '[]')
- except json.JSONDecodeError as exc:
- raise ValueError(name + ' 必须为 JSON 数组,例如 [1045,1106]') from exc
- if not isinstance(values, list):
- raise ValueError(name + ' 必须为 JSON 数组,例如 [1045,1106]')
- excluded = set()
- for value in values:
- if type(value) not in (int, str):
- raise ValueError(name + ' 的每项必须是数字部门编号或其字符串')
- dept = str(value).strip()
- if not dept.isascii() or not dept.isdigit() or len(dept) > 64 or int(dept) == 0:
- raise ValueError(name + ' 的每项必须是非零、最多64位的数字部门编号')
- excluded.add(dept)
- return excluded
- class LocalBatchController:
- def __init__(self):
- output = Path(config.get_str('MAIN_FAN_BATCH_OUTPUT_DIR', 'outputs/main_fan_batches'))
- self.root = (output if output.is_absolute() else config.ROOT / output).resolve()
- self.pdf = None
- if config.get_str('MAIN_FAN_REPORT_MODE', 'crawler').lower() == 'mock':
- source = Path(config.get_str('MAIN_FAN_MOCK_REPORT_PDF'))
- self.pdf = (source if source.is_absolute() else config.ROOT / source).resolve()
- from extensions.main_fan.crawler_reports import report_source
- report_source(self.pdf) # 启动时校验配置,不发请求。
- self.max_mines = config.get_int('MAIN_FAN_BATCH_MAX_MINES', 2)
- if self.max_mines < 0:
- raise ValueError('MAIN_FAN_BATCH_MAX_MINES 不能小于0;0表示不限制批次矿数')
- self.startup_enabled = config.get_bool('MAIN_FAN_BATCH_RUN_ON_STARTUP', True)
- self.startup_max_mines = config.get_int('MAIN_FAN_BATCH_STARTUP_MAX_MINES', 2)
- if self.startup_enabled and self.startup_max_mines <= 0:
- raise ValueError('MAIN_FAN_BATCH_STARTUP_MAX_MINES 必须大于0')
- self.tasks = {}
- self.lock = asyncio.Semaphore(1)
- self.startup_task = None
- self.monthly_task = None
- self.closed = False
- self.monthly = MonthlyBatchPlanner(self)
- def submit(self, request, *, key=None, trigger='manual'):
- if self.closed:
- raise RuntimeError('主通风机批次队列正在关闭')
- request = BatchRequest.model_validate(request)
- if request.all_producing_mines and not config.get_bool('MAIN_FAN_BATCH_ALLOW_ALL', False):
- raise ValueError('全矿研判未启用,请设置 MAIN_FAN_BATCH_ALLOW_ALL=True 或显式选择煤矿')
- if self.max_mines and len(request.dept_ids) > self.max_mines:
- raise ValueError('本地批次超过配置的矿井数量上限')
- identifier = hashlib.sha256(key.encode()).hexdigest()[:32] if key else uuid4().hex
- folder = self.root / identifier
- job = folder / 'job.json'
- if job.is_file():
- original = json.loads(job.read_text())
- if original['request'] != request.model_dump(mode='json'):
- raise ValueError('同一批次标识的请求内容发生变化')
- state_file = folder / 'batch.json'
- if identifier in self.tasks or (state_file.is_file() and json.loads(state_file.read_text())['status'] == 'completed'):
- return {'batch_id': identifier, 'status': original['status'], 'output': str(folder)}
- _write(job, {'batch_id': identifier, 'status': 'queued', 'request': request.model_dump(mode='json'),
- 'publish': publish_enabled(), 'trigger': trigger})
- self.tasks[identifier] = asyncio.create_task(self.work(identifier, request), name='main-fan-batch-' + identifier)
- return {'batch_id': identifier, 'status': 'queued', 'output': str(folder)}
- async def work(self, identifier, request):
- folder = self.root / identifier
- job = json.loads((folder / 'job.json').read_text())
- try:
- async with self.lock:
- job['status'] = 'running'
- _write(folder / 'job.json', job)
- result = await BatchRunner(folder, self.pdf,
- max_mines=self.max_mines or None).run(request)
- job['status'] = result['status']
- except asyncio.CancelledError:
- job['status'] = 'interrupted'
- raise
- except Exception as exc:
- logger.exception('批次队列执行失败', extra={'kv': {'event': 'batch.queue.failed', 'batch_id': identifier}})
- job.update(status='failed', error=type(exc).__name__ + ': ' + str(exc)[:500])
- finally:
- _write(folder / 'job.json', job)
- self.tasks.pop(identifier, None)
- def get(self, identifier):
- if len(identifier) != 32 or any(char not in '0123456789abcdef' for char in identifier):
- raise ValueError('无效批次标识')
- folder = self.root / identifier
- job = json.loads((folder / 'job.json').read_text())
- state = folder / 'batch.json'
- return {**job, 'output': str(folder), 'batch': json.loads(state.read_text()) if state.is_file() else None}
- def start_on_startup(self):
- """服务和任务执行器就绪后调用;每个进程生命周期只启动一批,不阻塞就绪。"""
- if (not self.closed and self.monthly.days > 1 and config.get_str('MAIN_FAN_BATCH_CRON')
- and self.monthly_task is None):
- self.monthly_task = asyncio.create_task(self._resume_monthly(), name='main-fan-monthly-resume')
- if not self.closed and self.startup_enabled and self.startup_task is None:
- self.startup_task = asyncio.create_task(self._startup_once(), name='main-fan-startup-selection')
- async def _resume_monthly(self):
- try:
- await self.monthly.dispatch(resume_only=True)
- except asyncio.CancelledError:
- raise
- except Exception:
- logger.exception('恢复月度分日批次失败,日调度仍保留',
- extra={'kv': {'event': 'batch.monthly.resume.failed'}})
- async def _startup_once(self):
- try:
- depts = configured_departments()
- excluded = configured_startup_exclusions()
- snapshot = await mine_catalog.refresh()
- available = {mine['dept_id']: mine for mine in snapshot['mines']}
- if depts == ['all']:
- eligible = list(available)
- else:
- if len(set(depts)) != len(depts) or any(dept not in available for dept in depts):
- raise ValueError('启动批次的部门编号重复或不在正常生产煤矿清单中')
- eligible = depts
- skipped = [dept for dept in eligible if dept in excluded]
- eligible = [dept for dept in eligible if dept not in excluded]
- if excluded:
- logger.info('启动批次已应用部门排除列表', extra={'kv': {
- 'event': 'batch.startup.filtered', 'configured_excluded_dept_ids': sorted(excluded),
- 'excluded_dept_ids': skipped, 'excluded_count': len(skipped), 'eligible_count': len(eligible)}})
- limit = min(self.startup_max_mines, self.max_mines) if self.max_mines else self.startup_max_mines
- selected = eligible[:limit]
- if not selected:
- logger.info('没有可研判的正常生产煤矿,跳过启动批次',
- extra={'kv': {'event': 'batch.startup.empty'}})
- return
- request = BatchRequest(dept_ids=selected, snapshot_id=snapshot['snapshot_id'])
- receipt = self.submit(request, trigger='startup')
- logger.info('启动研判批次已入队', extra={'kv': {'event': 'batch.startup.queued',
- 'batch_id': receipt['batch_id'], 'selected_dept_ids': selected, 'mine_count': len(selected),
- 'excluded_dept_ids': skipped,
- 'eligible_count': len(eligible), 'startup_max_mines': self.startup_max_mines,
- 'effective_limit': limit}})
- except asyncio.CancelledError:
- raise
- except Exception:
- logger.exception('启动研判批次创建失败,月度定时仍保留',
- extra={'kv': {'event': 'batch.startup.failed'}})
- async def close(self):
- self.closed = True
- selections = [task for task in (self.startup_task, self.monthly_task) if task is not None]
- for task in selections:
- task.cancel()
- if selections:
- await asyncio.gather(*selections, return_exceptions=True)
- pending = list(self.tasks.items())
- tasks = [task for _, task in pending]
- for task in tasks:
- task.cancel()
- if tasks:
- await asyncio.gather(*tasks, return_exceptions=True)
- # 尚未获得执行机会的Task在进入work前就可被取消,仍须保存明确状态。
- for identifier, _ in pending:
- path = self.root / identifier / 'job.json'
- job = json.loads(path.read_text())
- if job['status'] in {'queued', 'running'}:
- job['status'] = 'interrupted'
- _write(path, job)
- self.tasks.clear()
- @asynccontextmanager
- async def service(dependencies):
- controller = LocalBatchController()
- try:
- yield controller
- finally:
- await controller.close()
- async def monthly_callback():
- from extensions import get_service
- depts = configured_departments()
- controller = get_service('main_fan_batches')
- if controller.monthly.days > 1:
- return await controller.monthly.start(depts)
- request = BatchRequest(all_producing_mines=depts == ['all'], dept_ids=[] if depts == ['all'] else depts)
- month = datetime.now(ZoneInfo('Asia/Shanghai')).strftime('%Y-%m')
- key = 'main-fan-monthly:' + WORKFLOW_VERSION + ':' + month + ':' + ','.join(depts)
- receipt = controller.submit(request, key=key, trigger='monthly')
- logger.info('月度研判批次已入队', extra={'kv': {'event': 'batch.monthly.queued',
- 'batch_id': receipt['batch_id'], 'month': month, 'departments': depts}})
- return receipt
- async def daily_callback():
- from extensions import get_service
- return await get_service('main_fan_batches').monthly.dispatch()
|