Files

507 lines
20 KiB
Markdown
Raw Permalink Normal View History

# 命令行控制台
`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 <task_id> --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` 契约。