mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-01 19:06:01 +00:00
docs(schedule): lead the walkthrough with a behavioural spec
Restructure per review: state what the system promises before how the code delivers it. The thin product-decision table becomes a full §1 behavioural spec written in zero implementation vocabulary -- user operations, scheduling semantics, the skip-not-queue overlap decision, the busy-wait guarantees (delayed, never lost; ordered by due time; no catch-up of missed cron periods), the failure/recovery table, and an explicit non-goals list (no queue, no punctuality guarantee, no task credentials, single scheduler instance) -- each promise cross-referenced to the section whose mechanism delivers it. The mechanism chapters absorb what the spec now promises: §6.2 explains where waiting goes (the table as an implicit priority queue via the claim query's ordering), §6.3 and a new pitfall entry name the budget-leak boundary when a completion callback is lost, §8's defence table gains the race-window column and a per-layer error-translation table as the journey's unhappy half, and the HTTP endpoint block moves beside the commands it carries.
This commit is contained in:
parent
cd857e1ca4
commit
f68fb0c4cf
@ -1,40 +1,74 @@
|
||||
# 定时任务(Schedule)模块设计
|
||||
|
||||
> [六边形设计规范](HEXAGONAL_ARCHITECTURE_zh.md) 的**参考实现走读**。本文假定你已读过规范:术语(端口、适配器、聚合、command、防腐层)与通用规则不再重复解释,只讲三件事——schedule 特有的产品决定、每条规范规则落在哪个文件、以及 schedule 特有的陷阱。读完你将能回答:一条定时规则从创建到执行经历了什么、它的每条并发防线由谁守卫、想加一种调度类型该改哪几个文件。
|
||||
> [六边形设计规范](HEXAGONAL_ARCHITECTURE_zh.md) 的**参考实现走读**。本文假定你已读过规范:术语(端口、适配器、聚合、command、防腐层)与通用规则不再重复解释。结构上业务先行:§1 是零实现词汇的行为规格(系统承诺了什么),§2 起才讲每条承诺由哪个文件的什么机制兑现、以及 schedule 特有的陷阱。读完你将能回答:一条定时规则从创建到执行经历了什么、它的每条并发防线由谁守卫、想加一种调度类型该改哪几个文件。
|
||||
>
|
||||
> 姊妹文档:[FEEDBACK_DESIGN_zh.md](FEEDBACK_DESIGN_zh.md)——首个切片、规范的最小实例。schedule 是同样的结构,复杂度高一个量级:两个聚合、两个状态机、三种驱动源、四道并发防线。
|
||||
|
||||
---
|
||||
|
||||
## 1. 这个模块做什么
|
||||
## 1. 业务需求与行为规格
|
||||
|
||||
让用户注册一条「到点、或按 cron 用这段 prompt 起一次 agent run」的规则,由后台轮询器按时把它派发进**既有的** Gateway run 生命周期。
|
||||
让用户注册一条「到点、或按 cron 用这段 prompt 起一次 agent run」的规则,由系统按时执行并记录每次执行的结果。本章只讲系统**承诺的行为**;每条承诺由什么机制兑现,见括号里指向的章节。
|
||||
|
||||
产品层面的四条决定,解释了后面所有设计:
|
||||
### 1.1 用户能做什么
|
||||
|
||||
| 决定 | 后果 |
|
||||
| 操作 | 语义要点 |
|
||||
|---|---|
|
||||
| **调度器只决定 when,不建第二套执行栈**(`backend/AGENTS.md` 的硬约束) | 派发最终只做一件事:在正确的时刻调用一次现有的 run 启动入口,然后记账。全部复杂度在"正确的时刻"与"记账"的并发与崩溃语义上 |
|
||||
| **规则与执行是两回事** | 两个聚合、两张表:`ScheduledTask`(规则,长期存在)与 `ScheduledRun`(一次触发的历史记录)。互相只有 `task_id` 字符串引用,各自独立事务 |
|
||||
| **重叠就跳过**(MVP 固定 `overlap_policy="skip"`) | 一个任务同时最多一条活跃执行;到点时上一次还没结束,这一次被跳过并留终态墓碑 |
|
||||
| **定时运行不可交互** | 派发走 `non_interactive=True` 的内部启动路径,lead-agent 工具集不含 `ask_clarification` |
|
||||
| 创建任务 | 两种调度:cron(周期)或 once(一次);两种执行上下文:每次新会话,或固定在某个已有会话里(后者要求该会话存在且归本人) |
|
||||
| 部分更新 | 只改提交的字段;任务正在派发中时拒绝编辑;把一个已结束的任务改到未来时间会自动重新启用它 |
|
||||
| 暂停 / 恢复 | 暂停的任务不被自动调度,但仍可手动触发,触发后保持暂停 |
|
||||
| 立即执行 | 无视调度时间跑一次;撞上该任务还有执行在跑时直接拒绝(409),不排队 |
|
||||
| 删除 | 任何状态都可删,包括执行中——飞行中的 run 不受影响,只是结果无处写回 |
|
||||
| 查执行历史 | 每次触发(包括被跳过的)都留一条记录 |
|
||||
|
||||
对应的 HTTP 面(`app/gateway/routers/schedule/`):
|
||||
所有读写按用户隔离;别人的任务表现为"不存在",不是"无权限"。
|
||||
|
||||
```
|
||||
GET /api/scheduled-tasks 列表
|
||||
POST /api/scheduled-tasks 创建
|
||||
GET /api/scheduled-tasks/{id} 详情
|
||||
PATCH /api/scheduled-tasks/{id} 部分更新
|
||||
POST /api/scheduled-tasks/{id}/pause 暂停
|
||||
POST /api/scheduled-tasks/{id}/resume 恢复
|
||||
POST /api/scheduled-tasks/{id}/trigger 立即执行(409/502/200 三种码)
|
||||
DELETE /api/scheduled-tasks/{id} 删除
|
||||
GET /api/scheduled-tasks/{id}/runs 执行历史
|
||||
GET /api/threads/{tid}/scheduled-tasks 某 thread 绑定的任务
|
||||
```
|
||||
### 1.2 调度语义:什么时候跑
|
||||
|
||||
但 HTTP 只是三种驱动源之一——轮询器与运行完成回调走同样的用例,见 §8。
|
||||
- cron 表达式在**任务声明的时区**里求值。夏令时切换被正确吸收:纽约的"每天 9 点"在冬夏令时对应不同的 UTC 时刻,用户视角始终是"每天 9 点"。
|
||||
- once 任务的时刻必须在未来,且距提交至少一个运营下限(默认 60 秒),防止"立刻就要跑"的任务;cron 不受此限制。
|
||||
- 定时触发的 run 是**非交互的**:执行中的 agent 不能停下来向用户提问——后台没有人会回答。
|
||||
- 执行走普通的 agent run 生命周期,调度器只决定 when(§8)。
|
||||
|
||||
### 1.3 重叠:跳过,不排队
|
||||
|
||||
一个任务同时最多一次执行在跑。到点时上一次还没结束:
|
||||
|
||||
- **自动调度**:这一次被**跳过**,留一条终态记录作审计;cron 任务等下一个自然周期,once 任务记为**失败**——唯一的机会丢了,说"完成"等于谎称执行过。
|
||||
- **手动触发**:直接拒绝,让用户自己决定,不替他排队。
|
||||
|
||||
选择跳过而非排队是产品决定:对"每天 9 点总结昨天"这类任务,一个迟到两小时才补跑的 occurrence 产出的往往已经不是用户要的东西。(机制:§8 的四道防线之一;将来若需要排队策略,改动面见 §10.4。)
|
||||
|
||||
### 1.4 忙时等待:延迟,不丢失
|
||||
|
||||
系统对同时执行的定时 run 总数设全局上限(运营配置)。到期任务多于余量时:
|
||||
|
||||
- 多出来的任务**等待**,按到期时间先后依次放行——不会丢失,也不会被随机跳过;
|
||||
- 等待没有超时:预算被长任务占满多久,就等多久。预算的回收依赖每个 run 终将结束(§1.5 的恢复语义为此兜底);
|
||||
- 等待期间错过的 cron 周期**不补跑**:轮到它时跑一次,然后从当前时刻算下一个周期。宕机三天的每日任务恢复后也只跑一次,不连跑三次。
|
||||
|
||||
推论:长期预算不足的表现不是积压爆炸(系统里每任务最多一条"欠账"),而是低频任务被静默降频——这是容量报警该盯的信号。
|
||||
|
||||
### 1.5 失败与恢复
|
||||
|
||||
| 出了什么事 | 用户看到什么 |
|
||||
|---|---|
|
||||
| 提交的规则非法(坏 cron、过近的 once…) | 立即拒绝,任何数据都不写 |
|
||||
| run 启动失败 | 执行记录记失败;once 任务失败,cron 任务照常排下一次并展示上次的错误 |
|
||||
| 目标会话正忙(固定会话模式) | 自动调度视同一次重叠跳过;手动触发拒绝 |
|
||||
| run 执行中失败 / 超时 | once 记失败;cron 保住调度,展示错误 |
|
||||
| run 被用户取消 | once 记**取消**(不是失败——用户主动行为);cron 不受影响 |
|
||||
| 服务进程崩溃 | 重启后自动清理:跑到一半的执行记录标"中断",等一个永远不会来的完成信号的 once 任务标"取消";调度立即恢复,不需要人工干预 |
|
||||
|
||||
已成功启动的执行**绝不**因系统内部的记账失败被追认为失败(§6.1)。
|
||||
|
||||
### 1.6 明确的非目标
|
||||
|
||||
- **没有执行队列**——重叠跳过、忙时靠下轮重试,见 1.3/1.4;
|
||||
- **不补跑错过的周期**(no catch-up);
|
||||
- **不保证准点**——执行时刻 = 到期后的下一个轮询拍点再加预算等待,轮询间隔是运营配置;
|
||||
- **任务不携带凭据**——定时 run 拿不到请求级 secrets(它没有"每次请求重新提供"的人);
|
||||
- **单调度器实例**——多实例下互斥机制仍正确,但预算各算各的(§9 的测试边界)。
|
||||
|
||||
## 2. 内圈全景
|
||||
|
||||
@ -218,6 +252,21 @@ flowchart TD
|
||||
| `DeleteTask` | `ScheduleService.delete_task` | 同上 |
|
||||
| `TriggerTask` | `ScheduleService.trigger_task` | 同上 |
|
||||
|
||||
对应的 wire 契约(`app/gateway/routers/schedule/`;HTTP 只是三种驱动源之一,另两种见 §8):
|
||||
|
||||
```
|
||||
GET /api/scheduled-tasks 列表
|
||||
POST /api/scheduled-tasks 创建
|
||||
GET /api/scheduled-tasks/{id} 详情
|
||||
PATCH /api/scheduled-tasks/{id} 部分更新
|
||||
POST /api/scheduled-tasks/{id}/pause 暂停
|
||||
POST /api/scheduled-tasks/{id}/resume 恢复
|
||||
POST /api/scheduled-tasks/{id}/trigger 立即执行(409/502/200 三种码)
|
||||
DELETE /api/scheduled-tasks/{id} 删除
|
||||
GET /api/scheduled-tasks/{id}/runs 执行历史
|
||||
GET /api/threads/{tid}/scheduled-tasks 某 thread 绑定的任务
|
||||
```
|
||||
|
||||
命名链三拼写互为变体(规范 §2.1),grep 任何一个就能找到用例全部三层。command 是哑数据(`context_mode="not-a-mode"` 能构造出来——校验归聚合,错误归因顺序归 handler 的构造顺序);身份不进请求模型(`user_id` 服务端解析后经 `to_command` 参数注入,测试钉住两个请求模型上都没有该字段)。
|
||||
|
||||
schedule 在 feedback 之上多出三个设计点:
|
||||
@ -322,12 +371,16 @@ claimed = await tasks.claim_due(now=..., lease_seconds=..., limit=budget)
|
||||
|
||||
`max_concurrent_runs` 限制**同时活跃的执行总数**,不是每轮批量。长任务跨轮次累积,每轮只能认领进剩余额度——当成"每轮最多 N 个"会让长任务压垮系统。
|
||||
|
||||
**等待去哪了(§1.4 承诺的兑现处)**:没轮到的到期任务不进任何等待结构——它就留在表里,`next_run_at` 停在过去。`claim_due` 的 `ORDER BY next_run_at ASC, id ASC` + `LIMIT budget` 让表本身成为隐式优先队列:出队 = 认领,重新入队 = `record_launch` 重排 `next_run_at`;预算释放后最早到期的先被放行,轮询间隔就是消费节拍。已跑过的 cron 任务 `next_run_at` 立即排到未来、退出到期集合,所以高频任务不可能把低频任务永远压在队尾。这是"不建第二套执行栈"的直接推论——一张有索引的表加一条排序查询,比一个要考虑持久化、崩溃恢复、重复消费的队列组件便宜得多。
|
||||
|
||||
### 6.3 完成回调与启动清扫
|
||||
|
||||
`handle_run_completion(outcome, now)` 先写执行记录终态,再问聚合这个结局意味着什么、只写那个答案:once 推向终态,cron 保持不变。**但 `last_error` 无条件写入**——cron 保住了调度,仍要报告上次出了什么问题。任务在 run 飞行途中被删掉不是错误,静默返回。
|
||||
|
||||
`reconcile_on_startup(error)` 清扫崩溃留下的两种残骸:永远不会结束的活跃执行记录、和停在 `running` 等一个已死回调的 once 任务(后者不被过期认领覆盖——已启动的任务释放了租约,认领查询永远看不见它)。它**不吞异常**——部分清扫失败要不要阻塞启动,是调用方的策略。
|
||||
|
||||
这个回调同时是 §1.4 全局预算的回收信号:`count_active` 数的活跃执行记录靠它写成终态。回调丢失(run 已结束但钩子没落库)会让那格预算一直被占——泄漏到上限时调度整体停摆,直到下一次进程重启的清扫把它收回。清扫刻意只在启动时跑:进程存活时"活跃记录多老算僵死"无法安全判定(run 真的可能跑很久),运行中的周期性清扫需要先有 run 表侧的对账依据(§11 有对应陷阱条目)。
|
||||
|
||||
## 7. 适配器与组合根
|
||||
|
||||
四个从适配器 + 一个入站适配器住在 `app/adapters/schedule/`,一个端口一个文件;两形态判据见规范 §2。
|
||||
@ -406,14 +459,27 @@ sequenceDiagram
|
||||
S->>TR: record_completion(status_after_completion, error)
|
||||
```
|
||||
|
||||
四道并发防线在这条时序上各守一段:
|
||||
四道并发防线在这条时序上各守一段——每条对应一个真实的竞态窗口:
|
||||
|
||||
| # | 防线 | 守什么 |
|
||||
| # | 竞态场景 | 窗口在哪 | 防线 |
|
||||
|---|---|---|---|
|
||||
| 1 | 双击 / 客户端重试 / 手动撞轮询,同一任务两个派发并发 | `has_active` 快检与插入之间隔着 await 点,两边都能通过 | 部分唯一索引 `uq_scheduled_task_run_active` 仲裁;败者收敛到与快路径**字节一致**的结局(调用方分不出被哪种机制拦下,重试行为才不分叉) |
|
||||
| 2 | 两个轮询进程抢同一批到期任务;认领者崩溃后任务卡在 `running` | `claim_due` 的选取与更新之间;租约期内无人能动 | `FOR UPDATE SKIP LOCKED` + 租约过期接管分支 |
|
||||
| 3 | 快速失败 run 的回调先于派发记账落库;`record_launch` 与 `record_completion` 并发写同一任务行 | launch 返回与两笔回写之间;两个独立事务 | 双向 `protect_terminal` CAS + 分字段所有权(§7.1 ②)——任何一方读-改-写整聚合都会重放过期快照 |
|
||||
| 4 | 进程死亡留下活跃记录与卡住的 once 任务 | 崩溃点到重启之间 | 启动清扫 `reconcile_on_startup` |
|
||||
|
||||
时序讲完 happy path,异常是它的另一半——每层失败的翻译点与最终去向:
|
||||
|
||||
| 异常来源 | 翻译点 | 最终去向 |
|
||||
|---|---|---|
|
||||
| 1 | 部分唯一索引 `uq_scheduled_task_run_active` | 两个并发派发(双击、重试、手动撞轮询)最多一个拿到活跃槽位 |
|
||||
| 2 | `claim_due` 的 `FOR UPDATE SKIP LOCKED` + 租约 | 认领本身原子;崩溃的认领者过期后可被接管 |
|
||||
| 3 | 双向 `protect_terminal` CAS | 快速失败 run 的回调 与 启动路径的迟到写,谁先谁后都不丢裁决 |
|
||||
| 4 | 启动清扫 `reconcile_on_startup` | 进程死亡留下的活跃记录与卡住的 once 任务 |
|
||||
| 客户端提交非法(坏 cron、未知 mode、改 running 中的任务) | 聚合构造期 / 转换期抛领域错误 | router 单表映射:404 / 422 / 409 |
|
||||
| 手动触发撞活跃执行 | `DispatchOutcome.CONFLICT` | 409,不留 run 记录 |
|
||||
| 执行 thread 忙(reuse_thread 撞用户会话) | launcher 译成 `ThreadBusyError` | 自动路径 = 一次正常跳过(墓碑);手动 = 409 |
|
||||
| 启动失败(其余一切,launcher 兜底 `except Exception`;返回值缺 run_id 同罪) | → `LaunchFailedError` | 执行记录 `FAILED`;once 失败、cron 重排并记 `last_error`;手动映 502(故障在下游,任务完好)。兜底刻意不捕 `CancelledError`——关机是控制流,不是启动结果 |
|
||||
| 记账写入失败(launch 成功之后) | **不捕获**,如实冒泡 | 只有 launch 被 try 包住——已成功启动的执行绝不因记账失败被标成 failed(§6.1) |
|
||||
| 存储坏行 | 适配器 `_to_domain` → `CorruptStoredScheduleError` | 单行读 500(服务端故障),列表读跳过该行并记日志(§7.1 ④) |
|
||||
| 一整轮 poll 失败(如 database is locked) | poller 循环捕获并记日志 | 下个周期重试——瞬时故障不能让所有定时任务静默停摆 |
|
||||
| 启动清扫失败 | poller 记日志后照常开始调度 | 因遗留脏行拒绝启动比带着脏行运行更糟 |
|
||||
|
||||
## 9. 测试分层
|
||||
|
||||
@ -502,6 +568,7 @@ sequenceDiagram
|
||||
| 坏行错误进了 router 的 422 映射 | 存储故障被报成客户端错误,PATCH 修复路径自身 422 |
|
||||
| 把契约测试全绿当成可以多实例 | fake 没有原子性;并发由真数据库的 race 测试负责 |
|
||||
| 显式继承 Protocol 拼错方法名 | 静默继承 `...` 体返回 `None`,`isinstance` 抓不到——契约用例必须断言返回值 |
|
||||
| 完成回调丢失(run 结束但钩子没落库) | 执行记录停在 `running`,占着一格全局预算;泄漏到上限时调度停摆,直到进程重启的清扫收回(§6.3)。想加运行中的周期清扫,先回答"多老算僵死"——那需要 run 表侧的对账依据,不宜在 schedule 侧拍超时数字 |
|
||||
|
||||
## 12. 代码索引
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user