---
name: job-engine
description: Microi 定时任务与可靠后台任务规范。用于配置 Microi.Job、Quartz 和接口引擎任务，设计多节点租约、幂等、重试、停机排空、恢复、进度与验收。
---

> **Codex 非阻塞自动更新：** 当前宿主为 Codex 时，吾码 CLI、Codex 插件与工作区 AI/MCP 由后台自动更新；需要诊断时读取 `../microi-codex-installer/SKILL.md`。更新失败、等待空闲或尚未重载均不得阻断当前、正在进行或新建任务。非 Codex 宿主跳过此项。

# Microi Job 定时与后台任务

## 何时使用

- 周期扫描、补偿、归档：`Microi.Job`
- 用户触发的长耗时安装/导入/同步：菜单后台任务
- 可靠跨服务异步：MQ + outbox/inbox
- 请求内等待外部结果：`await`，不要创建后台线程

禁止用后端 `setTimeout`、`Task.Run`、`static bool` 或本机定时器承载可靠业务。

## 平台对象

Quartz 管理能力包含查询、添加、更新、暂停、恢复、删除任务。V8 中的 `V8.Method.ManageScheduleJob` 用于能力发现、任务查询和受控管理，必须绑定当前租户的管理员身份。任务配置、接口引擎代码和执行日志属于控制面，只允许 `Level >= 9999` 维护；业务用户只能触发明确授权的后台动作。

任务通常调用稳定的 `ApiEngineKey`。接口引擎返回 `Code=1` 只表示本次执行成功，不代表调度系统可忽略重试与幂等。

## 多节点设计

### 执行日志与只读诊断

- 新执行/失败/跳过记录使用 `ScheduleExecutionLog` 进入系统日志队列，落入 `sys_log_<tenant>/log_yyyyMM`；固定 `TargetType=ScheduledJob`、`TargetId=JobName`，`EventId` 幂等重放。日志队列失败不得改变业务结果，不再回写关系库日志表。
- 表单使用字段级 Tabs，分别放置“运行日志”和“历史日志”只读表格；仅显示的页签发起查询。历史 `diy_schedule_job_log` 保留，不自动删除、搬迁或清空。
- `platform-schedule-job` 的 `logs/historylogs` 要求任务名、月份，每页最多 100 条，按时间+Id 游标多取一条判断 `HasMore`，不计算多年数据总数。Mongo 索引为 `(TargetType,TargetId,CreateTime,EventId)`；历史表索引 `(JobName,CreateTime,Id)` 通过声明式应用包交付并现场回读。
- 用 `microi_query_job_runtime` 或 `microi_run_engine` 的 `Action=diagnostics` 核验启动、待机、实际执行数、领取进展、设置读取失败、触发器与心跳。一个租户的设置读取超时只能关闭该租户，不能拖住其它租户；每租户最多一个在途读，不能按轮询叠加请求。
- MongoDB 连接或协议不兼容必须显示查询失败，不能伪装为空日志；先验证目标 Mongo 与驱动兼容。镜像更新不等于 Mongo 升级，队列接受不等于日志已持久化，spool 应在持久卷并验收恢复重放。

### 运行时间刷新与配置版本分离

- Quartz 周期同步 `LastTime/NextTime` 是运行状态投影，不能调用通用 `UptFormData` 生成配置版本或数据日志；用户保存名称、Cron、代码等配置仍走原表单事件和版本链路。
- 只读取 Id、任务名和两个时间列；时间未变化时不写入。变化时由可信调度内核向同一租户主库执行固定两列参数化 CAS，匹配原时间、任务名、正常状态与未删除条件；并发/陈旧结果命中 0 行时不重放。
- 历史版本保留不自动删除。持续高 CPU 时核对 `mic_data_version` 实际物理索引，不以应用包已声明索引推断旧库已安装；用 MCP 回读 `(TableId,TableRowId,CreateTime)`，并比较 MySQL digest 两次采样的执行数、扫描行数与耗时差值。
- 此修复要求更新调度后端；既有 `platform-schedule-job`、任务元数据及 MCP 查询/保存协议保持兼容。验收覆盖两租户同名任务、两连接竞争、未变化、暂停/软删除/重命名后陈旧写入、主库选择和配置字段不变。

