feat: 任务优先级调度 + 条件分支构造器 + 大图增量就绪集优化 — DependencyRunner 重写为事件驱动优先级调度;新增 px.switch/px.branch DAG 构造器;_build_dependency_index 替代 O(N) 每轮扫描使大图调度从 O(N²) 降至 O(N),10k 任务 <1s
CI / Lint, Typecheck & Test (push) Has been cancelled

This commit is contained in:
2026-07-08 12:28:43 +08:00
parent bd3651e337
commit fbabbc98ad
8 changed files with 676 additions and 83 deletions
+84
View File
@@ -0,0 +1,84 @@
# iter-27:功能扩展与性能优化
## 本轮目标
用户请求"继续扩展功能和优化性能",选定 3 个方向:
1. **P1:任务优先级调度** —— DependencyRunner 支持按 `priority` 降序调度就绪任务
2. **P2:条件分支/switch 模式** —— 新增 `px.switch()``px.branch()` DAG 构造器
3. **P3:大图内存与并发优化** —— 消除 `_find_ready()` 的 O(N) 每轮扫描
## 改动文件清单
| 文件 | 变更 |
|------|------|
| `src/pyflowx/executors.py` | DependencyRunner 重写为事件驱动优先级调度;`_build_dependency_index` 模块级函数;`_on_complete`/`_register_dynamic` 闭包;模块 docstring 更新 |
| `src/pyflowx/pipelines.py` | 新增 `switch()``branch()` 函数 |
| `src/pyflowx/__init__.py` | 导出 `switch`/`branch` |
| `tests/test_advanced_features.py` | 新增 2 个优先级调度测试 |
| `tests/test_pipelines.py` | 新增 `TestSwitch`5 测试)和 `TestBranch`4 测试) |
| `benchmarks/__main__.py` | 新增 `bench_large_graph()` 大图基准(1k/5k/10k 任务) |
| `.trae/skills/pyflowx-development/SKILL.md` | 第三节新增"事件驱动优先级调度"和"增量就绪集优化";第十七节新增 switch/branch |
## 关键决策与依据
### P1:事件驱动优先级调度
**决策**:重写 DependencyRunner 从"一次性创建所有 asyncio Task"为"事件驱动按需创建"。
**依据**
- 旧实现 `asyncio.gather(*[create_task(n) for n in all])` 一次性创建所有 Task
无法按优先级排序创建顺序(Task 一旦创建即进入事件循环调度队列)。
- 新实现用 `completed`/`in_flight`/`remaining` 三集合跟踪生命周期,
仅当任务依赖全部完成时才创建 asyncio Task,按 `priority` 降序创建。
- 测试验证:`concurrency_key="g"` + `concurrency_limits={"g": 1}` 串行化无依赖
任务,使优先级顺序可观测(`order == ["high", "mid", "low"]`)。
### P2:条件分支构造器
**决策**:基于已有 `BuiltinConditions` 原语构建 switch/branch,不引入新条件类型。
**依据**
- `DEP_EQUALS`/`DEP_MATCHES`/`NOT`/`AND` 已覆盖 switch/branch 所需语义。
- `switch(selector, cases, *, default=None)`:每个 case 追加
`DEP_EQUALS(selector.name, case_key)`default 追加
`AND(NOT(DEP_EQUALS(..., k)) for k in cases)`
- `branch(selector, predicate, if_true, *, if_false=None)`if_true 追加
`DEP_MATCHES`if_false 追加 `NOT(DEP_MATCHES)`
-`dataclasses.replace` 追加 `depends_on``conditions`,不修改原 spec。
### P3:增量就绪集优化
**决策**:用 `in_degree` 计数器 + `dependents` 反向邻接表替代每轮 O(N) 扫描。
**依据**
-`_find_ready()` 每轮扫描全部 `remaining` 任务检查依赖是否满足,
O(N·D) per round、O(N²·D) total。10k 任务链式图(10k 轮)成为瓶颈。
- 新方案预计算 `in_degree[name]`(尚未完成的硬+软依赖数)+ `dependents[d]`
(反向邻接表)。任务完成时 `_on_complete` 仅遍历 `dependents[name]`O(D_out)),
递减 `in_degree`;降为 0 时加入 `ready` 集合。
- 动态任务通过 `_register_dynamic` 接入增量结构:spawner 此时未标记 completed
(仍在 in_flight),故计入 unsatisfiedspawner 完成后 `_on_complete` 递减并触发就绪。
- 提取 `_build_dependency_index` 为模块级函数,控制 `execute` 的 PLR0912 分支数 ≤12。
## 验证结果
### 基准(dependency 策略,noop 任务)
| 图形状 | 1k | 5k | 10k |
|--------|-----|-----|------|
| chain | 87ms | 459ms | 948ms |
| diamond | 53ms | 320ms | 741ms |
| wide | 37ms | 247ms | 789ms |
chain 线性扩展(1k→5k→10k ≈ 5.3x→10.9x),确认 O(N) 调度复杂度。
### 门禁
- pytest1705 passed
- coverage97.21%branch,≥95% 门槛)
- ruffAll checks passed
- pyrefly0 errors
## 遗留事项
- 无。P1/P2/P3 全部交付完毕。
+42
View File
@@ -243,6 +243,39 @@ sequential(500) 从 764 → 930 ops/s+22%),thread(500) 从 108 → 119 op
- **适用边界**async/dependency 策略对 noop 任务的固有并发开销(事件循环 +
线程池提交)主导,per-task 优化相对影响小;真实 I/O 任务才能体现并行优势。
### 事件驱动优先级调度(iter-27)
DependencyRunner 从"一次性创建所有 asyncio Task"重写为"事件驱动按需创建":
仅当任务依赖全部完成时才创建 asyncio Task,多个就绪任务按 `priority` 降序
创建,高优先级任务优先获取事件循环与线程池资源。
- **`completed` / `in_flight` / `remaining` 三集合**:跟踪任务生命周期。
`completed` = 已完成(含 SUCCESS/SKIPPED/FAILED);`in_flight` = asyncio
Task 已创建未完成;`remaining` = 尚未调度。
- **`concurrency_key=1` 串行化验证优先级**:测试中用 `concurrency_key="g"`
+ `concurrency_limits={"g": 1}` 使无依赖任务串行执行,优先级顺序可观测。
无 concurrency_key 时任务真并行,优先级仅影响 asyncio Task 创建顺序。
- **与层策略优先级的差异**:层策略(sequential/thread/async)仅在同层内
按优先级排序;dependency 策略跨层,按就绪时刻排序——依赖完成后立即就绪,
不等同层其他任务。
### 增量就绪集优化(iter-27
DependencyRunner 主循环每轮调用 `_find_ready()` 扫描全部 `remaining` 任务
检查依赖是否满足,O(N*D) per round、O(N²·D) total。10k 任务链式图
10k 轮调度)下成为瓶颈。
- **`_build_dependency_index` 模块级函数**:预计算 `in_degree[name]`
(尚未完成的硬+软依赖数)+ `dependents[d]`(依赖 d 的任务列表,反向邻接表)
+ `ready`in_degree=0 的初始就绪集)。复杂度从 O(N²·D) 降至 O(N·D)。
- **`_on_complete(name)`**:任务完成后遍历 `dependents[name]`,逐个递减
`in_degree`;降为 0 时加入 `ready` 集合。每完成一个任务仅 O(D_out) 操作。
- **`_register_dynamic(raw_spec, spawner)`**:动态生成任务接入增量结构。
spawner 此时未标记 completed(仍在 in_flight),故计入 unsatisfied
spawner 完成后 `_on_complete` 会递减并触发就绪。
- **基准验证**chain(10000) ~950ms、diamond(10000) ~740ms、wide(10000)
~790ms。chain 线性扩展(1k:87ms → 5k:459ms → 10k:948ms),确认 O(N) 调度。
### 踩坑总结
- **`lru_cache` 对签名内省有 dict lookup 开销**:即便 `functools.lru_cache`
@@ -896,6 +929,15 @@ sequential(500) 从 764 → 930 ops/s+22%),thread(500) 从 108 → 119 op
- **闭包工厂模式绑定参数**`_make_worker_fn(worker, item)` 与
`_make_reduce_fn(reduce, worker_names)` 参照 `imaging._make_step_fn` 模式,
闭包捕获函数参数(非循环变量),避免 Python 闭包"延迟绑定"陷阱。
- **`switch`/`branch` 条件分支构造器(iter-27)**:基于已有条件原语
`DEP_EQUALS`/`DEP_MATCHES`/`NOT`/`AND`)构建,不引入新条件类型。
`switch(selector, cases, *, default=None)` 为每个 case 追加
`DEP_EQUALS(selector.name, case_key)` 条件;`default` 追加
`AND(NOT(DEP_EQUALS(..., k)) for k in cases)` 条件。
`branch(selector, predicate, if_true, *, if_false=None)` 为 `if_true` 追加
`DEP_MATCHES(selector.name, predicate)`,为 `if_false` 追加 `NOT(...)`。
用 `dataclasses.replace` 在不修改原 spec 的前提下追加 `depends_on` 和
`conditions`。
### 设计决策
+26
View File
@@ -202,6 +202,31 @@ def bench_dependency() -> None:
print_results("执行策略: dependency", results)
def bench_large_graph() -> None:
"""大图调度基准:验证增量就绪集优化效果(1k–10k 任务)。
覆盖三种图形状:
* chain —— 深链(每轮仅 1 个就绪,调度轮数 = N)
* wide —— 完全并行(首轮全部就绪,仅 1 轮调度)
* diamond —— 菱形(多层,每层多任务)
"""
results = []
def noop() -> None:
pass
for shape, maker in (("chain", make_chain), ("diamond", make_diamond), ("wide", make_wide)):
for n in (1000, 5000, 10000):
specs = maker(n)
# 替换为 noop fn(避免子进程开销,纯测调度性能)
specs = [TaskSpec(s.name, fn=noop, depends_on=s.depends_on) for s in specs]
graph = Graph.from_specs(specs)
ms, ops = time_it(lambda g=graph: px.run(g, strategy="dependency"), iterations=3, warmup=1)
results.append((f"dependency-{shape}({n})", 3, ms, ops))
print_results("大图调度 (dependency, 增量就绪集)", results)
def bench_cmd_execution() -> None:
"""cmd 任务执行基准(真实子进程)。"""
results = []
@@ -226,6 +251,7 @@ def run_execution() -> None:
bench_thread()
bench_async()
bench_dependency()
bench_large_graph()
bench_cmd_execution()
+4 -1
View File
@@ -14,6 +14,7 @@
* :class:`TaskHooks` —— 任务生命周期钩子(pre_run/post_run/on_failure)。
* :class:`GraphDefaults` —— 图级默认值。
* :func:`compose` —— 编程式组合多图。
* :func:`switch` / :func:`branch` —— 条件分支 DAG 构造器。
* :func:`task_template` —— 批量生成相似 TaskSpec 的工厂。
* 状态后端::class:`StateBackend`、:class:`MemoryBackend`、:class:`JSONBackend`、:class:`SQLiteBackend`。
@@ -86,7 +87,7 @@ from .notification import (
WebhookNotifier,
WeChatNotifier,
)
from .pipelines import command_chain, data_pipeline, fan_out_fan_in
from .pipelines import branch, command_chain, data_pipeline, fan_out_fan_in, switch
from .profiling import ProfileReport, TaskProfile
from .progress import ProgressCallback, RichProgressMonitor
from .report import RunReport
@@ -193,6 +194,7 @@ __all__ = [
"ToolSpec",
"WeChatNotifier",
"WebhookNotifier",
"branch",
"build_call_args",
"cmd",
"command_chain",
@@ -214,6 +216,7 @@ __all__ = [
"run_iter",
"run_tool",
"start_metrics_server",
"switch",
"task",
"task_template",
"tool",
+109 -42
View File
@@ -24,13 +24,15 @@
* :class:`SequentialLayerRunner` / :class:`ThreadedLayerRunner` /
:class:`AsyncLayerRunner` —— 层级执行器,调用上述模块级辅助。
* :class:`DependencyRunner` —— 依赖驱动调度(非层模型),同样调用模块级辅助。
使用 **增量就绪集**``in_degree`` 计数器 + ``dependents`` 反向邻接表)替代
每轮 O(N) 扫描,大图(10k+ 任务)调度开销从 O(N²) 降至 O(N)。
所有策略共享统一异步内核,支持:
* :class:`RetryPolicy`max_attempts/delay/backoff/jitter/retry_on
* 软依赖注入与默认值
* :class:`TaskHooks`pre_run/post_run/on_failure
* 按任务策略覆盖
* 优先级排序(同层内)
* 优先级排序(同层内 / 依赖驱动策略中按就绪顺序
* 并发限制(concurrency_key + concurrency_limits
* ``continue_on_error``
* ``cache_key`` 存储键
@@ -245,6 +247,33 @@ def _sort_by_priority(layer: list[str], specs: Mapping[str, TaskSpec[Any]]) -> l
return sorted(layer, key=lambda n: -specs[n].priority)
def _build_dependency_index(
remaining: set[str],
all_specs: Mapping[str, TaskSpec[Any]],
completed: set[str],
) -> tuple[dict[str, int], dict[str, list[str]], set[str]]:
"""构建增量就绪集索引:in_degree 计数器 + dependents 反向邻接表 + 初始 ready 集合。
用于 :class:`DependencyRunner` 替代每轮 O(N) 扫描 ``remaining``。
每轮调度开销从 O(N*D) 降至 O(D_out),大图(10k+ 任务)显著加速。
"""
in_degree: dict[str, int] = {}
dependents: dict[str, list[str]] = {name: [] for name in all_specs}
ready: set[str] = set()
for name in remaining:
spec = all_specs[name]
deps = (*spec.depends_on, *spec.soft_depends_on)
unsatisfied = [d for d in deps if d not in completed]
in_degree[name] = len(unsatisfied)
for d in unsatisfied:
if d not in dependents:
dependents[d] = []
dependents[d].append(name)
if in_degree[name] == 0:
ready.add(name)
return in_degree, dependents, ready
# ---------------------------------------------------------------------- #
# 任务级跳过 / 重试 / 成功处理:模块级函数
# ---------------------------------------------------------------------- #
@@ -766,6 +795,9 @@ class DependencyRunner:
所有任务通过 asyncio 并发调度。同步任务卸载到线程池。
优先级调度:当多个任务同时就绪(依赖完成)时,按 ``priority`` 降序
创建 asyncio Task,高优先级任务优先获取事件循环与线程池资源。
本类不继承层 Mixin:依赖驱动调度不是层模型,直接调用模块级共享辅助
函数(:func:`_build_semaphores` / :func:`_get_sem` / :func:`_store_result`),
职责更清晰。
@@ -782,23 +814,57 @@ class DependencyRunner:
cancel_event: threading.Event | CancelToken | None = None,
) -> None:
all_names = list(graph.all_specs().keys())
all_specs = {name: graph.resolved_spec(name) for name in all_names}
all_specs: dict[str, TaskSpec[Any]] = {name: graph.resolved_spec(name) for name in all_names}
semaphores = _build_semaphores(all_names, all_specs, asyncio.Semaphore, concurrency_limits)
futures: dict[str, asyncio.Task[TaskResult[Any]]] = {}
# 事件驱动调度:跟踪 completed / in_flight / remaining。
# 仅当任务依赖全部完成时才创建 asyncio Task,按优先级降序调度。
completed: set[str] = set()
in_flight: dict[str, asyncio.Task[TaskResult[Any]]] = {}
remaining: set[str] = set(all_names)
# 检查点恢复:已在 report 中的 SUCCESS 任务直接标记完成,跳过调度。
for name in list(remaining):
if name in report.results and report.results[name].status == TaskStatus.SUCCESS:
completed.add(name)
remaining.discard(name)
context[name] = report.results[name].value
# 增量就绪集:用 in_degree 计数器 + dependents 反向邻接表替代每轮 O(N) 扫描。
# 每轮调度开销从 O(N*D) 降至 O(D_out),大图(10k+ 任务)显著加速。
in_degree, dependents, ready = _build_dependency_index(remaining, all_specs, completed)
def _on_complete(name: str) -> None:
"""任务完成后,递减其依赖者的 in_degree,新就绪的加入 ready 集合。"""
for dependent in dependents.get(name, ()):
in_degree[dependent] -= 1
if in_degree[dependent] == 0:
ready.add(dependent)
def _register_dynamic(raw_spec: TaskSpec[Any], spawner: str) -> None:
"""注册动态生成的 spec,计算 in_degree 并接入 dependents/semaphores。"""
final_spec = raw_spec
if spawner not in raw_spec.depends_on and spawner not in raw_spec.soft_depends_on:
final_spec = dc_replace(raw_spec, depends_on=(*raw_spec.depends_on, spawner))
graph._register_single(final_spec)
all_specs[final_spec.name] = graph.resolved_spec(final_spec.name)
remaining.add(final_spec.name)
if final_spec.concurrency_key and final_spec.concurrency_key not in semaphores:
limit = concurrency_limits.get(final_spec.concurrency_key, 1)
semaphores[final_spec.concurrency_key] = asyncio.Semaphore(limit)
# 计算新任务的 in_degreespawner 可能尚未标记 completed,计入 unsatisfied
deps = (*final_spec.depends_on, *final_spec.soft_depends_on)
unsatisfied = [d for d in deps if d not in completed]
in_degree[final_spec.name] = len(unsatisfied)
for d in unsatisfied:
if d not in dependents:
dependents[d] = []
dependents[d].append(final_spec.name)
if in_degree[final_spec.name] == 0:
ready.add(final_spec.name)
async def _run_task(name: str) -> TaskResult[Any]:
spec = graph.resolved_spec(name)
# 检查点恢复:已在 report 中的 SUCCESS 任务直接返回
if name in report.results and report.results[name].status == TaskStatus.SUCCESS:
return report.results[name]
# 等待所有硬依赖完成
for dep in spec.depends_on:
if dep in futures:
await futures[dep]
# 等待所有软依赖完成(但不检查其状态)
for dep in spec.soft_depends_on:
if dep in futures:
await futures[dep]
spec = all_specs[name]
# 取消检查:依赖完成后、执行前检查;已取消则标记 SKIPPED
if _is_cancelled(cancel_event) and name not in report.results:
@@ -824,47 +890,48 @@ class DependencyRunner:
if spawned:
spawned_names = [s.name for s in spawned]
result = dc_replace(result, value=spawned_names)
# 重新存储已修改的 result
context[name] = result.value
report.results[name] = result
_emit(on_event, result)
else:
_store_result(name, result, spec, task_ctx, context, report, backend, on_event)
# 注册并调度动态生成的 specs
# 注册动态生成的 specs,接入增量调度结构
for raw_spec in spawned:
# 确保新 spec 依赖生成方(保证顺序与上下文可见性)
final_spec = raw_spec
if name not in raw_spec.depends_on and name not in raw_spec.soft_depends_on:
final_spec = dc_replace(raw_spec, depends_on=(*raw_spec.depends_on, name))
graph._register_single(final_spec)
# 为新 spec 的 concurrency_key 创建信号量
if final_spec.concurrency_key and final_spec.concurrency_key not in semaphores:
limit = concurrency_limits.get(final_spec.concurrency_key, 1)
semaphores[final_spec.concurrency_key] = asyncio.Semaphore(limit)
futures[final_spec.name] = loop.create_task(_run_task(final_spec.name))
_register_dynamic(raw_spec, name)
return result
loop = asyncio.get_running_loop()
for name in all_names:
futures[name] = loop.create_task(_run_task(name))
# 循环等待所有 futures(含动态生成的),直到全部完成
# asyncio.wait 不像 gather 自动传播异常,需手动检查并 re-raise,
# 匹配 gather 的 fail-fast 行为(首个异常即取消剩余任务并抛出)。
while futures:
await asyncio.wait(futures.values(), return_when=asyncio.FIRST_COMPLETED)
completed_names = [n for n, f in futures.items() if f.done()]
for done_name in completed_names:
f = futures[done_name]
exc = f.exception()
# 循环:调度就绪任务 → 等待完成 → 更新 completed → 重复
# fail-fast:首个异常即取消剩余任务并抛出(匹配 gather 语义)。
while remaining or in_flight:
# 按优先级降序调度所有就绪任务
if ready:
to_schedule = _sort_by_priority(list(ready), all_specs)
ready.clear()
for name in to_schedule:
remaining.discard(name)
in_flight[name] = loop.create_task(_run_task(name))
if not in_flight:
if remaining: # pragma: no cover - 图已校验无环,防御性处理
raise RuntimeError(f"调度死锁:剩余任务 {remaining} 无法就绪")
break
done, _ = await asyncio.wait(in_flight.values(), return_when=asyncio.FIRST_COMPLETED)
for task in done:
done_name = next(n for n, t in in_flight.items() if t is task)
del in_flight[done_name]
completed.add(done_name)
_on_complete(done_name)
exc = task.exception()
if exc is not None:
for remaining in futures.values():
if not remaining.done():
remaining.cancel()
for t in in_flight.values():
if not t.done():
t.cancel()
raise exc
del futures[done_name]
# ---------------------------------------------------------------------- #
+124 -2
View File
@@ -1,10 +1,12 @@
"""通用 DAG 构造器集合.
提供 3 个高层 DAG 构造器, 封装常见的任务编排模式:
提供 5 个高层 DAG 构造器, 封装常见的任务编排模式:
* :func:`data_pipeline` 函数式数据流水线 (链式函数组合)
* :func:`command_chain` 命令链 (串行命令执行)
* :func:`fan_out_fan_in` map-reduce (扇出处理 + 扇入聚合)
* :func:`switch` 条件分支 (根据上游结果选择执行路径)
* :func:`branch` 二路分支 (if/else 模式)
:mod:`pyflowx.imaging` :func:`image_pipeline` 同级, 复用
:meth:`Graph.chain` / :meth:`Graph.map` 的依赖链接模式, 但提供更高层次
@@ -21,13 +23,15 @@
from __future__ import annotations
from collections.abc import Callable, Mapping, Sequence
from dataclasses import replace
from pathlib import Path
from typing import Any
from .conditions import BuiltinConditions
from .graph import Graph
from .task import Context, TaskSpec
__all__ = ["command_chain", "data_pipeline", "fan_out_fan_in"]
__all__ = ["branch", "command_chain", "data_pipeline", "fan_out_fan_in", "switch"]
# ---------------------------------------------------------------------- #
@@ -308,3 +312,121 @@ def _make_reduce_fn(
return reduce(results)
return _run
# ---------------------------------------------------------------------- #
# 条件分支
# ---------------------------------------------------------------------- #
def switch(
selector: TaskSpec[Any],
cases: Mapping[str, TaskSpec[Any]],
*,
default: TaskSpec[Any] | None = None,
) -> Graph:
"""条件分支 DAG:根据 ``selector`` 任务的结果值选择执行哪个 case.
每个 case 任务自动依赖 ``selector``并附加
:meth:`BuiltinConditions.DEP_EQUALS` 条件仅当 ``selector`` 返回值
等于 case key 时才执行其余 case 被跳过 (SKIPPED)
Parameters
----------
selector : TaskSpec
其返回值决定执行哪个分支的任务
cases : Mapping[str, TaskSpec]
``结果值 任务`` 映射每个 case 任务的 fn 参数名匹配
``selector.name`` 时会收到 selector 的结果
default : TaskSpec | None
case 匹配时执行的任务可选未提供时所有 case 不匹配则
全部跳过
Returns
-------
Graph
包含 selector + 所有 case (+ default) DAG
Example
-------
>>> @px.task
... def classify(data: list[int]) -> str: ...
>>> @px.task
... def handle_positive(classify: str) -> str: ...
>>> @px.task
... def handle_negative(classify: str) -> str: ...
>>> graph = px.switch(classify, {"positive": handle_positive, "negative": handle_negative})
>>> report = px.run(graph)
>>> # 仅 handle_positive 或 handle_negative 之一执行
"""
specs: list[TaskSpec[Any] | str] = [selector]
sel_name = selector.name
for case_key, case_spec in cases.items():
cond = BuiltinConditions.DEP_EQUALS(sel_name, case_key)
deps = (*case_spec.depends_on, sel_name) if sel_name not in case_spec.depends_on else case_spec.depends_on
conds = (*case_spec.conditions, cond) if case_spec.conditions else (cond,)
specs.append(replace(case_spec, depends_on=deps, conditions=conds))
if default is not None:
# default 条件:所有 case 都不匹配
not_match_conds = [BuiltinConditions.NOT(BuiltinConditions.DEP_EQUALS(sel_name, k)) for k in cases]
default_cond = BuiltinConditions.AND(*not_match_conds) if len(not_match_conds) > 1 else not_match_conds[0]
deps = (*default.depends_on, sel_name) if sel_name not in default.depends_on else default.depends_on
conds = (*default.conditions, default_cond) if default.conditions else (default_cond,)
specs.append(replace(default, depends_on=deps, conditions=conds))
return Graph.from_specs(specs)
def branch(
selector: TaskSpec[Any],
predicate: Callable[[Any], bool],
if_true: TaskSpec[Any],
*,
if_false: TaskSpec[Any] | None = None,
) -> Graph:
"""二路分支 DAG (if/else):根据 ``predicate`` 判断 ``selector`` 结果走哪条路径.
``if_true`` 任务附加 :meth:`BuiltinConditions.DEP_MATCHES` 条件
(predicate 返回 True 时执行)``if_false`` 任务附加取反条件
(predicate 返回 False 时执行)未匹配的分支被跳过 (SKIPPED)
Parameters
----------
selector : TaskSpec
其返回值由 ``predicate`` 判断
predicate : Callable[[Any], bool]
判断函数接收 ``selector`` 的返回值返回布尔值
if_true : TaskSpec
``predicate`` 返回 True 时执行的任务
if_false : TaskSpec | None
``predicate`` 返回 False 时执行的任务可选
Returns
-------
Graph
包含 selector + if_true (+ if_false) DAG
Example
-------
>>> @px.task
... def compute(data: list[int]) -> int: return sum(data)
>>> @px.task
... def handle_positive(compute: int) -> str: ...
>>> @px.task
... def handle_non_positive(compute: int) -> str: ...
>>> graph = px.branch(compute, lambda x: x > 0, handle_positive, if_false=handle_non_positive)
"""
sel_name = selector.name
match_cond = BuiltinConditions.DEP_MATCHES(sel_name, predicate)
true_deps = (*if_true.depends_on, sel_name) if sel_name not in if_true.depends_on else if_true.depends_on
true_conds = (*if_true.conditions, match_cond) if if_true.conditions else (match_cond,)
specs: list[TaskSpec[Any]] = [selector, replace(if_true, depends_on=true_deps, conditions=true_conds)]
if if_false is not None:
not_match_cond = BuiltinConditions.NOT(match_cond)
false_deps = (*if_false.depends_on, sel_name) if sel_name not in if_false.depends_on else if_false.depends_on
false_conds = (*if_false.conditions, not_match_cond) if if_false.conditions else (not_match_cond,)
specs.append(replace(if_false, depends_on=false_deps, conditions=false_conds))
return Graph.from_specs(specs)
+49
View File
@@ -722,6 +722,55 @@ class TestPriority:
spec = px.TaskSpec("a", lambda: "ok")
assert spec.priority == 0
def test_priority_orders_in_dependency_strategy(self) -> None:
"""dependency 策略也应按优先级降序调度就绪任务。
concurrency_key=1 串行化无依赖任务使优先级顺序可观测
"""
order: list[str] = []
def make_fn(name: str) -> Any:
def fn() -> str:
order.append(name)
return name
return fn
graph = px.Graph.from_specs(
[
px.TaskSpec("low", make_fn("low"), priority=1, concurrency_key="g"),
px.TaskSpec("high", make_fn("high"), priority=10, concurrency_key="g"),
px.TaskSpec("mid", make_fn("mid"), priority=5, concurrency_key="g"),
]
)
report = px.run(graph, strategy="dependency", concurrency_limits={"g": 1})
assert report.success
assert order == ["high", "mid", "low"]
def test_priority_orders_ready_tasks_after_dep_completes(self) -> None:
"""依赖完成后,多个就绪任务按优先级降序调度。"""
order: list[str] = []
def make_fn(name: str) -> Any:
def fn() -> str:
order.append(name)
return name
return fn
graph = px.Graph.from_specs(
[
px.TaskSpec("root", make_fn("root")),
px.TaskSpec("low", make_fn("low"), depends_on=("root",), priority=1, concurrency_key="g"),
px.TaskSpec("high", make_fn("high"), depends_on=("root",), priority=10, concurrency_key="g"),
px.TaskSpec("mid", make_fn("mid"), depends_on=("root",), priority=5, concurrency_key="g"),
]
)
report = px.run(graph, strategy="dependency", concurrency_limits={"g": 1})
assert report.success
# root 先执行,然后 high/mid/low 按优先级降序
assert order == ["root", "high", "mid", "low"]
# ---------------------------------------------------------------------- #
# continue_on_error 容错
+238 -38
View File
@@ -1,13 +1,15 @@
"""通用 DAG 构造器测试.
验证 :func:`data_pipeline` / :func:`command_chain` / :func:`fan_out_fan_in`
个构造器的拓扑生成执行结果错误处理与 DAG 组合能力.
/ :func:`switch` / :func:`branch` 个构造器的拓扑生成执行结果错误处理与 DAG 组合能力.
测试组织:
* ``TestDataPipeline`` 数据流水线拓扑/执行/命名/错误
* ``TestCommandChain`` 命令链拓扑/执行/透传/错误
* ``TestFanOutFanIn`` map-reduce 拓扑/执行/顺序/错误
* ``TestPipelinesWithFileops`` fileops 模块联用组合示例
* ``TestSwitch`` 条件分支 (多路分发) 拓扑/执行/默认/条件共存
* ``TestBranch`` 二路分支 (if/else) 拓扑/执行/predicate
"""
from __future__ import annotations
@@ -91,12 +93,10 @@ class TestDataPipeline:
def test_lambda_fallback_naming(self) -> None:
"""lambda 函数回退为 step 命名."""
graph = data_pipeline(
[
lambda: 1,
lambda step: step + 10,
]
)
graph = data_pipeline([
lambda: 1,
lambda step: step + 10,
])
specs = graph.all_specs()
assert "step" in specs
assert "step_1" in specs
@@ -190,13 +190,11 @@ class TestCommandChain:
def test_basic_chain_topology(self) -> None:
"""三命令链式依赖."""
graph = command_chain(
[
["echo", "first"],
["echo", "second"],
["echo", "third"],
]
)
graph = command_chain([
["echo", "first"],
["echo", "second"],
["echo", "third"],
])
specs = graph.all_specs()
assert len(specs) == 3
@@ -207,24 +205,20 @@ class TestCommandChain:
def test_auto_naming_with_first_arg(self) -> None:
"""自动命名用 cmd_{i:02d}_{first_arg}."""
graph = command_chain(
[
["echo", "hello"],
["echo", "world"],
]
)
graph = command_chain([
["echo", "hello"],
["echo", "world"],
])
specs = graph.all_specs()
assert "cmd_00_echo" in specs
assert "cmd_01_echo" in specs
def test_string_command_auto_naming(self) -> None:
"""shell 字符串命令自动命名取首个 token."""
graph = command_chain(
[
"echo hello",
"ls -la",
]
)
graph = command_chain([
"echo hello",
"ls -la",
])
specs = graph.all_specs()
assert "cmd_00_echo" in specs
assert "cmd_01_ls" in specs
@@ -243,12 +237,10 @@ class TestCommandChain:
def test_execution_succeeds(self) -> None:
"""命令链实际执行成功."""
graph = command_chain(
[
[sys.executable, "-c", "print('first')"],
[sys.executable, "-c", "print('second')"],
]
)
graph = command_chain([
[sys.executable, "-c", "print('first')"],
[sys.executable, "-c", "print('second')"],
])
report = px.run(graph, strategy="sequential")
assert report.success
@@ -312,12 +304,10 @@ class TestCommandChain:
def test_command_failure_aborts_chain(self) -> None:
"""命令失败时抛 TaskFailedError (continue_on_error=False)."""
graph = command_chain(
[
[sys.executable, "-c", "import sys; sys.exit(1)"],
[sys.executable, "-c", "print('should not run')"],
]
)
graph = command_chain([
[sys.executable, "-c", "import sys; sys.exit(1)"],
[sys.executable, "-c", "print('should not run')"],
])
with pytest.raises(px.TaskFailedError):
px.run(graph, strategy="sequential")
@@ -578,3 +568,213 @@ class TestPipelinesWithFileops:
report = px.run(graph, strategy="sequential")
assert report.success
assert report["third"] == "first\nsecond\n"
# ---------------------------------------------------------------------- #
# 条件分支: switch / branch
# ---------------------------------------------------------------------- #
class TestSwitch:
"""测试 px.switch() 条件分支构造器."""
def test_switch_executes_matching_case(self) -> None:
"""selector 结果匹配的 case 执行,其余跳过。"""
def classify() -> str:
return "positive"
def handle_positive(classify: str) -> str:
return f"pos:{classify}"
def handle_negative(classify: str) -> str:
return f"neg:{classify}"
graph = px.switch(
px.TaskSpec("classify", classify),
{
"positive": px.TaskSpec("handle_positive", handle_positive),
"negative": px.TaskSpec("handle_negative", handle_negative),
},
)
report = px.run(graph)
assert report.success
assert report["classify"] == "positive"
assert report["handle_positive"] == "pos:positive"
assert report.results["handle_negative"].status == px.TaskStatus.SKIPPED
def test_switch_negative_case(self) -> None:
"""selector 返回 negative 时执行 negative case。"""
def classify() -> str:
return "negative"
def handle_positive(classify: str) -> str:
return "pos"
def handle_negative(classify: str) -> str:
return "neg"
graph = px.switch(
px.TaskSpec("classify", classify),
{
"positive": px.TaskSpec("handle_positive", handle_positive),
"negative": px.TaskSpec("handle_negative", handle_negative),
},
)
report = px.run(graph)
assert report.success
assert report["handle_negative"] == "neg"
assert report.results["handle_positive"].status == px.TaskStatus.SKIPPED
def test_switch_default_when_no_match(self) -> None:
"""无 case 匹配时执行 default 任务。"""
def classify() -> str:
return "unknown"
def handle_a(classify: str) -> str:
return "a"
def handle_default(classify: str) -> str:
return "default"
graph = px.switch(
px.TaskSpec("classify", classify),
{"a": px.TaskSpec("handle_a", handle_a)},
default=px.TaskSpec("handle_default", handle_default),
)
report = px.run(graph)
assert report.success
assert report.results["handle_a"].status == px.TaskStatus.SKIPPED
assert report["handle_default"] == "default"
def test_switch_all_skipped_without_default(self) -> None:
"""无 case 匹配且无 default 时所有 case 跳过。"""
def classify() -> str:
return "unknown"
def handle_a(classify: str) -> str:
return "a"
graph = px.switch(
px.TaskSpec("classify", classify),
{"a": px.TaskSpec("handle_a", handle_a)},
)
report = px.run(graph)
assert report.success
assert report.results["handle_a"].status == px.TaskStatus.SKIPPED
def test_switch_preserves_existing_conditions(self) -> None:
"""switch 附加的条件与已有条件共存(AND 语义由执行器保证)。"""
def classify() -> str:
return "go"
def handle_go(classify: str) -> str:
return "went"
spec = px.TaskSpec("handle_go", handle_go, conditions=(px.IS_LINUX,))
graph = px.switch(
px.TaskSpec("classify", classify),
{"go": spec},
)
report = px.run(graph)
# IS_LINUX + DEP_EQUALS("classify","go") 均满足时才执行
if px.IS_LINUX({}):
assert report["handle_go"] == "went"
else:
assert report.results["handle_go"].status == px.TaskStatus.SKIPPED
class TestBranch:
"""测试 px.branch() 二路分支构造器."""
def test_branch_true_path_executes(self) -> None:
"""predicate 返回 True 时执行 if_true。"""
def compute() -> int:
return 42
def handle_positive(compute: int) -> str:
return f"positive:{compute}"
def handle_non_positive(compute: int) -> str:
return "non-positive"
graph = px.branch(
px.TaskSpec("compute", compute),
lambda x: x > 0,
px.TaskSpec("handle_positive", handle_positive),
if_false=px.TaskSpec("handle_non_positive", handle_non_positive),
)
report = px.run(graph)
assert report.success
assert report["handle_positive"] == "positive:42"
assert report.results["handle_non_positive"].status == px.TaskStatus.SKIPPED
def test_branch_false_path_executes(self) -> None:
"""predicate 返回 False 时执行 if_false。"""
def compute() -> int:
return -5
def handle_positive(compute: int) -> str:
return "positive"
def handle_non_positive(compute: int) -> str:
return f"non-positive:{compute}"
graph = px.branch(
px.TaskSpec("compute", compute),
lambda x: x > 0,
px.TaskSpec("handle_positive", handle_positive),
if_false=px.TaskSpec("handle_non_positive", handle_non_positive),
)
report = px.run(graph)
assert report.success
assert report["handle_non_positive"] == "non-positive:-5"
assert report.results["handle_positive"].status == px.TaskStatus.SKIPPED
def test_branch_without_if_false(self) -> None:
"""无 if_false 时,predicate 返回 False 则 if_true 被跳过。"""
def compute() -> int:
return -1
def handle_positive(compute: int) -> str:
return "positive"
graph = px.branch(
px.TaskSpec("compute", compute),
lambda x: x > 0,
px.TaskSpec("handle_positive", handle_positive),
)
report = px.run(graph)
assert report.success
assert report.results["handle_positive"].status == px.TaskStatus.SKIPPED
def test_branch_with_predicate_function(self) -> None:
"""使用命名函数作为 predicate。"""
def compute() -> list[int]:
return [1, 2, 3]
def is_long(items: list[int]) -> bool:
return len(items) > 2
def handle_long(compute: list[int]) -> str:
return "long"
def handle_short(compute: list[int]) -> str:
return "short"
graph = px.branch(
px.TaskSpec("compute", compute),
is_long,
px.TaskSpec("handle_long", handle_long),
if_false=px.TaskSpec("handle_short", handle_short),
)
report = px.run(graph)
assert report.success
assert report["handle_long"] == "long"
assert report.results["handle_short"].status == px.TaskStatus.SKIPPED