# 命令行控制台 `chongming-task` 是**另一个进程**:它看不到任务进程的内存,只能通过共享存储查询与 控制。这正是持久层存在的理由——没有它,`ps` 什么也看不到。 ```bash # 一个终端里跑任务(前台执行到结束) chongming-task --db /var/lib/tasks.db run --tasks myapp.tasks sync --arg tenant=A # 另一个终端里看和管 chongming-task --db /var/lib/tasks.db ps chongming-task --db /var/lib/tasks.db status demo-1 chongming-task --db /var/lib/tasks.db stop demo-1 --reason "发布窗口" ``` --- ## 1. 命令总览 | 命令 | 作用 | |---|---| | `tasks --tasks MODULE` | 列出某个任务模块里注册的定义(名字、标签、描述) | | `run TASK --tasks MODULE [--executor thread\|process]` | 提交并**前台执行**一个任务,直到落终态 | | `ps [--all] [--status S] [--bucket N] [--pipeline RUN_ID] [--mine] [--limit N]` | 列出任务。默认只显示**未落终态**的 | | `status TASK_ID` | 单个任务的详情 + 迁移历史 | | `history TASK_ID` | 只列迁移历史(可审计轨迹) | | `stats` | 各状态计数 | | `buckets [--num-buckets N]` | 每个桶的任务数 | | `reconcile [--dry-run] [--stale-after S] [--reason R]` | **重启恢复**:清理上次进程留下的僵尸任务 | | `stop TASK_ID [--reason R]` | 请求停止 | | `pause TASK_ID [--timeout S]` | 请求暂停 | | `resume TASK_ID` | 恢复被暂停的任务 | 全局选项: | 选项 | 说明 | |---|---| | `--db PATH_OR_DSN` | 任务存储:SQLite 文件路径,或 `postgresql://` 连接串(跨机部署;需要 `pip install "chongming-task[postgres]"`)。未给出时读环境变量 `CHONGMING_TASK_DB` | | `--num-buckets N` | 集群的桶数。**必须与部署一致**——它决定 `--owned-buckets` 的合法性与 `buckets` 视图的行数;不给出按默认值 64 | | `--owned-buckets SPEC` | 本节点负责的桶,如 `0-15,20,30`。给出后 `reconcile` 只清理这些桶、`ps --mine` 只看这些桶 | | `--json` | 以 JSON 输出,便于脚本消费 | | `--owner NAME` | 本客户端的标识,会写进它下发的指令里,便于事后追溯是谁按的停止 | ### 退出码 | 码 | 含义 | |---|---| | `0` | 命令成功 | | `1` | 命令本身失败(参数错、任务不存在、存储打不开、目录不存在……) | | `3` | 命令成功,但它查询/执行的**任务没有成功** | `3` 是给脚本判分支用的: ```bash if ! chongming-task --db tasks.db run --tasks myapp.tasks load --arg day=2026-10-02; then echo "任务没成功,别继续往下走了" >&2 exit 1 fi ``` > 注意 `1` 与 `3` 的区别:`1` 是"我连命令都没跑成",`3` 是"命令跑成了,但任务本身 > 没成功"。混在一起会让脚本无法区分"运维脚本写错了"和"数据质量有问题"。 --- ## 2. 任务定义从哪里来 CLI **只传任务名,绝不接受代码**——一个能启动任意可调用对象的命令行入口等于任意 代码执行。所以必须先有一个模块把定义登记进来: ```python # myapp/tasks.py def sync_tenant(ctx, tenant: str, batches: int = 120) -> str: """同步一个租户的数据。""" for index in range(batches): ctx.checkpoint() ... return f"{tenant} 同步完成" def register(controller) -> None: """约定:CLI 导入模块后会调用它。""" controller.register("sync", sync_tenant, tags={"io"}) ``` 然后 `--tasks myapp.tasks`。模块必须在 `PYTHONPATH` 上(或是一个已安装的包)。 仓库里的 `examples/tasks_demo.py` 就是一个可直接用的例子: ```bash PYTHONPATH=examples chongming-task tasks --tasks tasks_demo ``` ``` NAME TAGS MAX_ATTEMPTS DESCRIPTION ------------------------------------------------------------ add cpu 1 两数相加。 flaky - 1 前几次故意失败,用来演示重试。 sync io 1 同步一个租户的数据。 ``` 模块没有 `register` 时会给一句明确的话,而不是 `AttributeError`: ``` 错误:任务模块 'json' 必须定义 register(controller) 函数。 ``` --- ## 3. 完整走查 以下全部是**真实运行输出**(`examples/tasks_demo.py`,先在一个终端启动,再从另一个 终端查询与控制)。 ### 3.1 启动一个长任务(前台) ```bash chongming-task --db .cli-demo/tasks.db run --tasks tasks_demo sync \ --arg tenant=租户A --arg batches=20000 --bucket-key tenant-A \ --task-id demo-1 --label A-同步 ``` `run` 是**前台**的:它自己就是 worker,执行到任务落终态才退出。要后台跑就交给 `nohup`、`systemd` 或你已有的进程管理器——CLI 不假装自己是守护进程管理器。 ### 3.2 从另一个进程查询 ```bash chongming-task --db .cli-demo/tasks.db ps ``` ``` TASK_ID NAME BUCKET STATUS ATTEMPT ELAPSED PROGRESS FAILURE ------- ------ ------ ------- ------- ------- -------- ------- demo-1 A-同步 7 running 1/1 7.4s - - ``` ```bash chongming-task --db .cli-demo/tasks.db status demo-1 ``` ``` task_id : demo-1 name : A-同步 status : running bucket : 7 tags : io bucket_key : tenant-A attempt : 1/1 status_version : 3 executor : thread created_at : 2026-10-02T14:01:23+00:00 started_at : 2026-10-02T14:01:23+00:00 ended_at : - last_heartbeat_at : 2026-10-02T14:01:31+00:00 elapsed : 7.8s progress : - failure : - retryable : - overridden : False 迁移历史: VER FROM TO AT TRIGGER ACTOR REASON --- -------- --------- ------------------------- ---------------------------------- ------ ------ 1 pending queued 2026-10-02T14:01:23+00:00 orchestrator:dependencies_resolved system - 2 queued starting 2026-10-02T14:01:23+00:00 scheduler:dispatch system - 3 starting running 2026-10-02T14:01:23+00:00 executor:launched system - ``` **`last_heartbeat_at` 只有在这里才有意义。** 它由任务自己的 `ctx.checkpoint()` 驱动, 所以一个"卡住但没报错"的任务会显示心跳停滞——这是唯一能发现死锁的信号。 `ps` 的 `PROGRESS` 列来自任务里的 `ctx.progress(...)`。它走**高频旁路**(不参与乐观 并发),由持有任务的进程定期回写,因此可能有最多一个轮询周期的延迟: ``` TASK_ID NAME STATUS PROGRESS ---------------------- ------------------- --------- -------- 2b4e5e326776:extract daily-etl/extract succeeded 6/6 2b4e5e326776:load daily-etl/load succeeded 4/4 ``` `stats` 与 `buckets`: ``` $ chongming-task --db .cli-demo/tasks.db stats running 1 -------------------- total 1 unfinished 1 $ chongming-task --db .cli-demo/tasks.db buckets --num-buckets 10 BUCKET TASKS -------------- 0 0 ... 7 1 ... ``` ### 3.3 暂停与恢复 ```bash chongming-task --db .cli-demo/tasks.db --owner ops@console pause demo-1 ``` ``` 已请求暂停 demo-1(用 status 观察是否真的挂起) 暂停**未确认**。若任务由别的进程持有,这是正常的——跨进程没有回执通道,请用 status 观察结果。 ``` 第一行在 stdout,第二行在 stderr,**退出码仍是 0**。跨进程没有回执通道,所以 `pause` 只能如实说"未确认"——这不是失败,而是它做不到的事就别假装做到。 紧接着 `ps` 能看到它确实停了: ``` TASK_ID NAME BUCKET STATUS ATTEMPT ELAPSED PROGRESS FAILURE ------- ------ ------ ------ ------- ------- -------- ------- demo-1 A-同步 7 paused 1/1 18.4s - - ``` `resume` 之后回到 `running`。 ### 3.4 停止,并留下原因 ```bash chongming-task --db .cli-demo/tasks.db --owner ops@console stop demo-1 --reason "发布窗口" chongming-task --db .cli-demo/tasks.db ps --all ``` ``` TASK_ID NAME BUCKET STATUS ATTEMPT ELAPSED PROGRESS FAILURE ------- ------ ------ ------- ------- ------- -------- --------- demo-1 A-同步 7 stopped 1/1 20.9s - cancelled ``` 原因会完整落进审计轨迹,并且**区分了是谁按的**: ``` $ chongming-task --db .cli-demo/tasks.db history demo-1 VER FROM TO AT TRIGGER ACTOR REASON --- -------- -------- ------------------------- ---------------------------------- ------ ------ 1 pending queued 2026-10-02T14:01:23+00:00 orchestrator:dependencies_resolved system - 2 queued starting 2026-10-02T14:01:23+00:00 scheduler:dispatch system - 3 starting running 2026-10-02T14:01:23+00:00 executor:launched system - 4 running pausing 2026-10-02T14:01:40+00:00 user:pause user - 5 pausing paused 2026-10-02T14:01:40+00:00 runtime:pause_reached system - 6 paused running 2026-10-02T14:01:42+00:00 user:resume user - 7 running stopping 2026-10-02T14:01:44+00:00 user:stop user 发布窗口 8 stopping stopped 2026-10-02T14:01:44+00:00 runtime:stop_confirmed system 发布窗口 ``` 第 4–6 行是暂停往返,第 7–8 行是停止。**`REASON` 只在人为动作上出现**——机器自己的 迁移没有原因要记。 ### 3.5 `--json`:给脚本用 ```bash chongming-task --db .cli-demo/tasks.db --json ps --all ``` ```json [{"attempt": 1, "bucket": 7, "bucket_key": "tenant-A", "created_at": "2026-10-02T14:01:23.570947+00:00", "ended_at": "2026-10-02T14:01:44.485082+00:00", "executor": "thread", "failure": "{...}", "last_heartbeat_at": "2026-10-02T14:01:44.430100+00:00", "max_attempts": 1, "name": "A-同步", "outcome": null, "overridden": 0, "status": "stopped", "status_version": 8, "tags": "[\"io\"]", "task_id": "demo-1"}] ``` JSON 与**存储行同构**(全部是原语),因此脚本消费它不需要了解 `datetime` / 枚举的 序列化规则。三点约定: * 时间是 ISO 字符串(UTC); * 枚举是 `.value` 字符串; * 嵌套结构(`failure` / `outcome` / `tags`)是 JSON **字符串**而不是对象——保持与 存储行一致,代价是脚本里要多一次 `json.loads`。 错误走 stderr,stdout 保持干净,所以 `--json` 的输出可以直接管道给 `jq`。 ### 3.6 按管道查看 管道里的每个任务都带 `pipeline_run_id`(形如 `ead248368f4a:load` 的 task_id),因此 可以整条一起看: ```bash chongming-task --db tasks.db ps --all --pipeline ead248368f4a ``` 过滤**下推到存储层**(SQLite 侧有 `idx_tasks_pipeline_run` 索引),不是取回全部再筛。 `--pipeline` 也可以与 `--status` 组合: ```bash # 这条管道里有哪些节点没成功 chongming-task --db tasks.db ps --status failed --pipeline ead248368f4a ``` --- ## 4. 多节点:`--owned-buckets` 与 `--num-buckets` 多个节点各跑各的任务时,靠桶区分"哪一份是我的"。两个选项都必须与部署一致: ```bash # 只看"我这一份" chongming-task --db tasks.db --num-buckets 64 --owned-buckets 0-15 ps --all --mine # 每个桶多少任务;星号标出本节点负责的桶 chongming-task --db tasks.db --num-buckets 64 --owned-buckets 0-15 buckets ``` ``` BUCKET TASKS ---------------- * 0 3 * 1 0 2 0 ``` `reconcile` 会**限定在自己负责的桶内**: ```bash chongming-task --db tasks.db --owned-buckets 0-15 reconcile --dry-run ``` **`run` 同样受归属约束**:限定了 `--owned-buckets` 时,`--bucket-key` 是必需的——没有归属 桶的任务不会有任何节点认领它,所以 CLI 在提交时就报错,而不是留下一个永远排队的任务: ``` $ chongming-task --db tasks.db --num-buckets 64 --owned-buckets 0-15 run \ --tasks myapp.tasks worker --task-id orders-worker 错误:本节点只负责桶 [0, 1, ..., 15],因此提交任务 'orders-worker' 时必须给出 bucket_key ——否则它没有归属桶,不会有任何节点认领它。 ``` 这很重要:统一存储里有别的节点的任务,去动它既越界(那是它的责任)也没有意义(它的租约 由它自己续)。 **桶归属的校验在做任何事之前完成**,包括 `ps` / `stats` / `buckets` 这些不构造 `Controller`、直接查存储的命令。否则越界的桶号会被静默接受——筛选条件匹配不到任何东西, 看起来就像"这个桶没有任务": ``` $ chongming-task --db tasks.db --owned-buckets 0-999 ps 错误:桶号越界:64;桶数是 64(合法范围 0..63)。若部署的桶数不是 64,请用 --num-buckets 指明。 ``` ### 4.1 `run --executor process`:能被真的杀掉的执行方式 默认的线程执行器在"任务不响应停止"时只能放弃等待(记 `lost`,结果未知)。需要**停得住** 的保证时,让任务跑在独立子进程里: ```bash # 前台跑一个可能不配合的任务 chongming-task --db tasks.db run --tasks tasks_demo sync --arg tenant=acme --executor process --graceful-stop 5 # 另一个终端:优雅窗口用尽后会 terminate() → kill(),任务被真的停掉 chongming-task --db tasks.db stop --reason "跑太久" ``` 代价写在这里,省得踩了才知道(与库的行为完全一致,见[用户指南 §7.4](user-guide.md#74-进程执行器硬性停止--绕开-gil--崩溃隔离)): - **参数与返回值必须可 pickle**——`lambda`、闭包、局部函数都不行;违反时提交那一刻就报错; - **任务函数必须在可导入的模块里**(`--tasks` 指定的那个模块正好满足),且该模块的顶层 不能有副作用; - 停止与暂停有约 20ms 的轮询延迟(信号要过进程边界)。 `run` 输出里的 `pid` 就是那个子进程的进程号,`ps` 能直接对上号。 ### 4.2 跨机部署:`--db` 换成连接串 SQLite 文件不能跨机共享。多台机器共用一套存储时,给 `--db` 一个 Postgres 连接串即可: ```bash pip install "chongming-task[postgres]" chongming-task --db 'postgresql://user:pw@db.internal:5432/chongming' \ --num-buckets 64 --owned-buckets 0-15 ps --all --mine ``` **除连接串本身,其余一切都一样**:`--json`、退出码、`--owned-buckets` 校验、`reconcile` 的语义都不变。三张表(`tasks` / `transitions` / `commands`)在首次连接时自动创建,不需要 手工迁移;当前角色没有建表权限时,它检测到表已存在就直接使用——**只读运维账号因此也能连**。 没装可选依赖时给的是安装命令,而不是一个 `ModuleNotFoundError`: ``` $ chongming-task --db postgresql://… ps 错误:--db 给的是 Postgres 连接串,但可选依赖未安装:PostgresTaskStore 需要可选依赖 psycopg 与 psycopg-pool。安装: pip install "chongming-task[postgres]" 或:pip install "psycopg[binary]" psycopg-pool ``` 连不上时同样只说该说的——DSN 里的**密码不会出现在任何错误信息里**: ``` $ chongming-task --db 'postgresql://someuser:s3cret@nope.invalid:5432/x' ps 错误:无法连接 Postgres(someuser@nope.invalid:5432/x):failed to resolve host 'nope.invalid': [Errno 11001] getaddrinfo failed ``` --- ## 5. 重启恢复:`reconcile` 进程意外退出后,存储里会留下一批永远停在 `running` 的任务。**新进程启动时先跑一次 `reconcile`**,否则看板会永远显示一个假的"运行中"。 真实输出。先看崩溃后的现场: ``` $ chongming-task --db crash.db ps TASK_ID NAME BUCKET STATUS ATTEMPT ELAPSED PROGRESS FAILURE ----------- ------------ ------ ------- ------- ------- -------- ------- job-queued 等待中的任务 - queued 0/1 - - - job-crashed 夜间抽取 - running 1/1 600.4s - - ``` `job-crashed` 已经"运行"了 600 秒——它的进程其实十分钟前就没了。 **先预演**(这类操作会改状态,看一眼是值得的): ```bash chongming-task --db crash.db reconcile --dry-run ``` ``` OUTCOME TASK_ID WAS DETAIL ----------- ----------- ------- ----------------- adoptable job-queued queued 没有执行载体,等待重新提交 marked-lost job-crashed running 监督者已失联(进程重启后未再续租) 预演:有 1 个任务**会被**标记为 lost,1 个等待重新提交。未写入任何状态。 注意:本命令不会、也无法重新执行任务——存储里记的是状态,不是参数。 ``` 确认无误后真正执行: ```bash chongming-task --db crash.db reconcile --reason "worker-old 崩溃" ``` ``` 已把 1 个僵尸任务标记为 lost,1 个任务无人接手(状态未改动,需要重新提交)。 ``` 之后 `ps --all` 里 `job-crashed` 是 `lost`,而 `job-queued` **仍然是 `queued`**—— 它没占执行资源,只是没人接手,把它的排队事实改掉才是错的。原因也进了审计轨迹: ``` $ chongming-task --db crash.db history job-crashed VER FROM TO AT TRIGGER ACTOR REASON --- ------- ---- ------------------------- ---------------------- ------ ------------- 4 running lost 2026-10-02T14:10:47+00:00 runtime:heartbeat_lost system worker-old 崩溃 ``` ### 两件必须清楚的事 1. **它不会、也无法重新执行任务。** 存储里记的是状态,不是配方:任务的函数与参数从未 落库。所以"重跑"只能靠你重新 `run`,而那需要你自己知道参数。 2. **判据是租约,不是心跳。** 一个长时间不调 `checkpoint()` 的健康任务心跳也会停滞; 若拿心跳判断进程存活,它会被误判丢失并**杀掉**。所以心跳与租约是两个独立字段。 `--stale-after` 只影响"从未续过租的任务"的宽限期(默认取租约时长的两倍),用来避开 "刚提交、工作线程还没轮到第一次续租"这个窗口。 --- ## 6. 错误路径 ``` $ chongming-task ps 错误:未指定任务存储。请用 --db PATH,或设置环境变量 CHONGMING_TASK_DB。 CLI 是另一个进程,没有共享存储就看不到任何任务。 $ chongming-task --db .cli-demo/tasks.db stop typo-task-id 错误:没有找到任务 typo-task-id;无法停止。 $ chongming-task --db /nope/tasks.db ps 错误:数据库所在目录不存在:/nope。请先创建它,或改用别的路径。 ``` 最后一条是刻意加的:SQLite 原本只会报 `unable to open database file`,完全看不出是 目录不存在——这是最容易浪费时间的错误之一。 --- ## 7. 桌面上的注意事项 * **`run` 是前台进程。** 它退出时任务就没人管了。要长期跑请交给进程管理器。 * **`pause` 跨进程返回"未确认"。** 想看结果请 `status`;`stop` 则是"请求已受理"。 * **`stop` 一个不存在的任务会失败并留下零条指令。** 拼错的 id 不会制造孤儿指令。 * **`ps` 默认不含已结束的任务。** 想看历史加 `--all`,或按状态过滤 `--status failed`。 * **`--db` 必须显式给出或设环境变量。** 不会静默在当前目录建一个库——那类"我的任务 去哪了"的问题很难查。 --- ## 8. 与库的关系 CLI 是 `Controller` 之上的一层**薄适配**:它做的全部事情就是解析参数、调 `Controller` / `TaskStore`、渲染输出。所以: * 库能做的,CLI 不一定都暴露(比如 `wait_all`、`close_store`、`prune`)——直接在代码里用 [Controller](user-guide.md#8-管理一批任务controller) 更灵活; * CLI 不引入任何库没有的概念,也不绕过任何不变量(比如它不会直接改状态)。 替换持久层的步骤见 [持久层实现指南](store-guide.md)——CLI 会自动用上你的实现,因为它 只依赖 `TaskStore` 契约。