单任务受控执行(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。
493 lines
23 KiB
Python
493 lines
23 KiB
Python
"""示例 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())
|