任务中心与日志
本文是 1.x 的归档文档(冻结于 1.x)。该分支只接收勘误修正, 内容基本不再变动;文中的源码路径、配置项与版本号均以 1.x 为准,不建议用于新项目。
新项目请查看 5.x 最新文档。已在 1.x 上运行的项目,本文仍是可靠依据。
这一页面向排障:任务记录里的每个字段由谁写入、进度台阶是哪几行代码推出来的、卡死任务由谁收尾、拿到一条失败任务按什么顺序读。
记录存在 app_install_task(DDL 与完整字段清单见 数据库参考)。入口是我的应用 → 应用任务(路由 /myapps/task,源码 plus-ui/src/views/appstore/tasks/index.vue),权限沿用 app:install:list。
先分清:哪些失败没有任务记录
任务行由 AppInstallServiceImpl.runTask 插入,而参数校验与状态机前置校验都发生在插入之前。所以有一类失败在任务中心里永远查不到:
| 失败时机 | 典型文案 | 有任务记录吗 |
|---|---|---|
| 参数校验 | 租户编号与应用编码不能为空 | 无 |
| 已在目标状态 | 应用已处于「已启用」状态,无需重复操作 | 无 |
| 版本相同 | 当前已是该版本,无需升级: 1.0.1 | 无 |
| 状态机前置校验 | 非法的应用状态迁移: enabled -> installing(AppLifecycleStateMachine.transit 抛 IllegalStateException) | 无 |
| 动作执行中或之后 | 写在 error_msg 里的服务端异常文本 | 有,status=failed |
判据是代码里的先后顺序:AppInstallServiceImpl.runTask 先算 successPathOf、逐跳过 AppLifecycleStateMachine.transit,全部通过后才 appInstallTaskMapper.insert(task)。所以「任务中心里没有这条」本身就是一条信息 —— 它说明请求没走到动作执行。
接口同步抛异常时前端拿不到 taskId(例如后端插件未加载就拒绝安装)。但任务行在动作执行之前就已落库,所以失败弹窗会把你送去「应用任务」。见 在部署里使用应用。
字段:谁写的、排障时看它干什么
| 字段 | 写入者 | 排障时看它 |
|---|---|---|
task_id | 雪花 ID(MyBatis-Plus ASSIGN_ID) | 单调递增,列表按它倒序 = 最新的在最上面(不依赖 start_time,同一秒可能有多条) |
install_id | runTask | 指向 app_install。应用名是事后反查出来的(install_id → app_install.app_id → app_info,走 IAppCatalogService),刻意不写 JOIN,所以安装记录删掉后历史任务仍读得出来 |
task_type | 枚举 AppTaskType | install / upgrade / enable / disable / uninstall;枚举里还有 repair / rollback,但 successPathOf 对这两个直接抛 尚未实现的生命周期动作,实际不会产生记录 |
from_version / to_version | 调用方传入 | 见下一张表,卸载的 to_version 是 NULL |
current_step | AppTaskRecorder.step / 终态回写 | 措辞面向用户,直接对应台阶名;失败时会被改写成「失败」,见下面的 warning |
progress | AppTaskRecorder.step | 0–100,只增不减 |
log_text | AppTaskRecorder | 真正的失败现场。格式见下一节 |
status | AppTaskStatus | running / success / failed / canceled(canceled 有枚举、当前没有代码写入) |
error_msg | markFailed / 清扫器 | 服务端异常文本,多数情况下原因就在这一句 |
start_time / end_time | runTask / markFailed / 清扫器 | 详情里的 durationMs 由它们算:end_time 为空时按「到现在」算,所以运行中的任务也有耗时 |
tenant_id / create_by / create_time / update_by / update_time | 实体 extends TenantEntity,由平台自动填充 | update_time 是超时清扫的判据(见下) |
各任务类型的版本两列取值:
| 任务类型 | from_version | to_version |
|---|---|---|
install | NULL | 安装的目标版本 |
upgrade | 升级前的版本 | 目标版本 |
enable / disable | 当前版本 | 当前版本(两列相同) |
uninstall | 当前版本 | NULL |
接口返回的是 VO(AppInstallTaskVo),比实体多 taskTypeLabel / statusLabel / durationMs / appCode / appName / appIcon / appMissing。列表接口不带日志正文 —— toTaskVos 里显式 vo.setLogText(null),日志只在详情接口返回(一条日志以 KB 计,一页几十条就是几 MB)。
appMissing=true 说明反查不到应用名(安装记录已被「彻底删除」物理删掉,而任务记录刻意保留当审计线索),列表里显示「(应用已删除)」。
进度台阶:每一步是谁写的
进度由 AppTaskRecorder.step(名称, 进度) 推进,调用点固定在这几处:
| 进度 | current_step 的写法 | 写入者 |
|---|---|---|
| 10 | 执行安装 / 执行升级 / 执行卸载…("执行" + taskType.getLabel()) | AppInstallServiceImpl.runTask |
| 20 | 从应用市场拉取安装包 | PluginAppInstaller.ensureArtifactsInstalled |
| 30 | 执行版本迁移脚本 | PluginAppInstaller.applyMigrations |
| 40 | 校验后端产物:<backend 相对路径>;纯前端应用写作 校验后端产物 | PluginAppInstaller.assertBackendLoaded |
| 60 | 同步菜单与授权 | PluginAppInstaller.install / upgrade |
| 100 | 完成 | AppInstallServiceImpl.runTask 成功路径 |
三条会让人误判的规则:
- 第 ② 步会整步跳过。同版本重装、且前后端产物都已在磁盘上时,
ensureArtifactsInstalled直接返回,只在日志里记一句「前后端产物均已在磁盘上,跳过拉包(将在迁移脚本之后加载)」。所以进度从 10 直接跳到 30 是正常的,不是丢了步骤。 - 只有插件型应用有中间台阶。
app_type=module的应用(system/myapps这类,登记在script/sql/app_catalog_seed.sql)走ModuleAppInstaller,里面没有任何step调用;启用 / 停用 / 卸载也同理。这些任务只有 10 与 100,过程细节全在[INFO]行里 —— 看日志,不要看进度条。 - 进度只增不减。
step(名称)不带进度时不动progress;带进度时取Math.min(100, 进度)。
current_step 的取值只有这么几种:初始 开始;成功 完成;失败 失败(markFailed 覆盖掉原来那一步的名字);超时收尾 超时失败。
也就是说,失败任务的 current_step 里看不到它当时走到哪一步。正确读法是 log_text 里最后一条 步骤 N: … —— 那是最后一次真实进入的步骤。
日志行的格式
AppTaskRecorder 每写一行都会拼上 [HH:mm:ss] 前缀并立刻 updateById,没有节流(步骤数个位数、日志以 KB 计,多一次 UPDATE 的代价远小于「进度卡住不动」):
| 行的形状 | 谁写的 |
|---|---|
[HH:mm:ss] 步骤 N: 名称 | step。N 是该任务内自增的序号,从 1 开始,前端据此显示「第几步」 |
[HH:mm:ss] [INFO] 文本 | info |
[HH:mm:ss] [WARN] 文本 | warn(不影响成功、但操作者应当知道的事:旧 jar 删不掉、共享闸跳过了删除…) |
[HH:mm:ss] 安装成功 | runTask 成功路径(taskType.getLabel() + "成功") |
[HH:mm:ss] 失败: 原因 | markFailed |
[超时] 原因 | 清扫器追加,注意这一行没有 [HH:mm:ss] 前缀 |
形状示例(合成,用于说明格式,不是抓取的真实日志):
[10:00:01] 步骤 1: 执行安装
[10:00:02] 步骤 2: 从应用市场拉取安装包
[10:00:03] [INFO] 已从市场拉取并部署:释放 12 个文件,后端加载=推迟到迁移之后,迁移脚本 1 个
[10:00:04] 步骤 3: 执行版本迁移脚本
[10:00:04] [INFO] 已执行: 01-create-example-tables.sql
[10:00:05] 步骤 4: 校验后端产物:backend/wujies-example-plugin-1.0.0.jar
[10:00:05] [INFO] 后端产物已加载(插件运行时):wujies-example-plugin-1.0.0.jar
[10:00:06] 步骤 5: 同步菜单与授权
[10:00:07] 安装成功三条不能改坏的实现约束(改这块代码时别破坏):
- ThreadLocal 传递,不改
AppInstaller的接口签名 —— 安装任务在单线程上同步执行(runTask→action.run(context)),而AppInstaller是已对外发布的扩展点契约。 - 任务结束必须
end()(在finally里)。线程池会复用线程,不解绑会让下一个任务把日志写进上一个任务的记录里。 - 进度上报独立于业务事务。任务失败回滚业务数据时日志必须留下来。
进度上报被两层 safeRecord 包着(AppInstallServiceImpl 与 PluginAppInstaller 各一份),只记 WARN、绝不影响业务;AppTaskRecorder.flush 自己写库失败也只打日志。
所以日志不完整时,去服务端日志里搜这两句:任务进度上报不可用(不影响本次操作) 与 任务进度写入失败: taskId=…。真实踩到过的例子是类路径不完整(jar 未更新、部分重编译)时记录器本身抛 NoClassDefFoundError —— 业务逻辑没有任何问题,但一次正常的卸载变成了 500。
接口与直接查库
| 用途 | 接口 | 权限 |
|---|---|---|
分页列表(不含 log_text) | GET /app/install/task/list,条件 status / taskType / installId | app:install:list |
详情(含 log_text) | GET /app/install/task/{taskId} | app:install:list |
| 删除一条记录 | DELETE /app/install/task/{taskId} | app:install:edit |
浏览器里读不方便时直接查库(表就是 app_install_task):
-- 某次安装/卸载的全部任务(最新的在最上面)
SELECT task_id, task_type, from_version, to_version, status, progress,
current_step, error_msg, start_time, end_time, update_time
FROM app_install_task
WHERE install_id = ? ORDER BY task_id DESC;
-- 单条任务的日志正文
SELECT log_text FROM app_install_task WHERE task_id = ?;界面侧的两个行为(都在 plus-ui/src/views/appstore/tasks/index.vue):
- 轮询,不是订阅:
setInterval(tick, 1500),且只在「列表里有running任务」或「日志抽屉开着且那条是running」时才真正发请求。想冻住画面逐字读日志,把标题栏的「自动刷新」开关关掉。 ⚠️ 实体AppInstallTask的类注释里那句「前端通过 SSE 订阅进度与日志」是旧文案,任务中心没有 SSE 订阅 —— 别照着去找 SSE 接口。 - 运行中的任务不能删,后端会拒绝:
任务正在执行中,不能删除。可等它结束,或重启服务后再清理。删除只删这条执行留痕,不影响应用的安装状态。
卡死任务由超时清扫收尾
任务状态只被两处改写:runTask 的正常/异常路径,以及 AppTaskTimeoutSweeper。进程在任务执行中途被杀(重启、OOM、容器被驱逐)时不会走前者,那条记录会永远停在 running —— 前端显示「一直在跑」,点删除也被拒,而它早就不会再动了。
| 项 | 值 |
|---|---|
| 判据 | status='running' 且 update_time < now - timeoutMinutes(不用 start_time) |
| 阈值 | app-market.task.timeout-minutes,注解兜底 10 分钟 |
| 调度 | @Scheduled(initialDelayString = "app-market.task.sweep-initial-delay-ms:30000", fixedDelayString = "app-market.task.sweep-interval-ms:60000") |
| 收敛动作 | status=failed、current_step='超时失败'、写 end_time、error_msg 说明原因,日志尾部追加一行 [超时] … |
| 租户上下文 | 整段查询包在 TenantHelper.ignore(...) 里 —— 定时任务没有租户上下文,不 ignore 会一条都扫不到 |
三个键当前都没有写在 wujies-admin/src/main/resources/application.yml 里,运行时取的是注解兜底值;要改就在 app-market: 下新增 task: 段(改完重启)。三个键的逐项说明见 配置参考。
AppTaskRecorder 每推进一步、每写一条日志都 updateById,自动填充会刷新 update_time。所以「update_time 很久没动」= 真的没有进度;用 start_time 会把耗时较长但一直在推进的任务误杀。
清扫本身抛异常不会影响调度线程(catch (Throwable) 在 TenantHelper.ignore 外层,吞掉并打 WARN),下一次还会再来。
AppTaskTimeoutSweeper 只 updateById 任务行,不碰 app_install。若进程是在安装中途被杀的,那条安装记录还是 installing,而状态机里 installing 只能去 installed / failed:
- 再点安装:
install()开头的AppLifecycleStateMachine.transit(from, INSTALLING)直接抛非法的应用状态迁移: installing -> installing,且不留任务记录; - 升级 / 停用 / 卸载同样非法(都不是
installing的合法去向)。
处置:先确认 app_install.status,确实是 installing 时才把它置为 failed(failed → installing / upgrading / uninstalling 是合法迁移),然后重新操作。这是查看过状态机之后才动手的少数手工改库场景之一 —— 先看 应用生命周期 的合法迁移表再改。
拿到一条失败任务:按顺序读四处
log_text里最后一条步骤 N: …—— 真正的卡点(不是current_step,见上面的 warning)。error_msg—— 服务端异常原文。下表是代码里实际会出现的字符串,可以按它反查来源。- 日志尾部的
[WARN]行 —— 失败前的警告往往已经说明了原因(共享闸跳过了删除、旧产物删不掉、后端产物需重启才生效…)。 status+update_time——failed+current_step='超时失败'是清扫器写的;running且update_time很久没动说明进程已被打断,等清扫收尾。
真实存在的错误文本(按出处核对):
error_msg 里的文本 | 出处 |
|---|---|
应用包未签名(缺少 signature.txt)。… | PackageSecurityInspector |
应用包签名校验失败: … | PackageSecurityInspector |
安装包 MD5 不匹配,拒绝部署。期望 …,实际 … | AppPackageDeployService.verifyChecksum |
下载应用包失败(请确认包地址可访问): … | AppPackageDeployService.download |
该版本没有安装包,无法部署 | AppPackageDeployService.deploy |
应用包内没有找到 assets 声明的产物,请检查包结构。期望 backend=…,frontend 目录=… | AppPackageDeployService.deploy |
插件 <appCode> 声明了后端产物 <backend>,但当前 JVM 没有加载 <jar>,拒绝安装。(后面接「jar 不存在,还没落位」或「jar 在但不在运行时 classpath 上」两条互不相同的解法) | PluginAppInstaller.buildNotLoadedMessage |
迁移脚本 <script> 执行失败: … | AppMigrationService |
迁移脚本 <script> 的内容已被修改(摘要与首次执行时不一致)… | AppMigrationService |
启用失败:插件后端无法加载 —— … | PluginAppInstaller.loadRuntime |
没有可处理该应用的安装器,app_type=… | AppInstallServiceImpl.requireInstaller |
应用 <code> 与当前平台不兼容:… | AppInstallServiceImpl.assertEngineCompatible |
按症状查处置(含上面这些错误的下一步动作)在 故障排查;签名相关的处置见 包安全与签名;迁移脚本的规矩见 版本化迁移;插件加载相关的排查见 插件运行时与类加载器。
任务日志只保留给操作者看的那一层:这次操作做了什么、停在哪。类加载、Spring 上下文、MyBatis 映射冲突、远程调用异常栈这类框架级原因要去服务端日志补。
⚠️ 最常用的那份 logs/sys-console.log 只留一天(maxHistory=1),而它是唯一收 WARN 的落盘文件 —— 需要留证就当场复制走。日志位置与文件分工见 运维手册。