### 新旧平台共库过渡：停用新版调度

- 系统设置“开发配置”的 `DisableTaskScheduling`（停用任务调度）默认 `0`，缺字段、空值也按正常调度处理；只有已实现此能力的新版后端识别它。字段由“系统设置”应用和 SaaS 空库基础包交付，不写租户配置值，不逐条 Pause/修改任务状态。
- 开启时在 Quartz 领取触发器、处理 Misfire 之前按租户过滤；领取后、真正触发前重新读取系统设置缓存。禁止仅在 `IJob.Execute`、Listener veto 或业务 V8 入口 return：共享 Quartz 可能已经推进 NextFireTime，造成旧版漏执行。
- 集群故障恢复也不得清理停用租户的在途记录或创建其补偿触发器。Quartz 以故障节点为单位清理记录，所以同一故障节点混有被停用租户时，新版推迟该故障节点整组恢复，交给旧节点或关闭开关后处理；其它健康节点仍正常领取。
- 正常保存系统设置在事务提交后失效缓存，下一次调度检查生效，不用重启；不是强制中断，已经开始的任务允许完成。缓存/配置读取失败拒绝该租户的新领取，不影响其它健康租户；Redis/失效通知异常时不能承诺硬实时。
- 停用期间只读观察周期计划，不推进共享触发器；每个“租户 + Job + Trigger + 计划时间”向 Mongo 日志队列提交确定性事件，包含 `Status=Skipped`、`Executed=false`、`Reason=SystemTaskSchedulingDisabled` 和中文说明。Redis 原子预留与 Mongo 确定性主键共同防止多新版节点重复记录；日志说明仅代表新版未执行，旧版仍可能正常执行。不补跑业务、不补写进程停机历史；新增/修改计划的日志目录最多约 10 秒更新。
- 普通接口调用、MQ 消费、升级/应用安装等持久后台任务不属于此开关的范围。手动触发 Quartz 任务仍经过门禁。
- 交付顺序：先安装字段并打开开关，再让新版节点参与调度；确认旧版所有调度进程已停止且在途任务完成后，关闭开关交接。不同业务库分别设置，不能用一个租户的值控制全部租户。
- 验收至少覆盖：默认兼容、开关实时读取、租户隔离、共享库旧节点继续领取、领取后开关变化不推进触发器、Misfire 不误推进、多节点日志去重；不得在真实生产任务上制造副作用做测试。

每个任务必须同时具备：

1. 分布式租约：Key 至少含 `OsClient + JobKey + 计划时间/业务分片`，有唯一持有者、TTL、续租和仅持有者释放。
2. 业务幂等：稳定 `IdempotencyKey/EventId`、数据库唯一约束或条件状态迁移。
3. 可恢复状态：待处理、处理中、成功、失败、下次重试时间写共享数据库/Redis/MQ。
4. fencing：锁可能过期的资金、库存等任务使用版本号/条件更新拒绝旧持有者写入。

锁只能减少并发，不能替代幂等。

## 任务骨架

```js
// 接口引擎由 Job 调用；JobRunId/FireTime 由调度层传入
var idempotencyKey = String(V8.Param.JobRunId || '');
if (!idempotencyKey) return { Code: 0, Msg: '缺少 JobRunId' };

// 推荐调用专用后端能力，以唯一约束抢占执行记录
var claim = V8.FormEngine.AddFormData('job_execution', {
  JobKey: 'daily_order_summary',
  IdempotencyKey: idempotencyKey,
  Status: 'Running'
});
if (claim.Code !== 1) return { Code: 1, Msg: '已执行或正在执行' };

// 分页处理；每个业务副作用仍需自己的幂等键
return { Code: 1, Data: { IdempotencyKey: idempotencyKey } };
```

