tasklite-engine 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tasklite/pipeline.py ADDED
@@ -0,0 +1,914 @@
1
+ """Core pipeline engine for tasklite."""
2
+
3
+ import logging
4
+ import math
5
+ import multiprocessing as mp
6
+ import pickle
7
+ import signal
8
+ import time
9
+ import uuid
10
+ from pathlib import Path
11
+ from typing import Any, Callable, Dict, List, NamedTuple, Optional, Sequence, Tuple, Union
12
+
13
+ from .backend.base import AbstractStateBackend, classify_error_type
14
+ from .backend.sqlite_backend import SQLiteStateBackend
15
+ from .models.context import TaskContext
16
+ from .engine.executor import ExecutionResult, MultiprocessingExecutor
17
+ from .engine.completion import CompletionMachine
18
+ from .engine.dispatch import DispatchMachine
19
+ from .engine.failure import FailureMachine
20
+ from .engine.inflight import InFlightJob as _InFlightJob
21
+ from .engine.loop import LoopRunner
22
+ from .engine.recovery import RecoveryMachine
23
+ from .engine.resource import CapacityResource, Resource
24
+ from .engine.retry import apply_discovery_rerun, rerun_skips
25
+ from .engine.runtime import (
26
+ META_RESOURCE_SUSPENDS as _META_RESOURCE_SUSPENDS,
27
+ RunContext, StopMode, TaskStats, WORKER_RESOURCE, inject_worker_resource,
28
+ RT_BACKOFF_UNTIL,
29
+ RT_BACKOFF_WALL_DEADLINE,
30
+ )
31
+ from .engine.scheduler import JobScheduler
32
+ from .exceptions import (
33
+ TransientRegistry, _CommitCrashSignal, _JobTerminated,
34
+ )
35
+ from .models.job import Job
36
+ from .models.state import PipelineState, uid_from_job_dict
37
+ from .utils.jsonutil import dumps, loads
38
+ from .utils.lockfile import release_lock, try_acquire_lock
39
+ from .utils.validation import validate_resource_amounts
40
+
41
+ logger = logging.getLogger("tasklite")
42
+
43
+
44
+ class DLQEntry(NamedTuple):
45
+ """DLQ 只读查询(``pipeline.list_dlq()``)返回的结构化条目。
46
+
47
+ - ``error_type``:结构化分类(fatal / dependency / deadlock /
48
+ transient_exhausted / no_handler / validation / commit_failure /
49
+ dispatch / unknown)。
50
+ - ``error``:原始错误消息/错误码。
51
+ - ``attempts``:写入 DLQ 的次数(``_attempt`` 计数)。
52
+ - ``failed_at``:最近一次失败时间(UTC ISO 8601;历史行可能为 None)。
53
+ - ``meta``:完整 DLQ payload(只读视图)。
54
+ """
55
+
56
+ uid: str
57
+ error_type: str
58
+ error: str
59
+ attempts: int
60
+ failed_at: Optional[str]
61
+ meta: Dict[str, Any]
62
+
63
+
64
+ class HandlerEntry(NamedTuple):
65
+ """注册 handler 的结构化条目——调度器与派发路径按字段名访问。"""
66
+ func: Callable
67
+ default_resources: Dict[str, float]
68
+ payload_schema: Optional[type]
69
+
70
+ _BACKEND_SQLITE = "sqlite"
71
+
72
+ # 内部 worker 资源名:每个 job 默认占用 1 个 worker 槽位。
73
+ # 通过 CapacityResource 实现,复用现有资源调度逻辑控制并发度。
74
+ # 常量本体在 engine.runtime(机器模块不反向 import pipeline)。
75
+ _DEFAULT_MAX_WORKERS = 4
76
+
77
+
78
+ class TaskLite:
79
+ """Main pipeline orchestrator for task execution."""
80
+
81
+ def __init__(
82
+ self,
83
+ name: str,
84
+ state_dir: Union[str, Path],
85
+ backend: Union[str, AbstractStateBackend] = "sqlite",
86
+ output_root: Union[str, Path, Sequence[Union[str, Path]], None] = None,
87
+ max_workers: int = _DEFAULT_MAX_WORKERS,
88
+ on_run_start: Optional[Callable[[], None]] = None,
89
+ on_run_end: Optional[Callable[[str], None]] = None,
90
+ on_job_completed: Optional[Callable[[str, dict, bool, bool], None]] = None,
91
+ strict_picklable: bool = False,
92
+ fatal_exceptions: Optional[tuple] = None,
93
+ transient_exceptions: Optional[tuple] = None,
94
+ dep_grace_seconds: Optional[float] = None,
95
+ commit_failure_dlq_threshold: Optional[int] = None,
96
+ deadlock_gap_max_rounds: Optional[int] = None,
97
+ ):
98
+ """Initialize the pipeline.
99
+
100
+ Args:
101
+ name: Pipeline name, used for state file naming (e.g. ``{name}_state.db``).
102
+ state_dir: Directory for persistent state files. Created if not exists.
103
+ backend: ``"sqlite"`` (default) or an ``AbstractStateBackend`` instance.
104
+ Production must use ``"sqlite"`` for ACID guarantees.
105
+ output_root: Root directory for declared outputs. If set, ``declare_output``
106
+ paths are sandboxed under this root. If ``None``, no sandboxing.
107
+ max_workers: Max concurrent subprocesses. Implemented as internal
108
+ ``CapacityResource("__workers__", N)``; override via ``add_resource``.
109
+ on_run_start: 生命周期钩子——run() 开始前同步调用(无参数)。
110
+ on_run_end: 生命周期钩子——run() 结束时调用(统一 finally,
111
+ 覆盖正常/中断/崩溃全部退出路径),参数为 exit_reason:
112
+ ``"completed"`` | ``"stopped_draining"``(stop 请求,等完
113
+ 在途后退出)| ``"stopped_aborting"``(stop(force)/二次信号,
114
+ 强杀在途立即退出)| ``"interrupted"`` | ``"error"``。
115
+ on_job_completed: 生命周期钩子——每次 attempt 完成时同步调用
116
+ (同一 job 跨重试生命周期会触发多次),参数 (uid, result_meta,
117
+ success, going_to_retry);``going_to_retry=True`` 表示将退避重试、
118
+ ``False`` 才是终局(成功/DLQ)。在 stats 更新之后、下一 job
119
+ 派发之前调用;钩子内读 stats 保证一致。
120
+ strict_picklable: 代码级预检——True 时 run() 前对全部
121
+ handler 做 pickle 预检(fail-loud),False 为默认(保留单测
122
+ lambda 兼容)。
123
+
124
+ 钩子契约(与防御层同原则):同步、主线程执行、必须轻量
125
+ 非阻塞(重活业务方自丢线程池);抛异常 → catch + warning +
126
+ ``stats["hook_errors"]`` 计数,**绝不影响主循环**——钩子按不可信
127
+ 代码对待。多方订阅由业务自封装分发器,框架不维护监听器列表。
128
+ """
129
+ self.name = name
130
+ self.state_dir = Path(state_dir)
131
+ self.state_dir.mkdir(parents=True, exist_ok=True)
132
+
133
+ self._mp_ctx = mp.get_context("spawn")
134
+
135
+ if output_root is not None:
136
+ # 多根支持——list 时声明路径属于任一根即通过
137
+ # 沙盒校验(跨盘输出场景)。
138
+ if isinstance(output_root, (list, tuple)):
139
+ self.output_root = [Path(r).resolve() for r in output_root]
140
+ for r in self.output_root:
141
+ r.mkdir(parents=True, exist_ok=True)
142
+ else:
143
+ self.output_root = Path(output_root).resolve()
144
+ self.output_root.mkdir(parents=True, exist_ok=True)
145
+ else:
146
+ self.output_root = None
147
+
148
+ if isinstance(backend, str):
149
+ self.backend_type = backend
150
+ if backend != _BACKEND_SQLITE:
151
+ raise ValueError(
152
+ f"Unknown backend: {backend!r}. Supported backend is 'sqlite'."
153
+ )
154
+ try:
155
+ self._backend = SQLiteStateBackend(self.state_dir / f"{name}_state.db")
156
+ except Exception as e:
157
+ logger.critical(
158
+ f"Failed to initialize SQLite backend for '{name}' at "
159
+ f"{self.state_dir}: {e}. "
160
+ f"Check disk space, directory permissions, and filesystem health."
161
+ )
162
+ raise RuntimeError(
163
+ f"SQLite backend initialization failed for '{name}': {e}"
164
+ ) from e
165
+ elif isinstance(backend, AbstractStateBackend):
166
+ # 内置 SQLite 实例与字符串 "sqlite" 统一命名,避免同一后端两种 backend_type。
167
+ self.backend_type = (
168
+ "sqlite" if isinstance(backend, SQLiteStateBackend)
169
+ else backend.__class__.__name__
170
+ )
171
+ self._backend = backend
172
+ else:
173
+ raise TypeError(
174
+ f"backend must be 'sqlite' or AbstractStateBackend instance, "
175
+ f"got {type(backend).__name__}"
176
+ )
177
+
178
+ # handler -> (func, default_resources, payload_schema)
179
+ self.handlers: Dict[str, HandlerEntry] = {}
180
+ # discovery task_type -> 默认 rerun 策略——enqueue/spawn
181
+ # 时经 apply_discovery_rerun 注入(见 enqueue docstring),使「固定
182
+ # uid 每会话重扫」成为默认。
183
+ self._discovery_rerun: Dict[str, str] = {}
184
+ # 瞬态异常注册表是 **per-pipeline 实例态**——不跨 pipeline/run
185
+ # 累积;子进程只消费 ctx 携带的不可变快照(见
186
+ # register_transient_exception / _dispatch_job)。
187
+ self.transient_registry = TransientRegistry()
188
+ # per-pipeline 异常分类覆盖(None=用 exceptions 模块内置元组):
189
+ # 确定性/瞬态启发式的成员集合可按 pipeline 定制,快照经 ctx 下发
190
+ # 子进程——与瞬态注册表同一作用域纪律。
191
+ self._fatal_exceptions: Optional[tuple] = (
192
+ tuple(fatal_exceptions) if fatal_exceptions is not None else None)
193
+ self._transient_exceptions: Optional[tuple] = (
194
+ tuple(transient_exceptions) if transient_exceptions is not None else None)
195
+ self.resources: Dict[str, Resource] = {}
196
+ # 内部 worker 资源:控制并发度。每个 job 默认占用 1 个 worker 槽位,
197
+ # CapacityResource.used 实时反映 in-flight 占用,scheduler 的
198
+ # can_acquire 自然阻止过度派发。用户可通过 add_resource 覆盖。
199
+ # 防御 bool 类型:bool 是 int 子类,True<1 为 False 会静默通过
200
+ # 再被 float(True) 变成 1 worker——显式拒绝 bool 保持类型严格。
201
+ if (not isinstance(max_workers, int) or isinstance(max_workers, bool)
202
+ or max_workers < 1):
203
+ raise ValueError(f"max_workers must be an int >= 1, got {max_workers!r}")
204
+ self.resources[WORKER_RESOURCE] = CapacityResource(
205
+ WORKER_RESOURCE, float(max_workers)
206
+ )
207
+ # IPC 落盘目录(state_dir/ipc)——子进程结果/信号写文件,
208
+ # 主进程轮询文件存在(无 mp.Queue 伪阻塞点)。
209
+ self.ipc_dir = str(self.state_dir / "ipc")
210
+ Path(self.ipc_dir).mkdir(parents=True, exist_ok=True)
211
+ self.executor = MultiprocessingExecutor(mp_ctx=self._mp_ctx, ipc_dir=self.ipc_dir)
212
+ # 传入 handlers 引用,调度器按「handler 默认资源 ∪ job 资源」检查
213
+ # 可用性,与 _dispatch_job 的实际 acquire 一致(堵住限速/容量绕过)。
214
+ self.scheduler = JobScheduler(self.resources, self.handlers)
215
+
216
+ # 生命周期钩子(单 callable,构造注册;异常隔离见钩子契约)
217
+ # on_run_start/on_run_end/on_job_completed 统一经 RunContext 持有。
218
+ self.strict_picklable = strict_picklable
219
+ # 代码级限制:run 进行中禁止管理 API / enqueue;
220
+ # 同 state_dir 并发 run 由 pipeline 级文件锁阻止。
221
+ self._run_started = False
222
+ self._run_lock_fd: Optional[int] = None
223
+ # 运行上下文是「一次 run 的运行时真相源」——
224
+ # 常驻服务引用 + 每-run 可变状态都在这里;各机器只依赖 ctx,
225
+ # 不反向引用宿主 pipeline。下方同名属性(stats/_state/_in_flight/...)
226
+ # 是兼容测试与旧调用面的代理。
227
+ self._ctx = RunContext(
228
+ name=self.name,
229
+ backend=self.backend,
230
+ scheduler=self.scheduler,
231
+ resources=self.resources,
232
+ handlers=self.handlers,
233
+ executor=self.executor,
234
+ ipc_dir=self.ipc_dir,
235
+ output_root=self.output_root,
236
+ on_run_start=on_run_start,
237
+ on_job_completed=on_job_completed,
238
+ on_run_end=on_run_end,
239
+ transient_registry=self.transient_registry,
240
+ fatal_exceptions=self._fatal_exceptions,
241
+ transient_exceptions=self._transient_exceptions,
242
+ dep_grace_seconds=dep_grace_seconds,
243
+ commit_failure_dlq_threshold=commit_failure_dlq_threshold,
244
+ deadlock_gap_max_rounds=deadlock_gap_max_rounds,
245
+ discovery_rerun=self._discovery_rerun,
246
+ )
247
+ # 失败机器(3-strike/级联/死锁归因/宽限)注入 RunContext;完成/
248
+ # 派发机器同样只依赖 RunContext + 失败机器。本类保留同名薄转发
249
+ # (测试直调面 + 生产路径兼容),职责是「配置 + 加载修复 + 主循环编排」。
250
+ self._failure = FailureMachine(self._ctx)
251
+ self._completion = CompletionMachine(self._ctx, self._failure)
252
+ self._dispatch = DispatchMachine(self._ctx, self._failure, self._completion)
253
+ self._recovery = RecoveryMachine(self._ctx, self._completion)
254
+ self._loop = LoopRunner(
255
+ self._ctx, self._recovery, self._dispatch, self._failure, self._completion,
256
+ )
257
+
258
+ # ── RunContext 代理属性 ───────────────────────────────────────
259
+ # 运行态真相源在 self._ctx;这些属性代理保留 TaskLite 的调用面
260
+ # (生产方法、测试直调、monkeypatch 赋值),代理写入即时同步真相源。
261
+ @property
262
+ def backend(self):
263
+ return self._backend
264
+
265
+ @backend.setter
266
+ def backend(self, value) -> None:
267
+ # 测试/运维可能替换 backend(如注入 FailingBackend)——RunContext
268
+ # 是运行时真相源,必须同步,避免失败机器仍持旧引用。
269
+ self._backend = value
270
+ if hasattr(self, "_ctx"):
271
+ self._ctx.backend = value
272
+
273
+ @property
274
+ def stats(self) -> TaskStats:
275
+ return self._ctx.stats
276
+
277
+ @stats.setter
278
+ def stats(self, value: dict) -> None:
279
+ self._ctx.stats = value
280
+
281
+ @property
282
+ def _state(self):
283
+ return self._ctx.state
284
+
285
+ @_state.setter
286
+ def _state(self, value) -> None:
287
+ self._ctx.state = value
288
+
289
+ @property
290
+ def _in_flight(self) -> Dict[str, _InFlightJob]:
291
+ return self._ctx.in_flight
292
+
293
+ @_in_flight.setter
294
+ def _in_flight(self, value: Dict[str, _InFlightJob]) -> None:
295
+ self._ctx.in_flight = value
296
+
297
+ @property
298
+ def _deadlock_gap_rounds(self) -> int:
299
+ return self._ctx.deadlock_gap_rounds
300
+
301
+ @_deadlock_gap_rounds.setter
302
+ def _deadlock_gap_rounds(self, value: int) -> None:
303
+ self._ctx.deadlock_gap_rounds = value
304
+
305
+ @property
306
+ def _dep_grace_missing(self):
307
+ return self._ctx.dep_grace_missing
308
+
309
+ @_dep_grace_missing.setter
310
+ def _dep_grace_missing(self, value) -> None:
311
+ self._ctx.dep_grace_missing = value
312
+
313
+ @property
314
+ def _dep_grace_deadline(self):
315
+ return self._ctx.dep_grace_deadline
316
+
317
+ @_dep_grace_deadline.setter
318
+ def _dep_grace_deadline(self, value) -> None:
319
+ self._ctx.dep_grace_deadline = value
320
+
321
+ # 停机状态机经 self._ctx.stop_mode(StopMode 枚举)直接读写。
322
+
323
+ @property
324
+ def _run_id(self):
325
+ return self._ctx.run_id
326
+
327
+ @_run_id.setter
328
+ def _run_id(self, value) -> None:
329
+ self._ctx.run_id = value
330
+
331
+ @property
332
+ def _dispatch_seq(self) -> int:
333
+ return self._ctx.dispatch_seq
334
+
335
+ @_dispatch_seq.setter
336
+ def _dispatch_seq(self, value: int) -> None:
337
+ self._ctx.dispatch_seq = value
338
+
339
+ @property
340
+ def on_run_start(self):
341
+ return self._ctx.on_run_start
342
+
343
+ @on_run_start.setter
344
+ def on_run_start(self, value) -> None:
345
+ self._ctx.on_run_start = value
346
+
347
+ @property
348
+ def on_job_completed(self):
349
+ return self._ctx.on_job_completed
350
+
351
+ @on_job_completed.setter
352
+ def on_job_completed(self, value) -> None:
353
+ self._ctx.on_job_completed = value
354
+
355
+ @property
356
+ def on_run_end(self):
357
+ return self._ctx.on_run_end
358
+
359
+ @on_run_end.setter
360
+ def on_run_end(self, value) -> None:
361
+ self._ctx.on_run_end = value
362
+
363
+ def _ensure_not_running(self, api_name: str) -> None:
364
+ """管理/入队 API 的 run 期间守卫(把文档限制变成代码级 RuntimeError)。"""
365
+ if self._run_started:
366
+ raise RuntimeError(
367
+ f"{api_name}() is only allowed outside run(); "
368
+ f"current run is in progress. See README『开发与 Agent 约束』."
369
+ )
370
+
371
+ def _preflight_picklable_callbacks(self) -> None:
372
+ """strict_picklable=True 时,run 前校验全部 handler 可 pickle(fail-loud)。"""
373
+ if not self.strict_picklable:
374
+ return
375
+ for task_type, entry in self.handlers.items():
376
+ try:
377
+ pickle.dumps(entry.func)
378
+ except Exception as e:
379
+ raise TypeError(
380
+ f"strict_picklable: handler for task_type '{task_type}' is not "
381
+ f"module-level picklable: {e}"
382
+ ) from e
383
+
384
+ def add_resource(self, resource: Resource) -> None:
385
+ """Add a resource scheduler to the pipeline.
386
+
387
+ If ``resource.name`` already exists (including the internal ``__workers__``),
388
+ it is overwritten with a warning. Overriding ``__workers__`` changes the
389
+ max concurrency of the pipeline.
390
+ """
391
+ if not isinstance(resource, Resource):
392
+ raise TypeError(
393
+ f"add_resource expects a Resource instance, "
394
+ f"got {type(resource).__name__}"
395
+ )
396
+ if resource.name in self.resources:
397
+ logger.warning(f"Overwriting existing resource '{resource.name}'")
398
+ self.resources[resource.name] = resource
399
+
400
+ def register_handler(
401
+ self,
402
+ task_type: str,
403
+ handler_func: Callable[[Job, TaskContext], Any],
404
+ default_resources: Optional[Dict[str, float]] = None,
405
+ payload_schema: Optional[type] = None,
406
+ ) -> None:
407
+ """
408
+ Register a handler for a task type.
409
+
410
+ Handler can return:
411
+ - None (Implies success)
412
+ - True / False
413
+ - dict (Metadata for success)
414
+ - Tuple[bool, dict]
415
+ Raise RetryError to push back to queue.
416
+ Raise FatalError for non-retryable bugs (direct DLQ, no retries).
417
+ Raise Exception to fail and push to DLQ.
418
+
419
+ payload_schema: Optional TypedDict class for runtime payload validation.
420
+ Validated BEFORE forking the subprocess. Validation failures go directly to DLQ.
421
+ """
422
+ if not isinstance(task_type, str) or not task_type:
423
+ raise TypeError(
424
+ f"task_type must be a non-empty str, got {type(task_type).__name__} ({task_type!r})"
425
+ )
426
+ if "::" in task_type:
427
+ raise ValueError(f"task_type must not contain '::', got {task_type!r}")
428
+ if not callable(handler_func):
429
+ raise TypeError(f"handler_func must be callable, got {type(handler_func).__name__}")
430
+ if task_type in self.handlers:
431
+ logger.warning(f"Overwriting existing handler for task_type '{task_type}'")
432
+ # 与 Job.__init__ 同级校验 handler 默认资源——负值/NaN 会绕过 Job 构造
433
+ # 校验,在派发时导致 acquire 崩溃或 NaN 污染调度(管线无限空转);
434
+ # 非 dict 类型与 Job.__init__ 的显式 dict 守卫(job.py)对称,入口拒绝。
435
+ if default_resources is not None and not isinstance(default_resources, dict):
436
+ raise TypeError(
437
+ f"default_resources must be a dict or None, "
438
+ f"got {type(default_resources).__name__}"
439
+ )
440
+ if default_resources:
441
+ self._validate_resource_amounts(default_resources, "default_resources")
442
+ if payload_schema is not None and not isinstance(payload_schema, type):
443
+ # 代码级限制:文档约定 payload_schema 必须是 TypedDict 类;
444
+ # 传实例/字符串等会在运行时校验时静默失效,入口 fail-loud。
445
+ raise TypeError(
446
+ f"payload_schema must be a type (e.g. TypedDict class), "
447
+ f"got {type(payload_schema).__name__}"
448
+ )
449
+ self.handlers[task_type] = HandlerEntry(handler_func, default_resources or {}, payload_schema)
450
+
451
+ def set_discovery_rerun(self, task_type: str, rerun: str) -> None:
452
+ """登记 discovery task_type 的默认 rerun 策略。
453
+
454
+ ``wrappers.discovery.register_discovery`` 的框架无关适配经本公开方法
455
+ 写入默认 rerun——宿主实现细节(`_discovery_rerun` 私有字典)不
456
+ 暴露给 discovery 模块。enqueue/spawn 时若 job 未指定 rerun
457
+ (Job.rerun=None 哨兵)则经
458
+ ``apply_discovery_rerun`` 注入该默认值,使固定 uid 的 discovery
459
+ job 每会话重扫;显式值(含 "never")一律尊重。
460
+ """
461
+ if not isinstance(task_type, str) or not task_type:
462
+ raise TypeError(
463
+ f"task_type must be a non-empty str, got {type(task_type).__name__} ({task_type!r})"
464
+ )
465
+ if "::" in task_type:
466
+ raise ValueError(f"task_type must not contain '::', got {task_type!r}")
467
+ if rerun not in ("never", "on_failure", "every_run", "on_input_change"):
468
+ raise ValueError(
469
+ f"rerun must be one of 'never'/'on_failure'/'every_run'/"
470
+ f"'on_input_change', got {rerun!r}"
471
+ )
472
+ if task_type in self._discovery_rerun:
473
+ logger.warning(
474
+ f"Overwriting discovery rerun for task_type '{task_type}'"
475
+ )
476
+ self._discovery_rerun[task_type] = rerun
477
+
478
+ def register_transient_exception(self, exception_cls: type) -> None:
479
+ """把业务自有异常类注册为瞬态(自动重试),**per-pipeline 语义**。
480
+
481
+ 注册表是本 pipeline 实例态——不同 pipeline 的注册互不可见、
482
+ 跨 run 不累积。分类决策发生在子进程,因此类必须为模块级
483
+ 可 pickle(入口 fail-loud 预检);注册表快照随 ``TaskContext``
484
+ 显式下发子进程。
485
+ """
486
+ self.transient_registry.register(exception_cls)
487
+
488
+ def enqueue(self, jobs: Union[Job, Sequence[Job]], front: bool = False) -> None:
489
+ """Add jobs to the queue.
490
+
491
+ Args:
492
+ jobs: Job 或 Job 列表(单个 Job 会自动包成列表)。重复 uid 静默跳过。
493
+ front: If ``True``, insert at queue head (for requeue-style usage);
494
+ else append to tail (default).
495
+
496
+ Note:
497
+ Not thread-safe. Do not call concurrently with ``run()``(见
498
+ README 已知限制). Use ``ctx.spawn()`` for runtime
499
+ child job generation inside handlers.
500
+
501
+ 写入走后端 ``enqueue_jobs`` 增量 API(单事务原子插入,不做
502
+ DELETE 全表重写)——与 run() 的 delta commit 并发时互不覆盖。
503
+
504
+ discovery task_type 的 job 在入队时注入该 discovery
505
+ 注册的默认 rerun(通常 "every_run")——用户**未指定**(Job.rerun=None,
506
+ 哨兵语义)时注入 discovery 默认,使「固定 uid 每会话重扫」成为
507
+ 默认姿势;显式指定(含 "never")一律尊重,不覆盖、不告警。
508
+ """
509
+ self._ensure_not_running("enqueue")
510
+ # 兼容单个 Job 与 list/tuple——与 add_resource/
511
+ # ctx.spawn 的单数语义对齐,避免“必须包一层 []”的非直觉用法。
512
+ if isinstance(jobs, Job):
513
+ jobs_list = [jobs]
514
+ elif isinstance(jobs, (list, tuple)):
515
+ jobs_list = list(jobs)
516
+ else:
517
+ raise TypeError(
518
+ "enqueue() expects a Job or a list of Job objects, "
519
+ f"got {type(jobs).__name__}"
520
+ )
521
+ if not jobs_list:
522
+ return
523
+
524
+ jobs_dicts = []
525
+ for j in jobs_list:
526
+ if not isinstance(j, Job):
527
+ raise TypeError(
528
+ "enqueue() expects Job objects, "
529
+ f"got {type(j).__name__}"
530
+ )
531
+ # 预检 payload JSON 可序列化,避免子进程 IPC 时崩溃。
532
+ # allow_nan=False 与 ctx.spawn 的 JSON 序列化预检对齐——默认
533
+ # allow_nan=True 会让 float('nan') 通过预检,产出非标准 JSON
534
+ # "Infinity",下游 json.loads 反序列化出 NaN 污染计算。
535
+ try:
536
+ dumps(j.payload)
537
+ except (TypeError, ValueError) as e:
538
+ raise ValueError(
539
+ f"Payload for job {j.uid} is not JSON-serializable: {e}"
540
+ ) from e
541
+ # enqueue 不合并 handler 默认 resources——合并会把注册时的
542
+ # 默认值烤进持久化 job_dict(handler 默认资源变更后磁盘留旧值);
543
+ # 运行时由 scheduler 的 _effective_resources(扫描可见性)与
544
+ # _dispatch_job(acquire 实际值)两处合并。浅拷贝避免修改传入的 Job 对象。
545
+ job_dict = j.to_dict()
546
+ # discovery 默认 rerun 注入(单点函数;None=未指定
547
+ # 哨兵才注入,显式值含 "never" 一律尊重)
548
+ apply_discovery_rerun(job_dict, j.task_type, self._discovery_rerun)
549
+ self._inject_worker_resource(job_dict)
550
+ jobs_dicts.append(job_dict)
551
+
552
+ if not jobs_dicts:
553
+ return
554
+
555
+ # 增量入队(后端在单事务内去重 + 插入),返回实际插入 uid
556
+ inserted = self.backend.enqueue_jobs(jobs_dicts, front=front)
557
+ skipped = len(jobs_dicts) - len(inserted)
558
+ if skipped:
559
+ logger.info(f"Enqueued {len(inserted)} job(s), skipped {skipped} duplicate(s).")
560
+ elif inserted:
561
+ logger.info(f"Enqueued {len(inserted)} job(s).")
562
+
563
+ def list_dlq(self) -> List[DLQEntry]:
564
+ """只读查询 DLQ,返回结构化条目(uid / error_type / error / attempts / failed_at / meta)。
565
+
566
+ error_type 分类:fatal(FatalError)/ dependency(级联)/
567
+ deadlock / transient_exhausted(重试耗尽)/ no_handler /
568
+ validation / commit_failure / dispatch / unknown(见 ``backend.base.classify_error_type``)。
569
+ 只读,不改变任何状态;仅限 run() 之外调用。
570
+ """
571
+ self._ensure_not_running("list_dlq")
572
+ failed = self.backend.load_failed()
573
+ entries: List[DLQEntry] = []
574
+ for uid, meta in sorted(failed.items()):
575
+ if not isinstance(meta, dict):
576
+ # 与 classify_error_type 同款防御:手改/遗留损坏行不炸掉整个
577
+ # 排障工具——合法 JSON 标量("boom"/42/true
578
+ # 等)也要兜底:dict(标量) 会抛 TypeError/ValueError,此处
579
+ # 统一按未知分类展示,排障者可修复。
580
+ entries.append(DLQEntry(
581
+ uid=uid,
582
+ error_type=classify_error_type(meta),
583
+ error="",
584
+ attempts=0,
585
+ failed_at=None,
586
+ meta={},
587
+ ))
588
+ continue
589
+ attempts = meta.get("_attempt", 0)
590
+ if not isinstance(attempts, int):
591
+ attempts = 0 # 非 int 的 _attempt(手改/遗留行)不炸 list_dlq()
592
+ entries.append(DLQEntry(
593
+ uid=uid,
594
+ error_type=classify_error_type(meta),
595
+ error=str(meta.get("error", "")),
596
+ attempts=attempts,
597
+ failed_at=meta.get("failed_at"),
598
+ meta=dict(meta),
599
+ ))
600
+ return entries
601
+
602
+ def clear_dlq(
603
+ self,
604
+ task_types: Optional[Sequence[str]] = None,
605
+ *,
606
+ keep_fatal: bool = True,
607
+ ) -> int:
608
+ """从 DLQ 删除匹配条目(默认保留 fatal=true 的确定性失败),返回删除数。
609
+
610
+ 清除 = 删 DLQ + 调用方随后 enqueue 同名任务重跑(is_known 不再
611
+ 把该 uid 算「已知」)。``task_types`` 过滤只删这些 task_type 前缀的
612
+ 条目(str 列表/tuple);None = 全部。``keep_fatal=False`` 连 FatalError
613
+ 条目一并删除。仅限 run() 之外调用(改变 is_known 判定基础,与 enqueue 同纪律)。
614
+ """
615
+ self._ensure_not_running("clear_dlq")
616
+ if task_types is not None:
617
+ if not isinstance(task_types, (list, tuple)):
618
+ raise TypeError(
619
+ f"task_types must be a list/tuple of str or None, "
620
+ f"got {type(task_types).__name__}"
621
+ )
622
+ for t in task_types:
623
+ if not isinstance(t, str) or not t:
624
+ raise TypeError(
625
+ f"task_types must contain only non-empty str, got {t!r}"
626
+ )
627
+ task_types = list(task_types)
628
+ failed = self.backend.load_failed()
629
+ to_delete = [
630
+ uid for uid, meta in failed.items()
631
+ if (task_types is None
632
+ or any(uid.startswith(t + "::") for t in task_types))
633
+ # 非 dict 损坏行(合法 JSON 标量)不炸 revive——
634
+ # 无 fatal 标志可读,按「可删除」处理(删除本身就是修复手段)。
635
+ and not (keep_fatal and isinstance(meta, dict) and meta.get("fatal"))
636
+ ]
637
+ if not to_delete:
638
+ return 0
639
+ return self.backend.delete_failed(to_delete)
640
+
641
+ def clear_history(
642
+ self,
643
+ targets: Union[str, Sequence[str]],
644
+ *,
645
+ where: Sequence[str] = ("wall", "failed"),
646
+ ) -> int:
647
+ """从 wall 和/或 DLQ 删除条目——「误删文件强制重下」「历史垃圾清理」的官方通道。
648
+
649
+ ``targets``:str 或 str 列表。完整 uid 精确删除;**以 ``::``
650
+ 结尾的字符串按前缀匹配**(如 ``"download::"`` 删全部 download 任务)
651
+ ——防止 ``"download"`` 误匹配 ``"downloads::"``(前缀误匹配痛点 )。
652
+ ``where``:含 ``"wall"`` / ``"failed"`` 的序列,默认两者都清。
653
+ 返回删除总数。仅限 run() 之外调用(改变 is_known 判定基础)。
654
+ """
655
+ self._ensure_not_running("clear_history")
656
+ if isinstance(targets, str):
657
+ patterns = [targets]
658
+ elif isinstance(targets, (list, tuple)):
659
+ patterns = list(targets)
660
+ else:
661
+ raise TypeError(
662
+ f"targets must be a str or a list/tuple of str, "
663
+ f"got {type(targets).__name__}"
664
+ )
665
+ for p in patterns:
666
+ if not isinstance(p, str):
667
+ raise TypeError(
668
+ f"targets must contain only str, got {type(p).__name__} ({p!r})"
669
+ )
670
+ if not isinstance(where, (list, tuple)):
671
+ raise TypeError(
672
+ f"where must be a sequence of 'wall'/'failed', got {type(where).__name__}"
673
+ )
674
+ where_set = set(where)
675
+ unknown = where_set - {"wall", "failed"}
676
+ if unknown:
677
+ raise ValueError(
678
+ f"where contains unknown target(s): {sorted(unknown)!r}; "
679
+ f"allowed: 'wall', 'failed'"
680
+ )
681
+
682
+ def _matches(uid: str) -> bool:
683
+ return any(
684
+ uid == p or (p.endswith("::") and uid.startswith(p))
685
+ for p in patterns
686
+ )
687
+
688
+ total = 0
689
+ if "wall" in where:
690
+ wall = self.backend.load_wall()
691
+ matched = [u for u in wall if _matches(u)]
692
+ if matched:
693
+ total += self.backend.delete_wall(matched)
694
+ if "failed" in where:
695
+ failed = self.backend.load_failed()
696
+ matched = [u for u in failed if _matches(u)]
697
+ if matched:
698
+ total += self.backend.delete_failed(matched)
699
+ return total
700
+
701
+ def seed_wall(self, uids: Sequence[str]) -> int:
702
+ """把 uid 批量写入 wall(存档迁移标记「已处理」),返回实际写入数。
703
+
704
+ 媒体/数据资产存档迁移(硬链接 + wall 种子)从此不用裸 SQL。
705
+ uid 必须为 ``"task_type::job_id"`` 形式 str,且 task_type/job_id 均
706
+ 非空、不含额外 ``::``;meta 为空 dict。仅限 run() 之外调用。
707
+ """
708
+ self._ensure_not_running("seed_wall")
709
+ if not isinstance(uids, (list, tuple)):
710
+ raise TypeError(
711
+ f"uids must be a list/tuple of str, got {type(uids).__name__}"
712
+ )
713
+ for u in uids:
714
+ if not isinstance(u, str) or u.count("::") != 1:
715
+ raise ValueError(
716
+ f"seed_wall uid must be 'task_type::job_id' str with exactly "
717
+ f"one '::' separator, got {u!r}"
718
+ )
719
+ task_type, job_id = u.split("::", 1)
720
+ if not task_type or not job_id:
721
+ raise ValueError(
722
+ f"seed_wall uid must have non-empty task_type and job_id, got {u!r}"
723
+ )
724
+ return self.backend.seed_wall(list(uids))
725
+
726
+ def seed_cursor(self, key: str, value: str) -> None:
727
+ """预填一个 cursor(幂等)——存档迁移/进度书签恢复。
728
+
729
+ 注意:discovery 的已见判定走 wall/failed(见
730
+ ``wrappers/discovery.py`` 头部),不再使用 cursor——「已见预填」请用
731
+ ``seed_wall``(把 process 任务的 uid 写入 wall)。本方法服务
732
+ 通用业务 cursor(``ctx.get_cursor`` 可读)。仅限 run() 之外调用。
733
+ """
734
+ self._ensure_not_running("seed_cursor")
735
+ self.backend.seed_cursor(key, value)
736
+
737
+ def _validate_resource_amounts(self, resources: Dict[str, float], where: str) -> None:
738
+ """数值校验转发(与 Job.__init__ 共用 utils.validation 单点)。
739
+
740
+ handler 默认资源在注册时即校验,防止负值/NaN/Inf 绕过 Job 构造
741
+ 校验后在派发时引发 acquire 崩溃(负值)或调度 NaN 污染(无限空转)。
742
+ """
743
+ validate_resource_amounts(resources, where)
744
+
745
+ def _inject_worker_resource(self, job_dict: dict) -> None:
746
+ """给 job_dict 注入默认 worker 槽位(单点实现见 engine.runtime)。"""
747
+ inject_worker_resource(job_dict)
748
+
749
+ def stop(self, force: bool = False) -> None:
750
+ """请求管线停止(停机状态机)。
751
+
752
+ - ``stop(force=False)``(默认):**DRAINING**——不再派发新 job,
753
+ 允许当前 in-flight 自然完成后再退出(优雅停机)。
754
+ - ``stop(force=True)``:**ABORTING**——分类消费在途任务:**已完成**
755
+ (结果文件已落盘、只差 drain 回收)的 job 走 ``_complete_job`` 提交
756
+ (进 wall/failed,不 kill、不删产出、不 requeue),仅对**进行中**
757
+ 的 job 执行 kill + 清半成品 + requeue 后退出。
758
+ """
759
+ self._ctx.stop_mode = StopMode.ABORTING if force else StopMode.DRAINING
760
+
761
+ def _handle_stop_signal(self, signum, frame) -> None:
762
+ """SIGTERM/SIGINT 信号处理器:请求 DRAINING 优雅停机;二次强制 ABORTING。
763
+
764
+ 容器编排(docker stop / k8s pod 终止)默认发 SIGTERM;终端 Ctrl+C
765
+ 发 SIGINT。两者复用同一处理器:
766
+ 首次信号 → DRAINING:不派发新 job,等 in-flight 自然完成。
767
+ 二次信号(含两种信号交叉)→ ABORTING:分类消费在途任务——**已完成**
768
+ (结果文件已落盘)的 job 被消费提交(进 wall/failed,不重跑),仅对
769
+ **进行中**的 job 执行 kill + 清半成品 + requeue,快速退出(对应容器
770
+ 优雅停机超时后的强杀信号 / 用户第二次 Ctrl+C)。
771
+ """
772
+ try:
773
+ sig_name = signal.Signals(signum).name
774
+ except (ValueError, AttributeError):
775
+ sig_name = str(signum)
776
+ if self._ctx.stop_mode is not StopMode.NONE:
777
+ logger.warning(f"Second {sig_name} received, forcing abort.")
778
+ self._ctx.stop_mode = StopMode.ABORTING
779
+ else:
780
+ logger.warning(
781
+ f"Received {sig_name}, requesting graceful shutdown (draining)."
782
+ )
783
+ self._ctx.stop_mode = StopMode.DRAINING
784
+
785
+
786
+
787
+
788
+ def run(self) -> None:
789
+ """Start the pipeline and run until queue is empty (or a drain/abort stop is requested)."""
790
+ logger.info(f"=== Starting Pipeline: {self.name} (Backend: {self.backend_type}) ===")
791
+ self._ctx.stop_mode = StopMode.NONE
792
+ # 代码级限制:run 进行中禁止 enqueue / list_dlq / clear_dlq / clear_history /
793
+ # seed_wall / seed_cursor;on_run_start 也在保护范围内。
794
+ self._run_started = True
795
+ # 预置全部已知键——hook_errors/deferred_orphan 是运行期动态键,
796
+ # 预置后累加不再依赖拼写正确(.get(...,0)+1 会静默容忍拼错)。
797
+ self.stats = TaskStats()
798
+ # on_run_end 钩子幂等标志重置(run 只触发一次)。
799
+ self._ctx.reset_run_end_fired()
800
+ # 同 state_dir 并发 run 防护:pipeline 级文件锁(锁文件常驻,
801
+ # 由 fd 生命周期保证;进程崩溃内核自动释放)。
802
+ self._run_lock_fd = try_acquire_lock(self.ipc_dir, "__pipeline_run__", timeout=0)
803
+ if self._run_lock_fd is None:
804
+ self._run_started = False
805
+ raise RuntimeError(
806
+ f"Another run() is in progress for state_dir {self.state_dir}; "
807
+ f"concurrent runs on the same state are forbidden."
808
+ )
809
+ # episode 治理态重置(依赖宽限截止、缺失集合追踪、死锁缺口轮数)
810
+ self._ctx.reset_episode()
811
+
812
+ # 注册 SIGTERM/SIGINT handler(两种信号复用同一优雅停机路径:
813
+ # DRAINING/ABORTING 状态机,与容器 stop 语义一致;SIGINT 不以
814
+ # KeyboardInterrupt 穿透主循环)。
815
+ # 信号只能在主线程注册;子线程调用 run 会抛 ValueError,安全跳过。
816
+ # run 退出后恢复原 handler,避免影响进程其他部分。
817
+ old_sigterm = None
818
+ old_sigint = None
819
+ try:
820
+ old_sigterm = signal.signal(signal.SIGTERM, self._handle_stop_signal)
821
+ except (ValueError, OSError):
822
+ logger.debug("Could not install SIGTERM handler (non-main thread or unsupported platform).")
823
+ try:
824
+ old_sigint = signal.signal(signal.SIGINT, self._handle_stop_signal)
825
+ except (ValueError, OSError):
826
+ logger.debug("Could not install SIGINT handler (non-main thread or unsupported platform).")
827
+
828
+ try:
829
+ self._preflight_picklable_callbacks()
830
+ # on_run_start 异常隔离——钩子按不可信代码对待
831
+ if self.on_run_start is not None:
832
+ try:
833
+ self.on_run_start()
834
+ except Exception as e:
835
+ logger.warning(f"on_run_start hook raised: {e}")
836
+ self.stats["hook_errors"] += 1
837
+ self._run_body()
838
+ except BaseException as e:
839
+ # 未进入主循环/主循环未覆盖的退出路径也触发 on_run_end。
840
+ self._ctx.fire_run_end(
841
+ "interrupted" if isinstance(e, KeyboardInterrupt) else "error"
842
+ )
843
+ raise
844
+ finally:
845
+ # 正常完成路径的兜底触发;若上面 except 已触发,幂等忽略。
846
+ self._ctx.fire_run_end("completed")
847
+ release_lock(self._run_lock_fd)
848
+ self._run_lock_fd = None
849
+ self._run_started = False
850
+ if old_sigterm is not None:
851
+ signal.signal(signal.SIGTERM, old_sigterm)
852
+ if old_sigint is not None:
853
+ signal.signal(signal.SIGINT, old_sigint)
854
+
855
+ def _run_body(self) -> None:
856
+ """run() 的实际执行体,由 run() 包裹在 SIGTERM 安装/恢复之间调用。"""
857
+ # 调度缓存按 run 生命周期清空——跨 run 的
858
+ # clear_history + 重新 enqueue 同 uid 不同内容场景需在加载期覆盖陈旧缓存。
859
+ self.scheduler.begin_round()
860
+ wall = self.backend.load_wall()
861
+ failed = self.backend.load_failed()
862
+ cursors = self.backend.load_cursors()
863
+ q_data = self.backend.load_queue()
864
+
865
+ # fencing:每次 run 生成新的 run_id 并持久化到 meta 表
866
+ # (记录上次 run 的 id,供运维排障/未来清理残留),seq 计数器重置。
867
+ self._run_id = uuid.uuid4().hex
868
+ self._dispatch_seq = 0
869
+ # run_id 持久化失败必须 fail-loud——静默降级 = 无 fence 运行,
870
+ # 孤儿 fencing 承诺形同虚设(且 meta 表写失败说明 DB 已故障,
871
+ # 队列持久化同样不可靠,启动即应失败而非带病运行)。
872
+ try:
873
+ self.backend.set_meta("last_run_id", self._run_id)
874
+ except Exception as e:
875
+ logger.critical(
876
+ f"Failed to persist run_id to meta table — fencing disabled, "
877
+ f"refusing to run degraded: {e}"
878
+ )
879
+ raise
880
+
881
+ # 加载期队列整理:退避换算 + 残留过滤 + 去重(统一由 RecoveryMachine 原子执行)。
882
+ q_data = self._recovery.repair_queue_on_load(q_data, wall, failed)
883
+
884
+ # 加载持久化的资源 suspend 状态(meta 表 wall-clock 截止换算回 monotonic)
885
+ self._recovery.load_resource_suspends()
886
+
887
+ self._ctx.set_state(PipelineState(wall, failed, cursors, q_data))
888
+
889
+ # 启动主事件循环
890
+ self._run_loop()
891
+
892
+ # ── 内部组件委托方法(供测试与生命周期直调)─────────────────
893
+
894
+ def _run_loop(self) -> None:
895
+ return self._loop.run_loop()
896
+
897
+ def _save_queue_crash_safe(self) -> None:
898
+ return self._recovery.save_queue_crash_safe()
899
+
900
+ def _abort_in_flight(self) -> None:
901
+ return self._recovery.abort_in_flight()
902
+
903
+ def _release_acquired(self, acquired, uid=None) -> None:
904
+ return self._completion.release_acquired(acquired, uid=uid)
905
+
906
+ def _dispatch_job(self, sched):
907
+ return self._dispatch.dispatch_job(sched)
908
+
909
+ def _apply_result(self, uid, job, job_dict, result, job_start=None, expect_in_flight=True):
910
+ return self._completion.apply_result(
911
+ uid, job, job_dict, result, job_start=job_start, expect_in_flight=expect_in_flight,
912
+ )
913
+
914
+