batch_service.py 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107
  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. class LocalBatchController:
  15. def __init__(self):
  16. output = Path(config.get_str('MAIN_FAN_BATCH_OUTPUT_DIR', 'outputs/main_fan_batches'))
  17. self.root = (output if output.is_absolute() else config.ROOT / output).resolve()
  18. source = Path(config.get_str('MAIN_FAN_MOCK_REPORT_PDF'))
  19. self.pdf = (source if source.is_absolute() else config.ROOT / source).resolve()
  20. if not self.pdf.is_file() or self.pdf.suffix.lower() != '.pdf':
  21. raise ValueError('启用本地批次需配置 MAIN_FAN_MOCK_REPORT_PDF 指向测试PDF')
  22. self.tasks = {}
  23. self.lock = asyncio.Semaphore(1)
  24. def submit(self, request, *, key=None):
  25. request = BatchRequest.model_validate(request)
  26. if request.all_producing_mines and not config.get_bool('MAIN_FAN_BATCH_ALLOW_ALL', False):
  27. raise ValueError('本地预览未开放全部煤矿执行,请显式选择一两个矿')
  28. if len(request.dept_ids) > config.get_int('MAIN_FAN_BATCH_MAX_MINES', 2):
  29. raise ValueError('本地批次超过配置的矿井数量上限')
  30. identifier = hashlib.sha256(key.encode()).hexdigest()[:32] if key else uuid4().hex
  31. folder = self.root / identifier
  32. job = folder / 'job.json'
  33. if job.is_file():
  34. original = json.loads(job.read_text())
  35. if original['request'] != request.model_dump(mode='json'):
  36. raise ValueError('同一批次标识的请求内容发生变化')
  37. state_file = folder / 'batch.json'
  38. if identifier in self.tasks or (state_file.is_file() and json.loads(state_file.read_text())['status'] == 'completed'):
  39. return {'batch_id': identifier, 'status': original['status'], 'output': str(folder)}
  40. _write(job, {'batch_id': identifier, 'status': 'queued', 'request': request.model_dump(mode='json'), 'publish': False})
  41. self.tasks[identifier] = asyncio.create_task(self.work(identifier, request), name='main-fan-batch-' + identifier)
  42. return {'batch_id': identifier, 'status': 'queued', 'output': str(folder)}
  43. async def work(self, identifier, request):
  44. folder = self.root / identifier
  45. job = json.loads((folder / 'job.json').read_text())
  46. try:
  47. async with self.lock:
  48. job['status'] = 'running'
  49. _write(folder / 'job.json', job)
  50. result = await BatchRunner(folder, self.pdf,
  51. max_mines=config.get_int('MAIN_FAN_BATCH_MAX_MINES', 2)).run(request)
  52. job['status'] = result['status']
  53. except asyncio.CancelledError:
  54. job['status'] = 'interrupted'
  55. raise
  56. except Exception as exc:
  57. job.update(status='failed', error=type(exc).__name__ + ': ' + str(exc)[:500])
  58. finally:
  59. _write(folder / 'job.json', job)
  60. self.tasks.pop(identifier, None)
  61. def get(self, identifier):
  62. if len(identifier) != 32 or any(char not in '0123456789abcdef' for char in identifier):
  63. raise ValueError('无效批次标识')
  64. folder = self.root / identifier
  65. job = json.loads((folder / 'job.json').read_text())
  66. state = folder / 'batch.json'
  67. return {**job, 'output': str(folder), 'batch': json.loads(state.read_text()) if state.is_file() else None}
  68. async def close(self):
  69. pending = list(self.tasks.items())
  70. tasks = [task for _, task in pending]
  71. for task in tasks:
  72. task.cancel()
  73. if tasks:
  74. await asyncio.gather(*tasks, return_exceptions=True)
  75. # 尚未获得执行机会的Task在进入work前就可被取消,仍须保存明确状态。
  76. for identifier, _ in pending:
  77. path = self.root / identifier / 'job.json'
  78. job = json.loads(path.read_text())
  79. if job['status'] in {'queued', 'running'}:
  80. job['status'] = 'interrupted'
  81. _write(path, job)
  82. self.tasks.clear()
  83. @asynccontextmanager
  84. async def service(dependencies):
  85. controller = LocalBatchController()
  86. try:
  87. yield controller
  88. finally:
  89. await controller.close()
  90. async def monthly_callback():
  91. from extensions import get_service
  92. depts = [value.strip() for value in config.get_str('MAIN_FAN_BATCH_DEPT_IDS').split(',') if value.strip()]
  93. request = BatchRequest(all_producing_mines=depts == ['all'], dept_ids=[] if depts == ['all'] else depts)
  94. month = datetime.now(ZoneInfo('Asia/Shanghai')).strftime('%Y-%m')
  95. key = 'main-fan-monthly:' + month + ':' + ','.join(depts)
  96. return get_service('main_fan_batches').submit(request, key=key)