初始提交: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:
2026-10-08 23:47:50 +08:00
commit f4de43c743
85 changed files with 25809 additions and 0 deletions
+49
View File
@@ -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()
+74
View File
@@ -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()
+86
View File
@@ -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()
+82
View File
@@ -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()
+70
View File
@@ -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()
+91
View File
@@ -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()
+112
View File
@@ -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()
+119
View File
@@ -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()
+180
View File
@@ -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()
+177
View File
@@ -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())
+492
View File
@@ -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())
+201
View File
@@ -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())
+47
View File
@@ -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)
+48
View File
@@ -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