Files
orbit/docs/api/schedules.md

226 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 调度计划接口 v1
状态:已实现五段 Cron、fixed-rate、不可变版本、暂停/恢复、三种漏跑与重叠策略、触发历史及独立 SDK。调度记录、任务创建、事件与进度同事务提交。当前实际验证与边界见 [阶段报告](../reports/phase-3-schedules.md),整体仍按 [开发计划](../08-development-plan.md) 推进。
## 1. 权限与生命周期
| 接口 | 权限 | SDK |
| --- | --- | --- |
| POST /v1/schedules | schedules:manage + tasks:submit + 模板队列范围 | CreateSchedule |
| GET /v1/schedules?queue_id=… | schedules:read + 队列范围 | ListSchedules |
| GET /v1/schedules/{id} | schedules:read + 计划队列范围 | GetSchedule |
| PATCH /v1/schedules/{id} | schedules:manage + tasks:submit + 计划队列范围 | PublishSchedule |
| POST /v1/schedules/{id}:pause | schedules:manage + 计划队列范围 | PauseSchedule |
| POST /v1/schedules/{id}:resume | schedules:manage + 计划队列范围 | ResumeSchedule |
| GET /v1/schedules/{id}/versions/{version} | schedules:read + 计划队列范围 | GetScheduleVersion |
| GET /v1/schedules/{id}/occurrences | schedules:read + 计划队列范围 | ListScheduleOccurrences |
计划终身绑定一个队列。发布完整新定义时可以改变时间、参数和策略,不能更换队列。计划属于命名空间;创建凭据后续到期/撤销不自动撤销已创建的计划。停止计划使用暂停接口,禁用应用或命名空间会阻止后台继续创建任务。每次管理与查询仍重新校验当前凭据。
计划暂停只停止产生新的任务,已经受理的任务继续执行。队列暂停只停止任务派发,不停止计划受理,可能形成积压。SERIAL 策略将 WAITING、LEASED、RUNNING、NEEDS_REVIEW 都视为未完成;未知效果未处理时不能开始下一轮。
请求使用 Bearer、application/json 和稳定的 Idempotency-Key,沿用 [任务接口](tasks.md) 的 512 KiB 请求、严格 JSON、命名空间隔离和精确数字规则。ID 为 32 位小写十六进制,revision/version 为十进制字符串,时间为 UTC RFC 3339 微秒精度。
## 2. 创建与定义字段
CreateScheduleRequest:name、definition 必填;paused 默认 false。name 为命名空间内唯一的 1–128 位 ASCII 名称,字符规则同任务处理器。definition 包含 rule、policy、task,三者均需明确提供。
| rule 字段 | 含义与边界 |
| --- | --- |
| kind | CRON 或 FIXED_RATE |
| timezone | 必填 IANA 名称,如 UTC、Asia/Shanghai;不接受 Local 或文件路径 |
| calendar_version | 可省略,v1 固定默认 `go1.27.1-b2d18a7c8fa8`;规范化后写入版本快照,其他未支持值拒绝 |
| start_at | 必填基准,包含边界;年份 2000–9998,最多微秒 |
| end_at | 可选排除边界,必须晚于 start_at;达到后不再产生新触发点 |
| cron | CRON 必填五段表达式,最多 256 字符;不接受秒段、@every 等描述符或内嵌 TZ= |
| interval_ms | FIXED_RATE 必填,1000–2592000000 毫秒;此时不能设置 cron |
新计划的第一个点不早于数据库创建时刻;start_at 在过去时只用作规则基准,不追溯生成计划存在前的任务。创建时必须还有未来触发点。新发布版本可以设置已经结束的规则,此时先处理旧积压,再结束后续调度。
Cron 段顺序为分钟、小时、月日、月、星期;支持 `*`、列表、范围、步长、月份/星期名称,星期为 0–6(周日为 0)。月日与星期均受限时按 OR 匹配,有通配段时按标准组合规则匹配。采用 [robfig/cron v3.0.1 的解析器](https://github.com/robfig/cron/tree/v3.0.1),Orbit 自行按时区偏移区间转换 UTC 触发点,不启动该库的内存调度器。
时区快照内置于服务二进制,不读取宿主机时区文件或 ZONEINFO。快照来自 Go 1.27.1 的 zoneinfo.zip,完整 SHA-256 为 `b2d18a7c8fa8142097a48c99609fb3c92db5ee98bc740294e57eab8ae9f94779`。升级时必须保留旧快照可读能力和 v1 默认语义,不能把旧计划静默换成新的时区规则。
- 夏令时缺失的本地时刻跳过;重复的本地时刻按两个不同 UTC 时间分别触发。
- 半小时跳变、整天跳过、非整点时区和世纪闰日都有测试。
- fixed-rate 始终按 `start_at + n × interval_ms` 的实际经过时间计算,不跟随上一任务的完成时间漂移。
- 无解日期规则、混合字段和无效时区在发布前拒绝。无后续触发点与计算错误分开处理,错误不能静默当作完成。
| policy 字段 | 要求 |
| --- | --- |
| misfire | 必填:SKIP、COALESCE、CATCH_UP |
| overlap | 必填:PARALLEL、SERIAL、SKIP |
| catchup_limit | 必填 1–100,每批最多生成任务数;大参数批次还受约 1 MiB 参数复制量限制 |
| catchup_window_ms | 必填 1000–2592000000,未处理触发点最多保留的时间窗口 |
| grace_ms | 必填 1000–60000,正常派发允许延迟;不能超过 catchup_window_ms |
所有任务策略在 task 内冻结,字段同 SubmitTaskRequest;payload 最大 256 KiB,数字文本保持精确。task 中禁止绝对 available_at / start_deadline_at 和非零 delay_ms,时间由计划规则定义,不静默忽略这些字段。每次生成时立即可受理,task_timeout_ms 从本次实际创建时间计算。计划结束不取消已经生成的任务,也不缩短其任务时限。
生成任务受 [队列积压上限](queues.md) 约束。满额时保留未受理触发点,本批较早已生成部分仍提交;释放空间后按冻结的漏跑策略接续,超过补偿窗口的点仍按策略跳过。队列 paused 仅停止领取/开始,停止计划生成需使用计划暂停接口。
创建请求示例,ID 和未来时间替换为实际值:
```json
{
"name":"object-snapshot",
"paused":true,
"definition":{
"rule":{"kind":"FIXED_RATE","timezone":"UTC","start_at":"2026-10-01T00:00:00Z","interval_ms":60000},
"policy":{"misfire":"COALESCE","overlap":"SERIAL","catchup_limit":10,"catchup_window_ms":86400000,"grace_ms":5000},
"task":{"queue_id":"11111111111111111111111111111111","handler":"object.snapshot","handler_version":"v1","payload_version":"v1","payload":{"source_ref":"objects/sample","sequence":9007199254740993},"task_timeout_ms":30000,"retry":{"max_attempts":1,"attempt_timeout_ms":5000}}
}
}
```
```sh
curl "$ORBIT_URL/v1/schedules" \
-H "Authorization: Bearer $ORBIT_TOKEN" \
-H 'Content-Type: application/json' -H 'Idempotency-Key: snapshot-plan-001' \
--data-binary @schedule.json
```
将上方 JSON 保存为 schedule.json。首次返回 201,同键同内容返回 200;均不表示已有任务完成。响应示例:
```json
{
"request_id":"44444444444444444444444444444444",
"data":{
"deduplicated":false,
"schedule":{
"id":"22222222222222222222222222222222","namespace_id":"33333333333333333333333333333333",
"queue_id":"11111111111111111111111111111111","name":"object-snapshot",
"revision":"1","current_version":"1","processing_version":"1","paused":true,
"next_fire_at":"2026-10-01T00:00:00Z","check_at":"2026-10-01T00:00:00Z",
"created_at":"2026-09-17T08:00:00Z","updated_at":"2026-09-17T08:00:00Z",
"current":{
"version":"1","effective_at":"2026-09-17T08:00:00Z",
"definition":{
"rule":{"calendar_version":"go1.27.1-b2d18a7c8fa8","kind":"FIXED_RATE","timezone":"UTC","start_at":"2026-10-01T00:00:00Z","interval_ms":60000},
"policy":{"misfire":"COALESCE","overlap":"SERIAL","catchup_limit":10,"catchup_window_ms":86400000,"grace_ms":5000},
"task":{"queue_id":"11111111111111111111111111111111","handler":"object.snapshot","handler_version":"v1","payload_version":"v1","payload":{"source_ref":"objects/sample","sequence":9007199254740993},"task_timeout_ms":30000,"recovery_safety":"RECONCILE_ONLY","retry":{"max_attempts":1,"initial_delay_ms":0,"max_delay_ms":0,"attempt_timeout_ms":5000}}
}
}
}
}
}
```
GetSchedule 的 data 直接为 schedule 对象。列表 Items 省略 current/大参数,其他字段相同。next_fire_at 是下一原计划时间,积压时可能在过去;check_at 是平台下次允许扫描时间,并不保证届时执行。next_fire_at 为空时,可能尚待初始化某个版本的首个触发点,也可能已经结束;即使 processing_version 等于 current_version,也必须结合 check_at 判断。check_at 非空表示仍有扫描工作,为空且处理到最新版本表示已无后续触发。
## 3. 漏跑、重叠与有限补偿
先处理超过 catchup_window_ms 的范围并记录 CATCHUP_WINDOW_EXCEEDED,再应用漏跑规则:
| 策略 | 行为 |
| --- | --- |
| SKIP | 早于 now − grace_ms 的点合为跳过范围;容差内的点仍可受理 |
| COALESCE | 若最早积压点超过 grace_ms,将当前版本内本轮所有已到期点合为一个任务 |
| CATCH_UP | 按原时间逐点创建,单批不超过 catchup_limit,剩余进度保留到下一批 |
| PARALLEL | 可以产生多条未完成任务,实际执行仍受队列与 Worker 并发许可限制 |
| SERIAL | 有任一先前计划任务未终结时保留游标等待;没有未完成任务时本批最多创建一条 |
| SKIP 重叠策略 | 有先前未终结任务时跳过本轮已到期范围;否则本批最多创建一条 |
SERIAL 等待也受时间窗口约束,过旧范围会被记录为跳过,避免无界补偿。NEEDS_REVIEW 不属于明确终态,会持续阻止串行后续任务;取消或失败不能假装外部效果已消失。
每节点每秒扫描最多 100 个候选计划,每个计划单独事务。同一计划相邻批次的数据库决策时间至少间隔一秒,check_at 持久化,多节点不会各自补跑一份。每批最多创建一个历史版本的任务,跨版本也不绕过批量上限;空版本每批最多推进 16 个。当前是有界运行基线,不是已验证的生产吞吐承诺。
每个触发身份由命名空间、计划 ID、原 UTC scheduled_at 唯一确定。暂停、重连和发布新版本都不会改变旧身份。跳过/合并记录的覆盖区间为 `[scheduled_at, covered_until)`,结束边界不包含;范围不会跨越版本生效时间,也不会为范围内每个点额外创建任务。
## 4. 发布、暂停与恢复
PATCH 是完整发布,必须包含 expected_revision(非零十进制字符串)、reason(非空,最多 1024 字符)、definition(完整新定义)。发布后 current_version 增加一,新版本以事务读取的数据库时间立即生效。
尚未处理且早于新版本生效边界的点仍按旧规则、旧模板和旧策略决定。后续原时间由新版本计算,已生成任务保持原快照。数据库触发器禁止覆盖已发布版本。GET versions/{version} 返回 version、effective_at、definition;version 必须是无前导零的非零十进制数。
暂停/恢复请求体相同:
```json
{"expected_revision":"1","reason":"start regular snapshots"}
```
成功返回 200,data 与创建回执结构相同。暂停不清空游标,恢复按冻结漏跑规则处理暂停期间的点。对已经处于目标暂停状态的计划换键操作返回 STATE_CONFLICT;原键原内容重报仍返回原回执。
- 管理操作、不可变版本、事件、审计和幂等回执同事务保存。后台触发持久化 occurrence、task.accepted、schedule.decided、schedule.advanced,记录执行节点。
- 每次扫描也更新 revision。遇到明确 STATE_CONFLICT,先 GET 新状态,再用新的命令身份和新 expected_revision 操作;网络结果不明时必须复用原键和原版本核对。
- 同键更改定义、reason 或 expected_revision 返回 IDEMPOTENCY_CONFLICT。原回执不表示计划当前仍是该状态。
- 计划名称不能修改,暂无删除或单次手动触发接口。WAITING 的已生成计划任务可用任务取消接口取消,计划本身继续运行;流程归属任务仍由后续流程控制管理。
## 5. 列表与触发记录
ListSchedules 需要 queue_id;计划列表和 occurrences 均可用 limit(默认 20,1–100)、cursor(最多 1024 字符)。未知、重复查询参数或错误游标返回 400。详情接口不接受查询参数。
计划按 `(created_at DESC,id DESC)`,触发按 `(scheduled_at DESC,id DESC)` 分页,不使用 offset。游标绑定命名空间、队列或计划及查询类型。读取不承诺跨页快照;空列表为 items: [],有下一页才返回 next_cursor。
```json
{
"request_id":"44444444444444444444444444444444",
"data":{"items":[{
"id":"55555555555555555555555555555555","schedule_id":"22222222222222222222222222222222",
"schedule_version":"1","scheduled_at":"2026-10-01T00:00:00Z","decision":"COALESCED",
"task_id":"66666666666666666666666666666666","covered_until":"2026-10-01T00:03:00.000001Z",
"reason":"MISFIRE_COALESCED","created_at":"2026-10-01T00:03:00Z"
}]}
}
```
FIRED 表示已创建单点任务;COALESCED 表示范围已合并到 task_id;SKIPPED 没有 task_id。它们都不是业务执行结果,继续 GetTask 查询,再通过 [尝试与证据接口](execution-history.md) 核对执行。
从任务反查计划使用[GetTaskSource](task-source.md),返回来源计划、触发记录、当时固定版本和合并边界。该只读引用需要tasks:read,完整计划/版本内容仍需schedules:read;发布新版本不改变老任务来源。
## 6. SDK 与完整示例
所有方法首参数为 context.Context,写操作需要稳定 key。ListSchedules 的参数为 `(ctx, queueID, SchedulePageRequest)`;ListScheduleOccurrences 为 `(ctx, scheduleID, SchedulePageRequest)`。
在已有服务和当前迁移基础上,创建专用开发凭据:
```sh
go run ./cmd/orbit-admin bootstrap \
--application schedule-demo --namespace default --environment development \
--queue snapshots --worker-access --schedule-access \
--output .local/schedule.credentials.json
cd examples
ORBIT_URL=http://127.0.0.1:8080 \
ORBIT_CREDENTIALS_FILE=../.local/schedule.credentials.json \
go run ./schedules
```
前置数据库与目录步骤见 [SDK 接入](../sdk/README.md)。源码:[完整调度示例](../../examples/schedules/main.go)。示例创建暂停的每秒计划,恢复后让自动 Worker 返回对象输入快照,查询两次完成,再暂停并排空。计划本身有十秒结束边界,任务与历史保留;重复运行生成独立身份,不调用外部业务系统。
```text
source_kind=SCHEDULE fixed_version=1
schedule=<计划ID> occurrence=<触发ID> task=<任务ID> version=1 state=SUCCEEDED
source_kind=SCHEDULE fixed_version=1
schedule=<计划ID> occurrence=<触发ID> task=<任务ID> version=1 state=SUCCEEDED
paused=true completed_tasks=2 current_version=1
worker_drained=true running=0
```
调度或查询有延迟时可能观察到更多已产生的任务。异常时非零退出,Worker 尝试有界收尾;暂停不回滚已产生任务。接入函数可直接用以下片段发布 Cron 版本:
```go
current, err := client.GetSchedule(ctx, scheduleID)
if err != nil { return err }
definition := current.Current.Definition
definition.Rule.Kind = "CRON"
definition.Rule.Cron = "0 9 * * MON-FRI"
definition.Rule.IntervalMS = 0
definition.Rule.Timezone = "Asia/Shanghai"
updated, err := client.PublishSchedule(ctx, scheduleID, operationKey, orbit.PublishScheduleRequest{
ExpectedRevision: current.Revision,
Reason: "use weekday snapshot cadence",
Definition: definition,
})
if err != nil { return err }
fmt.Println(updated.Schedule.CurrentVersion)
```
保留原 calendar_version,并核对 start_at/end_at 是否仍覆盖期望时间。发布时不会取消旧版本已创建任务。
## 7. 错误与升级
错误信封同 [任务接口](tasks.md)。400 表示字段、时间、时区快照、游标或队列变更非法;401 表示凭据/应用/命名空间不可用;403 表示缺少动作或列表/创建队列范围;404 隐藏不存在或单资源越权;409 表示名称、状态、版本或幂等内容冲突;503 时写操作可能已经提交,应保留原身份核对。
迁移 000006 增加绑定队列、历史处理版本、扫描时间、窗口与容差,以及调度/任务来源索引。此前只有结构、没有创建计划接口;原人工行的 queue_id/check_at 为 NULL,不自动启用。迁移不会猜测任务模板对应的授权队列;此类行需由管理员核查后通过正式接口建立受控计划。
服务仍只依赖 MySQL,不需要外部 Cron、消息队列或协调服务。生产跨主机、数据库切换、在线迁移、海量积压与公平性测试尚待 P6;当前计划能力不代表完整生产验收已通过。