22 · 独立后台任务:关掉页面后,谁继续完成导出#
客服小陈想导出“客户无法登录”相关工单。点击后页面立即提示“导出已受理”,但下载按钮稍后才出现。与此同时,另一张未解决工单还要在一分钟后提醒负责人。这两件事为什么不在原来的 HTTP 请求里全部做完?关闭页面会不会把工作取消?
第五阶段围绕这两个场景展开:第 22 章认清进程和账本,第 23 章追踪投递,第 24、25 章分别执行提醒和导出,第 26 章分析失败恢复,第 27 章用不同层次的证据验收。文件仍存数据库,通知仍是站内通知。
1. 先预测一次导出的四个时刻#
在专门的学习团队准备管理员小周、客服小陈、客户小林和另一客户。先不启动你自己的开发 Worker,由小陈筛选标题后提交一次导出。
| 时刻 | 可以看到的事实 | 还不能推出什么 |
|---|---|---|
| 点击按钮,接口尚未返回 | 浏览器正在提交筛选 | 服务端一定接受了任务 |
| 返回任务 ID,跳到任务页 | 数据库已保存一条 EXPORT 待办 | CSV 已生成,Redis 一定在线 |
| Worker 开始执行 | 账本 RUNNING,attempts 推进 | 结果一定成功,页面一定看到了中间进度 |
| 账本 COMPLETED,结果可用 | 文件保存成功,符合权限时可下载 | 文件永久保留,所有成员都可下载 |
小任务可能快到轮询只看到 WAITING 和 COMPLETED,没看到 RUNNING 并不证明没执行。状态来自数据库,页面不靠自己计时把进度条“走完”。
2. 沿着点击先找到受理边界#
按下面顺序打开文件并搜索符号:
| 阅读点 | 搜索符号 | 追踪的问题 |
|---|---|---|
| 工单工作台 | exportTask、createExport、router.push | 交了什么输入,成功后去了哪里 |
| 共享输入 | exportFiltersSchema | 为什么没有 page / pageSize |
| router | task.createExport | userId 与 teamId 分别来自哪里 |
| 任务服务 | createExport | 授权后到底保存什么 |
组件提交 status、priority、assignment、q;成功回调显示“已受理”,跳到 /tasks?team=...。接口通过 protectedProcedure 验证身份,schema 验证输入,再把会话中的 userId 交给服务。服务取团队锁、查当前成员、检查导出数量,最后创建 BackgroundTask 并返回 { id }。
这条链路没有等待 CSV,也没有在创建导出的方法里访问 Redis。可以类比提交表单后生成一个订单号,但类比到此为止:这个 ID 表示本项目数据库待办,并没有支付、消息推送等附加能力。
3. 四个部件怎样接棒#
浏览器 → Web → PostgreSQL:保存 BackgroundTask
↑ ↓ 扫描待办
保存结果 Worker 内的 dispatcher
↑ ↓ queue.add
processor ← Redis / BullMQ
浏览器 → task.list → PostgreSQL:读取状态与下载资格Web 负责当前请求,Worker 是另一个 Node.js 进程。Redis 保存 BullMQ 的队列、延迟和重试信息;PostgreSQL 保存任务的业务状态和结果。dispatcher 与 processor 在同一个 Worker 程序里承担不同职责,并非仓库另有一个独立投递服务。
打开 worker/index.ts,进入 runWorkerProcess,顺着 createTaskQueue → createTaskWorker 中的 new Worker → processTask 阅读,再回生命周期看 triggerTick。消费并发是 2;数据库连接池 max=5,给导出读取、进度写入和投递保留连接。并发 2 不代表 PostgreSQL 一次只能处理两条 SQL。
页面关闭只停止这个页面的查询,不撤销账本,也不停止 Worker。反过来,只有 Web 活着,也没有谁会替这个独立进程消费任务。
4. 账本与队列消息为什么内容不同#
BackgroundTask 可按用途分组读:
| 字段 | 含义 |
|---|---|
| id、kind、teamId、ownerId、ticketId | 任务身份、类型、资源与归属 |
| filters、scopeCreatorId、reminderRevision、runAt | 当时受理的业务意图 |
| status、progress、attempts、lastError | 当前处理状况 |
| run、executionId、dispatchedAt | 轮次、提交资格与投递核对时间 |
| resultCsv、resultSummary、expiresAt | 结果及保留期限 |
queue.ts 的消息类型则只有:
// 源码节选
export type TaskJob = { taskId: string; run: number };消息是“处理哪条账本的哪一轮”,不是保存 Cookie 或工单全文的副本。processor 用 taskId 重新查数据库,从而检查任务是否已完成、轮次是否过期、资格是否变化。
WAITING 可以是尚未投递、延迟未到期或等待重试;COMPLETED 也可能是旧提醒正常跳过。必须结合 kind、runAt、resultSummary 判断,不能只把状态翻译成一个成功图标。
5. 自己启动并观察接棒#
按第 01 章准备 .env 后,在项目根目录运行:
pnpm db:up
pnpm db:migrate
pnpm db:generate
pnpm dev另开终端,待完成“先无 Worker 提交一次”的实验后运行:
pnpm workerWeb 与 Worker 要连接同一个 DATABASE_URL、REDIS_URL 和 QUEUE_PREFIX。开发 Compose 的宿主机端口是 PostgreSQL 5433、Redis 6380;容器内地址见部署阶段,不直接照搬 localhost。QUEUE_PREFIX 是队列命名空间,不是团队授权条件。
开发 Compose 为 Redis 配置 AOF 和 noeviction:前者用于持久化,后者避免把队列键当可淘汰缓存删除。内存耗尽仍可导致写入失败;这两项不保证任务永远成功。不要为本实验清空 Redis,也不要停止别人使用的 Worker。
6. 页面怎样知道任务进展#
TasksWorkspace 在正常活动页分别约每 2.5 秒查 task.list、5 秒查 notifications、10 秒查 health。轮询是重新读取,不是订阅完成事件;后台标签页也不承诺同样频率。
任务列表服务先查当前团队成员:导出只列调用者自己的;工作人员还可看本团队提醒,客户不看工作人员提醒。管理员能看团队工单,不等于能下载另一人的导出文件。
health.ts 检查 Redis 和近期 Worker 心跳。Worker 在成功投递扫描且没有开始退出时,续上 20 秒有效期的独立进程登记;心跳集合按有效成员判断,SIGTERM 主动撤销自己的登记。详见第 30 章。因此刚启动、心跳未过期、查询缓存等都可能影响看到的时间。绿色健康状态说明有近期活动,不能证明指定 CSV 的内容正确。
7. 两种故障先分清边界#
故障 A:有任务 ID,但一直等待。 在 Prisma Studio 只读查该 ID 的 status、runAt、dispatchedAt。若 runAt 已到,再核对两个进程的连接配置、Worker 日志和 Redis 健康。不要反复提交;新请求会创建新任务,还可能触发限流。
故障 B:关页面后再打开,看起来任务消失了。 先核对账号、团队和任务列表分页,再按 ID 查账本。数据库未丢任务时,问题可能是查看范围变化。查询报错和真正的空列表也要区别处理。
拓展阅读为什么不用页面 setTimeout 或请求结束后的 Promise?
浏览器计时器依赖页面,Web 进程内未持久化的异步工作依赖该进程存活。重启后,两者都没有自动恢复待办的依据。独立 Worker 加账本把“工作还存在”与“当前谁来做”分开,但仍需要后续章节的恢复规则。
8. 动手记录与练习#
提交一次导出,记下任务 ID;Worker 停止时检查 WAITING,启动后检查 resultSummary 与下载入口。离开页面再回来,确认读取同一条任务。若任务太快,不要求眼睛捕捉所有中间状态。将“请求返回时间”和“账本完成时间”分开记。
练习 A:接口已返回 ID,Redis 随后离线,是否应重新提交#
动手练习查看参考答案
参考答案:先查原账本。已受理的任务仍有投递恢复依据,重新提交会产生另一个任务。恢复连接并观察原任务,不能把队列故障当成提交一定失败。
练习 B:Worker 日志在执行,页面却看不到那条任务#
动手练习查看参考答案
参考答案:用 taskId 核对数据库、账号、teamId、列表分页和任务种类。日志可能来自另一个环境或另一个人的任务;前端按资源权限过滤是正常行为,不能直接放宽列表权限来修复。
练习 C:为什么不能看到 COMPLETED 就提示“已通知负责人”#
动手练习查看参考答案
参考答案:EXPORT 完成表示导出保存,REMINDER 完成也可能是取消后跳过。要根据 kind 和 resultSummary 展示结果;即使生成站内通知,也不代表用户已读。
本章链路:页面提交筛选 → 接口验证输入与成员 → 数据库保存任务 → Worker 投递与执行 → 数据库保存结果 → 页面按权限轮询读取。 下一章专门检查数据库和队列之间的缝隙。