24 · 超时提醒:旧任务醒来后,为什么不能照旧通知#
小林的新工单默认明天提醒。小周为了跟进,把它改为一分钟后提醒;半分钟后又取消了。Redis 中早先的延迟 job 可能仍在,难道一分钟后还应该通知小陈?另一种情况:提醒设置没变,只是负责人从小陈换成小赵,通知该发给谁?
本章从这两种变化出发,追踪“提醒意图”和“当前责任人”如何分别判断。请使用新建的学习工单;以下版本推演不混入第 19 章那张已经关闭的工单。
1. 先区分三个编号和一个时间#
| 值 | 回答的问题 | 哪种变化会影响它 |
|---|---|---|
| Ticket.version | 用户看到的整张工单是否过期 | 回复、分配、状态、提醒等有效修改 |
| reminderRevision | 这份提醒安排是不是最新意图 | 设置、取消、解决 / 关闭、重新打开 |
| BackgroundTask.run | 同一任务是否进入手动重试新轮次 | 对 FAILED 任务手动重试 |
| dueAt / runAt | 工单当前到期时间 / 此任务计划时间 | 设置或重新计算时限 |
只用 Ticket.version 检查提醒,会使客户补一条回复也把提醒废掉;只看 task.run,又分不清“同一个工单已经改了提醒安排”。这些数字都在变化,却没有相同含义。
2. 沿页面、schema 和服务追踪设置#
打开 ReminderSettings,顺着 minutes → schedule.mutate → onSuccess 阅读。设置提交 { teamId, id, version, minutes },取消提交 minutes: null;不是 0。输入框初始显示 60 分钟,不表示数据库已经设置成 60 分钟,实际到期展示取 dueAt。
reminderScheduleSchema 要求 minutes 为 1~10080 的整数或 null,version 为非负整数。router 的 task.schedule 使用会话身份,合并团队 schema。
任务服务 的 schedule 依次取得团队锁、requireTicketAccess、拒绝客户、拒绝已解决 / 关闭、检查 version,再更新工单:
// 源码节选:schedule 中的更新内容
data: {
dueAt:
input.minutes === null
? null
: new Date(Date.now() + input.minutes * 60000),
reminderRevision: { increment: 1 },
version: { increment: 1 },
},同一事务继续写 REMINDER_SCHEDULED 事件,再 scheduleReminder。取消也记录这个 kind,reason 说明“取消超时提醒”,不能只凭 kind 断定新建了一个延迟任务。
成功回调失效详情、事件、任务列表;错误回调也失效详情,方便读取新版本。页面隐藏操作、InputNumber 范围与禁用按钮改善体验,服务检查仍是最终边界。
3. 用新工单逐步推演取消和重设#
outbox.ts 的默认时限是紧急 1 小时、高 4 小时、普通 24 小时、低 48 小时。从同一 now 加固定小时数,不扣除夜晚或节假日;旧工单不会仅因这段代码存在就自动追补时限。
新普通工单,暂停实验 Worker 时应看到:
| 动作 | 工单 version / reminderRevision | 数据库待办 |
|---|---|---|
| 创建 | v0 / r0,dueAt 约为创建后 24 小时 | T0,r0,WAITING |
| 管理员设 1 分钟 | v1 / r1,新 dueAt | T0 COMPLETED 跳过;新 T1 WAITING |
| 用最新版本取消 | v2 / r2,dueAt=null | T1 COMPLETED 跳过;不创建 T2 |
| 用最新版本再设 1 分钟 | v3 / r3,新 dueAt | 新 T3 WAITING;旧行保留 |
没有 r2 的任务是正常结果:取消会推进提醒版本,但没有下一次计划时间。精确版本建立在没有额外操作的前提上。
scheduleReminder 总是先结束本工单旧 WAITING / RUNNING 提醒,清租约、进度置 100、写跳过摘要,再决定是否新建。它更新的是数据库任务,不承诺删除 Redis 里旧 job。
4. processor 的两道门怎样挡住旧任务#
打开 processor.ts,先读函数开头,再读 REMINDER 分支:
- 查任务:不存在、run 不符、已 COMPLETED 或 FAILED,直接返回,不认领。
- 认领后,在团队锁内再次按租约和 RUNNING 取 current;不再属于自己就返回。
- 查
{ id: task.ticketId, teamId }的工单,检查 dueAt、reminderRevision 和状态。
业务有效性条件是:
// 源码节选:提醒业务分支
ticket &&
ticket.dueAt &&
ticket.reminderRevision === task.reminderRevision &&
!['RESOLVED', 'CLOSED'].includes(ticket.status);条件不成立时,本次仍有效的任务租约可被完成并写“已跳过”;如果任务早已被取消并完成,第一道门就返回,不会再写一遍。条件成立但 dueAt 仍在未来,会抛“提醒尚未到期”的普通错误,交给重试处理,不会提前通知。
测试直接调用 processTask 可以绕过队列延迟,因此“没有到时间”不是只靠 Redis 保证。 反过来,延迟到期也只是可以处理的时间,Worker 离线或积压仍会晚到。
5. 接收人为什么执行时再查#
同一提醒事务查询当前本团队 Membership:已分配时只取该负责人且角色属于 ADMIN / AGENT;未分配时取管理员。任务 ownerId 不决定通知接收人:新建工单与手动设置时,helper 收到的是工单 creatorId;但当前重新打开分支传入的是操作者 userId。它们不能一概描述成“永远是原创建者”,接收者仍由本段的当前责任规则决定。
因此设置后换负责人,提醒当前负责人;若未分配,可能生成多条管理员通知。客户不会因创建工单而收到工作人员通知。成员降级 / 移除与解除分配使用共同团队锁协调,具体链路见第 10、13 章。
“执行时取当前人”适合本项目的当前责任提醒,但不是所有通知都应这样设计。例如合同签署回执可能必须发给当时指定收件人,需要单独定义业务语义。
6. 生成通知为何还要与历史一起提交#
在一个数据库事务里写四项内容:创建 ReminderNotification、Ticket.version 加 1、写 TIMEOUT_REMINDED 系统事件、完成任务并清 executionId。数据库写入抛错会回滚;最后的条件更新匹配 0 行本来不会自动抛错,因此代码明确抛 LeaseLost,强制回滚前面三项业务写入。外层识别后直接退出,不标记别人的新租约失败。
模型 的通知唯一约束 [ticketId, reminderRevision, userId] 配合 createMany.skipDuplicates 防重。系统事件直接写 actorName='系统',不是假造一个有会话的客服。提醒意图版本在通知后不递增,整张工单 version 会递增。
已完成任务被顺序再次调用,会在状态门返回;唯一约束是额外的数据边界。认领允许在团队锁外替换 RUNNING 的 executionId,但提交门会发现旧租约失效并回滚。第 26 章 用两名执行者的同步关卡和恢复扫描状态迁移分别验证交接中间态与最终态,检查通知、事件、version 和任务状态的一致性。保证范围是这条数据库提醒链路,不是所有外部系统的严格一次执行。
摘要按 recipients.length 记录计划接收人数,不取 createMany 的实际插入数;有重复通知被跳过时,这个数字不能当作本次新增行数的严格统计。没有符合资格的人时,也可能完成并写生成 0 条。
7. 故障案例与动手验证#
取消了仍显示完成,是不是误提醒? 先看旧任务的 resultSummary 与该工单实际通知,而不是只看 COMPLETED。按上表取消后,完成正是避免继续执行的结果。
到期却没有当前账号的通知。 核对工单负责人、账号角色、teamId、当前提醒版本、Worker 状态及摘要;通知列表只查当前用户、最近 30 条。不要把“我没看到”直接等同“没生成”。
第 3 节暂停 Worker 只用于推演和观察旧待办;下面等待真实通知前,先在同一学习配置下恢复 pnpm worker。新建两张工单,用以下步骤记录证据:
- 第一张设 1 分钟,分配给小陈;等待到期后检查小陈站内通知、系统事件与工单新版本,管理员不应因这张已分配工单额外收到通知。
- 第二张先设再取消,确认旧任务摘要跳过、无新增通知;再重设并换负责人,检查新版本与实际接收人。
- 另用新工单走 OPEN → IN_PROGRESS → RESOLVED,填写说明;旧任务失效。重新打开并说明,应有新的默认时限与任务。
不要用已经关闭的工单演示重新打开,也不要在共享数据库改时间跳过等待。测试代码可以在隔离夹具里将 dueAt 设为过去,这是受控准备,不是生产操作步骤。
8. 从现有测试读证据,再做练习#
任务集成测试 的“超时通知和系统记录幂等”把 dueAt 调到过去,然后连续执行同一任务两次,断言管理员通知 1 条、系统事件 1 条,并验证已读和客户通知空列表;它不经过真实延迟队列。
“提醒并发接手”的两条用例与“提醒写入事件后故障”补充验证回滚、接手和重试,具体同步点见第 26、27 章。
“已分配工单仅提醒当前负责人”直接准备 assigneeMembershipId,断言接收人数组。它证明执行时读取当前字段,但没有在运行中的任务里模拟“设置后 A 换成 B”的完整并发过程。
“取消和重新设置”这一测试名下实际执行的是取消、旧任务无通知、用旧 version 再设被 CONFLICT 拒绝,并未断言成功重设。学习时应看语句与断言,不能只复述用例名字。
练习 A:客户回复后 version 变了,提醒为什么仍有效#
动手练习查看参考答案
参考答案:回复推进工单 version,但没有改变 reminderRevision 与 dueAt。执行器用提醒意图版本判断,不把无关编辑当作取消提醒。
练习 B:删掉 Redis 的旧 job 能替代提醒版本吗#
动手练习查看参考答案
参考答案:不能。旧 job 可能已被认领,删除可能失败,也可能被账本再投递。服务端版本、任务状态与租约使执行者独立判断旧意图,不能依赖一次队列删除。
练习 C:怎样补上成功重设的验证#
动手练习查看参考答案
参考答案:取消后读取最新 Ticket.version,再合法设置新提醒;断言 reminderRevision 推进、旧任务完成、新任务对应新 revision 和 runAt。将新 dueAt 在隔离夹具中准备到期后执行新任务,再执行旧任务,检查只有新 revision 的通知与一次新系统事件。这是补充测试设计,不是当前已有断言。
链路:工作人员提交分钟数和版本 → 授权与状态检查 → 事务内更新意图、历史和待办 → 延迟投递 → 执行时重查版本与责任人 → 通知、系统历史和完成状态共同提交 → 当前用户读取站内通知。