Chronaxis用户手册English
本页目录

chronaxis.batch.runner

源码基线:8904c2f744951857659abd40aaa3535d57faf53c

1"""在外层多进程执行独立计划,复用公共AnalysisRunner完成分析、发布和关闭。23每个任务拥有自己的RunSession、预算和目录;不拆分一个会话的时间轴,不共用4活动、迟滞或配对状态。最多process_count个future在途,未开始的任务不读计划文件。5取消或fail_fast停止派发,已经开始的任务正常完成关闭;不向工作进程注入异常。6只承诺每任务原有预算及同时运行数,不承诺全进程RSS或固定进程数带来的性能收益。7"""89from dataclasses import replace10import time11import os12from pathlib import Path13from threading import Event14from time import perf_counter1516from ..planning import load_analysis_plan17from ..planning.plan import AnalysisPlan18from ..runner import AnalysisRunner19from ..results.spool import _cleanup_owner20from .._batch_worker import _diagnostics21from .models import BatchJob, BatchJobResult, BatchReport, CleanupHandle222324def _new_root(value) -> Path:25    """检查可复用批量目录。参数: value为str/Path。返回: 绝对Path。异常: 类型/已有/只读路径错误。"""26    from ..results.publication import output_root27    return output_root(value)282930def _facts(job, plan=None) -> dict:31    """区分配置和输入事实。参数: job原声明;plan可选已加载计划。返回: 字段dict。异常: 无。"""32    loaded = plan if plan is not None else job.plan if isinstance(job.plan, AnalysisPlan) else None33    result = dict(job_id=job.job_id, plan_path=str(job.plan.absolute()) if isinstance(job.plan, Path) else None)34    if loaded is not None:35        result.update(plan_identity=loaded.identity, dataset_id=loaded.dataset.dataset_id,36                      entity_id=loaded.dataset.entity, session_id=loaded.dataset.session_id,37                      memory_budget_bytes=loaded.execution.memory_budget_bytes)38    return result394041def _cancelled(job, reason) -> BatchJobResult:42    """记录未启动取消。参数: job原声明;reason原因。返回: 取消结果。异常: 无,不读取计划。"""43    return BatchJobResult(**_facts(job), status='cancelled', error_type='not_started', error_message=reason)444546def _report(root, jobs, results, process_count, fail_fast, cancelled, label, reason) -> BatchReport:47    """补齐未启动项。参数: 原声明/结果及调度选项。返回: 完整报告。异常: 模型错误。"""48    outcomes = tuple(_cancelled(job, reason) if value is None else value49                     for job, value in zip(jobs, results))50    # worker中的真实KeyboardInterrupt也属于取消请求,不只记录主线程的Event。51    requested = cancelled or any(j.error_type in ('KeyboardInterrupt', 'SystemExit') for j in outcomes)52    return BatchReport(root, outcomes, process_count, fail_fast, requested, label)535455def _run_job(job, directory, backend, stop, cancel) -> BatchJobResult:56    """执行一个独立任务并关闭所有资源。5758    参数: job为BatchJob;directory为独占新子目录;backend为覆盖;stop/cancel为Event。59    返回: 含实际发布和身份的最终结果;恢复失败保最高owner,不把异常栈留在结果中。60    异常: 普通分析/发布/取消转换成该任务结果;无法构造结果的内部错误交由调度器收尾。61    """62    if stop.is_set() or cancel.is_set():63        return _cancelled(job, '任务启动前已停止派发')64    started = perf_counter()65    plan = result = manifest = recovery = None66    metadata = {'worker_pid': os.getpid()}67    try:68        # 路径计划到本任务实际开始才加载,未派发的坏文件不会影响其他任务。69        plan = job.plan if isinstance(job.plan, AnalysisPlan) else load_analysis_plan(job.plan)70        directory.mkdir(parents=True, exist_ok=False)71        result = AnalysisRunner(backend).run(plan, work_dir=directory / 'work')72        metadata.update(input_identity=result.metadata.input_identity, run_id=result.metadata.run_id)73        manifest = result.write(directory / 'output')74        metadata['output_dir'] = manifest.output_dir75        metadata['manifest_path'] = manifest.manifest_path76        result.close()77        return BatchJobResult(**_facts(job, plan), **metadata, status='completed',78                              elapsed_seconds=perf_counter() - started)79    except BaseException as error:80        completed = getattr(error, 'completed_manifest', None)81        if completed is not None:82            metadata.update(output_dir=completed.output_dir, manifest_path=completed.manifest_path)83        # 结果已经返回后由本层负责close;未返回的运行恢复owner继续沿AnalysisRunner原协议。84        if result is not None:85            _cleanup_owner(result, error, 'batch_cleanup_owner', '批量任务结果')86        owner = getattr(error, 'batch_cleanup_owner', None)87        if owner is None:88            owner = getattr(error, 'runner_cleanup_owner', None)89        # AnalysisRunner可能保留已经成功关闭的恢复属性;只有仍实际持有资源才转交报告。90        if owner is not None and not (getattr(owner, 'closed', False) or getattr(owner, '_closed', False)):91            recovery = CleanupHandle(owner)92        cancelled = isinstance(error, (KeyboardInterrupt, SystemExit))93        # 资源耗尽或真实取消应停止新派发;普通业务失败由fail_fast决定是否停止。94        if cancelled or isinstance(error, MemoryError):95            stop.set()96        return BatchJobResult(**_facts(job, plan), **metadata,97                              status='cancelled' if cancelled else 'failed',98                              error_type=type(error).__name__, error_message=str(error),99                              error_chain=_diagnostics(error), recovery=recovery,100                              elapsed_seconds=perf_counter() - started)101102103def _pause():104    """让出调度CPU等待下一次结果。参数: 无。返回: None。异常: 主进程取消。"""105    time.sleep(0.01)106107108class BatchRunner:109    """通过有限spawn工作进程执行独立任务;同一任务的连续文件保持原顺序。"""110111    def __init__(self, process_count=1, *, fail_fast=False, backend=None):112        """配置调度。参数: process_count正整数;fail_fast布尔;backend可选计算实现。113114        返回: None。异常: 参数不合法;构造时不启动进程或读取业务输入。115        """116        if type(process_count) is not int or process_count < 1:117            raise ValueError('process_count需要正原生int')118        if type(fail_fast) is not bool:119            raise TypeError('fail_fast需要bool')120        if backend is not None:121            AnalysisRunner(backend)122        self.process_count, self.fail_fast, self.backend = process_count, fail_fast, backend123124    def run(self, jobs, output_dir, *, cancel_event=None, device_label='pending') -> BatchReport:125        """运行独立任务,原声明顺序返回报告。126127        参数: jobs为BatchJob tuple;output_dir可复用目录;cancel_event可选threading.Event;128            device_label为报告来源标签。返回: BatchReport,close可重试子进程内失败清理。129        异常: 声明和路径错误在启动前拒绝;调度基础设施错误携带batch_report。130        副作用: 启动最多process_count个工作进程,每任务独立发布;取消只阻止未派发任务。131        """132        return self._run(jobs, output_dir, cancel_event=cancel_event, device_label=device_label)133134    def _run(self, jobs, output_dir, *, cancel_event, device_label, fast_specs=None, fast_rows=None):135        """共享派发/清理循环;fast_specs 仅由 fast 校验入口构造,可选 fast_rows 接收汇总所需小行。"""136        from .process_transport import ProcessWorker137        if type(jobs) is not tuple or any(not isinstance(j, BatchJob) for j in jobs):138            raise TypeError('批量任务需要BatchJob tuple')139        if len({j.job_id.casefold() for j in jobs}) != len(jobs):140            raise ValueError('批量job_id不能重复,大小写冲突也拒绝')141        if cancel_event is not None and not isinstance(cancel_event, Event):142            raise TypeError('cancel_event需要threading.Event')143        if type(device_label) is not str or not device_label.strip():144            raise ValueError('device_label需要非空文本')145        # 有待派发任务的独立batch即使上限为1也会创建受管工作进程。146        # 在创建输出前提示;不修改既有派发、失败报告或退出码。147        from ..process_capabilities import warn_managed_worker148        warn_managed_worker(bool(jobs) and not (cancel_event is not None and cancel_event.is_set()),149                            '本独立批量任务')150        root = _new_root(output_dir)151        from ..results.publication import PublicationNames152        publication = PublicationNames(root, (*[j.job_id for j in jobs], 'batch_report.json'))153        cancel = Event() if cancel_event is None else cancel_event154        workers, free, active, last_job = [], [], {}, {}155        results = [None] * len(jobs)156        next_job, stopped, interrupted, infrastructure = 0, False, False, None157        while next_job < len(jobs) or active:158            try:159                if cancel.is_set():160                    stopped = True161                # 先收齐本轮已完成结果再派发,避免观察到失败后继续启动下一任务。162                for worker, index in list(active.items()):163                    try:164                        if worker.ready():165                            outcome = worker.receive()166                            if fast_rows is not None and outcome.status == 'completed':167                                fast_rows[jobs[index].job_id] = worker.fast_rows168                        elif (fast_specs is not None and (cancel.is_set() or interrupted or infrastructure is not None)169                              and worker.cancel_fast()):170                            outcome = replace(_cancelled(jobs[index], '取消后工作进程未及时退出,已回收'),171                                              worker_pid=worker.pid,error_type='cancel_timeout')172                        else:173                            continue174                    except Exception as error:175                        outcome = BatchJobResult(**_facts(jobs[index]), status='failed',176                            worker_pid=worker.pid, error_type=type(error).__name__,177                            error_message=str(error), error_chain=_diagnostics(error))178                        infrastructure = infrastructure or error179                        stopped = True180                    results[index] = outcome181                    del active[worker]182                    if self.fail_fast and outcome.status == 'failed':183                        stopped = True184                    if outcome.error_type in ('MemoryError', 'KeyboardInterrupt', 'SystemExit'):185                        stopped = True186                    if not worker.pending:187                        free.append(worker)188                while not stopped and next_job < len(jobs):189                    if cancel.is_set():190                        stopped = True191                        break192                    if free:193                        worker = free.pop()194                    elif len(workers) < min(self.process_count, len(jobs)):195                        worker = ProcessWorker() if fast_specs is None else ProcessWorker(fast=True)196                        workers.append(worker)197                    else:198                        break199                    index = next_job200                    try:201                        directory = root / publication.names[jobs[index].job_id]202                        if fast_specs is None:203                            worker.submit(jobs[index], directory, self.backend)204                        else:205                            worker.submit_fast(jobs[index], directory, fast_specs[jobs[index].job_id], fast_rows is not None)206                    except Exception as error:207                        results[index] = BatchJobResult(**_facts(jobs[index]), status='failed',208                            worker_pid=worker.pid, error_type=type(error).__name__, error_message=str(error))209                        infrastructure = infrastructure or error210                        stopped = True211                        break212                    active[worker], last_job[worker] = index, index213                    next_job += 1214                if not active:215                    # 所有工作进程若都因未完成清理而暂停,剩余任务不再派发。216                    break217                _pause()218            except KeyboardInterrupt as error:219                interrupted, stopped = True, True220                # 构造取消且清理持续失败时,原异常拥有尚未关闭的真实worker;221                # 不能仅返回取消报告而丢失该恢复入口。已清理的Ctrl+C仍按原流程返回。222                if getattr(error, 'batch_worker_cleanup_owner', None) is not None:223                    infrastructure = infrastructure or error224            except Exception as error:225                infrastructure, stopped = infrastructure or error, True226        # 在途任务已结束;持续关闭失败留远程owner,不因结束调度而丢掉恢复入口。227        for worker in workers:228            if worker.pending:229                continue230            try:231                worker.close()232            except BaseException as error:233                index = last_job.get(worker)234                if index is not None:235                    results[index] = replace(results[index], status='failed',236                        error_type=type(error).__name__, error_message=str(error),237                        recovery=CleanupHandle(worker))238                infrastructure = infrastructure or error239        reason = '用户取消,尚未启动' if cancel.is_set() or interrupted else '失败或未完成清理后停止派发'240        report = _report(root, jobs, results, self.process_count, self.fail_fast,241                         cancel.is_set() or interrupted, device_label, reason)242        report = replace(report, worker_process_count=len(workers), _publication=publication)243        if infrastructure is not None:244            report = replace(report, infrastructure_error=type(infrastructure).__name__ + ': ' + str(infrastructure))245            infrastructure.batch_report = report246            raise infrastructure247        return report248
Chronaxis 用户手册 · 离线阅读