Wujies 无界平台
运维

任务中心与日志

本文是 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,不等于没有任务记录

接口同步抛异常时前端拿不到 taskId(例如后端插件未加载就拒绝安装)。但任务行在动作执行之前就已落库,所以失败弹窗会把你送去「应用任务」。见 在部署里使用应用。

字段:谁写的、排障时看它干什么

字段写入者排障时看它
task_id雪花 ID(MyBatis-Plus ASSIGN_ID)单调递增,列表按它倒序 = 最新的在最上面(不依赖 start_time,同一秒可能有多条)
install_idrunTask指向 app_install。应用名是事后反查出来的(install_id → app_install.app_id → app_info,走 IAppCatalogService),刻意不写 JOIN,所以安装记录删掉后历史任务仍读得出来
task_type枚举 AppTaskTypeinstall / upgrade / enable / disable / uninstall;枚举里还有 repair / rollback,但 successPathOf 对这两个直接抛 尚未实现的生命周期动作,实际不会产生记录
from_version / to_version调用方传入见下一张表,卸载的 to_version 是 NULL
current_stepAppTaskRecorder.step / 终态回写措辞面向用户,直接对应台阶名;失败时会被改写成「失败」,见下面的 warning
progressAppTaskRecorder.step0–100,只增不减
log_textAppTaskRecorder真正的失败现场。格式见下一节
statusAppTaskStatusrunning / success / failed / canceled(canceled 有枚举、当前没有代码写入)
error_msgmarkFailed / 清扫器服务端异常文本,多数情况下原因就在这一句
start_time / end_timerunTask / markFailed / 清扫器详情里的 durationMs 由它们算:end_time 为空时按「到现在」算,所以运行中的任务也有耗时
tenant_id / create_by / create_time / update_by / update_time实体 extends TenantEntity,由平台自动填充update_time 是超时清扫的判据(见下)

各任务类型的版本两列取值:

任务类型from_versionto_version
installNULL安装的目标版本
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 成功路径

三条会让人误判的规则:

  1. 第 ② 步会整步跳过。同版本重装、且前后端产物都已在磁盘上时,ensureArtifactsInstalled 直接返回,只在日志里记一句「前后端产物均已在磁盘上,跳过拉包(将在迁移脚本之后加载)」。所以进度从 10 直接跳到 30 是正常的,不是丢了步骤。
  2. 只有插件型应用有中间台阶。app_type=module 的应用(system / myapps 这类,登记在 script/sql/app_catalog_seed.sql)走 ModuleAppInstaller,里面没有任何 step 调用;启用 / 停用 / 卸载也同理。这些任务只有 10 与 100,过程细节全在 [INFO] 行里 —— 看日志,不要看进度条。
  3. 进度只增不减。step(名称) 不带进度时不动 progress;带进度时取 Math.min(100, 进度)。
判断「卡在哪一步」必须读日志,不能读 current_step

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] 安装成功

三条不能改坏的实现约束(改这块代码时别破坏):

  1. ThreadLocal 传递,不改 AppInstaller 的接口签名 —— 安装任务在单线程上同步执行(runTask → action.run(context)),而 AppInstaller 是已对外发布的扩展点契约。
  2. 任务结束必须 end()(在 finally 里)。线程池会复用线程,不解绑会让下一个任务把日志写进上一个任务的记录里。
  3. 进度上报独立于业务事务。任务失败回滚业务数据时日志必须留下来。
日志缺行、或任务「成功但日志是空的」怎么解释

进度上报被两层 safeRecord 包着(AppInstallServiceImpl 与 PluginAppInstaller 各一份),只记 WARN、绝不影响业务;AppTaskRecorder.flush 自己写库失败也只打日志。

所以日志不完整时,去服务端日志里搜这两句:任务进度上报不可用(不影响本次操作) 与 任务进度写入失败: taskId=…。真实踩到过的例子是类路径不完整(jar 未更新、部分重编译)时记录器本身抛 NoClassDefFoundError —— 业务逻辑没有任何问题,但一次正常的卸载变成了 500。

接口与直接查库

用途接口权限
分页列表(不含 log_text)GET /app/install/task/list,条件 status / taskType / installIdapp: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: 段(改完重启)。三个键的逐项说明见 配置参考。

为什么判据是 update_time

AppTaskRecorder 每推进一步、每写一条日志都 updateById,自动填充会刷新 update_time。所以「update_time 很久没动」= 真的没有进度;用 start_time 会把耗时较长但一直在推进的任务误杀。

清扫本身抛异常不会影响调度线程(catch (Throwable) 在 TenantHelper.ignore 外层,吞掉并打 WARN),下一次还会再来。

清扫只收敛任务表,app_install 可能仍停在 installing

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 是合法迁移),然后重新操作。这是查看过状态机之后才动手的少数手工改库场景之一 —— 先看 应用生命周期 的合法迁移表再改。

拿到一条失败任务:按顺序读四处

  1. log_text 里最后一条 步骤 N: … —— 真正的卡点(不是 current_step,见上面的 warning)。
  2. error_msg —— 服务端异常原文。下表是代码里实际会出现的字符串,可以按它反查来源。
  3. 日志尾部的 [WARN] 行 —— 失败前的警告往往已经说明了原因(共享闸跳过了删除、旧产物删不掉、后端产物需重启才生效…)。
  4. 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 的落盘文件 —— 需要留证就当场复制走。日志位置与文件分工见 运维手册。

相关阅读

目录