实际项目优先由数据库唯一索引和接口引擎事务完成抢占，不能仅用“先查再新增”。该唯一索引必须声明在 Manifest `tables[].indexes`，并用 `microi_create_table_index` 创建、`microi_get_table_indexes` 回读；禁止在 Job/V8 内手写 `CREATE INDEX`。任务扫描还应按实际 SQL 建立 `(OsClient, Status, NextRetryTime)` 或 `(OsClient, JobKey, ScheduleTime)` 等组合索引。

## 失败、重试与停机

- 先证明任务真的在执行：读 `Scheduler.IsStarted`、`Scheduler.NumberOfJobsExecuted` 和 `Acquisition.LastAcquisitionError`，参考 `microi.doc/docs/doc/system-engine/job.md` 的“任务不执行”。`diy_schedule_job.Status=正常`、`diy_schedule_job.NextTime` 和“插件启动成功”都不能证明触发成功；列表/详情页的 `LastTime/NextTime` 来自运行时，直接查表看到的是库内快照。
- 领取错误含 `Key 'IDX_microi_job_T_NFT_ST' doesn't exist` 时，是 Quartz 3.19 MySQL 方言依赖 `USE INDEX (IDX_{tablePrefix}T_NFT_ST)` 与 `IDX_{tablePrefix}T_NFT_ST_MISFIRE`，而触发器表缺少这两个索引（常见于历史租户库仍是旧 `QRTZ_` 前缀索引名）。平台调度器初始化会按前缀幂等补齐；人工抢修可直接执行文档中的两条 `CREATE INDEX`，索引名大小写不敏感，补完后无需重建任务或 Cron。
- 失败记录错误分类、重试次数和 `NextRetryTime`，采用有上限退避；永久错误进入人工处理。
- 外部调用设置超时；无法确认对方是否成功时用业务幂等号查询，不盲目重发。
- 服务停机先停止接单，再在有限宽限期排空或持久化；重启扫描未完成任务。
- 若要求 `kill -9` 前也零丢失，业务成功响应前必须获得共享 outbox/MQ/WAL 持久化确认。

## 后台按钮

满足任一条件即按后台任务设计：预计超过 2 分钟、500 条以上、1000 个以上扇出子操作、100 次以上外部调用、总量未知且可能持续运行，或安装/初始化/批量导入/批量生成/全量同步/迁移/备份。预计超过 10 分钟时，仅设置 `RunBackground=true` 仍不够，必须按 checkpoint 分片，每片独立事务。

菜单按钮设置 `RunBackground/BackgroundTask/IsBackgroundTask=true` 和 `ApiEngineKey`，并配置 `BackgroundTaskOptions`：

- `IdempotencyKey` 或 `IdempotencyKeyFields`：跨节点、重试和重复点击保持稳定。
- `ConcurrencyKey`：DDL、安装等不能并行的工作使用同一租约组。
- `BusinessTable + BusinessId`：关联业务记录。
- `BusinessStatusField + BusinessTaskIdField`：业务记录至少标记“后台处理中”和任务 Id；推荐再配置 `BusinessProgressField + BusinessEtaField`。

按钮提交成功后，平台前端会通过当前用户的 `V8.FormEngine` 权限把业务记录标记为“后台处理中”并写入任务 Id；后台服务不能直接相信客户端字段名而绕过表单权限。接口引擎仍必须在最后一片或异常补偿中把该业务记录改成“已完成 / 失败 / 已取消”，并保留任务 Id 供详情追溯：

```js
var task = V8.Param._BackgroundTask || {};
if (task.BusinessTable && task.BusinessId) {
  var patch = { Id: task.BusinessId };
  patch[task.BusinessStatusField] = '后台处理中';
  patch[task.BusinessTaskIdField] = task.Id;
  V8.FormEngine.UptFormData(task.BusinessTable, patch);
}
```

不得让通用后台服务按客户端传入的任意表名/字段名直接写库；需要脱离前端自动标记的专用任务，应在受控接口引擎中使用固定表名和固定字段名。

