初始提交:chongming-task 任务控制库
单任务受控执行(15 态状态机 + 审计轨迹)、DAG 管道、Controller 门面。
执行器三个:线程(默认,含 async def 任务)、内联(同步 / 测试)、进程
(spawn 子进程:硬性停止、绕开 GIL、崩溃隔离)。
调度:并发额度 + 每桶限流 + 具名资源信号量(按标签)+ 优先级排队。
持久层:TaskStore 契约 + 内存 / SQLite / Postgres 实现,契约测试随包发布。
跨进程与多节点:写穿 / 读穿、指令通道与租约、owned_buckets 划界、reconcile。
CLI、12 个可直接运行的示例、docs/ 下 5 份文档。
门禁:ruff / ruff format / mypy(strict) 干净;
pytest 563 passed(44 skipped 是需要真实 Postgres 的那批);
带一台可连的 Postgres 时 607 passed。
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
"""示例 1:跑一个任务,并观察它在生命周期中经过的状态。
|
||||
|
||||
运行::
|
||||
|
||||
uv run python examples/01_quickstart.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from chongming_task.run import TaskRun
|
||||
from chongming_task.runtime.cancellation import CancellationToken
|
||||
|
||||
|
||||
def extract(ctx: CancellationToken, source: str) -> list[int]:
|
||||
"""模拟一次数据抽取。
|
||||
|
||||
要点:长耗时循环里必须周期性地调用 ``ctx.checkpoint()``。它同时做三件事:
|
||||
响应停止请求、响应暂停请求、刷新心跳。
|
||||
"""
|
||||
rows: list[int] = []
|
||||
for index in range(5):
|
||||
ctx.checkpoint()
|
||||
time.sleep(0.05)
|
||||
rows.append(index)
|
||||
print(f" 抽取完成:{source} -> {len(rows)} 行")
|
||||
return rows
|
||||
|
||||
|
||||
def main() -> None:
|
||||
run = TaskRun(extract, ("s3://demo/rows",), task_id="demo-1", name="抽取")
|
||||
run.start()
|
||||
outcome = run.wait(timeout=30)
|
||||
|
||||
print("\n状态迁移历史(只追加、可审计):")
|
||||
for record in run.transitions:
|
||||
print(
|
||||
f" {record.from_status.value:>10} -> {record.to_status.value:<10} by {record.trigger}"
|
||||
)
|
||||
|
||||
print(f"\n最终状态:{outcome.status.value}")
|
||||
print(f"返回值 :{outcome.value}")
|
||||
elapsed = (outcome.snapshot.ended_at - outcome.snapshot.started_at).total_seconds()
|
||||
print(f"耗时 :{elapsed:.2f}s")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,74 @@
|
||||
"""示例 2:运行期干预 —— 暂停、恢复、停止。
|
||||
|
||||
演示协作式暂停(D6 定案的语义)与"停不掉时会如实上报"。
|
||||
|
||||
运行::
|
||||
|
||||
uv run python examples/02_control.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from chongming_task.run import TaskRun
|
||||
|
||||
|
||||
def long_running(ctx) -> str:
|
||||
"""持续做小步工作,每一步都经过协作点。"""
|
||||
for _ in range(10_000):
|
||||
ctx.checkpoint()
|
||||
time.sleep(0.01)
|
||||
return "跑完了"
|
||||
|
||||
|
||||
def uncooperative(ctx) -> str:
|
||||
"""刻意**不**调用 checkpoint:这样的任务既停不掉也暂停不了。
|
||||
|
||||
它约 4 秒后自行结束,以免在示例里留下后台线程。
|
||||
"""
|
||||
for _ in range(400):
|
||||
time.sleep(0.01)
|
||||
return "无视了停止请求"
|
||||
|
||||
|
||||
def demo_pause_resume() -> None:
|
||||
print("== 暂停 / 恢复 ==")
|
||||
run = TaskRun(long_running, task_id="demo-pause")
|
||||
run.start()
|
||||
time.sleep(0.2)
|
||||
|
||||
print(f" 暂停请求已受理:{run.pause(timeout=5)};当前状态 {run.status.value}")
|
||||
time.sleep(0.2)
|
||||
print(f" 暂停期间状态保持:{run.status.value}")
|
||||
|
||||
run.resume()
|
||||
print(f" 恢复后状态 :{run.status.value}")
|
||||
|
||||
run.stop(reason="演示主动停止")
|
||||
outcome = run.wait(timeout=10)
|
||||
print(f" 停止后终态 :{outcome.status.value}")
|
||||
print(f" 失败归类 :{outcome.failure.kind.value if outcome.failure else '-'}")
|
||||
print(f" 可重试 :{outcome.failure.retryable if outcome.failure else '-'}")
|
||||
|
||||
|
||||
def demo_unstoppable() -> None:
|
||||
print("\n== 停不掉的任务:如实上报 ==")
|
||||
run = TaskRun(uncooperative, task_id="demo-stubborn", graceful_stop=0.3)
|
||||
run.start()
|
||||
time.sleep(0.2)
|
||||
run.stop(reason="试一下")
|
||||
|
||||
outcome = run.wait(timeout=10)
|
||||
print(f" 终态 :{outcome.status.value}")
|
||||
print(f" 失败归类 :{outcome.failure.kind.value if outcome.failure else '-'}")
|
||||
print(" 含义 :线程无法强杀,任务可能仍在后台产生副作用,因此不敢说'已停止'")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
demo_pause_resume()
|
||||
demo_unstoppable()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,86 @@
|
||||
"""示例 3:重试与超时。
|
||||
|
||||
运行::
|
||||
|
||||
uv run python examples/03_retry_and_timeout.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from chongming_task.policies import ExponentialBackoff, RetryPolicy, Timeout
|
||||
from chongming_task.run import run_task
|
||||
|
||||
_attempts = 0
|
||||
|
||||
|
||||
def flaky(ctx, succeed_on_attempt: int) -> str:
|
||||
"""前几次故意失败,用来演示重试与退避。"""
|
||||
global _attempts
|
||||
_attempts += 1
|
||||
if _attempts < succeed_on_attempt:
|
||||
raise RuntimeError(f"第 {_attempts} 次尝试遇到暂时性故障")
|
||||
return f"第 {_attempts} 次尝试成功"
|
||||
|
||||
|
||||
def slow(ctx) -> str:
|
||||
"""跑得很慢,用来演示超时后会被请求停止。"""
|
||||
for _ in range(10_000):
|
||||
ctx.checkpoint()
|
||||
time.sleep(0.01)
|
||||
return "跑完了"
|
||||
|
||||
|
||||
def demo_retry() -> None:
|
||||
print("== 重试:失败两次后成功 ==")
|
||||
_attempts = 0
|
||||
outcome = run_task(
|
||||
flaky,
|
||||
3,
|
||||
retry=RetryPolicy(max_attempts=5, backoff=ExponentialBackoff(base=0.05, factor=2)),
|
||||
task_id="demo-retry",
|
||||
name="会抖的任务",
|
||||
)
|
||||
print(f" 终态 :{outcome.status.value}")
|
||||
print(f" 返回值 :{outcome.value}")
|
||||
print(f" 尝试次数 :{outcome.snapshot.attempt}/{outcome.snapshot.max_attempts}")
|
||||
|
||||
|
||||
def demo_retry_exhausted() -> None:
|
||||
print("\n== 重试:预算耗尽 ==")
|
||||
global _attempts
|
||||
_attempts = 0
|
||||
outcome = run_task(
|
||||
flaky,
|
||||
99, # 永远不会成功
|
||||
retry=RetryPolicy(max_attempts=2, backoff=ExponentialBackoff(base=0.05, factor=1)),
|
||||
task_id="demo-exhausted",
|
||||
name="必定失败",
|
||||
)
|
||||
print(f" 终态 :{outcome.status.value}")
|
||||
print(f" 尝试次数 :{outcome.snapshot.attempt}/{outcome.snapshot.max_attempts}")
|
||||
print(f" 失败归类 :{outcome.failure.kind.value if outcome.failure else '-'}")
|
||||
|
||||
|
||||
def demo_timeout() -> None:
|
||||
print("\n== 超时:单次尝试不超过 0.5 秒 ==")
|
||||
outcome = run_task(
|
||||
slow,
|
||||
timeout=Timeout(per_attempt=0.5),
|
||||
task_id="demo-timeout",
|
||||
name="慢任务",
|
||||
)
|
||||
print(f" 终态 :{outcome.status.value}")
|
||||
print(f" 失败归类 :{outcome.failure.kind.value if outcome.failure else '-'}")
|
||||
print(f" 可重试 :{outcome.failure.retryable if outcome.failure else '-'}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
demo_retry()
|
||||
demo_retry_exhausted()
|
||||
demo_timeout()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,82 @@
|
||||
"""示例 4:用 Controller 管理一批任务(注册 / 提交 / 排队 / 查询 / 控制)。
|
||||
|
||||
这是管理后台的核心:**一个统一入口**持有任务定义、运行中的任务与并发额度。
|
||||
将来的 CLI 或看板都只是这一层的薄适配。
|
||||
|
||||
运行::
|
||||
|
||||
uv run python examples/04_controller.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from chongming_task.controller import Controller, render_table
|
||||
|
||||
|
||||
def transform(ctx, rows: int) -> int:
|
||||
"""转换:逐行推进。"""
|
||||
for _ in range(30):
|
||||
ctx.checkpoint()
|
||||
time.sleep(0.02)
|
||||
return rows * 2
|
||||
|
||||
|
||||
def extract(ctx, source: str) -> str:
|
||||
"""抽取:比转换慢一倍。"""
|
||||
for _ in range(60):
|
||||
ctx.checkpoint()
|
||||
time.sleep(0.02)
|
||||
return f"{source} -> 1200 行"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
controller = Controller(max_parallel=2)
|
||||
controller.register("transform", transform, tags={"cpu"})
|
||||
controller.register("extract", extract, tags={"io"})
|
||||
controller.register("load", transform, description="写回目标库")
|
||||
|
||||
print("已注册任务:", "、".join(controller.registry.names()))
|
||||
print("并发上限:2\n")
|
||||
|
||||
for index in range(4):
|
||||
controller.submit(
|
||||
"transform", 100 * (index + 1), task_id=f"t{index}", label=f"转换-{index + 1}"
|
||||
)
|
||||
print(f"提交了 4 个任务;此刻排队 {controller.queued_count} 个(超出并发上限的部分)")
|
||||
|
||||
time.sleep(0.4)
|
||||
print("\n--- 查询:运行中的视图 ---")
|
||||
print(render_table(controller.list_tasks()))
|
||||
stats = controller.stats()
|
||||
print(
|
||||
f" 未结束 {stats.unfinished}"
|
||||
f"(其中排队 {controller.queued_count},占用执行额度 {controller.active_count})"
|
||||
)
|
||||
|
||||
controller.wait_all(timeout=30)
|
||||
print("\n--- 查询:全部结束 ---")
|
||||
print(render_table(controller.list_tasks()))
|
||||
|
||||
print("\n>>> 提交一个长任务,然后中途停止它")
|
||||
controller.submit("extract", "s3://demo/big", task_id="t-long", label="抽取(大)")
|
||||
time.sleep(0.3)
|
||||
print(f" 当前状态:{controller.get('t-long').status.value}")
|
||||
controller.stop("t-long", reason="手工干预")
|
||||
outcome = controller.wait("t-long", timeout=10)
|
||||
print(
|
||||
f" 终态:{outcome.status.value}"
|
||||
f"(失败归类 {outcome.failure.kind.value},可重试 {outcome.failure.retryable})"
|
||||
)
|
||||
|
||||
print("\n--- 最终统计 ---")
|
||||
final = controller.stats()
|
||||
for status, count in sorted(final.by_status.items(), key=lambda item: item[0].value):
|
||||
print(f" {status.value:<10} {count}")
|
||||
print("\n提示:另一个进程里的 chongming-task ps 看不到这些任务——内存不共享,")
|
||||
print(" 跨进程查询需要共享存储(stdlib sqlite3),那是下一步。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,70 @@
|
||||
"""示例 5:任务分桶 —— 本地并发隔离,以及为分布式预留的分配单位。
|
||||
|
||||
运行::
|
||||
|
||||
uv run python examples/05_buckets.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from chongming_task.controller import Controller, render_table
|
||||
|
||||
|
||||
def sync_tenant(ctx, tenant: str) -> str:
|
||||
"""同步一个租户的数据。"""
|
||||
for _ in range(20):
|
||||
ctx.checkpoint()
|
||||
time.sleep(0.02)
|
||||
return f"{tenant} 同步完成"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
controller = Controller(max_parallel=10, max_parallel_per_bucket=1)
|
||||
controller.register("sync", sync_tenant)
|
||||
policy = controller.bucket_policy
|
||||
|
||||
print(f"分桶策略:{policy.num_buckets} 个桶")
|
||||
print("并发限制:全局 10,但每个桶最多 1 个\n")
|
||||
|
||||
for index in range(4):
|
||||
controller.submit(
|
||||
"sync", "租户A", task_id=f"a{index}", label="A-同步", bucket_key="tenant-A"
|
||||
)
|
||||
for index in range(2):
|
||||
controller.submit(
|
||||
"sync", "租户B", task_id=f"b{index}", label="B-同步", bucket_key="tenant-B"
|
||||
)
|
||||
|
||||
print("业务键 -> 桶号(同一个键在任何进程、任何机器上都会落到同一个桶):")
|
||||
for key in ("tenant-A", "tenant-B"):
|
||||
print(f" {key:<10} -> bucket {policy.bucket_for(key)}")
|
||||
|
||||
time.sleep(0.5)
|
||||
print("\n--- 查询:A 桶积压,但不影响 B 桶 ---")
|
||||
print(render_table(controller.list_tasks()))
|
||||
print(
|
||||
f" 占用额度 {controller.active_count},排队 {controller.queued_count},"
|
||||
f"A 桶在用 {controller.active_in_bucket(policy.bucket_for('tenant-A'))}"
|
||||
)
|
||||
|
||||
controller.wait_all(timeout=30)
|
||||
|
||||
print("\n--- 查询:全部结束 ---")
|
||||
print(render_table(controller.list_tasks()))
|
||||
|
||||
print("\n各桶任务数(分布式调度要靠它决定把桶分给谁):")
|
||||
for bucket, count in sorted(
|
||||
controller.bucket_counts().items(), key=lambda kv: (kv[0] is None, kv[0])
|
||||
):
|
||||
print(f" bucket {bucket}: {count}")
|
||||
|
||||
bucket_a = policy.bucket_for("tenant-A")
|
||||
print(f"\n按桶查询(只取 A 桶):{[s.task_id for s in controller.list_tasks(bucket=bucket_a)]}")
|
||||
|
||||
print("\n分布式预留:桶是分配单位——一台机器拥有一组桶,只取自己那组桶的任务即可。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,91 @@
|
||||
"""示例 6:跨进程 —— 一个进程跑任务,另一个进程查询与控制。
|
||||
|
||||
这里用**同一个 SQLite 文件上的两个独立连接**模拟两个进程。真正的两个进程行为完全
|
||||
一样:它们看不到彼此的内存,只能通过存储交换状态与指令。
|
||||
|
||||
运行::
|
||||
|
||||
uv run python examples/06_cross_process.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from chongming_task import Controller, SqliteTaskStore, render_table
|
||||
|
||||
|
||||
def sync_tenant(ctx, tenant: str) -> str:
|
||||
"""稳步推进直到被停止。"""
|
||||
for _ in range(300):
|
||||
ctx.checkpoint()
|
||||
time.sleep(0.02)
|
||||
return f"{tenant} 同步完成"
|
||||
|
||||
|
||||
def _wait_for_status(client: Controller, task_id: str, wanted: str, timeout: float = 20.0) -> bool:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
snapshot = client.get(task_id)
|
||||
if snapshot is not None and snapshot.status.value == wanted:
|
||||
return True
|
||||
time.sleep(0.02)
|
||||
return False
|
||||
|
||||
|
||||
def main() -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="chongming-demo-") as directory:
|
||||
path = Path(directory) / "tasks.db"
|
||||
|
||||
# ---- 进程 A:真正跑任务的 worker ----
|
||||
worker = Controller(
|
||||
store=SqliteTaskStore(path),
|
||||
max_parallel=2,
|
||||
command_poll_interval=0.1,
|
||||
owner="worker-1",
|
||||
)
|
||||
worker.register("sync", sync_tenant)
|
||||
worker.submit("sync", "租户A", task_id="job-1", label="A-同步", bucket_key="tenant-A")
|
||||
worker.submit("sync", "租户B", task_id="job-2", label="B-同步", bucket_key="tenant-B")
|
||||
print("worker-1 已提交 2 个任务(并发上限 2)\n")
|
||||
|
||||
time.sleep(0.5)
|
||||
|
||||
# ---- 进程 B:管理客户端,只查询与控制,不执行任何任务 ----
|
||||
client = Controller(store=SqliteTaskStore(path), owner="ops-console")
|
||||
print("--- ops-console 看到的状态(它并不持有这些任务)---")
|
||||
print(render_table(client.list_tasks()))
|
||||
print(f" 它本地的任务句柄数:{len(client.runs())}")
|
||||
print(f" 统计:{ {s.value: n for s, n in client.stats().by_status.items()} }")
|
||||
|
||||
print("\n>>> ops-console 跨进程停止 job-1")
|
||||
print(f" 下发结果:{client.stop('job-1', reason='运维从后台停止')}")
|
||||
print(" (客户端无法给另一个进程的线程发信号,只能写一条指令)")
|
||||
|
||||
if _wait_for_status(client, "job-1", "stopped"):
|
||||
print(" worker-1 已取走指令并停止该任务")
|
||||
else:
|
||||
print(" !! 超时:指令没有被取走")
|
||||
|
||||
print("\n--- job-1 的迁移历史(从存储读,可审计)---")
|
||||
for record in client.store.history("job-1"): # type: ignore[union-attr]
|
||||
print(
|
||||
f" {record.version:>2} {record.from_status.value:>10} -> "
|
||||
f"{record.to_status.value:<10} by {record.trigger}"
|
||||
)
|
||||
|
||||
print("\n--- 让 job-2 自己跑完,再看最终状态 ---")
|
||||
worker.wait_all(timeout=30)
|
||||
print(render_table(client.list_tasks()))
|
||||
|
||||
# 收尾:两个 Controller 各自关掉自己的存储连接。没关的 SQLite 连接会泄漏
|
||||
# 文件句柄,在 Windows 上还会让数据库文件无法删除。
|
||||
worker.close(timeout=10, close_store=True)
|
||||
client.close(timeout=5, close_store=True)
|
||||
print("\n要点:worker 负责执行,console 负责查询与控制,两者只通过存储耦合。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,112 @@
|
||||
"""示例 7:DAG 管道 —— 一个源 → 两个分支 → 汇合。
|
||||
|
||||
管道把多个任务按依赖关系串起来,由一个调度器推进:节点先停在 ``blocked`` 等上游,
|
||||
上游满足后才放行;注定跑不了的下游会被 ``skipped``。
|
||||
|
||||
运行::
|
||||
|
||||
uv run python examples/07_pipeline.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from chongming_task import Controller, Pipeline, render_pipeline
|
||||
from chongming_task.policies import AggregationPolicy, JoinMode
|
||||
|
||||
|
||||
def fetch(ctx: Any, day: str) -> str:
|
||||
"""取数:模拟一次耗时操作。"""
|
||||
for _ in range(10):
|
||||
ctx.checkpoint()
|
||||
time.sleep(0.03)
|
||||
return f"{day} 的数据"
|
||||
|
||||
|
||||
def transform(ctx: Any, day: str, shard: int) -> str:
|
||||
"""分片转换。"""
|
||||
for _ in range(8):
|
||||
ctx.checkpoint()
|
||||
time.sleep(0.02)
|
||||
return f"{day}/shard{shard}"
|
||||
|
||||
|
||||
def load(ctx: Any, day: str) -> str:
|
||||
"""入库:依赖全部分片完成。"""
|
||||
del ctx
|
||||
return f"{day} 已入库"
|
||||
|
||||
|
||||
def audit(ctx: Any, day: str) -> str:
|
||||
"""审计:**不管上游成败都要跑**(ALL_SETTLED)。"""
|
||||
del ctx
|
||||
return f"{day} 审计完成"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
controller = Controller(max_parallel=4)
|
||||
|
||||
# ---- 定义 ----
|
||||
pipeline = Pipeline("nightly-etl", aggregation=AggregationPolicy.all_settled())
|
||||
|
||||
controller.register("fetch", fetch, tags={"io"})
|
||||
controller.register("transform", transform)
|
||||
controller.register("load", load, tags={"io"})
|
||||
controller.register("audit", audit)
|
||||
|
||||
fetch_node = pipeline.node("fetch", "2026-10-02", node_id="fetch")
|
||||
shards = [
|
||||
pipeline.node(
|
||||
"transform",
|
||||
"2026-10-02",
|
||||
index,
|
||||
node_id=f"shard{index}",
|
||||
depends_on=[fetch_node],
|
||||
)
|
||||
for index in range(3)
|
||||
]
|
||||
# 入库:等所有分片成功(默认 ALL_SUCCEEDED 屏障)
|
||||
load_node = pipeline.node("load", "2026-10-02", node_id="load", depends_on=shards)
|
||||
# 审计:等 load 落终态即可,不管成败
|
||||
pipeline.node(
|
||||
"audit",
|
||||
"2026-10-02",
|
||||
node_id="audit",
|
||||
depends_on=[load_node],
|
||||
join=JoinMode.ALL_SETTLED,
|
||||
)
|
||||
|
||||
print(f"管道 {pipeline.name!r} 共 {len(pipeline)} 个节点,拓扑序:")
|
||||
print(" " + " -> ".join(node.node_id for node in pipeline.topological_order()))
|
||||
print(" 注意:位置参数是传给任务的,节点名由 node_id= 指定\n")
|
||||
|
||||
# ---- 跑起来 ----
|
||||
run = controller.submit_pipeline(pipeline)
|
||||
print(f"已提交,pipeline_run_id = {run.run_id}\n")
|
||||
|
||||
time.sleep(0.05)
|
||||
print("--- 刚提交时:下游都停在 blocked ---")
|
||||
print(render_pipeline(run))
|
||||
|
||||
print("\n--- 等待收场 ---")
|
||||
outcome = run.wait(timeout=60)
|
||||
|
||||
print(render_pipeline(run))
|
||||
print(f"\n管道结论:{outcome.status.value}")
|
||||
for node_id, status in outcome.nodes.items():
|
||||
print(f" {node_id:<8} {status.value}")
|
||||
|
||||
print(f"\n每个任务都带 pipeline_run_id:{_run_id_of(controller, run, 'load')}")
|
||||
controller.close(timeout=10)
|
||||
print("\n要点:依赖由状态机表达(blocked -> queued),调度器只负责放行。")
|
||||
|
||||
|
||||
def _run_id_of(controller: Controller, run: Any, node_id: str) -> str:
|
||||
snapshot = controller.get(run.task_id_of(node_id))
|
||||
return "?" if snapshot is None else str(snapshot.pipeline_run_id)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,119 @@
|
||||
"""示例 8:``@task`` 装饰器与 ``TaskContext``。
|
||||
|
||||
演示四件事:
|
||||
|
||||
* ``@task`` 把元数据写在函数旁边,``register_module`` 一次登记整个模块;
|
||||
* ``ctx.progress(...)`` 上报进度——它会进快照、进存储,并出现在 ``ps`` 的 PROGRESS 列;
|
||||
* ``ctx.log(...)`` 走标准库 ``logging``,logger 名字是 ``chongming_task.task.<task_id>``;
|
||||
* ``ctx.input(节点id)`` 取上游节点的返回值——管道之间真正能传值。
|
||||
|
||||
运行::
|
||||
|
||||
uv run python examples/08_task_context.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from chongming_task import Controller, Pipeline, RetryPolicy, render_table, task
|
||||
|
||||
|
||||
@task(tags={"io"}, retry=RetryPolicy(max_attempts=2))
|
||||
def extract(ctx: Any, day: str) -> list[str]:
|
||||
"""按天抽取分区列表。"""
|
||||
ctx.log(f"开始抽取 {day}")
|
||||
partitions = [f"{day}/part{i}" for i in range(6)]
|
||||
for index, part in enumerate(partitions, start=1):
|
||||
ctx.checkpoint()
|
||||
ctx.progress(index, total=len(partitions), unit="partitions", message=part)
|
||||
time.sleep(0.05)
|
||||
return partitions
|
||||
|
||||
|
||||
@task
|
||||
def transform(ctx: Any, day: str) -> int:
|
||||
"""把上游抽出的分区逐个转换,返回处理条数。"""
|
||||
partitions = ctx.input("extract", [])
|
||||
ctx.log(f"待转换分区:{len(partitions)} 个")
|
||||
rows = 0
|
||||
for index, part in enumerate(partitions, start=1):
|
||||
ctx.checkpoint()
|
||||
rows += 100
|
||||
ctx.progress(index, total=len(partitions), unit="partitions", message=part)
|
||||
time.sleep(0.05)
|
||||
return rows
|
||||
|
||||
|
||||
@task
|
||||
def load(ctx: Any, day: str) -> str:
|
||||
"""入库:条数来自上游的返回值,而不是自己再算一遍。"""
|
||||
rows = ctx.input("transform", 0)
|
||||
ctx.log(f"{day} 共 {rows} 行待入库")
|
||||
for index in range(1, 5):
|
||||
ctx.checkpoint()
|
||||
ctx.progress(index, total=4, unit="batch")
|
||||
time.sleep(0.04)
|
||||
return f"{day}: {rows} 行已入库"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(levelname)-7s %(name)s %(message)s",
|
||||
stream=sys.stdout,
|
||||
)
|
||||
|
||||
controller = Controller(max_parallel=2)
|
||||
|
||||
# ---- 一次登记整个模块 ----
|
||||
registered = controller.register_module(sys.modules[__name__])
|
||||
print("从 @task 元数据登记的:")
|
||||
for item in registered:
|
||||
print(f" {item.name:<10} tags={sorted(item.tags) or '-'} {item.description}")
|
||||
|
||||
# ---- 串成管道:数据沿依赖流动 ----
|
||||
pipeline = Pipeline("daily-etl")
|
||||
extract_node = pipeline.node("extract", "2026-10-02", node_id="extract")
|
||||
transform_node = pipeline.node(
|
||||
"transform", "2026-10-02", node_id="transform", depends_on=[extract_node]
|
||||
)
|
||||
pipeline.node("load", "2026-10-02", node_id="load", depends_on=[transform_node])
|
||||
|
||||
run = controller.submit_pipeline(pipeline)
|
||||
print(f"\n已提交管道 {run.run_id}\n")
|
||||
|
||||
# ---- 跑的过程中看进度 ----
|
||||
deadline = time.monotonic() + 30
|
||||
while time.monotonic() < deadline and not run.settled:
|
||||
snapshots = controller.list_tasks(pipeline_run_id=run.run_id)
|
||||
cells: list[str] = []
|
||||
for snapshot in snapshots:
|
||||
node = snapshot.name.split("/", 1)[-1]
|
||||
progress = "-"
|
||||
if snapshot.progress is not None:
|
||||
progress = f"{snapshot.progress.done}/{snapshot.progress.total}"
|
||||
cells.append(f"{node:<10} {snapshot.status.value:<10} {progress:<8}")
|
||||
sys.stdout.write("\r" + " | ".join(cells).ljust(120))
|
||||
sys.stdout.flush()
|
||||
time.sleep(0.1)
|
||||
|
||||
outcome = run.wait(timeout=30)
|
||||
print("\n\n--- 结束 ---")
|
||||
print(render_table(controller.list_tasks(pipeline_run_id=run.run_id)))
|
||||
print(f"\n管道结论:{outcome.status.value}")
|
||||
|
||||
final = controller.get(run.task_id_of("load"))
|
||||
print(f"load 的返回值:{final.outcome if final else None}") # type: ignore[union-attr]
|
||||
|
||||
controller.close(timeout=10)
|
||||
print(
|
||||
"\n要点:ctx 是任务与运行时之间唯一的协作入口——停止、暂停、进度、日志、上游数据都在这里。"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,180 @@
|
||||
"""示例 9:无限循环任务的**启与停**。
|
||||
|
||||
一个"永远跑下去"的任务该怎么写、怎么启动、怎么停,以及三种收尾方式的终态差异。
|
||||
|
||||
运行::
|
||||
|
||||
uv run python examples/09_long_running.py
|
||||
|
||||
## 三条规则
|
||||
|
||||
1. **任务体每轮必须调 ``ctx.checkpoint()``。** 停止是**协作式**的:只有调用了它,任务
|
||||
才会看到停止请求。不调用 = 停不掉(见文件最后的对照)。
|
||||
2. **停止请求有两种收尾方式,终态不同**:
|
||||
|
||||
================================================ =========== ==========
|
||||
写法 终态 返回值
|
||||
================================================ =========== ==========
|
||||
``while True: ctx.checkpoint()``(让它抛) ``stopped`` 无
|
||||
检查 ``ctx.is_stop_requested`` 后正常 ``return`` ``succeeded`` ✅ 有
|
||||
完全不调 ``checkpoint()`` ``lost`` 无
|
||||
================================================ =========== ==========
|
||||
|
||||
"停止"在第一种写法里是"立刻中止",在第二种里是"**请收工**"——后者会把已经处理好的
|
||||
结果留下来,对"跑到下一个安全点再退出"这类需求更合适。
|
||||
3. **停止是终态。** 目前没有 ``retry``,停止/失败之后想再跑要换一个 ``task_id``
|
||||
重新提交(``create`` 会拒绝重复 id)。所以日常"先停一下"应该用 ``pause`` / ``resume``,
|
||||
``stop`` 留给"我要它彻底结束"。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from chongming_task import Controller, InMemoryTaskStore, render_table
|
||||
|
||||
|
||||
def worker(ctx: Any, source: str) -> int:
|
||||
"""无限循环:持续处理,直到收到停止请求后**优雅收尾**。"""
|
||||
processed = 0
|
||||
while not ctx.is_stop_requested:
|
||||
ctx.checkpoint()
|
||||
processed += 1
|
||||
ctx.progress(processed, unit="batch", message=f"{source} 第 {processed} 批")
|
||||
if processed % 20 == 0:
|
||||
ctx.log(f"已处理 {processed} 批")
|
||||
time.sleep(0.02)
|
||||
|
||||
# 走到这里说明收到停止请求了:做收尾,然后正常返回——
|
||||
# 返回值会被保留下来,终态是 succeeded 而不是 stopped。
|
||||
ctx.log(f"收到停止请求,收尾:本次共处理 {processed} 批")
|
||||
return processed
|
||||
|
||||
|
||||
def main() -> None:
|
||||
store = InMemoryTaskStore()
|
||||
controller = Controller(store=store, owner="node-a")
|
||||
controller.register("worker", worker)
|
||||
|
||||
# ---- 启动 ----
|
||||
run = controller.submit("worker", "kafka://orders", task_id="orders-worker")
|
||||
print(f"已启动 {run.snapshot.task_id} 状态={run.status.value}")
|
||||
time.sleep(0.5)
|
||||
|
||||
print("\n--- 运行中 ---")
|
||||
print(render_table(controller.list_tasks()))
|
||||
|
||||
# ---- 日常先停一下:pause / resume(不是 stop) ----
|
||||
print("\n--- pause(就地挂起,仍占用执行槽)---")
|
||||
print(f" pause 是否确认: {controller.pause('orders-worker', timeout=5)}")
|
||||
print(f" 状态: {run.status.value}")
|
||||
|
||||
print("\n--- resume ---")
|
||||
controller.resume("orders-worker")
|
||||
time.sleep(0.2)
|
||||
print(f" 状态: {run.status.value}")
|
||||
|
||||
# ---- 让它彻底结束:stop ----
|
||||
print("\n--- stop(终态,不可逆)---")
|
||||
controller.stop("orders-worker", reason="发布窗口")
|
||||
outcome = controller.wait("orders-worker", timeout=10)
|
||||
|
||||
print(f" 终态: {outcome.status.value}")
|
||||
print(f" 返回值: {outcome.value} ← 优雅收尾把已处理的数量带了出来")
|
||||
print("\n--- 结束后 ---")
|
||||
print(render_table(controller.list_tasks()))
|
||||
|
||||
print("\n--- 审计轨迹(谁按的、为什么,都在这里)---")
|
||||
for record in store.history("orders-worker"):
|
||||
reason = f" 原因={record.reason}" if record.reason else ""
|
||||
print(
|
||||
f" {record.version:>2} {record.from_status.value:>9} -> "
|
||||
f"{record.to_status.value:<9} {record.trigger}{reason}"
|
||||
)
|
||||
|
||||
# ---- 停止是终态:同一个 task_id 不能复活 ----
|
||||
print("\n--- 想再跑一次?同一个 task_id 会被拒绝 ---")
|
||||
try:
|
||||
controller.submit("worker", "kafka://orders", task_id="orders-worker")
|
||||
except Exception as exc:
|
||||
print(f" {type(exc).__name__}: {exc}")
|
||||
print(" 换一个 task_id 重新提交即可(或让进程一直活着,用 pause/resume 做日常启停)。")
|
||||
|
||||
controller.close(timeout=5)
|
||||
|
||||
_contrast_uncooperative()
|
||||
_print_cli_usage()
|
||||
|
||||
|
||||
def _contrast_uncooperative() -> None:
|
||||
"""对照:不调 ``checkpoint()`` 的任务**停不掉**。
|
||||
|
||||
这不是缺陷,是诚实:线程无法被强杀,所以系统只能如实报 ``lost``("结果未知"),
|
||||
而任务体仍在后台继续跑。真正终止它只能靠结束进程。
|
||||
"""
|
||||
print("\n" + "=" * 70)
|
||||
print("对照:一个从不调 checkpoint() 的无限任务")
|
||||
print("=" * 70)
|
||||
|
||||
release = threading.Event()
|
||||
ticks = {"n": 0}
|
||||
|
||||
def uncooperative(ctx: Any) -> int:
|
||||
while not release.is_set():
|
||||
ticks["n"] += 1
|
||||
time.sleep(0.02)
|
||||
return ticks["n"]
|
||||
|
||||
controller = Controller(store=InMemoryTaskStore(), graceful_stop=0.5)
|
||||
controller.register("stubborn", uncooperative)
|
||||
controller.submit("stubborn", task_id="stubborn")
|
||||
time.sleep(0.3)
|
||||
|
||||
print(" 停止请求已发出,等待载体响应……")
|
||||
controller.stop("stubborn")
|
||||
outcome = controller.wait("stubborn", timeout=8)
|
||||
print(f" 终态: {outcome.status.value} ← 不是 stopped,而是 lost(结果未知)")
|
||||
print(f" 失败类型: {outcome.failure.kind.value if outcome.failure else None}")
|
||||
|
||||
before = ticks["n"]
|
||||
time.sleep(0.4)
|
||||
alive = [t.name for t in threading.enumerate() if t.is_alive() and t.name.startswith("task-")]
|
||||
print(f" 任务体的线程还在吗: {alive}")
|
||||
print(f" 它还在继续干活吗: {ticks['n'] > before}({before} -> {ticks['n']})")
|
||||
print(" → 线程无法被强杀,只能靠结束进程来真正终止它。")
|
||||
|
||||
release.set()
|
||||
controller.close(timeout=3)
|
||||
|
||||
|
||||
def _print_cli_usage() -> None:
|
||||
"""运维视角:命令行怎么启、怎么停。"""
|
||||
print("\n" + "=" * 70)
|
||||
print("命令行(另一个进程,通过共享存储启停)")
|
||||
print("=" * 70)
|
||||
print(
|
||||
"""
|
||||
# 启动:前台执行,交给 systemd / nohup 保持存活
|
||||
# 注意 --bucket-key:限定了 --owned-buckets 时它是**必需**的,否则任务没有归属桶、
|
||||
# 不会有任何节点认领它(CLI 会在提交时报错)。
|
||||
chongming-task --db /var/lib/tasks.db --num-buckets 64 --owned-buckets 0-15 \\
|
||||
run --tasks myapp.tasks worker --arg source=kafka://orders \\
|
||||
--task-id orders-worker --bucket-key orders
|
||||
|
||||
# 另一个终端:看状态、停止
|
||||
chongming-task --db /var/lib/tasks.db --owned-buckets 0-15 ps --all --mine
|
||||
chongming-task --db /var/lib/tasks.db --owned-buckets 0-15 status orders-worker
|
||||
chongming-task --db /var/lib/tasks.db --owned-buckets 0-15 stop orders-worker --reason 发布窗口
|
||||
|
||||
# Ctrl-C 也可以:main() 会走 finally 里的 controller.close(),把在跑的任务停干净
|
||||
""".rstrip()
|
||||
)
|
||||
print(" 注意:`run` 是前台进程——它退出时任务就没人管了,请交给进程管理器。")
|
||||
sys.stdout.write("")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,177 @@
|
||||
"""示例 10:常驻任务的完整启停流程 —— **一个脚本跑通,不需要第二个终端**。
|
||||
|
||||
前面几轮把桶归属、跨进程、多终端全搅在一起了,所以这个示例刻意只用一条主线:
|
||||
|
||||
启动 → 边跑边看(进度/心跳/租约)→ 暂停 → 恢复 → 停止 → 校验
|
||||
|
||||
每一步都打印"你该看什么",最后**断言**进程内的结论与存储里的状态一致——不一致会
|
||||
失败退出,不会让你盯着两段输出自己猜。
|
||||
|
||||
运行::
|
||||
|
||||
uv run python examples/10_worker_walkthrough.py
|
||||
|
||||
命令行版本(需要第二个终端,因为 ``run`` 是前台进程)见文件末尾的说明。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from chongming_task import Controller, SqliteTaskStore, render_table
|
||||
|
||||
|
||||
def consume(ctx: Any, source: str, batch: int = 1000) -> int:
|
||||
"""常驻消费:无限循环 + 批次进度 + 收到停止请求后优雅收尾。"""
|
||||
total = 0
|
||||
while not ctx.is_stop_requested:
|
||||
ctx.checkpoint() # ← 不调用它 = 停不掉
|
||||
time.sleep(0.05)
|
||||
total += batch
|
||||
ctx.progress(total, unit="msg", message=source)
|
||||
return total
|
||||
|
||||
|
||||
def main() -> int:
|
||||
# 用**文件**存储:这样存储里的状态与进程内的结论是两份数据,能对得上才有意义。
|
||||
store = SqliteTaskStore(":memory:") # 想留下文件就把 ":memory:" 换成 "tasks.db"
|
||||
controller = Controller(store=store, owner="demo-node")
|
||||
controller.register("consume", consume)
|
||||
|
||||
# ---------------------------------------------------------------- 1. 启动
|
||||
print("=" * 72)
|
||||
print("1) 启动")
|
||||
print("=" * 72)
|
||||
run = controller.submit("consume", "kafka://orders", task_id="orders-worker")
|
||||
print(" controller.submit(...) 立即返回,任务在后台跑:")
|
||||
print(f" {run.snapshot.task_id} 状态={run.status.value}")
|
||||
print("\n 注意:`submit` 之后**必须有人让进程活着**(这里的 main 就是那个人)。")
|
||||
|
||||
# ------------------------------------------------- 2. 边跑边看(两个视角)
|
||||
print("\n" + "=" * 72)
|
||||
print("2) 边跑边看:进程内的句柄 vs 存储里的事实")
|
||||
print("=" * 72)
|
||||
first = _watch(store, seconds=1.0)
|
||||
second = _watch(store, seconds=1.0)
|
||||
assert second["progress"] > first["progress"], "进度没有推进——回写链路有问题"
|
||||
print(f"\n ✅ 进度在推进:{first['progress']} → {second['progress']}")
|
||||
print(' ✅ 租约新鲜 → 有监督者在管它(进程崩溃时这里会显示"已过期")')
|
||||
|
||||
# ---------------------------------------------------------------- 3. 暂停
|
||||
print("\n" + "=" * 72)
|
||||
print('3) pause:日常"先停一下"用这个,不要用 stop')
|
||||
print("=" * 72)
|
||||
confirmed = controller.pause("orders-worker", timeout=5)
|
||||
print(f" pause 返回 {confirmed}(True = 已确认挂起;False = 任务没到检查点,状态已回滚)")
|
||||
print(f" 状态: {store.get('orders-worker').status.value}") # type: ignore[union-attr]
|
||||
assert confirmed is True
|
||||
|
||||
frozen = _watch(store, seconds=0.6, show_progress=False)
|
||||
print(f"\n 挂起期间进度不再推进({frozen['progress']})—— 但注意:它**仍占着执行槽**")
|
||||
|
||||
print("\n resume 后状态: ", end="")
|
||||
controller.resume("orders-worker")
|
||||
time.sleep(0.2)
|
||||
print(store.get("orders-worker").status.value) # type: ignore[union-attr]
|
||||
|
||||
# ---------------------------------------------------------------- 4. 停止
|
||||
print("\n" + "=" * 72)
|
||||
print('4) stop:终态、不可逆。返回的是"请求已受理",不是"已停止"')
|
||||
print("=" * 72)
|
||||
controller.stop("orders-worker", reason="发布窗口")
|
||||
outcome = controller.wait("orders-worker", timeout=15)
|
||||
print(f" 终态: {outcome.status.value} 返回值: {outcome.value}")
|
||||
print(" ↑ succeeded 而不是 stopped:因为任务体看到 stop 请求后**正常返回**了,")
|
||||
print(" 已经处理好的数量被完整保留。(让它抛 TaskCancelled 才是 stopped。)")
|
||||
|
||||
# ---------------------------------------------------------------- 5. 校验
|
||||
print("\n" + "=" * 72)
|
||||
print("5) 校验:进程内的结论 与 存储里的状态 必须一致")
|
||||
print("=" * 72)
|
||||
print(render_table(controller.list_tasks()))
|
||||
|
||||
stored = store.get("orders-worker")
|
||||
assert stored is not None
|
||||
assert stored.status is outcome.status, (
|
||||
f"存储={stored.status.value} 与进程内={outcome.status.value} 不一致!"
|
||||
)
|
||||
assert stored.status_version == outcome.snapshot.status_version, "版本号不一致"
|
||||
assert stored.outcome == {"value": outcome.value}, "返回值没有落库"
|
||||
print(
|
||||
f"\n ✅ 一致:status={stored.status.value} "
|
||||
f"version={stored.status_version} outcome={stored.outcome}"
|
||||
)
|
||||
|
||||
print("\n 审计轨迹(终态为什么是 succeeded,看这里就清楚了):")
|
||||
for record in store.history("orders-worker"):
|
||||
reason = f" 原因={record.reason}" if record.reason else ""
|
||||
print(
|
||||
f" {record.version} {record.from_status.value:>9} -> "
|
||||
f"{record.to_status.value:<9} {record.trigger}{reason}"
|
||||
)
|
||||
|
||||
failures = controller.persist_failures or "无"
|
||||
print(f"\n 写穿失败(应当为空;非空说明存储落后于内存):{failures}")
|
||||
print(" 永久分叉的任务(应当为空):", controller.desynced_tasks or "无")
|
||||
assert controller.persist_failures == (), "有写穿失败——存储不可信"
|
||||
assert controller.desynced_tasks == {}, "有任务永久分叉"
|
||||
|
||||
controller.close(timeout=10, close_store=True)
|
||||
|
||||
print("\n" + "=" * 72)
|
||||
print("全部通过 ✅")
|
||||
print("=" * 72)
|
||||
_cli_recipe()
|
||||
return 0
|
||||
|
||||
|
||||
def _watch(store: SqliteTaskStore, *, seconds: float, show_progress: bool = True) -> dict[str, Any]:
|
||||
"""从**存储**采一次样(模拟另一个终端跑 `status`),返回关键字段。"""
|
||||
deadline = time.monotonic() + seconds
|
||||
snapshot = None
|
||||
while time.monotonic() < deadline:
|
||||
snapshot = store.get("orders-worker")
|
||||
time.sleep(0.1)
|
||||
assert snapshot is not None
|
||||
lease = "无"
|
||||
if snapshot.lease_until is not None:
|
||||
from chongming_task import utcnow
|
||||
|
||||
remaining = (snapshot.lease_until - utcnow()).total_seconds()
|
||||
lease = f"剩余 {remaining:.0f}s" if remaining > 0 else f"已过期 {-remaining:.0f}s"
|
||||
progress = snapshot.progress.done if snapshot.progress is not None else 0
|
||||
if show_progress:
|
||||
print(
|
||||
f" 存储: status={snapshot.status.value:<9} v={snapshot.status_version} "
|
||||
f"progress={progress:<7} 心跳={_hhmmss(snapshot)} 租约={lease}"
|
||||
)
|
||||
return {"progress": progress, "status": snapshot.status.value}
|
||||
|
||||
|
||||
def _hhmmss(snapshot: Any) -> str:
|
||||
return "-" if snapshot.last_heartbeat_at is None else str(snapshot.last_heartbeat_at)[11:19]
|
||||
|
||||
|
||||
def _cli_recipe() -> None:
|
||||
print(
|
||||
"""
|
||||
命令行版本(`run` 是前台进程,所以要**两个终端**)
|
||||
────────────────────────────────────────────────────────
|
||||
# 终端 1:启动(前台阻塞;生产上交给 systemd / nohup)
|
||||
set PYTHONPATH=examples
|
||||
uv run chongming-task --db ./tasks.db run --tasks worker_tasks consume ^
|
||||
--arg source=kafka://orders --task-id orders-worker
|
||||
|
||||
# 终端 2:看状态 → 停止 → 再确认
|
||||
uv run chongming-task --db ./tasks.db status orders-worker
|
||||
uv run chongming-task --db ./tasks.db stop orders-worker --reason 发布窗口
|
||||
uv run chongming-task --db ./tasks.db status orders-worker <- 这一步看到 succeeded 才算停干净
|
||||
""".rstrip()
|
||||
)
|
||||
print(" 提示:Ctrl-C 也能停(CLI 会走 finally 里的 close()),但 status 确认仍然要做。\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,492 @@
|
||||
"""示例 11:最佳实践 —— 一份可以直接照抄的多节点任务服务模板。
|
||||
|
||||
示例 01–10 各自演示一个机制;这一个把它们收拢成**一个完整、可运行、可运维的
|
||||
最小形态**,回答集成时的四个必答题:
|
||||
|
||||
① 桶怎么划(含节点划分)—— 业务键稳定映射到桶;桶按连续区间分给节点;
|
||||
一处计算,代码与 CLI 共用。
|
||||
② 谁跑谁的任务 —— 每个节点只接受自己桶里的任务;配错桶在**提交那一刻**
|
||||
报错,而不是产出一个永远没有节点执行的任务。
|
||||
③ 进程启停做什么 —— 启动先 ``reconcile()``(只清理自己桶里的僵尸);
|
||||
退出用 ``close(close_store=True)`` 把连接关干净。
|
||||
④ 怎么观测与控制 —— 所有节点共用一套存储:一个控制台看全部;
|
||||
跨节点停止走指令通道(写指令 -> 持有者轮询取走)。
|
||||
|
||||
文件按"照抄顺序"分四部分:
|
||||
|
||||
# 第一部分 部署参数与桶划分
|
||||
# 第二部分 任务定义(业务代码)
|
||||
# 第三部分 接线:build_node()(唯一集成点)
|
||||
# 第四部分 演示:启动 -> 提交 -> 观测 -> 控制 -> 收尾
|
||||
|
||||
运行::
|
||||
|
||||
uv run python examples/11_best_practice.py
|
||||
|
||||
本示例在一个进程里模拟"两个节点 + 一个运维控制台",是为了**一条命令跑通全流程**;
|
||||
生产上它们是各自独立的进程(同一份代码,只有 NODE_INDEX 不同),见文件末尾的部署说明。
|
||||
注意:SQLite 文件存储要求所有节点在同一台机器上;**跨机部署**把 ``build_node()`` 里的
|
||||
``SqliteTaskStore(deployment.db_path)`` 换成 ``PostgresTaskStore(dsn)``
|
||||
(``pip install "chongming-task[postgres]"``)即可,业务代码一行都不用改。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from chongming_task import (
|
||||
BucketPolicy,
|
||||
ConcurrencyPolicy,
|
||||
ConfigurationError,
|
||||
Controller,
|
||||
ExponentialBackoff,
|
||||
RetryPolicy,
|
||||
SqliteTaskStore,
|
||||
TaskContext,
|
||||
TaskSnapshot,
|
||||
TaskStatus,
|
||||
render_table,
|
||||
task,
|
||||
utcnow,
|
||||
)
|
||||
|
||||
_log = logging.getLogger("best_practice")
|
||||
|
||||
# =============================================================================
|
||||
# 第一部分:部署参数与桶划分(照抄进你的配置模块)
|
||||
# =============================================================================
|
||||
|
||||
#: 桶数。默认 64;这里取 16 让演示输出可读。
|
||||
#: 它是**部署范围的约定**,不是调优旋钮:所有节点与所有运维命令都必须用同一个数。
|
||||
#: 经验值:节点数的 4~16 倍(给节点增减留出重新划分的空间)。
|
||||
NUM_BUCKETS = 16
|
||||
|
||||
#: 本示例部署的节点数。生产上每个节点是一个独立进程。
|
||||
NODE_COUNT = 2
|
||||
|
||||
|
||||
def buckets_for_node(node_index: int, node_count: int, num_buckets: int = NUM_BUCKETS) -> range:
|
||||
"""把桶**连续**分给各节点:第 ``node_index`` 个节点负责 ``[start, stop)`` 区间。
|
||||
|
||||
为什么连续划分,而不是取模交错(``bucket % node_count``):
|
||||
|
||||
* 人可读可核对:``--owned-buckets 0-7`` 与代码里的 ``range(0, 8)`` 是同一件事;
|
||||
* 负载不偏:桶号本身就是 blake2b 的均匀散列,等长区间的期望负载相同;
|
||||
* 迁移简单:节点增减时整段区间搬运,不用逐桶列举。
|
||||
|
||||
除不尽时余数归靠前的节点,保证**不重不漏**——漏桶是静默积压、重桶是重复执行,
|
||||
两者都不报错,所以划分必须在一处算出来、处处引用。
|
||||
"""
|
||||
if node_count < 1:
|
||||
raise ValueError(f"node_count 至少为 1,收到 {node_count}")
|
||||
if num_buckets < node_count:
|
||||
raise ValueError(
|
||||
f"桶数({num_buckets})必须不小于节点数({node_count}):"
|
||||
"否则会有节点分不到桶。经验上桶数取节点数的 4~16 倍。"
|
||||
)
|
||||
if not 0 <= node_index < node_count:
|
||||
raise ValueError(f"node_index 必须在 [0, {node_count}) 内,收到 {node_index}")
|
||||
size, remainder = divmod(num_buckets, node_count)
|
||||
start = node_index * size + min(node_index, remainder)
|
||||
length = size + (1 if node_index < remainder else 0)
|
||||
return range(start, start + length)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Deployment:
|
||||
"""一个节点进程的部署参数。生产环境从环境变量 / 配置中心来,不要写死在代码里。"""
|
||||
|
||||
node_index: int
|
||||
node_count: int
|
||||
db_path: Path
|
||||
num_buckets: int = NUM_BUCKETS
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> Deployment:
|
||||
"""从环境变量读取部署参数(systemd / 容器编排注入)。"""
|
||||
return cls(
|
||||
node_index=int(os.environ.get("TASK_NODE_INDEX", "0")),
|
||||
node_count=int(os.environ.get("TASK_NODE_COUNT", "1")),
|
||||
db_path=Path(os.environ.get("TASK_DB", "tasks.db")),
|
||||
)
|
||||
|
||||
@property
|
||||
def owner(self) -> str:
|
||||
"""写进存储的节点身份。排障时能回答"是谁在管这个任务"。"""
|
||||
return f"node-{self.node_index}"
|
||||
|
||||
@property
|
||||
def owned_buckets(self) -> range:
|
||||
"""本节点负责的桶。三个地方必须用同一份结果:Controller 参数、CLI 参数、日志。"""
|
||||
return buckets_for_node(self.node_index, self.node_count, self.num_buckets)
|
||||
|
||||
@property
|
||||
def cli_buckets(self) -> str:
|
||||
"""同一个划分的 CLI 写法(``--owned-buckets 0-7``),供运维直接复制。"""
|
||||
owned = self.owned_buckets
|
||||
return f"{owned.start}-{owned.stop - 1}"
|
||||
|
||||
|
||||
def owner_of(bucket: int, deployments: Sequence[Deployment]) -> Deployment:
|
||||
"""桶号反查节点。查不到意味着划分有空洞或配置不一致——直接炸,不静默。"""
|
||||
for deployment in deployments:
|
||||
if bucket in deployment.owned_buckets:
|
||||
return deployment
|
||||
raise AssertionError(
|
||||
f"没有任何节点负责桶 {bucket}:请检查 num_buckets / node_count 是否各处一致"
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 第二部分:任务定义(业务代码:只依赖 ctx 约定,照抄进你的 tasks 模块)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@task(
|
||||
tags={"sync", "db"},
|
||||
retry=RetryPolicy(max_attempts=2, backoff=ExponentialBackoff(base=0.05, factor=2)),
|
||||
)
|
||||
def sync_tenant(ctx: TaskContext, tenant: str, batches: int = 24) -> dict[str, object]:
|
||||
"""同步一个租户的数据。
|
||||
|
||||
标签 ``db`` 不只是检索用的:它还是**具名资源信号量**的键——每个节点到库里的
|
||||
并发连接数是有限的,靠它限流比调小全局并发更准(见 ``build_node``)。
|
||||
|
||||
长耗时循环里必须周期性调用 ``ctx.checkpoint()``——停止、暂停、心跳都在那里生效;
|
||||
不调用它的任务既停不掉,也会被 idle 超时判定为疑似卡死。
|
||||
"""
|
||||
ctx.log(f"{tenant}:开始同步({batches} 批)")
|
||||
done = 0
|
||||
for index in range(1, batches + 1):
|
||||
if ctx.is_stop_requested:
|
||||
# 优雅收尾:正常 return -> 终态 succeeded,返回值保留("请收工"语义)。
|
||||
# 若希望"停止即中止",去掉这个检查、让 checkpoint() 抛 TaskCancelled
|
||||
# -> 终态 stopped、无返回值。两种都如实上报,见示例 09。
|
||||
break
|
||||
ctx.checkpoint()
|
||||
time.sleep(0.05)
|
||||
done = index
|
||||
ctx.progress(done, total=batches, unit="批", message=tenant)
|
||||
ctx.log(f"{tenant}:本次同步 {done}/{batches} 批")
|
||||
return {"tenant": tenant, "batches": done}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 第三部分:接线 —— build_node()(唯一集成点,应用启动时调用一次)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def register(controller: Controller) -> None:
|
||||
"""把任务定义登记进白名单。
|
||||
|
||||
两种用法共用同一份定义:应用嵌入时由 :func:`build_node` 调用;
|
||||
CLI 用 ``--tasks 本模块`` 时,由 CLI 调用(``register(controller)``
|
||||
正是 CLI 的约定函数名)。
|
||||
"""
|
||||
controller.register_all(sync_tenant)
|
||||
|
||||
|
||||
def build_node(deployment: Deployment) -> Controller:
|
||||
"""建好一个节点:存储 + 身份 + 桶归属 + 并发上限 + 任务定义 + 启动恢复。
|
||||
|
||||
这是应用**唯一**需要接进本库的地方:你的服务(Web / 定时器 / 消费者)启动时
|
||||
调用一次,把返回的 ``Controller`` 持有到进程结束;退出前 ``close(close_store=True)``。
|
||||
"""
|
||||
controller = Controller(
|
||||
store=SqliteTaskStore(deployment.db_path),
|
||||
owner=deployment.owner,
|
||||
bucket_policy=BucketPolicy(num_buckets=deployment.num_buckets),
|
||||
# 桶归属 = 划分的落地处:本节点只接受、只清理这些桶里的任务。
|
||||
owned_buckets=deployment.owned_buckets,
|
||||
# 并发与资源约束集中在这一处(ConcurrencyPolicy):
|
||||
# max_parallel=4 —— 本进程同时最多执行 4 个;
|
||||
# limits={"db": 3} —— 其中最多 3 个可以同时压数据库(带 db 标签的任务),
|
||||
# 保护的是"本节点到库里的连接数"这份预算;
|
||||
# priority=... —— 默认优先级,submit(priority=...) 可逐条覆盖。
|
||||
concurrency=ConcurrencyPolicy(max_parallel=4, limits={"db": 3}),
|
||||
max_parallel_per_bucket=1, # 同一个租户(同桶)串行,避免自己挤自己
|
||||
command_poll_interval=0.1, # 仅为让演示输出得快;生产用默认 0.5s 即可
|
||||
)
|
||||
register(controller)
|
||||
# 启动恢复:把上次进程留下的、占着资源却没人管的僵尸任务如实标为 lost。
|
||||
# 只看**自己负责的桶**——别的节点的任务由它自己的 reconcile 处理。
|
||||
report = controller.reconcile()
|
||||
owned = deployment.owned_buckets
|
||||
_log.info(
|
||||
"[%s] 启动:负责桶 %d-%d;reconcile:%d 个判失联,%d 个待重新提交",
|
||||
deployment.owner,
|
||||
owned.start,
|
||||
owned.stop - 1,
|
||||
len(report.lost),
|
||||
len(report.adoptable),
|
||||
)
|
||||
return controller
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 第四部分:演示(启动 -> 提交 -> 观测 -> 控制 -> 收尾;这一段不用照抄)
|
||||
# =============================================================================
|
||||
|
||||
#: 演示用的租户(业务键)。生产上它来自你的业务清单。
|
||||
TENANTS = ("tenant-acme", "tenant-umbrella", "tenant-globex", "tenant-hooli")
|
||||
|
||||
#: 给"跨节点停止"演示留足运行窗口:80 批 × 0.05s ≈ 4 秒。
|
||||
SLOW_BATCHES = {"tenant-globex": 80}
|
||||
|
||||
#: 模拟"上一个进程崩溃"遗留的僵尸任务的业务键(bucket 7,归 node-0)。
|
||||
ZOMBIE_KEY = "tenant-vandelay"
|
||||
|
||||
|
||||
def _plant_zombie(db_path: Path, policy: BucketPolicy, key: str) -> str:
|
||||
"""往存储里放一条"崩溃进程留下的"任务:RUNNING 且租约已过期。
|
||||
|
||||
真实场景里这是崩溃的进程自己留下的(写穿 + 租约续不上);这里手工造一条,
|
||||
让 reconcile 有东西可清、让"如实标记而不是假装恢复"看得见。
|
||||
"""
|
||||
now = utcnow()
|
||||
task_id = f"sync-{key}"
|
||||
snapshot = TaskSnapshot(
|
||||
task_id=task_id,
|
||||
name=f"同步-{key}",
|
||||
bucket=policy.bucket_for(key),
|
||||
bucket_key=key,
|
||||
status=TaskStatus.RUNNING,
|
||||
status_version=1,
|
||||
attempt=1,
|
||||
max_attempts=1,
|
||||
created_at=now - timedelta(minutes=12),
|
||||
queued_at=now - timedelta(minutes=12),
|
||||
started_at=now - timedelta(minutes=11),
|
||||
last_heartbeat_at=now - timedelta(minutes=11),
|
||||
executor="thread",
|
||||
lease_owner="node-0@dead-process",
|
||||
lease_until=now - timedelta(minutes=6),
|
||||
)
|
||||
store = SqliteTaskStore(db_path)
|
||||
try:
|
||||
store.create(snapshot)
|
||||
finally:
|
||||
store.close()
|
||||
return task_id
|
||||
|
||||
|
||||
def _print_plan(deployments: Sequence[Deployment], policy: BucketPolicy, db_path: Path) -> None:
|
||||
"""打印部署计划、桶划分与路由表——集成前先把"谁负责什么"看清楚。"""
|
||||
print("=" * 72)
|
||||
print(f"部署:{NUM_BUCKETS} 个桶,{NODE_COUNT} 个节点,共享一套存储({db_path.name})")
|
||||
print("=" * 72)
|
||||
print(" 节点 负责桶 等价的 CLI 参数")
|
||||
print(" -------- ---------- -----------------------------------")
|
||||
for deployment in deployments:
|
||||
owned = deployment.owned_buckets
|
||||
spec = f"--owned-buckets {deployment.cli_buckets}"
|
||||
print(f" {deployment.owner:<8} {owned.start:>2}-{owned.stop - 1:<8} {spec}")
|
||||
print(" 注意:--num-buckets 也必须一致,否则同一个键会落到不同的桶,且无任何报错。")
|
||||
|
||||
print("\n 路由表(blake2b 稳定哈希:同一个键在任何进程、任何机器上都落到同一个桶):")
|
||||
for tenant in TENANTS:
|
||||
bucket = policy.bucket_for(tenant)
|
||||
print(f" {tenant:<18} -> 桶 {bucket:>2} -> {owner_of(bucket, deployments).owner}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
# 输出到 stdout(默认是 stderr):这样日志与 print 的顺序一致,
|
||||
# 管道重定向时也不会看到"日志跑到前面去了"。
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s", stream=sys.stdout)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="chongming-best-practice-") as directory:
|
||||
db_path = Path(directory) / "tasks.db"
|
||||
deployments = [
|
||||
Deployment(node_index=index, node_count=NODE_COUNT, db_path=db_path)
|
||||
for index in range(NODE_COUNT)
|
||||
]
|
||||
policy = BucketPolicy(num_buckets=NUM_BUCKETS)
|
||||
|
||||
_print_plan(deployments, policy, db_path)
|
||||
|
||||
zombie_id = _plant_zombie(db_path, policy, ZOMBIE_KEY)
|
||||
zombie_bucket = policy.bucket_for(ZOMBIE_KEY)
|
||||
assert zombie_bucket in deployments[0].owned_buckets, "僵尸任务应落在 node-0 的桶里"
|
||||
print(
|
||||
f"\n[准备] 模拟上一个进程崩溃:存储里留有一条 RUNNING 且租约过期的任务 "
|
||||
f"{zombie_id}(桶 {zombie_bucket},归 node-0)"
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------- 启动
|
||||
print("\n" + "=" * 72)
|
||||
print("一、启动:每个节点先 reconcile(),只清理**自己桶里**的僵尸任务")
|
||||
print("=" * 72)
|
||||
nodes = [build_node(deployment) for deployment in deployments]
|
||||
# 运维控制台:一个不持有任务的 Controller,只查询与控制(不声明 owned_buckets)。
|
||||
console = Controller(store=SqliteTaskStore(db_path), owner="ops-console")
|
||||
|
||||
zombie = console.get(zombie_id)
|
||||
assert zombie is not None and zombie.status is TaskStatus.LOST
|
||||
print(
|
||||
f" 控制台看到 {zombie_id} 现在是 {zombie.status.value}"
|
||||
"(被如实标记为丢失,而不是永远显示 running;它不会被自动重跑——"
|
||||
"存储里只有状态、没有配方)"
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------- 提交
|
||||
print("\n" + "=" * 72)
|
||||
print("二、触发:按桶把任务交给拥有它的节点(配错桶 = 永远不会有人执行)")
|
||||
print("=" * 72)
|
||||
for tenant in TENANTS:
|
||||
bucket = policy.bucket_for(tenant)
|
||||
node = nodes[owner_of(bucket, deployments).node_index]
|
||||
run = node.submit(
|
||||
"sync_tenant",
|
||||
tenant,
|
||||
task_id=f"sync-{tenant}",
|
||||
label=f"同步-{tenant}",
|
||||
bucket_key=tenant,
|
||||
batches=SLOW_BATCHES.get(tenant, 24),
|
||||
)
|
||||
print(f" {tenant:<18} 桶 {bucket:>2} -> {node.owner} 提交 {run.snapshot.task_id}")
|
||||
|
||||
foreign_tenant = next(
|
||||
tenant
|
||||
for tenant in TENANTS
|
||||
if owner_of(policy.bucket_for(tenant), deployments).node_index != 0
|
||||
)
|
||||
foreign_bucket = policy.bucket_for(foreign_tenant)
|
||||
foreign_owner = owner_of(foreign_bucket, deployments).owner
|
||||
print(
|
||||
f"\n >>> 反例:把 {foreign_tenant}(桶 {foreign_bucket},归 {foreign_owner})"
|
||||
"提交给 node-0:"
|
||||
)
|
||||
try:
|
||||
nodes[0].submit(
|
||||
"sync_tenant",
|
||||
foreign_tenant,
|
||||
task_id="sync-misrouted",
|
||||
bucket_key=foreign_tenant,
|
||||
)
|
||||
except ConfigurationError as exc:
|
||||
print(f" ✅ 提交那一刻就拦住了:{exc}")
|
||||
else:
|
||||
raise AssertionError("提交给错误的节点居然成功了——桶归属校验失效")
|
||||
|
||||
# ---------------------------------------------------------------- 观测
|
||||
time.sleep(0.3)
|
||||
print("\n" + "=" * 72)
|
||||
print("三、观测:一套存储 = 一个看板看全部;按桶过滤 = 只看我这一份")
|
||||
print("=" * 72)
|
||||
print("--- 全局视图(控制台不在任何节点上,也能看到全部任务)---")
|
||||
print(render_table(console.list_tasks()))
|
||||
|
||||
owned_0 = deployments[0].owned_buckets
|
||||
print(f"\n--- node-0 的视角(list_tasks(buckets={owned_0.start}-{owned_0.stop - 1}))---")
|
||||
print(render_table(nodes[0].list_tasks(buckets=owned_0)))
|
||||
|
||||
for node in nodes:
|
||||
print(f" {node.owner}:占用执行额度 {node.active_count},排队 {node.queued_count}")
|
||||
|
||||
# ---------------------------------------------------------------- 控制
|
||||
target_id = f"sync-{foreign_tenant}"
|
||||
target = console.get(target_id)
|
||||
assert target is not None and not target.is_terminal, "演示假设:停止目标仍在运行"
|
||||
|
||||
print("\n" + "=" * 72)
|
||||
print("四、控制:跨节点停止(不能跨进程发信号 -> 写一条指令,由持有者取走)")
|
||||
print("=" * 72)
|
||||
print(
|
||||
f" ops-console 请求停止 {target_id}(持有者是 {foreign_owner}):"
|
||||
f"下发结果 {console.stop(target_id, reason='发布窗口')}"
|
||||
)
|
||||
outcome = nodes[1].wait(target_id, timeout=15)
|
||||
print(f" 终态:{outcome.status.value};返回值:{outcome.value}")
|
||||
print(" 说明:任务体看到停止请求后优雅收尾 -> succeeded 且数值保留;")
|
||||
print(" 让 checkpoint() 抛 TaskCancelled 则为 stopped。两种都如实上报。")
|
||||
print("\n 审计轨迹(从共享存储读;谁按的、为什么,都可追溯):")
|
||||
for record in console.store.history(target_id): # type: ignore[union-attr]
|
||||
reason = f" reason={record.reason}" if record.reason else ""
|
||||
print(
|
||||
f" {record.version:>2} {record.from_status.value:>9} -> "
|
||||
f"{record.to_status.value:<9} {record.trigger}{reason}"
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------- 收尾
|
||||
for node in nodes:
|
||||
node.wait_all(timeout=30)
|
||||
|
||||
print("\n" + "=" * 72)
|
||||
print("五、收尾:校验一致性 -> close(close_store=True)")
|
||||
print("=" * 72)
|
||||
print(render_table(console.list_tasks()))
|
||||
stats = console.stats()
|
||||
print(f" 统计:共 {stats.total},未结束 {stats.unfinished},已结束 {stats.finished}")
|
||||
|
||||
for tenant in TENANTS:
|
||||
snapshot = console.get(f"sync-{tenant}")
|
||||
assert snapshot is not None and snapshot.status in (
|
||||
TaskStatus.SUCCEEDED,
|
||||
TaskStatus.STOPPED,
|
||||
), f"{tenant} 的终态不对:{snapshot.status if snapshot else '不存在'}"
|
||||
for node in nodes:
|
||||
assert node.persist_failures == (), f"{node.owner} 有写穿失败——存储不可信"
|
||||
assert node.desynced_tasks == {}, f"{node.owner} 有永久分叉的任务"
|
||||
print(" ✅ 结论与存储一致:没有写穿失败,没有永久分叉,僵尸任务如实落 lost")
|
||||
|
||||
for node in nodes:
|
||||
node.close(timeout=30, close_store=True)
|
||||
console.close(timeout=5, close_store=True)
|
||||
|
||||
_print_ops_notes()
|
||||
|
||||
print("\n全部通过 ✅")
|
||||
return 0
|
||||
|
||||
|
||||
def _print_ops_notes() -> None:
|
||||
"""部署与运维说明。"""
|
||||
print(
|
||||
f"""
|
||||
生产部署:把它变成一个真正的服务
|
||||
────────────────────────────────────────────────────────────────────────
|
||||
# 每台机器 / 每个容器跑一个节点进程:同一份代码,只有 NODE_INDEX 不同
|
||||
TASK_NODE_INDEX=0 TASK_NODE_COUNT={NODE_COUNT} TASK_DB=/srv/tasks/tasks.db \\
|
||||
python -m your_service.node
|
||||
TASK_NODE_INDEX=1 TASK_NODE_COUNT={NODE_COUNT} TASK_DB=/srv/tasks/tasks.db \\
|
||||
python -m your_service.node
|
||||
|
||||
# 运维(任意一台机器;--num-buckets 与代码里的 NUM_BUCKETS 必须一致)
|
||||
uv run chongming-task --db /srv/tasks/tasks.db --num-buckets {NUM_BUCKETS} \\
|
||||
ps --all
|
||||
uv run chongming-task --db /srv/tasks/tasks.db --num-buckets {NUM_BUCKETS} \\
|
||||
--owned-buckets 0-7 ps --all --mine
|
||||
uv run chongming-task --db /srv/tasks/tasks.db --num-buckets {NUM_BUCKETS} \\
|
||||
--owned-buckets 0-7 reconcile --dry-run
|
||||
uv run chongming-task --db /srv/tasks/tasks.db --num-buckets {NUM_BUCKETS} \\
|
||||
stop sync-tenant-globex --reason "发布窗口"
|
||||
|
||||
# 本模块也能直接当 CLI 任务模块用(register(controller) 约定)
|
||||
PYTHONPATH=examples uv run chongming-task --db ./demo.db --num-buckets {NUM_BUCKETS} \\
|
||||
--owned-buckets 0-7 run --tasks 11_best_practice sync_tenant \\
|
||||
--arg tenant=tenant-acme --task-id sync-tenant-acme --bucket-key tenant-acme
|
||||
|
||||
集成三步(照抄本文件的三个"部分")
|
||||
────────────────────────────────────────────────────────────────────────
|
||||
# 1. 第一部分 + 第二部分 -> 你的配置模块与任务模块;
|
||||
# 2. 应用启动时 build_node(Deployment.from_env()),退出前 close(close_store=True);
|
||||
# 3. 触发处 submit(任务名, ..., bucket_key=业务键)。
|
||||
# 两条硬约束:任务只能提交给拥有它那个桶的节点;bucket_key 必须给
|
||||
# (限定了 owned_buckets 时不给会被当场拒绝——没有归属桶的任务没人认领)。
|
||||
#
|
||||
# 跨机部署:SQLite 文件不能跨机共享,把第三部分的 store 换成
|
||||
# PostgresTaskStore("postgresql://user:pw@db.internal:5432/chongming")
|
||||
# (pip install "chongming-task[postgres]")即可,其余部分不用改。""".rstrip()
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,201 @@
|
||||
"""示例 12:进程执行器 —— 硬性停止、绕开 GIL、崩溃隔离。
|
||||
|
||||
uv run python examples/12_process_executor.py
|
||||
|
||||
绝大多数任务该用默认的**线程执行器**:共享内存、没有 pickle 约束、能直接复用连接池。
|
||||
但有三件事线程做不到,只有子进程能:
|
||||
|
||||
1. **硬性停止保证**:任务不响应停止(不调 ``ctx.checkpoint()``)时,线程只能放弃等待
|
||||
并把结果记成 ``lost``(未知);子进程会被 ``terminate()`` → ``kill()`` 真的停掉;
|
||||
2. **绕开 GIL**:CPU 密集的计算能真正并行;
|
||||
3. **崩溃隔离**:任务里加载的原生扩展段错误,只会带走子进程。
|
||||
|
||||
本示例把 1 和 3 跑给你看,并把代价说清楚。
|
||||
|
||||
这个文件本身也是"目标函数必须是模块级"的演示:下面的任务都定义在模块顶层,因为
|
||||
spawn 起的子进程要通过"模块 + 函数名"把它们找回来。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from chongming_task import (
|
||||
ConfigurationError,
|
||||
FailureKind,
|
||||
ProcessExecutor,
|
||||
RetryPolicy,
|
||||
TaskStatus,
|
||||
Timeout,
|
||||
run_task,
|
||||
)
|
||||
from chongming_task.runtime.executors.base import ExecutionRequest
|
||||
|
||||
# 子进程不继承父进程的 logging 配置(spawn 起的是全新解释器),日志想看就得自己配。
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s", stream=sys.stdout)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 任务定义(模块级:spawn 的子进程要靠"模块 + 函数名"找回来)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def cpu_burn(ctx: Any, rounds: int = 6) -> dict[str, object]:
|
||||
"""CPU 密集的活:子进程里不受父进程 GIL 的影响。"""
|
||||
total = 0
|
||||
for _ in range(rounds):
|
||||
ctx.checkpoint()
|
||||
for index in range(400_000):
|
||||
total += index % 7
|
||||
return {"rounds": rounds, "checksum": total, "pid": os.getpid()}
|
||||
|
||||
|
||||
def deaf_loop(ctx: Any) -> str:
|
||||
"""**不**调用 checkpoint 的任务:停止请求它根本看不见。
|
||||
|
||||
这正是不配合的任务的样子——拿线程执行器它只会落 ``lost``;拿进程执行器它会被真的杀掉。
|
||||
"""
|
||||
del ctx
|
||||
end = time.monotonic() + 60
|
||||
while time.monotonic() < end:
|
||||
pass
|
||||
return "never"
|
||||
|
||||
|
||||
def crash_hard(ctx: Any) -> str:
|
||||
"""模拟原生扩展段错误:直接消失,一句话都不留。"""
|
||||
del ctx
|
||||
os._exit(139)
|
||||
return "never" # pragma: no cover - 走不到这里
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 演示
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def demo_cpu_bound() -> None:
|
||||
print("=" * 72)
|
||||
print("一、CPU 密集:任务跑在子进程里(父进程的 GIL 管不着它)")
|
||||
print("=" * 72)
|
||||
outcome = run_task(
|
||||
cpu_burn,
|
||||
6,
|
||||
task_id="cpu-1",
|
||||
executor=ProcessExecutor(),
|
||||
timeout=Timeout(per_attempt=30),
|
||||
)
|
||||
assert outcome.ok, outcome.failure
|
||||
payload = outcome.value
|
||||
print(f" 结果:{payload}")
|
||||
print(f" 监督进程 pid={os.getpid()},任务进程 pid={outcome.snapshot.pid}")
|
||||
print(f" 快照里的 executor={outcome.snapshot.executor}(写进存储,事后可查)")
|
||||
print()
|
||||
|
||||
|
||||
def demo_hard_stop() -> None:
|
||||
print("=" * 72)
|
||||
print("二、硬性停止:不响应停止的任务被真的杀掉(线程执行器只能放弃等待)")
|
||||
print("=" * 72)
|
||||
started = time.monotonic()
|
||||
outcome = run_task(
|
||||
deaf_loop,
|
||||
task_id="deaf-1",
|
||||
executor=ProcessExecutor(),
|
||||
timeout=Timeout(idle=1), # 一秒没有心跳就判卡死
|
||||
retry=RetryPolicy(max_attempts=1),
|
||||
graceful_stop=0.5, # 优雅窗口:0.5 秒内不响应就强杀
|
||||
)
|
||||
elapsed = time.monotonic() - started
|
||||
print(f" 任务自己的循环要跑 60 秒,实际用了 {elapsed:.1f} 秒")
|
||||
print(f" 终态:{outcome.status.value};失败类型:{outcome.failure.kind.value}")
|
||||
print(f" 子进程 pid={outcome.snapshot.pid} —— 它已经不在了(ps 查不到)")
|
||||
assert outcome.status is TaskStatus.FAILED
|
||||
assert outcome.failure.kind is FailureKind.TIMEOUT_IDLE, "超时如实上报,不谎报'已停止'"
|
||||
assert elapsed < 30, "不该等到任务自己结束"
|
||||
print(" 注意:超时仍然记成超时(不是'已停止')——但资源确实释放了。")
|
||||
print()
|
||||
|
||||
|
||||
def demo_crash_isolation() -> None:
|
||||
print("=" * 72)
|
||||
print("三、崩溃隔离:子进程崩了,监督进程活着并如实记一笔")
|
||||
print("=" * 72)
|
||||
outcome = run_task(
|
||||
crash_hard,
|
||||
task_id="crash-1",
|
||||
executor=ProcessExecutor(),
|
||||
timeout=Timeout(per_attempt=30),
|
||||
)
|
||||
assert not outcome.ok
|
||||
print(f" 监督进程还活着(pid={os.getpid()}),任务终态:{outcome.status.value}")
|
||||
print(f" 失败类型:{outcome.failure.kind.value};退出码:{outcome.failure.exit_code}")
|
||||
print(f" 说明:{outcome.failure.message}")
|
||||
print(" 换成线程执行器,这个崩溃会带走整个监督进程。")
|
||||
print()
|
||||
|
||||
|
||||
def demo_picklable_guard() -> None:
|
||||
print("=" * 72)
|
||||
print("四、代价:不可 pickle 的目标在**提交那一刻**就被拦下")
|
||||
print("=" * 72)
|
||||
|
||||
def local_closure(ctx: Any) -> str: # 局部函数:子进程里找不到它
|
||||
del ctx
|
||||
return "nope"
|
||||
|
||||
# 1) 直接交给执行器:当场抛错,并告诉你该改成什么。
|
||||
request = ExecutionRequest(task_id="bad-0", name="bad", target=local_closure, args=())
|
||||
try:
|
||||
ProcessExecutor().launch(request)
|
||||
except ConfigurationError as exc:
|
||||
print(f" launch() 直接报错 {type(exc).__name__}:")
|
||||
for line in str(exc).splitlines():
|
||||
print(f" {line}")
|
||||
else: # pragma: no cover - 应当抛错
|
||||
raise AssertionError("不可 pickle 的目标必须被拦下")
|
||||
|
||||
# 2) 走 run_task:任务**落 failed 并带上这条诊断**,调用方不会干等,也不会被
|
||||
# 说成"框架内部错误"——那是把任务侧的问题指向错误的排查方向。
|
||||
outcome = run_task(local_closure, task_id="bad-1", executor=ProcessExecutor())
|
||||
assert outcome.status is TaskStatus.FAILED
|
||||
assert outcome.failure is not None
|
||||
print(f"\n 走 run_task:终态 {outcome.status.value},retryable={outcome.failure.retryable}")
|
||||
print(f" 失败信息首行:{outcome.failure.message.splitlines()[0]}")
|
||||
print(" 配置类错误不重试——重试多少次都是同一个结果,只会白烧重试预算。")
|
||||
assert outcome.failure.retryable is False
|
||||
print()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print()
|
||||
demo_cpu_bound()
|
||||
demo_hard_stop()
|
||||
demo_crash_isolation()
|
||||
demo_picklable_guard()
|
||||
|
||||
print("=" * 72)
|
||||
print("选型:默认线程,需要这三种能力时才换进程")
|
||||
print("=" * 72)
|
||||
print(
|
||||
"""
|
||||
线程(默认) 进程(ProcessExecutor)
|
||||
------------------------ ------------------------------------------
|
||||
共享内存、可复用连接池 参数与返回值必须可 pickle
|
||||
闭包 / lambda 都能用 目标必须是模块级函数,调用方要有 __main__ 守卫
|
||||
停止/暂停即时生效 停止/暂停有约 20ms 轮询延迟
|
||||
不配合的任务只能放弃(lost) 不配合的任务被真的杀掉
|
||||
受 GIL 限制 真并行
|
||||
原生扩展段错误带走整个进程 只带走子进程
|
||||
"""
|
||||
)
|
||||
print("全部通过 ✅")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,47 @@
|
||||
"""给 CLI 用的任务模块。
|
||||
|
||||
CLI **只传任务名**,定义必须来自这样一个模块——一个能启动任意可调用对象的命令行
|
||||
入口等于任意代码执行,所以"任务定义从哪里来"必须先有答案。约定就是本文件里的
|
||||
``register(controller)``。
|
||||
|
||||
用法(把本目录加进 ``PYTHONPATH``,或把模块放进你自己的包里)::
|
||||
|
||||
chongming-task --db /tmp/tasks.db tasks --tasks tasks_demo
|
||||
chongming-task --db /tmp/tasks.db run --tasks tasks_demo add --arg left=2 --arg right=3
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
|
||||
def sync_tenant(ctx: Any, tenant: str, batches: int = 120) -> str:
|
||||
"""同步一个租户的数据。"""
|
||||
for index in range(batches):
|
||||
ctx.checkpoint()
|
||||
if index % 20 == 0:
|
||||
time.sleep(0.05)
|
||||
return f"{tenant} 同步完成({batches} 批)"
|
||||
|
||||
|
||||
def add(ctx: Any, left: int, right: int) -> int:
|
||||
"""两数相加。"""
|
||||
del ctx
|
||||
return left + right
|
||||
|
||||
|
||||
def flaky(ctx: Any, fail_times: int = 2) -> str:
|
||||
"""前几次故意失败,用来演示重试。"""
|
||||
state = getattr(flaky, "_attempts", 0) + 1
|
||||
flaky._attempts = state # type: ignore[attr-defined]
|
||||
if state <= fail_times:
|
||||
raise RuntimeError(f"第 {state} 次尝试遇到暂时性故障")
|
||||
return f"第 {state} 次尝试成功"
|
||||
|
||||
|
||||
def register(controller: Any) -> None:
|
||||
"""把任务登记进白名单。CLI 导入本模块后就会调用它。"""
|
||||
controller.register("sync", sync_tenant, tags={"io"})
|
||||
controller.register("add", add, tags={"cpu"})
|
||||
controller.register("flaky", flaky)
|
||||
@@ -0,0 +1,48 @@
|
||||
"""一个可以直接用的常驻任务模块(CLI 的 ``--tasks`` 就指向它)。
|
||||
|
||||
它是给命令行用的示例模块,也是你自己模块的模板。用法::
|
||||
|
||||
PYTHONPATH=examples uv run chongming-task --db ./tasks.db \
|
||||
run --tasks worker_tasks consume --arg source=kafka://orders --task-id orders-worker
|
||||
|
||||
## 三件必须做对的事
|
||||
|
||||
1. **每轮调 ``ctx.checkpoint()``**——停止是协作式的,不调用它就停不掉(会落 ``lost``)。
|
||||
2. **用 ``ctx.is_stop_requested`` 收尾**——这样任务终态是 ``succeeded``,返回值保留;
|
||||
让 ``checkpoint()`` 抛则是 ``stopped``、无返回值。
|
||||
3. **进度按批次上报,不要每条一次**——``ctx.progress()`` 每次都要取运行锁,它是给
|
||||
"人看得懂"的频率设计的。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from chongming_task import RetryPolicy, task
|
||||
|
||||
|
||||
@task(tags={"kafka"}, retry=RetryPolicy(max_attempts=1))
|
||||
def consume(ctx: Any, source: str, batch: int = 1000) -> int:
|
||||
"""常驻消费 source;收到停止请求后收尾并返回本次共处理多少条。"""
|
||||
ctx.log(f"开始消费 {source}")
|
||||
total = 0
|
||||
while not ctx.is_stop_requested:
|
||||
ctx.checkpoint()
|
||||
|
||||
# 假装取了一批消息。真实实现里这里是一次 poll / 一次拉取。
|
||||
time.sleep(0.05)
|
||||
total += batch
|
||||
|
||||
# 按批次上报进度(不是每条一次)。
|
||||
ctx.progress(total, unit="msg", message=source)
|
||||
|
||||
ctx.log(f"收到停止请求,收尾:本次共处理 {total} 条")
|
||||
return total
|
||||
|
||||
|
||||
@task
|
||||
def once(ctx: Any, value: int) -> int:
|
||||
"""一个短任务,用来对照"有始有终"的普通形态。"""
|
||||
del ctx
|
||||
return value * 2
|
||||
Reference in New Issue
Block a user