接口通过 `V8.Method.UpdateBackgroundTask({Current,Total,Msg,Log})` 上报已提交的真实工作量，`Log`/`AppendLog` 用于追加任务详情（不得包含密码、Token 或密钥）。平台按实际吞吐计算 `EstimatedEndTime`；总量未知时不传 `Total`，通知中心显示“不定进度/估算中”，禁止用固定 10%、阶段占位或计时器伪造进度。失败和取消停在最后真实进度，不得显示 100%。

分片接口在仍有后续工作时返回：

```js
return {
  Code: 1,
  Data: {
    BackgroundTask: {
      HasMore: true,
      Checkpoint: { LastId: lastId },
      Current: committedCount,
      Total: totalCount,
      NextDelaySeconds: 1,
      Msg: '本批已提交，等待下一批'
    }
  }
};
```

最后一片返回普通 `Code:1`。每个业务副作用还要用 `_BackgroundTaskIdempotencyKey + 业务行Id` 建唯一约束；`_BackgroundTaskFencingToken` 用于拒绝租约过期旧执行者的写入。

## 长任务的 Jint 预算

后台 Worker 最终仍调用接口引擎，因此每个执行片段都受 `Timeout`、`MaxStatements`、单层累计分配预算、根调用树累计分配预算、JavaScript 递归和接口嵌套深度限制。`LimitMemory=2048` 表示当前片段累计分配了多少托管字节，不表示实时占用或预留 2GB 物理内存。

- 总任务运行 10 分钟、30 分钟或数小时是允许的；单个连续 Jint 调用不应承担全部时长。
- 每片在预算内提交事务并返回 `HasMore + Checkpoint`；Worker 重新入队后会创建新的 Jint Engine，新片重新获得超时、语句和累计分配预算。
- `V8.ApiEngine.Run` 的多层编排可以保留。新版父子单层分配隔离后，子接口不会被所有祖先重复计费，但根调用树仍有整体预算；循环调用由独立的嵌套深度上限终止。
- 捕获失败时读取 `DataAppend.V8Limit.Code`。内存/调用树/语句/超时分别缩小批次，递归错误修复函数递归，嵌套深度错误检查循环编排；不要统一归因于服务器资源不足。
- 可记录 `V8.Limits` 到脱敏诊断日志，但不要在每条业务数据上重复输出。
- 接口引擎和表后端事件分别使用正向 `sys_apiengine.V8Limit`、`diy_table.V8Limit`；二者默认 `0`，不设置 Jint 单片预算，只有明确需要限制单片时才设为 `1` 并配置超时、语句、分配和递归值。后台任务仍优先 `HasMore + Checkpoint`，因为不限 Jint 预算不等于数据库长事务、进程常驻内存、取消、并发或节点故障风险消失。

## MCP 工作流

1. 读取表、接口引擎和现有任务。
2. 先设计幂等键、状态机、租约和补偿。
3. `microi_save_job` 保存任务，写入需明确确认。
4. 回读任务 cron、启用状态、Key、接口引擎。
5. 两节点同时触发、重复投递、持有者中止、Redis 故障和滚动升级验收。

## 验收清单

- [ ] 任务配置仅管理员可改
- [ ] 两节点同一时刻触发，业务副作用仅一次
- [ ] 重复消息/请求不会重复扣减或生成流水
- [ ] 锁持有者退出后可恢复，无永久死锁
- [ ] 失败可重试、可追踪、可人工补偿
- [ ] 新旧版本滚动共存，状态和消息合约兼容
- [ ] 未知总量不显示假百分比；已知总量由 Current/Total 唯一推导
- [ ] ETA 来自真实吞吐，样本不足时明确显示“估算中”
- [ ] 业务记录可通过 BackgroundTaskId 跳转通知中心排查
- [ ] 超过 10 分钟的任务有 checkpoint，重启后从最后已提交批次恢复
- [ ] 单片低于超时/语句/累计分配预算，任务总时长不依赖放大单次接口上限
- [ ] 嵌套接口没有循环调用，且错误日志包含结构化 `V8Limit` 分类和调用路径
